mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat(pi-hermes-memory): 导入上游 v0.9.10 源码快照
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import * as assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { DatabaseManager } from '../../src/store/db.js';
|
||||
import { addMemory, getMemoryUsageSignals } from '../../src/store/sqlite-memory-store.js';
|
||||
import { normalizeMemoryLookupText } from '../../src/store/memory-lookup.js';
|
||||
import { registerMemorySearchTool } from '../../src/tools/memory-search-tool.js';
|
||||
|
||||
let ROOT_DIR = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (ROOT_DIR) fs.rmSync(ROOT_DIR, { recursive: true, force: true });
|
||||
ROOT_DIR = '';
|
||||
});
|
||||
|
||||
function makeDbManager(): DatabaseManager {
|
||||
ROOT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-memory-search-tool-test-'));
|
||||
return new DatabaseManager(ROOT_DIR);
|
||||
}
|
||||
|
||||
describe('registerMemorySearchTool', () => {
|
||||
it('returns a broader natural-language match when strict term matching misses', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, "user's name is Naruto", 'user');
|
||||
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
captured = def;
|
||||
},
|
||||
} as any;
|
||||
|
||||
registerMemorySearchTool(mockPi, dbManager);
|
||||
|
||||
const result = await captured.execute('tc-1', { query: 'name identity Naruto', target: 'user' });
|
||||
|
||||
assert.strictEqual(result.details.success, true);
|
||||
assert.strictEqual(result.details.count, 1);
|
||||
assert.match(result.content[0].text, /Naruto/);
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
|
||||
it('labels every result with its mutation target and unambiguous scope', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, 'global deployment convention');
|
||||
addMemory(dbManager, 'user deployment preference', 'user');
|
||||
addMemory(dbManager, 'failure deployment lesson', 'failure');
|
||||
addMemory(dbManager, 'project deployment convention', 'memory', 'project-a');
|
||||
addMemory(dbManager, 'project failure deployment lesson', 'failure', 'project-a');
|
||||
|
||||
let captured: any;
|
||||
registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager);
|
||||
|
||||
const result = await captured.execute('tc-1', { query: 'deployment' });
|
||||
const text = result.content[0].text;
|
||||
|
||||
assert.match(text, /scope=global \[target=memory\] global deployment convention/);
|
||||
assert.match(text, /scope=global \[target=user\] user deployment preference/);
|
||||
assert.match(text, /scope=global \[target=failure\] failure deployment lesson/);
|
||||
assert.match(text, /scope=project:project-a \[target=project\] project deployment convention/);
|
||||
assert.match(text, /scope=project:project-a \[target=failure\] project failure deployment lesson/);
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
|
||||
it('accepts target "project" as a filter and shows the schema value', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, 'project deployment convention', 'memory', 'project-a');
|
||||
addMemory(dbManager, 'global deployment convention');
|
||||
addMemory(dbManager, 'project failure deployment lesson', 'failure', 'project-a');
|
||||
|
||||
let captured: any;
|
||||
registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager);
|
||||
|
||||
assert.ok(captured.parameters.properties.target.enum.includes('project'));
|
||||
|
||||
const result = await captured.execute('tc-1', { query: 'deployment', target: 'project' });
|
||||
const text = result.content[0].text;
|
||||
|
||||
assert.strictEqual(result.details.success, true);
|
||||
assert.strictEqual(result.details.count, 1);
|
||||
assert.match(text, /scope=project:project-a \[target=project\]/);
|
||||
assert.doesNotMatch(text, /scope=global/);
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
|
||||
it('keeps copied results reversible when a project name contains brackets', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, 'literal project entry', 'memory', 'foo] bar');
|
||||
|
||||
let captured: any;
|
||||
registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager);
|
||||
|
||||
const result = await captured.execute('tc-1', { query: 'literal' });
|
||||
const firstResultLine = result.content[0].text.split('\n').find((line: string) => line.startsWith('🧠'))!;
|
||||
|
||||
assert.match(firstResultLine, /scope=project:foo%5D%20bar \[target=project\]/);
|
||||
assert.equal(normalizeMemoryLookupText(firstResultLine), 'literal project entry');
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
|
||||
it('records a usage hit for every returned entry', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, 'recallable volcano note', 'memory');
|
||||
addMemory(dbManager, 'recallable tide note', 'memory');
|
||||
|
||||
let captured: any;
|
||||
registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager);
|
||||
|
||||
await captured.execute('tc-1', { query: 'recallable' });
|
||||
await captured.execute('tc-2', { query: 'volcano' });
|
||||
|
||||
const signals = getMemoryUsageSignals(dbManager, { target: 'memory', project: null });
|
||||
assert.strictEqual(signals.get('recallable volcano note')?.hits, 2);
|
||||
assert.strictEqual(signals.get('recallable tide note')?.hits, 1);
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
|
||||
it('records nothing when the search returns no results', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, 'unmatched entry', 'memory');
|
||||
|
||||
let captured: any;
|
||||
registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager);
|
||||
|
||||
const result = await captured.execute('tc-1', { query: 'zzz-no-match-q' });
|
||||
assert.strictEqual(result.details.count, 0);
|
||||
assert.strictEqual(getMemoryUsageSignals(dbManager, { target: 'memory', project: null }).size, 0);
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
|
||||
it('skips recording when usage tracking is disabled', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, 'tracked-off entry', 'memory');
|
||||
|
||||
let captured: any;
|
||||
registerMemorySearchTool(
|
||||
{ registerTool: (def: any) => { captured = def; } } as any,
|
||||
dbManager,
|
||||
{ usageTrackingEnabled: false },
|
||||
);
|
||||
|
||||
const result = await captured.execute('tc-1', { query: 'tracked-off' });
|
||||
assert.strictEqual(result.details.count, 1);
|
||||
assert.strictEqual(getMemoryUsageSignals(dbManager, { target: 'memory', project: null }).size, 0);
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
|
||||
it('never breaks the search when hit recording fails (fail-open)', async () => {
|
||||
const dbManager = makeDbManager();
|
||||
addMemory(dbManager, 'resilient entry', 'memory');
|
||||
|
||||
let getDbCalls = 0;
|
||||
const realGetDb = dbManager.getDb.bind(dbManager);
|
||||
const flaky = Object.create(dbManager) as DatabaseManager;
|
||||
flaky.getDb = () => {
|
||||
getDbCalls++;
|
||||
if (getDbCalls > 2) throw new Error('injected recording failure');
|
||||
return realGetDb();
|
||||
};
|
||||
|
||||
let captured: any;
|
||||
registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, flaky);
|
||||
|
||||
// getDb calls: getMemoryStats(1) + searchMemories(1) + recordSearchHits(1, throws).
|
||||
const result = await captured.execute('tc-1', { query: 'resilient' });
|
||||
assert.strictEqual(result.details.success, true);
|
||||
assert.strictEqual(result.details.count, 1);
|
||||
assert.match(result.content[0].text, /resilient entry/);
|
||||
// The hit did NOT land (recording failed) but the search succeeded.
|
||||
assert.strictEqual(getMemoryUsageSignals(dbManager, { target: 'memory', project: null }).size, 0);
|
||||
|
||||
dbManager.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,745 @@
|
||||
/**
|
||||
* Unit tests for memory tool registration and execute function.
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { registerMemoryTool } from "../../src/tools/memory-tool.js";
|
||||
import { MemoryStore } from "../../src/store/memory-store.js";
|
||||
import { DatabaseManager } from "../../src/store/db.js";
|
||||
import { getMemories, searchMemories, syncMemoryEntry } from "../../src/store/sqlite-memory-store.js";
|
||||
import { ENTRY_DELIMITER, MEMORY_FILE } from "../../src/constants.js";
|
||||
import { Value } from "typebox/value";
|
||||
|
||||
describe("registerMemoryTool", () => {
|
||||
let tmpDir: string;
|
||||
let dbManager: DatabaseManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memory-tool-test-"));
|
||||
dbManager = new DatabaseManager(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbManager.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers action-specific memory tools with required parameters", () => {
|
||||
const registeredTools: any[] = [];
|
||||
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
registeredTools.push(def);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
registerMemoryTool(mockPi, {} as MemoryStore, null);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
registeredTools.map((tool) => tool.name),
|
||||
["memory_add", "memory_replace", "memory_remove"],
|
||||
);
|
||||
for (const tool of registeredTools) {
|
||||
assert.ok(tool.description.length > 0);
|
||||
assert.ok(tool.promptSnippet.length > 0);
|
||||
assert.ok(Array.isArray(tool.promptGuidelines));
|
||||
assert.ok(tool.parameters);
|
||||
}
|
||||
});
|
||||
|
||||
it("execute add returns JSON with usage field", async () => {
|
||||
let capturedResult: any;
|
||||
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockStore = {
|
||||
add: () => ({
|
||||
success: true,
|
||||
target: "memory",
|
||||
entries: ["Entry one"],
|
||||
usage: "5% — 110/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Entry added.",
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
||||
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "Entry one" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
assert.strictEqual(result.content[0].type, "text", "content should be text type");
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true, "result should be success");
|
||||
assert.ok(parsed.usage.includes("chars"), "usage should contain 'chars'");
|
||||
assert.ok(parsed.usage.includes("5000"), "usage should show total limit");
|
||||
assert.strictEqual(parsed.entry_count, 1, "entry_count should be 1");
|
||||
assert.strictEqual(result.details.success, true, "details should mirror result");
|
||||
});
|
||||
|
||||
it("execute add with FIFO evictions returns normal text with full rotated entries", async () => {
|
||||
let capturedResult: any;
|
||||
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const evictedOne = "First rotated entry with full detail.";
|
||||
const evictedTwo = "Second rotated entry with\nmultiple lines preserved.";
|
||||
const mockStore = {
|
||||
add: () => ({
|
||||
success: true,
|
||||
target: "memory",
|
||||
entries: ["New entry"],
|
||||
usage: "90% — 4500/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Memory updated. Rotated 2 older entries to stay within the limit.",
|
||||
evicted_entries: [evictedOne, evictedTwo],
|
||||
evicted_count: 2,
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null);
|
||||
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "New entry" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
const text = result.content[0].text;
|
||||
assert.throws(() => JSON.parse(text));
|
||||
assert.match(text, /Memory updated\. Rotated 2 older entries/);
|
||||
assert.match(text, /Rotated active memory entries:/);
|
||||
assert.ok(text.includes(`1. ${evictedOne}`));
|
||||
assert.ok(text.includes(`2. ${evictedTwo}`));
|
||||
assert.match(text, /If one of these entries should stay active, add it again\./);
|
||||
assert.match(text, /Usage: 90%/);
|
||||
assert.deepStrictEqual(result.details.evicted_entries, [evictedOne, evictedTwo]);
|
||||
});
|
||||
|
||||
it("syncs successful adds into SQLite", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockStore = {
|
||||
add: () => ({
|
||||
success: true,
|
||||
target: "memory",
|
||||
entries: ["Entry one"],
|
||||
usage: "5% — 110/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Entry added.",
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
||||
await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "Entry one" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
const results = getMemories(dbManager, { target: 'memory', project: null });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].content, 'Entry one');
|
||||
});
|
||||
|
||||
it("lands over-cap policy-only adds in searchable SQLite", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
const store = new MemoryStore({
|
||||
memoryMode: "policy-only",
|
||||
memoryCharLimit: 40,
|
||||
userCharLimit: 5000,
|
||||
projectCharLimit: 5000,
|
||||
nudgeInterval: 10,
|
||||
reviewEnabled: false,
|
||||
flushOnCompact: false,
|
||||
flushOnShutdown: false,
|
||||
flushMinTurns: 6,
|
||||
autoConsolidate: false,
|
||||
correctionDetection: false,
|
||||
failureInjectionEnabled: true,
|
||||
failureInjectionMaxAgeDays: 7,
|
||||
failureInjectionMaxEntries: 5,
|
||||
nudgeToolCalls: 15,
|
||||
consolidationTimeoutMs: 60000,
|
||||
memoryDir: tmpDir,
|
||||
});
|
||||
await store.loadFromDisk();
|
||||
registerMemoryTool(mockPi, store, null, dbManager);
|
||||
const first = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "alpha entry" }, undefined as any, undefined as any, undefined as any);
|
||||
assert.strictEqual(first.details.success, true);
|
||||
const second = await capturedResult.execute("tc-2", { action: "add", target: "memory", content: "beta entry past the tiny cap" }, undefined as any, undefined as any, undefined as any);
|
||||
assert.strictEqual(second.details.success, true);
|
||||
const results = searchMemories(dbManager, "beta entry past the tiny cap", { target: "memory" });
|
||||
assert.ok(results.some((r) => r.content.includes("beta entry past the tiny cap")));
|
||||
});
|
||||
|
||||
it("reports the matching target when replace is sent to the wrong target", async () => {
|
||||
let removeTool: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (def.name === "memory_remove") removeTool = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
const store = new MemoryStore({
|
||||
memoryMode: "policy-only",
|
||||
memoryCharLimit: 5000,
|
||||
userCharLimit: 5000,
|
||||
projectCharLimit: 5000,
|
||||
nudgeInterval: 10,
|
||||
reviewEnabled: false,
|
||||
flushOnCompact: false,
|
||||
flushOnShutdown: false,
|
||||
flushMinTurns: 6,
|
||||
autoConsolidate: false,
|
||||
correctionDetection: false,
|
||||
failureInjectionEnabled: true,
|
||||
failureInjectionMaxAgeDays: 7,
|
||||
failureInjectionMaxEntries: 5,
|
||||
nudgeToolCalls: 15,
|
||||
consolidationTimeoutMs: 60000,
|
||||
memoryDir: tmpDir,
|
||||
});
|
||||
await store.loadFromDisk();
|
||||
await store.addFailure("use pnpm for lockfiles", { category: "correction" });
|
||||
|
||||
registerMemoryTool(mockPi, store, null);
|
||||
const result = await removeTool.execute(
|
||||
"tc-1",
|
||||
{ target: "memory", old_text: "use pnpm for lockfiles" },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
assert.equal(result.details.success, false);
|
||||
assert.match(result.details.error, /No match in target "memory"/);
|
||||
assert.match(result.details.error, /target "failure"/);
|
||||
assert.deepEqual(result.details.matching_targets, ["failure"]);
|
||||
});
|
||||
|
||||
it("prunes same-scope SQLite orphans after a Markdown mutation", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (definition: any) => {
|
||||
if (!capturedResult || definition.name === "memory_add") capturedResult = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
const store = new MemoryStore({
|
||||
memoryMode: "policy-only",
|
||||
memoryCharLimit: 5000,
|
||||
userCharLimit: 5000,
|
||||
projectCharLimit: 5000,
|
||||
nudgeInterval: 10,
|
||||
reviewEnabled: false,
|
||||
flushOnCompact: false,
|
||||
flushOnShutdown: false,
|
||||
flushMinTurns: 6,
|
||||
autoConsolidate: false,
|
||||
correctionDetection: false,
|
||||
failureInjectionEnabled: true,
|
||||
failureInjectionMaxAgeDays: 7,
|
||||
failureInjectionMaxEntries: 5,
|
||||
nudgeToolCalls: 15,
|
||||
consolidationTimeoutMs: 60000,
|
||||
memoryDir: tmpDir,
|
||||
});
|
||||
await store.loadFromDisk();
|
||||
syncMemoryEntry(dbManager, { content: "orphaned row", target: "memory", project: null });
|
||||
|
||||
registerMemoryTool(mockPi, store, null, dbManager);
|
||||
await capturedResult.execute(
|
||||
"tc-1",
|
||||
{ action: "add", target: "memory", content: "authoritative Markdown row" },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: "memory", project: null }).map((entry) => entry.content),
|
||||
["authoritative Markdown row"],
|
||||
);
|
||||
});
|
||||
|
||||
it("reconciles SQLite from fresh authoritative Markdown state", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (definition: any) => {
|
||||
if (!capturedResult || definition.name === "memory_add") capturedResult = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
const store = new MemoryStore({
|
||||
memoryMode: "policy-only",
|
||||
memoryCharLimit: 5000,
|
||||
userCharLimit: 5000,
|
||||
projectCharLimit: 5000,
|
||||
nudgeInterval: 10,
|
||||
reviewEnabled: false,
|
||||
flushOnCompact: false,
|
||||
flushOnShutdown: false,
|
||||
flushMinTurns: 6,
|
||||
autoConsolidate: false,
|
||||
correctionDetection: false,
|
||||
failureInjectionEnabled: true,
|
||||
failureInjectionMaxAgeDays: 7,
|
||||
failureInjectionMaxEntries: 5,
|
||||
nudgeToolCalls: 15,
|
||||
consolidationTimeoutMs: 60000,
|
||||
memoryDir: tmpDir,
|
||||
});
|
||||
await store.loadFromDisk();
|
||||
|
||||
const originalSave = (store as any).saveToDisk.bind(store);
|
||||
(store as any).saveToDisk = async (target: "memory") => {
|
||||
await originalSave(target);
|
||||
const markdownPath = path.join(tmpDir, MEMORY_FILE);
|
||||
const existing = fs.readFileSync(markdownPath, "utf-8");
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
fs.writeFileSync(markdownPath, `${existing}${ENTRY_DELIMITER}newer writer <!-- created=${date}, last=${date} -->`);
|
||||
syncMemoryEntry(dbManager, { content: "newer writer", target: "memory", project: null });
|
||||
};
|
||||
|
||||
registerMemoryTool(mockPi, store, null, dbManager);
|
||||
await capturedResult.execute(
|
||||
"tc-1",
|
||||
{ action: "add", target: "memory", content: "first writer" },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: "memory", project: null }).map((entry) => entry.content).sort(),
|
||||
["first writer", "newer writer"],
|
||||
);
|
||||
});
|
||||
|
||||
it("removes FIFO-evicted entries from the SQLite mirror", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
syncMemoryEntry(dbManager, {
|
||||
content: "Older entry",
|
||||
target: "memory",
|
||||
project: null,
|
||||
});
|
||||
syncMemoryEntry(dbManager, {
|
||||
content: "Older entry with extra detail",
|
||||
target: "memory",
|
||||
project: null,
|
||||
});
|
||||
|
||||
const mockStore = {
|
||||
add: () => ({
|
||||
success: true,
|
||||
target: "memory",
|
||||
entries: ["New entry"],
|
||||
usage: "90% — 4500/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Memory updated. Rotated 1 older entry to stay within the limit.",
|
||||
evicted_entries: ["Older entry"],
|
||||
evicted_count: 1,
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
||||
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "New entry" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
assert.match(result.content[0].text, /Rotated active memory entries:/);
|
||||
const rows = getMemories(dbManager, { target: "memory", project: null });
|
||||
assert.deepStrictEqual(rows.map((row) => row.content).sort(), ["New entry", "Older entry with extra detail"].sort());
|
||||
});
|
||||
|
||||
it("uses project scope when removing FIFO-evicted SQLite entries", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
syncMemoryEntry(dbManager, {
|
||||
content: "Shared wording",
|
||||
target: "memory",
|
||||
project: null,
|
||||
});
|
||||
syncMemoryEntry(dbManager, {
|
||||
content: "Shared wording",
|
||||
target: "memory",
|
||||
project: "project-a",
|
||||
});
|
||||
|
||||
const mockProjectStore = {
|
||||
add: () => ({
|
||||
success: true,
|
||||
target: "memory",
|
||||
entries: ["Project replacement"],
|
||||
usage: "90% — 4500/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Memory updated. Rotated 1 older entry to stay within the limit.",
|
||||
evicted_entries: ["Shared wording"],
|
||||
evicted_count: 1,
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, {} as MemoryStore, mockProjectStore, dbManager, "project-a");
|
||||
await capturedResult.execute("tc-1", { action: "add", target: "project", content: "Project replacement" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
const globalRows = getMemories(dbManager, { target: "memory", project: null });
|
||||
const projectRows = getMemories(dbManager, { target: "memory", project: "project-a" });
|
||||
assert.deepStrictEqual(globalRows.map((row) => row.content), ["Shared wording"]);
|
||||
assert.deepStrictEqual(projectRows.map((row) => row.content), ["Project replacement"]);
|
||||
});
|
||||
|
||||
it("maps project target to SQLite project scope", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const addTargets: string[] = [];
|
||||
const mockProjectStore = {
|
||||
add: (target: string) => {
|
||||
addTargets.push(target);
|
||||
return {
|
||||
success: true,
|
||||
target,
|
||||
entries: ["Project entry"],
|
||||
usage: "2% — 20/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Entry added.",
|
||||
};
|
||||
},
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, {} as MemoryStore, mockProjectStore, dbManager, 'project-a');
|
||||
const result = await capturedResult.execute("tc-1", { action: "add", target: "project", content: "Project entry" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.target, 'project');
|
||||
assert.strictEqual(result.details.target, 'project');
|
||||
assert.deepStrictEqual(addTargets, ['memory']);
|
||||
|
||||
const results = getMemories(dbManager, { project: 'project-a', target: 'memory' });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].content, 'Project entry');
|
||||
});
|
||||
|
||||
it("resolves the active project store and name for each mutation", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
let activeProjectName = "project-a";
|
||||
const calls: string[] = [];
|
||||
const stores = new Map<string, MemoryStore>();
|
||||
const makeProjectStore = (name: string) => ({
|
||||
add: (target: string, content: string) => {
|
||||
calls.push(`${name}:${target}:${content}`);
|
||||
return {
|
||||
success: true,
|
||||
target,
|
||||
entries: [content],
|
||||
usage: "2% — 20/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Entry added.",
|
||||
};
|
||||
},
|
||||
}) as unknown as MemoryStore;
|
||||
stores.set("project-a", makeProjectStore("project-a"));
|
||||
stores.set("project-b", makeProjectStore("project-b"));
|
||||
|
||||
registerMemoryTool(
|
||||
mockPi,
|
||||
{} as MemoryStore,
|
||||
() => stores.get(activeProjectName) ?? null,
|
||||
dbManager,
|
||||
() => activeProjectName,
|
||||
);
|
||||
|
||||
await capturedResult.execute("tc-1", { target: "project", content: "first" }, undefined as any, undefined as any, undefined as any);
|
||||
activeProjectName = "project-b";
|
||||
await capturedResult.execute("tc-2", { target: "project", content: "second" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
assert.deepStrictEqual(calls, ["project-a:memory:first", "project-b:memory:second"]);
|
||||
const projectRows = getMemories(dbManager, { project: "project-b", target: "memory" });
|
||||
assert.deepStrictEqual(projectRows.map((row) => row.content), ["second"]);
|
||||
});
|
||||
it("reconciles rebound project-store mutations in the project SQLite scope", async () => {
|
||||
let activeProjectName = "project-a";
|
||||
type MutationObserver = (
|
||||
target: "memory" | "user" | "failure",
|
||||
entries: string[],
|
||||
) => Promise<string | null | undefined>;
|
||||
const observers = new Map<string, MutationObserver>();
|
||||
const stores = new Map<string, MemoryStore>();
|
||||
const makeProjectStore = (name: string) => ({
|
||||
setMutationObserver: (observer: MutationObserver) => {
|
||||
observers.set(name, observer);
|
||||
},
|
||||
}) as unknown as MemoryStore;
|
||||
stores.set("project-a", makeProjectStore("project-a"));
|
||||
stores.set("project-b", makeProjectStore("project-b"));
|
||||
const mockPi = {
|
||||
registerTool: () => {},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const configureProjectStore = registerMemoryTool(
|
||||
mockPi,
|
||||
{} as MemoryStore,
|
||||
() => stores.get(activeProjectName) ?? null,
|
||||
dbManager,
|
||||
() => activeProjectName,
|
||||
);
|
||||
|
||||
await observers.get("project-a")?.("memory", ["first project entry"]);
|
||||
activeProjectName = "project-b";
|
||||
configureProjectStore(stores.get("project-b") ?? null);
|
||||
await observers.get("project-b")?.("memory", ["second project entry"]);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: "memory", project: "project-a" }).map((row) => row.content),
|
||||
["first project entry"],
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: "memory", project: "project-b" }).map((row) => row.content),
|
||||
["second project entry"],
|
||||
);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { target: "memory", project: null }), []);
|
||||
});
|
||||
|
||||
it("returns a warning instead of failing when SQLite sync errors", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockStore = {
|
||||
add: () => ({
|
||||
success: true,
|
||||
target: "memory",
|
||||
entries: ["Entry one"],
|
||||
usage: "5% — 110/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Entry added.",
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
const failingDbManager = {
|
||||
getDb: () => {
|
||||
throw new Error('sqlite unavailable');
|
||||
},
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null, failingDbManager);
|
||||
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "Entry one" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
assert.match(parsed.message, /SQLite search sync failed/);
|
||||
assert.match(parsed.warning, /sqlite unavailable/);
|
||||
});
|
||||
|
||||
it("does not sync to SQLite when core Markdown add fails", async () => {
|
||||
let capturedResult: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockStore = {
|
||||
add: () => ({
|
||||
success: false,
|
||||
error: "Memory at 5000/5000 chars. Adding this entry would exceed the limit.",
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
||||
const result = await capturedResult.execute(
|
||||
"tc-1",
|
||||
{ action: "add", target: "memory", content: "overflow entry" },
|
||||
undefined as any,
|
||||
undefined as any,
|
||||
undefined as any,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, false);
|
||||
|
||||
const rows = getMemories(dbManager, { target: "memory", project: null });
|
||||
assert.strictEqual(rows.length, 0, "SQLite should stay unchanged when core add fails");
|
||||
});
|
||||
|
||||
it("registers action-specific schemas that reject incomplete mutations", () => {
|
||||
const registeredTools: Record<string, any> = {};
|
||||
const mockPi = {
|
||||
registerTool: (definition: any) => {
|
||||
registeredTools[definition.name] = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
registerMemoryTool(mockPi, {} as MemoryStore, null);
|
||||
|
||||
assert.deepStrictEqual(Object.keys(registeredTools).sort(), [
|
||||
"memory_add",
|
||||
"memory_remove",
|
||||
"memory_replace",
|
||||
]);
|
||||
assert.strictEqual(
|
||||
Value.Check(registeredTools.memory_add.parameters, {
|
||||
action: "add",
|
||||
target: "failure",
|
||||
category: "tool-quirk",
|
||||
failure_reason: "missing content",
|
||||
}),
|
||||
false,
|
||||
"add without content must fail schema validation",
|
||||
);
|
||||
assert.strictEqual(
|
||||
Value.Check(registeredTools.memory_replace.parameters, {
|
||||
target: "memory",
|
||||
content: "new",
|
||||
}),
|
||||
false,
|
||||
"replace without old_text must fail schema validation",
|
||||
);
|
||||
assert.strictEqual(
|
||||
Value.Check(registeredTools.memory_remove.parameters, { target: "memory" }),
|
||||
false,
|
||||
"remove without old_text must fail schema validation",
|
||||
);
|
||||
assert.strictEqual(
|
||||
Value.Check(registeredTools.memory_add.parameters, {
|
||||
target: "memory",
|
||||
content: "durable fact",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
Value.Check(registeredTools.memory_replace.parameters, {
|
||||
target: "memory",
|
||||
old_text: "old",
|
||||
content: "new",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
Value.Check(registeredTools.memory_remove.parameters, {
|
||||
target: "memory",
|
||||
old_text: "old",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("execute delegates remove to store.remove", async () => {
|
||||
let capturedResult: any;
|
||||
let removeArgs: any;
|
||||
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (def.name === "memory_remove") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockStore = {
|
||||
remove: (...args: any[]) => {
|
||||
removeArgs = args;
|
||||
return { success: true, target: "memory", entries: [], usage: "0% — 0/5000 chars", entry_count: 0 };
|
||||
},
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null);
|
||||
await capturedResult.execute("tc-1", { target: "memory", old_text: "old entry" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
assert.deepStrictEqual(removeArgs, ["memory", "old entry"], "should pass target, old_text to store.remove");
|
||||
});
|
||||
|
||||
it("execute delegates replace to store.replace", async () => {
|
||||
let capturedResult: any;
|
||||
let replaceArgs: any;
|
||||
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (def.name === "memory_replace") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockStore = {
|
||||
replace: (...args: any[]) => {
|
||||
replaceArgs = args;
|
||||
return { success: true, target: "memory", entries: ["new"], usage: "5% — 110/5000 chars", entry_count: 1 };
|
||||
},
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null);
|
||||
await capturedResult.execute("tc-1", { action: "replace", target: "memory", content: "new", old_text: "old" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
assert.deepStrictEqual(replaceArgs, ["memory", "old", "new"], "should pass target, old_text, content to store.replace");
|
||||
});
|
||||
|
||||
it("binds project identity from execute ctx.cwd instead of a factory snapshot", async () => {
|
||||
let capturedResult: any;
|
||||
const boundCwds: string[] = [];
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockProjectStore = {
|
||||
add: () => ({
|
||||
success: true,
|
||||
target: "memory",
|
||||
entries: ["from session cwd"],
|
||||
usage: "2% — 20/5000 chars",
|
||||
entry_count: 1,
|
||||
message: "Entry added.",
|
||||
}),
|
||||
} as unknown as MemoryStore;
|
||||
|
||||
registerMemoryTool(
|
||||
mockPi,
|
||||
{} as MemoryStore,
|
||||
mockProjectStore,
|
||||
dbManager,
|
||||
"factory-project",
|
||||
(cwd) => {
|
||||
if (cwd) boundCwds.push(cwd);
|
||||
},
|
||||
);
|
||||
|
||||
await capturedResult.execute(
|
||||
"tc-1",
|
||||
{ target: "project", content: "from session cwd" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: "/tmp/opened-session-project" },
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(boundCwds, ["/tmp/opened-session-project"]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { registerSessionSearchTool } from "../../src/tools/session-search-tool.js";
|
||||
import { DatabaseManager } from "../../src/store/db.js";
|
||||
import { indexSession } from "../../src/store/session-indexer.js";
|
||||
|
||||
let ROOT_DIR = "";
|
||||
|
||||
afterEach(() => {
|
||||
if (ROOT_DIR) fs.rmSync(ROOT_DIR, { recursive: true, force: true });
|
||||
ROOT_DIR = "";
|
||||
});
|
||||
|
||||
function makeSessionsDir(): string {
|
||||
ROOT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pi-session-search-tool-test-"));
|
||||
return ROOT_DIR;
|
||||
}
|
||||
|
||||
describe("registerSessionSearchTool", () => {
|
||||
it("registers the legacy query schema by default", () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
registerSessionSearchTool(mockPi, {} as any);
|
||||
|
||||
const schema = JSON.stringify(captured.parameters);
|
||||
assert.strictEqual(captured.name, "session_search");
|
||||
assert.match(schema, /query/);
|
||||
assert.doesNotMatch(schema, /markdown/);
|
||||
assert.match(schema, /"minimum":1/);
|
||||
assert.match(schema, /"maximum":20/);
|
||||
});
|
||||
|
||||
it("clamps negative and fractional legacy limits before querying", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
const memoryDir = makeSessionsDir();
|
||||
const dbManager = new DatabaseManager(memoryDir);
|
||||
|
||||
try {
|
||||
indexSession(dbManager, {
|
||||
id: "bounded-limit-session",
|
||||
project: "bounded-project",
|
||||
cwd: "/work/bounded",
|
||||
startedAt: "2026-07-11T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
messages: Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `bounded-limit-message-${index}`,
|
||||
role: "assistant",
|
||||
content: `bounded-limit-needle ${index}`,
|
||||
timestamp: `2026-07-11T00:${String(index).padStart(2, "0")}:00.000Z`,
|
||||
})),
|
||||
});
|
||||
registerSessionSearchTool(mockPi, dbManager);
|
||||
|
||||
const negative = await captured.execute("tc-negative-limit", {
|
||||
query: "bounded-limit-needle",
|
||||
limit: -1,
|
||||
});
|
||||
const fractional = await captured.execute("tc-fractional-limit", {
|
||||
query: "bounded-limit-needle",
|
||||
limit: 2.9,
|
||||
});
|
||||
|
||||
assert.strictEqual(negative.details.count, 1);
|
||||
assert.strictEqual(fractional.details.count, 2);
|
||||
assert.ok(negative.content[0].text.length < 2_000);
|
||||
assert.ok(fractional.content[0].text.length < 4_000);
|
||||
} finally {
|
||||
dbManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps non-finite snippetChars values instead of propagating NaN", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
const memoryDir = makeSessionsDir();
|
||||
const dbManager = new DatabaseManager(memoryDir);
|
||||
|
||||
try {
|
||||
indexSession(dbManager, {
|
||||
id: "nan-snippet-session",
|
||||
project: "nan-snippet-project",
|
||||
cwd: "/work/nan-snippet",
|
||||
startedAt: "2026-07-11T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
messages: [{
|
||||
id: "nan-snippet-message",
|
||||
role: "assistant",
|
||||
content: `needle ${"a".repeat(500)}`,
|
||||
timestamp: "2026-07-11T00:01:00.000Z",
|
||||
}],
|
||||
});
|
||||
registerSessionSearchTool(mockPi, dbManager);
|
||||
|
||||
const nanResult = await captured.execute("tc-nan-snippet", {
|
||||
query: "needle",
|
||||
snippetChars: NaN,
|
||||
});
|
||||
const infinityResult = await captured.execute("tc-infinity-snippet", {
|
||||
query: "needle",
|
||||
snippetChars: Infinity,
|
||||
});
|
||||
const largeResult = await captured.execute("tc-large-snippet", {
|
||||
query: "needle",
|
||||
snippetChars: 999_999_999,
|
||||
});
|
||||
|
||||
assert.strictEqual(nanResult.details.snippetChars, 1_200);
|
||||
assert.strictEqual(infinityResult.details.snippetChars, 1_200);
|
||||
assert.strictEqual(largeResult.details.snippetChars, 4_000);
|
||||
assert.ok(Number.isFinite(nanResult.details.snippetChars));
|
||||
assert.match(nanResult.content[0].text, /needle a+/);
|
||||
} finally {
|
||||
dbManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds oversized legacy results and reports truncation without duplicating output in details", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
const memoryDir = makeSessionsDir();
|
||||
const dbManager = new DatabaseManager(memoryDir);
|
||||
const oversizedContent = `needle ${"x".repeat(6_000_000)}`;
|
||||
|
||||
try {
|
||||
// Seed a pre-cap database row directly. New ingestion paths must cap
|
||||
// message content, but search output still needs to stay bounded for
|
||||
// oversized rows written by older extension versions.
|
||||
const db = dbManager.getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"oversized-session",
|
||||
"oversized-project",
|
||||
"/work/oversized",
|
||||
"2026-07-11T00:00:00.000Z",
|
||||
null,
|
||||
1,
|
||||
);
|
||||
db.prepare(`
|
||||
INSERT INTO messages (id, session_id, role, content, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"oversized-message",
|
||||
"oversized-session",
|
||||
"assistant",
|
||||
oversizedContent,
|
||||
"2026-07-11T00:01:00.000Z",
|
||||
);
|
||||
registerSessionSearchTool(mockPi, dbManager);
|
||||
|
||||
const result = await captured.execute("tc-oversized", { query: "needle" });
|
||||
const output = result.content[0].text as string;
|
||||
|
||||
assert.ok(output.length <= 50 * 1024, `expected <= 50 KiB, got ${output.length}`);
|
||||
assert.match(output, /truncated/);
|
||||
assert.match(output, /6000007 chars total/);
|
||||
assert.strictEqual(result.details.truncatedCount, 1);
|
||||
assert.strictEqual(result.details.outputChars, output.length);
|
||||
assert.strictEqual(result.details.output, undefined);
|
||||
assert.ok(JSON.stringify(result.details).length < 1_000);
|
||||
} finally {
|
||||
dbManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a bounded snippetChars override for legacy searches", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
const memoryDir = makeSessionsDir();
|
||||
const dbManager = new DatabaseManager(memoryDir);
|
||||
|
||||
try {
|
||||
indexSession(dbManager, {
|
||||
id: "bounded-override-session",
|
||||
project: "bounded-project",
|
||||
cwd: "/work/bounded",
|
||||
startedAt: "2026-07-11T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
messages: [{
|
||||
id: "bounded-override-message",
|
||||
role: "assistant",
|
||||
content: `needle ${"y".repeat(10_000)}`,
|
||||
timestamp: "2026-07-11T00:01:00.000Z",
|
||||
}],
|
||||
});
|
||||
registerSessionSearchTool(mockPi, dbManager);
|
||||
|
||||
assert.match(JSON.stringify(captured.parameters), /snippetChars/);
|
||||
const result = await captured.execute("tc-bounded-override", {
|
||||
query: "needle",
|
||||
snippetChars: 2_000,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.details.snippetChars, 2_000);
|
||||
assert.strictEqual(result.details.truncatedCount, 1);
|
||||
assert.match(result.content[0].text, /10007 chars total/);
|
||||
assert.ok(result.content[0].text.length < 3_000);
|
||||
} finally {
|
||||
dbManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces a hard 50 KiB ceiling across many large legacy results", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
const memoryDir = makeSessionsDir();
|
||||
const dbManager = new DatabaseManager(memoryDir);
|
||||
|
||||
try {
|
||||
indexSession(dbManager, {
|
||||
id: "aggregate-ceiling-session",
|
||||
project: "aggregate-project",
|
||||
cwd: "/work/aggregate",
|
||||
startedAt: "2026-07-11T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
messages: Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `aggregate-message-${index}`,
|
||||
role: "assistant",
|
||||
content: `needle-${index} ${"z".repeat(10_000)}`,
|
||||
timestamp: `2026-07-11T00:${String(index).padStart(2, "0")}:00.000Z`,
|
||||
})),
|
||||
});
|
||||
registerSessionSearchTool(mockPi, dbManager);
|
||||
|
||||
const result = await captured.execute("tc-aggregate-ceiling", {
|
||||
query: "needle",
|
||||
limit: 20,
|
||||
snippetChars: 4_000,
|
||||
});
|
||||
const output = result.content[0].text as string;
|
||||
|
||||
assert.ok(output.length <= 50 * 1024, `expected <= 50 KiB, got ${output.length}`);
|
||||
assert.strictEqual(result.details.outputTruncated, true);
|
||||
assert.match(output, /output truncated/);
|
||||
assert.match(output, /refine the query or lower the result limit/);
|
||||
} finally {
|
||||
dbManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds the zero-result response without echoing an oversized query", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
const memoryDir = makeSessionsDir();
|
||||
const dbManager = new DatabaseManager(memoryDir);
|
||||
|
||||
try {
|
||||
indexSession(dbManager, {
|
||||
id: "zero-result-session",
|
||||
project: "zero-result-project",
|
||||
cwd: "/work/zero-result",
|
||||
startedAt: "2026-07-11T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
messages: [{
|
||||
id: "zero-result-message",
|
||||
role: "assistant",
|
||||
content: "indexed haystack",
|
||||
timestamp: "2026-07-11T00:01:00.000Z",
|
||||
}],
|
||||
});
|
||||
registerSessionSearchTool(mockPi, dbManager);
|
||||
const query = `${" ".repeat(60_000)}missing`;
|
||||
|
||||
const result = await captured.execute("tc-zero-result", { query });
|
||||
const output = result.content[0].text as string;
|
||||
|
||||
assert.strictEqual(result.details.count, 0);
|
||||
assert.ok(output.length <= 50 * 1024, `expected <= 50 KiB, got ${output.length}`);
|
||||
assert.strictEqual(output.includes(query), false);
|
||||
assert.ok(JSON.stringify(result.details).length < 1_000);
|
||||
} finally {
|
||||
dbManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("registers and executes the anchor markdown-only schema when configured", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
const sessionsDir = makeSessionsDir();
|
||||
const filePath = path.join(sessionsDir, "session.jsonl");
|
||||
fs.writeFileSync(filePath, `${JSON.stringify({
|
||||
type: "message",
|
||||
timestamp: "2026-05-15T10:00:00.000Z",
|
||||
sessionId: "session-1",
|
||||
cwd: "/work/project",
|
||||
message: { role: "user", content: "needle" },
|
||||
})}\n`);
|
||||
|
||||
registerSessionSearchTool(mockPi, {} as any, { variant: "anchors" }, { sessionsDir });
|
||||
|
||||
const schema = JSON.stringify(captured.parameters);
|
||||
assert.strictEqual(captured.name, "session_search");
|
||||
assert.match(schema, /markdown/);
|
||||
assert.doesNotMatch(schema, /query/);
|
||||
assert.match(captured.description, /all terms must match/);
|
||||
assert.match(captured.description, /any requires at least one listed term/);
|
||||
assert.match(captured.description, /exclude removes matching ranges/);
|
||||
assert.match(captured.description, /Output is plain text: count, optional message/);
|
||||
assert.match(captured.description, /path:startLine-endLine with a short reason/);
|
||||
assert.match(captured.description, /Example:\nfrom: 2026-05-14/);
|
||||
assert.match(captured.promptGuidelines.join("\n"), /Use all for required terms/);
|
||||
|
||||
const empty = await captured.execute("tc-1", { markdown: "" });
|
||||
assert.strictEqual(empty.details.success, false);
|
||||
assert.strictEqual(empty.details.message, "markdown is required");
|
||||
|
||||
const result = await captured.execute("tc-2", { markdown: "any:\n- needle" });
|
||||
assert.strictEqual(result.details.success, true);
|
||||
assert.strictEqual(result.details.count, 1);
|
||||
assert.deepStrictEqual(result.details.ranges.map((range: any) => ({
|
||||
path: range.path,
|
||||
startLine: range.startLine,
|
||||
endLine: range.endLine,
|
||||
reason: range.reason,
|
||||
})), [{ path: filePath, startLine: 1, endLine: 1, reason: "matched any: needle" }]);
|
||||
assert.strictEqual(result.details.output, result.content[0].text);
|
||||
assert.match(result.content[0].text, /^count: 1\nanchors:\n-/);
|
||||
assert.match(result.content[0].text, new RegExp(`${filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:1-1 — matched any: needle`));
|
||||
assert.doesNotMatch(result.content[0].text, /"ranges"/);
|
||||
assert.doesNotMatch(result.content[0].text, /"startLine"/);
|
||||
assert.doesNotMatch(result.content[0].text, /"sessionId"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { describe, it } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import { initTheme } from "@earendil-works/pi-coding-agent";
|
||||
import { Box, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
createSharedToolResultRenderer,
|
||||
type SharedOutputView,
|
||||
} from "../../src/tools/shared-output-view.js";
|
||||
import {
|
||||
memoryResultView,
|
||||
searchResultView,
|
||||
skillResultView,
|
||||
} from "../../src/tools/tool-result-views.js";
|
||||
|
||||
initTheme();
|
||||
|
||||
function fakeTheme() {
|
||||
return {
|
||||
fg: (_color: string, text: string): string => text,
|
||||
getBgAnsi: (_color: string): string => "",
|
||||
} as any;
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-?]*[ -/]*m/g, "");
|
||||
}
|
||||
|
||||
function renderPlain(component: Component, width = 120): string {
|
||||
return component.render(width).map((row) => stripAnsi(row).trimEnd()).join("\n");
|
||||
}
|
||||
|
||||
function result(text: string, details: Record<string, unknown> = { success: true }) {
|
||||
return { content: [{ type: "text", text }], details };
|
||||
}
|
||||
|
||||
function renderToolResult(
|
||||
toolResult: ReturnType<typeof result>,
|
||||
expanded: boolean,
|
||||
width = 120,
|
||||
): string {
|
||||
return renderPlain(
|
||||
createSharedToolResultRenderer()(
|
||||
toolResult,
|
||||
{ expanded, isPartial: false },
|
||||
fakeTheme(),
|
||||
{ isError: false },
|
||||
),
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
function renderView(view: SharedOutputView, expanded: boolean, width = 120): string {
|
||||
return renderPlain(
|
||||
createSharedToolResultRenderer(() => view)(
|
||||
result(view.expandedText),
|
||||
{ expanded, isPartial: false },
|
||||
fakeTheme(),
|
||||
{ isError: false },
|
||||
),
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
function assertRowKeepsBackground(row: string): void {
|
||||
let backgroundActive = false;
|
||||
for (let index = 0; index < row.length;) {
|
||||
const sgr = row.slice(index).match(/^\x1b\[([0-9;]*)m/);
|
||||
if (sgr) {
|
||||
const parameters = sgr[1];
|
||||
if (parameters === "" || parameters === "0" || parameters === "49") {
|
||||
backgroundActive = false;
|
||||
} else if (parameters.split(";")[0] === "48") {
|
||||
backgroundActive = true;
|
||||
}
|
||||
index += sgr[0].length;
|
||||
continue;
|
||||
}
|
||||
assert.equal(backgroundActive, true, `background cleared before ${JSON.stringify(row.slice(index))}`);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
describe("shared tool-result view", () => {
|
||||
it("collapses to one concise line and expands to the full bounded result", () => {
|
||||
const toolResult = result("Found 2 memories matching auth:\n\nfirst\nsecond", {
|
||||
success: true,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
const collapsed = renderToolResult(toolResult, false);
|
||||
assert.equal(collapsed.split("\n").length, 1);
|
||||
assert.match(collapsed, /Found 2 memories matching auth/);
|
||||
assert.doesNotMatch(collapsed, /second/);
|
||||
assert.match(collapsed, /expand/);
|
||||
|
||||
const expanded = renderToolResult(toolResult, true);
|
||||
assert.equal(expanded, toolResult.content[0].text);
|
||||
});
|
||||
|
||||
it("sanitizes only the rendered copy of hostile tool text and failure details", () => {
|
||||
const hostileText = [
|
||||
"safe 世界 \x1b]52;c;YXR0YWNr\x07 after OSC",
|
||||
"styled \x1b[31mred\x1b[0m moved \x1b[2J after CSI",
|
||||
"private \x1bPdanger\x1b\\ after DCS",
|
||||
"linked \x1b]8;;https://evil.example\x1b\\label\x1b]8;;\x1b\\ after ST",
|
||||
"carriage\rreturn binary\x00\x08\x7f end",
|
||||
].join("\n");
|
||||
const hostileReason = "query \x1b]52;c;YXR0YWNr\x07 is required\x00";
|
||||
const toolResult = result(hostileText, {
|
||||
success: false,
|
||||
message: hostileReason,
|
||||
nested: { keep: true },
|
||||
});
|
||||
const before = structuredClone(toolResult);
|
||||
|
||||
const collapsed = renderToolResult(toolResult, false);
|
||||
const expanded = renderToolResult(toolResult, true);
|
||||
|
||||
for (const rendered of [collapsed, expanded]) {
|
||||
assert.doesNotMatch(rendered, /\x1b|\x07|\x00|\x08|\x7f|\r/);
|
||||
}
|
||||
assert.match(collapsed, /query .* is required/);
|
||||
assert.match(expanded, /safe 世界/);
|
||||
assert.match(expanded, /styled red moved after CSI/);
|
||||
assert.match(expanded, /private danger\\ after DCS/);
|
||||
assert.match(expanded, /linked label after ST/);
|
||||
assert.match(expanded, /carriagereturn binary end/);
|
||||
assert.deepEqual(toolResult, before);
|
||||
|
||||
const adapterResult = result("safe", {
|
||||
success: true,
|
||||
message: "Entry added.",
|
||||
target: "memory\x1b[2J",
|
||||
usage: "1 entry\x00",
|
||||
});
|
||||
const adapterBefore = structuredClone(adapterResult);
|
||||
const adapterRendered = renderPlain(createSharedToolResultRenderer(memoryResultView)(
|
||||
adapterResult,
|
||||
{ expanded: false, isPartial: false },
|
||||
fakeTheme(),
|
||||
{ isError: false },
|
||||
));
|
||||
assert.doesNotMatch(adapterRendered, /\x1b|\x00/);
|
||||
assert.deepEqual(adapterResult, adapterBefore);
|
||||
});
|
||||
|
||||
it("does not mutate model-visible content or details", () => {
|
||||
const toolResult = result("immutable\nfull output", {
|
||||
success: true,
|
||||
nested: { keep: true },
|
||||
});
|
||||
const before = structuredClone(toolResult);
|
||||
|
||||
renderToolResult(toolResult, false);
|
||||
renderToolResult(toolResult, true);
|
||||
|
||||
assert.deepEqual(toolResult, before);
|
||||
});
|
||||
|
||||
it("keeps textual failure and warning reasons in collapsed output", () => {
|
||||
const failure = renderToolResult(
|
||||
result("query is required", { success: false, message: "query is required" }),
|
||||
false,
|
||||
);
|
||||
assert.match(failure, /query is required/);
|
||||
|
||||
const warningView = memoryResultView(result(JSON.stringify({
|
||||
success: true,
|
||||
message: "Entry added. Warning: SQLite mirror unavailable",
|
||||
warning: "SQLite mirror unavailable",
|
||||
warnings: ["SQLite mirror unavailable"],
|
||||
target: "memory",
|
||||
entry_count: 1,
|
||||
}), {
|
||||
success: true,
|
||||
message: "Entry added. Warning: SQLite mirror unavailable",
|
||||
warning: "SQLite mirror unavailable",
|
||||
warnings: ["SQLite mirror unavailable"],
|
||||
target: "memory",
|
||||
entry_count: 1,
|
||||
}));
|
||||
assert.match(renderView(warningView, false), /SQLite mirror unavailable/);
|
||||
});
|
||||
|
||||
it("fits ANSI, CJK, and partial rows while preserving the outer tool-card background", () => {
|
||||
const width = 34;
|
||||
const backgroundOpen = "\x1b[48;2;40;50;40m";
|
||||
const ansiTheme = {
|
||||
fg: (_color: string, text: string): string => `\x1b[38;2;145;148;145m${text}\x1b[39m`,
|
||||
getBgAnsi: (_color: string): string => backgroundOpen,
|
||||
} as any;
|
||||
const box = new Box(1, 1, (text: string) => `${backgroundOpen}${text}\x1b[49m`);
|
||||
box.addChild(createSharedToolResultRenderer()(
|
||||
result("处理中 世界世界世界世界 \x1b[1mvery long partial output\x1b[0m"),
|
||||
{ expanded: false, isPartial: true },
|
||||
ansiTheme,
|
||||
{ isError: false },
|
||||
));
|
||||
|
||||
const rows = box.render(width);
|
||||
assert.equal(rows.length, 3);
|
||||
assert.equal(visibleWidth(rows[1]), width);
|
||||
assert.match(stripAnsi(rows[1]), /处理中|In progress/);
|
||||
|
||||
assertRowKeepsBackground(rows[1]);
|
||||
});
|
||||
|
||||
it("keeps the tool-card background after sanitizing expanded ANSI and CJK text", () => {
|
||||
const width = 18;
|
||||
const backgroundOpen = "\x1b[48;2;40;50;40m";
|
||||
const ansiTheme = {
|
||||
fg: (_color: string, text: string): string => text,
|
||||
getBgAnsi: (_color: string): string => backgroundOpen,
|
||||
} as any;
|
||||
const expandedText = "第一行 世界世界世界\x1b[0m继续\n第二行 \x1b[49m背景保留";
|
||||
const box = new Box(1, 1, (text: string) => `${backgroundOpen}${text}\x1b[49m`);
|
||||
box.addChild(createSharedToolResultRenderer()(
|
||||
result(expandedText),
|
||||
{ expanded: true, isPartial: false },
|
||||
ansiTheme,
|
||||
{ isError: false },
|
||||
));
|
||||
|
||||
const rows = box.render(width);
|
||||
assert.ok(rows.length > 4);
|
||||
for (const row of rows) {
|
||||
assert.equal(visibleWidth(row), width);
|
||||
assertRowKeepsBackground(row);
|
||||
}
|
||||
const expandedRows = rows.slice(1, -1).map((row) => stripAnsi(row).trim());
|
||||
assert.deepEqual(expandedRows, ["第一行 世界世界", "世界继续", "第二行 背景保留"]);
|
||||
});
|
||||
|
||||
it("uses Pi shell state for the card background and logical status for foreground", () => {
|
||||
const scenarios = [
|
||||
{ partial: true, isError: false, success: false, background: "toolPendingBg", foreground: "warning" },
|
||||
{ partial: false, isError: true, success: true, background: "toolErrorBg", foreground: "error" },
|
||||
{ partial: false, isError: false, success: true, background: "toolSuccessBg", foreground: "toolOutput" },
|
||||
{ partial: false, isError: false, success: false, background: "toolSuccessBg", foreground: "error" },
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const backgrounds: string[] = [];
|
||||
const foregrounds: string[] = [];
|
||||
const theme = {
|
||||
fg: (color: string, text: string): string => {
|
||||
foregrounds.push(color);
|
||||
return text;
|
||||
},
|
||||
getBgAnsi: (color: string): string => {
|
||||
backgrounds.push(color);
|
||||
return "";
|
||||
},
|
||||
} as any;
|
||||
const message = scenario.success ? "done" : "query is required";
|
||||
const toolResult = result(message, { success: scenario.success, message });
|
||||
|
||||
const renderer = createSharedToolResultRenderer();
|
||||
const rendered = renderPlain(renderer(
|
||||
toolResult,
|
||||
{ expanded: false, isPartial: scenario.partial },
|
||||
theme,
|
||||
{ isError: scenario.isError },
|
||||
));
|
||||
renderPlain(renderer(
|
||||
toolResult,
|
||||
{ expanded: true, isPartial: scenario.partial },
|
||||
theme,
|
||||
{ isError: scenario.isError },
|
||||
));
|
||||
|
||||
assert.deepEqual(backgrounds, [scenario.background, scenario.background]);
|
||||
assert.deepEqual(foregrounds, [scenario.foreground]);
|
||||
if (!scenario.success) assert.match(rendered, /query is required/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-specific summaries", () => {
|
||||
it("summarizes memory, search/session, and skill results without changing expanded text", () => {
|
||||
const memoryText = JSON.stringify({
|
||||
success: true,
|
||||
message: "Entry added.",
|
||||
target: "failure",
|
||||
category: "tool-quirk",
|
||||
entry_count: 2,
|
||||
usage: "120 / 5,000 chars",
|
||||
});
|
||||
const memory = memoryResultView(result(memoryText, JSON.parse(memoryText)));
|
||||
assert.match(memory.summary, /Saved/);
|
||||
assert.match(memory.summary, /target: failure/);
|
||||
assert.match(memory.summary, /category: tool-quirk/);
|
||||
assert.equal(memory.expandedText, memoryText);
|
||||
|
||||
const searchText = "Found 3 memories matching auth:\n\nfirst\nsecond\nthird";
|
||||
const search = searchResultView(result(searchText, { success: true, count: 3 }));
|
||||
assert.match(search.summary, /3/);
|
||||
assert.equal(search.expandedText, searchText);
|
||||
|
||||
const skillText = JSON.stringify({ success: true, skillId: "global:deploy", name: "deploy" });
|
||||
const skill = skillResultView(result(skillText, JSON.parse(skillText)));
|
||||
assert.match(skill.summary, /deploy/i);
|
||||
assert.equal(skill.expandedText, skillText);
|
||||
});
|
||||
|
||||
it("renders a real skill-tool JSON failure as an actionable failure", () => {
|
||||
const error = "Skill 'global:missing' not found.";
|
||||
const skillText = JSON.stringify({ success: false, error });
|
||||
const toolResult = result(skillText, {});
|
||||
const skill = skillResultView(toolResult);
|
||||
|
||||
assert.equal(skill.status, "failure");
|
||||
assert.equal(skill.summary, `Error · ${error}`);
|
||||
assert.equal(skill.expandedText, skillText);
|
||||
assert.match(renderPlain(
|
||||
createSharedToolResultRenderer(skillResultView)(
|
||||
toolResult,
|
||||
{ expanded: false, isPartial: false },
|
||||
fakeTheme(),
|
||||
{ isError: false },
|
||||
),
|
||||
), new RegExp(error.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
});
|
||||
|
||||
it("summarizes successful failure memory from the real producer result shape", () => {
|
||||
// MemoryResult has no category field; producers encode it only in message.
|
||||
const details = {
|
||||
success: true,
|
||||
target: "failure",
|
||||
message: "Failure memory saved: tool-quirk",
|
||||
entry_count: 16,
|
||||
usage: "92% — 9271/10000 chars",
|
||||
};
|
||||
const fullText = JSON.stringify(details);
|
||||
const toolResult = result(fullText, details);
|
||||
const before = structuredClone(toolResult);
|
||||
const view = memoryResultView(toolResult);
|
||||
|
||||
assert.equal(view.status, "success");
|
||||
assert.equal(
|
||||
view.summary,
|
||||
"Saved · target: failure · category: tool-quirk · 16 entries · 92% — 9271/10000 chars",
|
||||
);
|
||||
assert.doesNotMatch(view.summary, /^failure:/);
|
||||
assert.equal(view.expandedText, fullText);
|
||||
assert.deepEqual(toolResult, before);
|
||||
|
||||
const collapsed = renderPlain(
|
||||
createSharedToolResultRenderer(memoryResultView)(
|
||||
toolResult,
|
||||
{ expanded: false, isPartial: false },
|
||||
fakeTheme(),
|
||||
{ isError: false },
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
collapsed,
|
||||
/Saved · target: failure · category: tool-quirk · 16 entries · 92% — 9271\/10000 chars/,
|
||||
);
|
||||
assert.doesNotMatch(collapsed, /^failure:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Unit tests for skill tool registration and execute function.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import { registerSkillTool, SKILL_MANAGE_TOOL_NAME } from "../../src/tools/skill-tool.js";
|
||||
import { SkillStore } from "../../src/store/skill-store.js";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import * as fs from "node:fs/promises";
|
||||
|
||||
let ROOT_DIR = "";
|
||||
let GLOBAL_SKILLS_DIR = "";
|
||||
let PROJECT_SKILLS_DIR = "";
|
||||
|
||||
async function makeStore(withProject = true): Promise<SkillStore> {
|
||||
ROOT_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "pi-skill-tool-test-"));
|
||||
GLOBAL_SKILLS_DIR = path.join(ROOT_DIR, "global-skills");
|
||||
PROJECT_SKILLS_DIR = path.join(ROOT_DIR, "project-skills");
|
||||
await fs.mkdir(GLOBAL_SKILLS_DIR, { recursive: true });
|
||||
if (withProject) await fs.mkdir(PROJECT_SKILLS_DIR, { recursive: true });
|
||||
|
||||
return new SkillStore({
|
||||
globalSkillsDir: GLOBAL_SKILLS_DIR,
|
||||
projectSkillsDir: withProject ? PROJECT_SKILLS_DIR : null,
|
||||
projectName: withProject ? "demo-project" : null,
|
||||
legacySkillsDir: path.join(ROOT_DIR, "legacy-skills"),
|
||||
migrationSentinelPath: path.join(ROOT_DIR, ".skill-migration"),
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
try {
|
||||
await fs.rm(ROOT_DIR, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
describe("registerSkillTool", () => {
|
||||
it("registers tool with name 'skill_manage'", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
await cleanup();
|
||||
|
||||
assert.strictEqual(captured.name, SKILL_MANAGE_TOOL_NAME);
|
||||
assert.strictEqual(captured.label, "Skill Manager");
|
||||
assert.ok(captured.description.length > 0);
|
||||
assert.ok(captured.promptSnippet.length > 0);
|
||||
assert.ok(Array.isArray(captured.promptGuidelines));
|
||||
assert.ok(captured.parameters);
|
||||
});
|
||||
|
||||
it("create requires name, description, a body or structured fields, and scope", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
let result = await captured.execute("tc-1", { action: "create", description: "desc", content: "body" }, undefined, undefined, undefined);
|
||||
assert.strictEqual(JSON.parse(result.content[0].text).success, false);
|
||||
|
||||
result = await captured.execute("tc-1", { action: "create", name: "test", content: "body" }, undefined, undefined, undefined);
|
||||
assert.strictEqual(JSON.parse(result.content[0].text).success, false);
|
||||
|
||||
result = await captured.execute("tc-1", { action: "create", name: "test", description: "desc" }, undefined, undefined, undefined);
|
||||
const missingBody = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(missingBody.success, false);
|
||||
assert.match(missingBody.error, /Either content or structured fields are required/i);
|
||||
|
||||
result = await captured.execute("tc-1", {
|
||||
action: "create",
|
||||
name: "test",
|
||||
description: "desc",
|
||||
content: "body",
|
||||
}, undefined, undefined, undefined);
|
||||
const missingScope = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(missingScope.success, false);
|
||||
assert.match(missingScope.error, /scope is required/i);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("create succeeds with raw markdown content and returns skill_id", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "create",
|
||||
name: "test-skill",
|
||||
description: "A test skill",
|
||||
scope: "global",
|
||||
content: "## Procedure\n1. Do it",
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
assert.strictEqual(parsed.skillId, "global:test-skill");
|
||||
assert.strictEqual(parsed.scope, "global");
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("create builds a valid skill body from structured fields", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "create",
|
||||
name: "debug-typescript-errors",
|
||||
description: "Debug TypeScript build failures in this repo",
|
||||
scope: "project",
|
||||
when_to_use: "Use when the repo's TypeScript build fails locally or in CI.",
|
||||
procedure_steps: [
|
||||
"Run pnpm tsc --noEmit to get the full error list.",
|
||||
"Fix dependency and config errors before leaf-module errors.",
|
||||
],
|
||||
pitfalls: [
|
||||
"Do not rely on editor-only diagnostics.",
|
||||
],
|
||||
verification_steps: [
|
||||
"pnpm tsc --noEmit exits successfully.",
|
||||
],
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
|
||||
const created = await store.loadSkill(parsed.skillId);
|
||||
assert.match(created?.body || "", /## When to Use/);
|
||||
assert.match(created?.body || "", /## Procedure/);
|
||||
assert.match(created?.body || "", /1\. Run pnpm tsc --noEmit/);
|
||||
assert.match(created?.body || "", /## Pitfalls/);
|
||||
assert.match(created?.body || "", /## Verification/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("create supports explicit project scope", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "create",
|
||||
name: "release-app",
|
||||
description: "Release this app",
|
||||
scope: "project",
|
||||
content: "## Procedure\n1. Run pnpm build",
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
assert.strictEqual(parsed.skillId, "project:demo-project:release-app");
|
||||
assert.strictEqual(parsed.scope, "project");
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("view without skill_id lists all skills", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
await store.create("skill-a", "First", "body a");
|
||||
await store.create("skill-b", "Second", "body b", "project");
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", { action: "view" }, undefined, undefined, undefined);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
assert.strictEqual(parsed.skills.length, 2);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("view with skill_id returns full document", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
const created = await store.create("my-skill", "A skill", "## Body content here");
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", { action: "view", skill_id: created.skillId }, undefined, undefined, undefined);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
assert.strictEqual(parsed.name, "my-skill");
|
||||
assert.ok(parsed.body.includes("## Body content here"));
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("view with invalid skill_id returns error", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", { action: "view", skill_id: "global:missing" }, undefined, undefined, undefined);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, false);
|
||||
assert.ok(parsed.error.includes("not found"));
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("patch requires skill_id, section, and content or structured fields", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
let result = await captured.execute("tc-1", { action: "patch", section: "Procedure", content: "new" }, undefined, undefined, undefined);
|
||||
assert.strictEqual(JSON.parse(result.content[0].text).success, false);
|
||||
|
||||
result = await captured.execute("tc-1", { action: "patch", skill_id: "global:test", content: "new" }, undefined, undefined, undefined);
|
||||
assert.strictEqual(JSON.parse(result.content[0].text).success, false);
|
||||
|
||||
result = await captured.execute("tc-1", { action: "patch", skill_id: "global:test", section: "Procedure" }, undefined, undefined, undefined);
|
||||
assert.strictEqual(JSON.parse(result.content[0].text).success, false);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("patch accepts structured procedure_steps for a section update", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
const created = await store.create(
|
||||
"patch-me",
|
||||
"desc",
|
||||
"## Procedure\n1. Old way\n\n## Pitfalls\n- Keep me\n\n## Verification\n1. Still works",
|
||||
);
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "patch",
|
||||
skill_id: created.skillId,
|
||||
section: "Procedure",
|
||||
procedure_steps: ["Do the new first step", "Do the new second step"],
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
|
||||
const doc = await store.loadSkill(created.skillId!);
|
||||
assert.match(doc!.body, /## Procedure\n1\. Do the new first step\n2\. Do the new second step/);
|
||||
assert.match(doc!.body, /## Pitfalls\n- Keep me/);
|
||||
assert.match(doc!.body, /## Verification\n1\. Still works/);
|
||||
assert.doesNotMatch(doc!.body, /Old way/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("patch rejects JSON object content strings", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
const created = await store.create("patch-me", "desc", "## Procedure\n1. Keep");
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "patch",
|
||||
skill_id: created.skillId,
|
||||
section: "Procedure",
|
||||
content: '{"steps":["bad"]}',
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, false);
|
||||
assert.match(parsed.error, /JSON object/i);
|
||||
assert.equal((await store.loadSkill(created.skillId!))!.version, 1);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("patch coerces JSON array content strings into Markdown lists", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
const created = await store.create("patch-me", "desc", "## Pitfalls\n- old");
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "patch",
|
||||
skill_id: created.skillId,
|
||||
section: "Pitfalls",
|
||||
content: '["Do not skip verification", "Do not wipe other sections"]',
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
|
||||
const doc = await store.loadSkill(created.skillId!);
|
||||
assert.match(doc!.body, /## Pitfalls\n- Do not skip verification\n- Do not wipe other sections/);
|
||||
assert.doesNotMatch(doc!.body, /\[\"Do not skip verification\"/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("update requires skill_id", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", { action: "update", description: "new desc" }, undefined, undefined, undefined);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, false);
|
||||
assert.ok(parsed.error.includes("skill_id"));
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("update aliases to full skill rewrite", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
const created = await store.create("my-skill", "Old desc", "## Old body", "global");
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "update",
|
||||
skill_id: created.skillId,
|
||||
description: "New desc",
|
||||
content: "## New body",
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
|
||||
const updated = await store.loadSkill(created.skillId!);
|
||||
assert.strictEqual(updated?.description, "New desc");
|
||||
assert.match(updated?.body || "", /New body/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("update can rebuild the body from structured fields", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
const created = await store.create("my-skill", "Old desc", "## Old body", "global");
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "update",
|
||||
skill_id: created.skillId,
|
||||
description: "New desc",
|
||||
when_to_use: "Use when validating the new rewrite path.",
|
||||
procedure_steps: ["Perform the new sequence."],
|
||||
verification_steps: ["Confirm the new sequence works."],
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
|
||||
const updated = await store.loadSkill(created.skillId!);
|
||||
assert.match(updated?.body || "", /## When to Use/);
|
||||
assert.match(updated?.body || "", /Perform the new sequence/);
|
||||
assert.match(updated?.body || "", /No notable pitfalls recorded yet/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("legacy edit alias still rewrites the skill", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
const created = await store.create("legacy-skill", "Old desc", "## Old body", "global");
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", {
|
||||
action: "edit",
|
||||
skill_id: created.skillId,
|
||||
description: "Legacy desc",
|
||||
content: "## Legacy body",
|
||||
}, undefined, undefined, undefined);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, true);
|
||||
|
||||
const updated = await store.loadSkill(created.skillId!);
|
||||
assert.strictEqual(updated?.description, "Legacy desc");
|
||||
assert.match(updated?.body || "", /Legacy body/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("delete requires skill_id", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", { action: "delete" }, undefined, undefined, undefined);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, false);
|
||||
assert.ok(parsed.error.includes("skill_id"));
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("unknown action returns error", async () => {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const result = await captured.execute("tc-1", { action: "explode" }, undefined, undefined, undefined);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
assert.strictEqual(parsed.success, false);
|
||||
assert.ok(parsed.error.includes("Unknown action"));
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill discovery wording (issue #244)", () => {
|
||||
async function captureTool(): Promise<any> {
|
||||
let captured: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => { captured = def; },
|
||||
} as any;
|
||||
|
||||
const store = await makeStore();
|
||||
registerSkillTool(mockPi, store);
|
||||
return captured;
|
||||
}
|
||||
|
||||
it("description param carries the discovery requirement, when_to_use notes the index boundary", async () => {
|
||||
const captured = await captureTool();
|
||||
const props = captured.parameters.properties;
|
||||
|
||||
const descriptionHelp: string = props.description.description;
|
||||
assert.match(descriptionHelp, /Pi indexes skills by this field alone/);
|
||||
assert.match(descriptionHelp, /trigger signals/);
|
||||
assert.match(descriptionHelp, /1024/);
|
||||
|
||||
const whenToUseHelp: string = props.when_to_use.description;
|
||||
assert.match(whenToUseHelp, /does not participate in Pi's skill index/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("tool description states the mechanism and demonstrates it in the example", async () => {
|
||||
const captured = await captureTool();
|
||||
const text: string = captured.description;
|
||||
|
||||
assert.match(text, /Pi indexes skills by this field alone/);
|
||||
assert.match(text, /discoverable trigger signals belong in description/);
|
||||
assert.match(text, /"description": "Debug TypeScript build failures in this repo: tsc --noEmit errors/);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("prompt guidelines point trigger signals at description", async () => {
|
||||
const captured = await captureTool();
|
||||
const guidelines: string[] = captured.promptGuidelines;
|
||||
|
||||
assert.ok(
|
||||
guidelines.some((g) => /Put the trigger signals a user would type in 'description'/.test(g)),
|
||||
"expected a guideline directing trigger signals to description",
|
||||
);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import { registerMemoryTool } from "../../src/tools/memory-tool.js";
|
||||
import { registerMemorySearchTool } from "../../src/tools/memory-search-tool.js";
|
||||
import { registerSessionSearchTool } from "../../src/tools/session-search-tool.js";
|
||||
import { registerSkillTool } from "../../src/tools/skill-tool.js";
|
||||
|
||||
function capture(register: (pi: any) => void): any {
|
||||
let definition: any;
|
||||
register({ registerTool(value: any) { definition = value; } });
|
||||
return definition;
|
||||
}
|
||||
|
||||
describe("tool result renderer wiring", () => {
|
||||
it("registers renderResult on memory and memory_search", () => {
|
||||
const memory = capture((pi) => registerMemoryTool(pi, {} as any, null));
|
||||
const memorySearch = capture((pi) => registerMemorySearchTool(pi, {} as any));
|
||||
|
||||
assert.equal(typeof memory.renderResult, "function");
|
||||
assert.equal(typeof memorySearch.renderResult, "function");
|
||||
});
|
||||
|
||||
it("registers renderResult on both session_search variants", () => {
|
||||
const legacy = capture((pi) => registerSessionSearchTool(pi, {} as any));
|
||||
const anchors = capture((pi) => registerSessionSearchTool(
|
||||
pi,
|
||||
{} as any,
|
||||
{ variant: "anchors" },
|
||||
{ sessionsDir: "/path-independent-sessions" },
|
||||
));
|
||||
|
||||
assert.equal(typeof legacy.renderResult, "function");
|
||||
assert.equal(typeof anchors.renderResult, "function");
|
||||
});
|
||||
|
||||
it("registers renderResult on skill_manage", () => {
|
||||
const skill = capture((pi) => registerSkillTool(pi, {} as any));
|
||||
assert.equal(typeof skill.renderResult, "function");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user