This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* tool-pack-gate.test.ts — 群ごと止める仕掛けの試験(#1126 段 C)。
|
||||
*
|
||||
* ここで守りたいこと。
|
||||
*
|
||||
* 1. **設定ファイルから、エージェントが受け取るツール一覧まで通す。**
|
||||
* 設定を読む純関数だけを固めても意味がない。実際に config.yaml を書いて、
|
||||
* 実際の getToolDefs / loadBuiltinEntries / resolveWorkspaceTools の返りを見る。
|
||||
* ここが薄いと「設定を効かせる 1 行」を消しても全部緑のままになる。
|
||||
*
|
||||
* 2. **旧ルートが無傷であること。**台帳に載っていない群は今までどおり全部
|
||||
* 使える。何も止めていない状態の実効値が、この仕掛けを入れる前と 1 つも
|
||||
* 変わらないことを、ツール名の実物で見る。
|
||||
*
|
||||
* 3. **走行中に入れ替わらないこと。**判定はプロセスに 1 回。設定ファイルを
|
||||
* 書き換えても、同じプロセスの中ではツール一覧が動かない。動くのは
|
||||
* 読み込み直した(=再起動した)ときだけ、という形で両側を見る。
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
import {
|
||||
TOOL_PACKS,
|
||||
listToolPackIds,
|
||||
isKnownToolPackId,
|
||||
normalizeToolModuleSpecifier,
|
||||
toolPackIdOfModule,
|
||||
} from './tool-pack-ledger.js';
|
||||
import {
|
||||
getToolPackGate,
|
||||
getToolPackDecisions,
|
||||
isToolModuleDisabled,
|
||||
isToolPackDisabled,
|
||||
_resetToolPackGateForTests,
|
||||
_setToolPackConfigReaderForTests,
|
||||
} from './tool-pack-gate.js';
|
||||
import {
|
||||
listToolCategories,
|
||||
getToolCategoryMap,
|
||||
META_TOOLS,
|
||||
SENSITIVE_TOOLS,
|
||||
_resetToolCategoryCacheForTests,
|
||||
} from './tool-categories.js';
|
||||
import {
|
||||
_resetToolModuleCacheForTests,
|
||||
_setToolModuleManifestOverridesForTests,
|
||||
listRegisteredToolModuleSpecifiers,
|
||||
loadRegisteredToolModules,
|
||||
} from './module-manifest.js';
|
||||
import { resolveWorkspaceTools } from '../workspace-tool-policy.js';
|
||||
import { validateConfig, loadConfig } from '../../config.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 補助
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 群の入り切りを差し替えて、下流のキャッシュを全部捨てる */
|
||||
function setDisabledPacks(disabled: string[]): void {
|
||||
_setToolPackConfigReaderForTests(() => ({ toolPacks: { disabled } }));
|
||||
_resetToolCategoryCacheForTests();
|
||||
_resetToolModuleCacheForTests();
|
||||
}
|
||||
|
||||
/** 差し替えを戻す */
|
||||
function restoreGate(): void {
|
||||
_setToolPackConfigReaderForTests(null);
|
||||
_resetToolPackGateForTests();
|
||||
_resetToolCategoryCacheForTests();
|
||||
_resetToolModuleCacheForTests();
|
||||
}
|
||||
|
||||
const X_TOOLS = ['XSearch', 'XUserPosts', 'XPostDetail', 'XFetchCardMedia', 'XTimeline'];
|
||||
const CONTROL_TOOLS = ['WebSearch', 'Read', 'SQLite', 'RenderMermaid'];
|
||||
|
||||
afterEach(() => {
|
||||
restoreGate();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. 台帳そのもの
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('台帳', () => {
|
||||
it('名前が重複しない', () => {
|
||||
const ids = listToolPackIds();
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('載っているモジュールは実在し、空でない TOOL_DEFS を持つ', async () => {
|
||||
// try/catch を書かないこと。書いた瞬間、台帳の綴り間違いを見逃す試験になる。
|
||||
for (const pack of TOOL_PACKS) {
|
||||
for (const specifier of pack.modules) {
|
||||
const mod = (await import(specifier)) as { TOOL_DEFS?: Record<string, unknown> };
|
||||
expect(Object.keys(mod.TOOL_DEFS ?? {}).length, `${pack.id} / ${specifier}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('モジュール指定はどちらの書き方でも同じ群に解決する', () => {
|
||||
expect(normalizeToolModuleSpecifier('../engine/tools/x.js')).toBe('./x.js');
|
||||
expect(toolPackIdOfModule('./x.js')).toBe('x');
|
||||
expect(toolPackIdOfModule('../engine/tools/x.js')).toBe('x');
|
||||
expect(toolPackIdOfModule('./core.js')).toBeNull();
|
||||
});
|
||||
|
||||
it('台帳の群は、中央の常時利用一覧・既定 OFF 一覧に名前を出していない', async () => {
|
||||
// 出していると、群を止めても中央の一覧経由で名前が許可一覧に残る。
|
||||
// 台帳に群を足す人がこの境目を踏み抜かないための歯止め。
|
||||
const central = new Set<string>([...META_TOOLS, ...SENSITIVE_TOOLS]);
|
||||
const offenders: string[] = [];
|
||||
for (const pack of TOOL_PACKS) {
|
||||
for (const specifier of pack.modules) {
|
||||
const mod = (await import(specifier)) as { TOOL_DEFS?: Record<string, unknown> };
|
||||
for (const name of Object.keys(mod.TOOL_DEFS ?? {})) {
|
||||
if (central.has(name)) offenders.push(`${pack.id}:${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. 旧ルート — 台帳に載っていない群は今までどおり全部使える
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('旧ルート', () => {
|
||||
it('何も止めていなければ、台帳の群も台帳外の群も全部使える', async () => {
|
||||
setDisabledPacks([]);
|
||||
const { allowedTools } = await resolveWorkspaceTools({});
|
||||
for (const name of [...X_TOOLS, ...CONTROL_TOOLS, 'GetYouTubeTranscript', 'SearchPlaces', 'SearchAmazon', 'TranscribeAudio', 'SearchMicrosoftLearn', 'BuildPptx']) {
|
||||
expect(allowedTools, name).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('設定そのものが無い(tool_packs 未記載)ときも全部使える', async () => {
|
||||
_setToolPackConfigReaderForTests(() => ({}));
|
||||
_resetToolCategoryCacheForTests();
|
||||
_resetToolModuleCacheForTests();
|
||||
const { allowedTools } = await resolveWorkspaceTools({});
|
||||
expect(allowedTools).toContain('XSearch');
|
||||
expect(allowedTools).toContain('GetYouTubeTranscript');
|
||||
expect(getToolPackGate().disabledPackIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('1 つ止めても、台帳外の群は 1 つも減らない', async () => {
|
||||
setDisabledPacks([]);
|
||||
const before = new Set((await resolveWorkspaceTools({})).allowedTools);
|
||||
setDisabledPacks(['x']);
|
||||
const after = new Set((await resolveWorkspaceTools({})).allowedTools);
|
||||
|
||||
const removed = [...before].filter((n) => !after.has(n)).sort();
|
||||
expect(removed).toEqual([...X_TOOLS].sort());
|
||||
// 逆向き(勝手に増える)も見る
|
||||
expect([...after].filter((n) => !before.has(n))).toEqual([]);
|
||||
});
|
||||
|
||||
it('止めた群の分類だけが消え、他の分類は残る', async () => {
|
||||
setDisabledPacks([]);
|
||||
const before = await listToolCategories();
|
||||
setDisabledPacks(['x', 'youtube']);
|
||||
const after = await listToolCategories();
|
||||
expect([...before].filter((c) => !after.includes(c)).sort()).toEqual(['x', 'youtube']);
|
||||
expect(after).toContain('core');
|
||||
expect(after).toContain('web');
|
||||
expect(after).toContain('browser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. 層の向き — 環境で外したものはワークスペースで入れられない
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('層の向き', () => {
|
||||
it('環境で止めた分類は、ワークスペースの「入れたもの」に書いてあっても入らない', async () => {
|
||||
// speak は分類そのものが既定 OFF。ワークスペース側で 1 回入れれば使える
|
||||
// ——という既存の道が、環境で止めたときに塞がることを見る。
|
||||
setDisabledPacks([]);
|
||||
const on = await resolveWorkspaceTools({ enabledSensitive: ['speak'] });
|
||||
expect(on.allowedTools, '前提: 環境で止めていなければ入る').toContain('Speak');
|
||||
|
||||
setDisabledPacks(['speak']);
|
||||
const off = await resolveWorkspaceTools({ enabledSensitive: ['speak'] });
|
||||
expect(off.allowedTools).not.toContain('Speak');
|
||||
});
|
||||
|
||||
it('環境で止めた分類は、ワークスペースの設定画面の分類一覧にも出ない', async () => {
|
||||
setDisabledPacks(['speak', 'x']);
|
||||
const cats = await listToolCategories();
|
||||
expect(cats).not.toContain('speak');
|
||||
expect(cats).not.toContain('x');
|
||||
});
|
||||
|
||||
it('止めた群のツールは分類の地図から消える(下流が全部ここを見る)', async () => {
|
||||
setDisabledPacks(['amazon']);
|
||||
const map = await getToolCategoryMap();
|
||||
expect(map.has('SearchAmazon')).toBe(false);
|
||||
expect(map.get('WebSearch')).toBe('web');
|
||||
});
|
||||
|
||||
it('環境で止めた群のツールは、個別に「入れたもの」へ書いても入らない', async () => {
|
||||
// いま台帳の群は 1 つも「既定 OFF のツール」を申告していない(上の歯止めで
|
||||
// そう縛ってある)。だから、これから誰かが申告したときに層の向きが崩れないか
|
||||
// ——を、申告を差し込んで先に確かめる。
|
||||
// 差し込みが無いと、この経路は 1 行消しても全部緑のままになる。
|
||||
_setToolModuleManifestOverridesForTests([
|
||||
{ id: 'x', category: 'x', sensitiveTools: ['XSearch'] },
|
||||
]);
|
||||
try {
|
||||
setDisabledPacks([]);
|
||||
const on = await resolveWorkspaceTools({ enabledSensitive: ['XSearch'] });
|
||||
expect(on.allowedTools, '前提: 止めていなければ個別の有効化で入る').toContain('XSearch');
|
||||
|
||||
setDisabledPacks(['x']);
|
||||
const off = await resolveWorkspaceTools({ enabledSensitive: ['XSearch'] });
|
||||
expect(off.allowedTools).not.toContain('XSearch');
|
||||
} finally {
|
||||
_setToolModuleManifestOverridesForTests(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('止めた群のモジュールは、申告の収集対象からも外れる', async () => {
|
||||
setDisabledPacks(['youtube']);
|
||||
expect(listRegisteredToolModuleSpecifiers()).not.toContain('./youtube.js');
|
||||
expect(listRegisteredToolModuleSpecifiers()).toContain('./web.js');
|
||||
});
|
||||
|
||||
it('保存済みの設定を持つワークスペースは、環境で止めたあとも設定を保存できる', async () => {
|
||||
// 保存の検査に使う一覧だけは、環境で止めた分類も「知っている名前」として
|
||||
// 数える。数えないと、群を止める前に開かれたままの古いタブと、API を
|
||||
// 直接叩くクライアントからの保存が、綴り間違いと同じ扱いで弾かれる。
|
||||
setDisabledPacks(['x']);
|
||||
expect(await listToolCategories({ includeEnvDisabled: true })).toContain('x');
|
||||
expect(await listToolCategories()).not.toContain('x');
|
||||
});
|
||||
|
||||
// 覚え書き(メモ化)は 2 本立てで、フィルタ済みと止めた群込みを別の鍵で持つ。
|
||||
// **本番はフィルタ済みが必ず先に埋まる**——movement ごとに getToolDefs と
|
||||
// resolveWorkspaceTools が読むため。設定画面の保存はそのあとに来る。
|
||||
// 鍵が 1 本に崩れると、保存の検査がフィルタ済みの一覧を受け取り、上で
|
||||
// 塞いだ罠がそのまま戻る。だから**この順番で**呼んで縛る。
|
||||
it('フィルタ済みを先に読んでも、includeEnvDisabled は止めた群のツールを返す', async () => {
|
||||
setDisabledPacks(['x']);
|
||||
const filtered = await getToolCategoryMap(); // 本番はこっちが先
|
||||
expect(filtered.has('XSearch')).toBe(false);
|
||||
const all = await getToolCategoryMap({ includeEnvDisabled: true });
|
||||
expect(all.has('XSearch'), '覚え書きの鍵が 1 本に崩れている').toBe(true);
|
||||
});
|
||||
|
||||
it('フィルタ済みを先に読んでも、includeEnvDisabled は止めた群のモジュールを読み込む', async () => {
|
||||
setDisabledPacks(['x']);
|
||||
const filtered = await loadRegisteredToolModules(); // 本番はこっちが先
|
||||
expect(filtered.loaded.some((l) => l.specifier === './x.js')).toBe(false);
|
||||
const all = await loadRegisteredToolModules({ includeEnvDisabled: true });
|
||||
expect(all.loaded.some((l) => l.specifier === './x.js'), '覚え書きの鍵が 1 本に崩れている').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. 知らない名前を黙って無視しない
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('知らない群の名前', () => {
|
||||
it('判定の一覧に「知らない名前」として残る', () => {
|
||||
setDisabledPacks(['x', 'nosuchpack']);
|
||||
const gate = getToolPackGate();
|
||||
expect(gate.unknownPackIds).toEqual(['nosuchpack']);
|
||||
const unknown = gate.decisions.filter((d) => d.status === 'unknown');
|
||||
expect(unknown).toHaveLength(1);
|
||||
expect(unknown[0]!.packId).toBe('nosuchpack');
|
||||
expect(unknown[0]!.reason).toBe('unknown-pack-id');
|
||||
});
|
||||
|
||||
it('知らない名前を書いても、他の群の入り切りは正しく効く', () => {
|
||||
setDisabledPacks(['nosuchpack', 'x']);
|
||||
expect(isToolPackDisabled('x')).toBe(true);
|
||||
expect(isToolPackDisabled('youtube')).toBe(false);
|
||||
expect(isToolPackDisabled('nosuchpack')).toBe(false);
|
||||
});
|
||||
|
||||
it('設定ファイルを読ませたときに、検査が知らない名前を指摘する', () => {
|
||||
// 設定ファイルの実物を読ませる。手で組み立てた形だけを検査に通すと、
|
||||
// スネークケースの読み替え(tool_packs → toolPacks)が抜けていても通る。
|
||||
const dir = mkdtempSync(join(tmpdir(), 'tool-packs-cfg-'));
|
||||
try {
|
||||
const bad = join(dir, 'bad.yaml');
|
||||
writeFileSync(bad, 'tool_packs:\n disabled:\n - x\n - nosuchpack\n', 'utf-8');
|
||||
const errors = validateConfig(loadConfig(bad));
|
||||
expect(errors.some((e) => e.includes('nosuchpack'))).toBe(true);
|
||||
|
||||
const good = join(dir, 'good.yaml');
|
||||
writeFileSync(good, 'tool_packs:\n disabled:\n - x\n', 'utf-8');
|
||||
const ok = validateConfig(loadConfig(good));
|
||||
expect(ok.some((e) => e.includes('toolPacks'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('設定ファイルのスネークケースが、そのまま群の入り切りとして効く', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'tool-packs-cfg-'));
|
||||
try {
|
||||
const p = join(dir, 'c.yaml');
|
||||
writeFileSync(p, 'tool_packs:\n disabled:\n - ms-learn\n', 'utf-8');
|
||||
expect(loadConfig(p).toolPacks?.disabled).toEqual(['ms-learn']);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('台帳にある名前は既知と判定される', () => {
|
||||
for (const id of listToolPackIds()) expect(isKnownToolPackId(id)).toBe(true);
|
||||
expect(isKnownToolPackId('nosuchpack')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 7. 判定が 1 か所に集まっていること(あとから群ごとの記録を出せる形)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('判定の記録', () => {
|
||||
it('台帳の全群について、いつ・なぜ・誰の設定で決まったかが 1 か所に揃う', () => {
|
||||
setDisabledPacks(['x']);
|
||||
const decisions = getToolPackDecisions();
|
||||
const known = decisions.filter((d) => d.status !== 'unknown');
|
||||
expect(known.map((d) => d.packId).sort()).toEqual(listToolPackIds().sort());
|
||||
|
||||
for (const d of known) {
|
||||
expect(d.source).toContain('tool_packs.disabled');
|
||||
expect(Number.isNaN(Date.parse(d.decidedAt))).toBe(false);
|
||||
}
|
||||
const x = known.find((d) => d.packId === 'x')!;
|
||||
expect(x.status).toBe('disabled');
|
||||
expect(x.reason).toBe('disabled-by-config');
|
||||
const yt = known.find((d) => d.packId === 'youtube')!;
|
||||
expect(yt.status).toBe('enabled');
|
||||
expect(yt.reason).toBe('not-listed-in-config');
|
||||
});
|
||||
|
||||
it('同じプロセスの中では、判定は何度読んでも同じもの', () => {
|
||||
setDisabledPacks(['x']);
|
||||
const first = getToolPackDecisions();
|
||||
const second = getToolPackDecisions();
|
||||
expect(second).toBe(first);
|
||||
expect(isToolModuleDisabled('./x.js')).toBe(true);
|
||||
expect(isToolModuleDisabled('../engine/tools/x.js')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user