mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
- 根包恢复 file:./pi-subagents 依赖与 extensions/subagents.ts 加载 (排在 permission-system 之后、Tool Search 之前),并刷新根锁文件; Agent / get_subagent_result / steer_subagent 权限设为 allow, 子 Agent 内的实际操作仍逐个经权限系统并转发主会话。 - 新增 config/prompt/subagent/:执行者角色 preamble 与组合包自带的 general-purpose / Explore / Plan 定义(替换上游内置,去掉 bash find 冲突规则与写死的 Haiku 模型)。 - pi-subagents 新增 basePrompt / bundledAgentsDir / excludedExtensionPaths 选项:子 Agent 不再嵌入父会话提示词,身份标记置于开头;子会话排除 pi-ask-user。未传选项时保持上游行为。 - system-prompt.ts:customPrompt 自带开场白时不再覆盖(修复子 Agent 角色 与 <active_agent> 身份被抹掉);仅凭 customPrompt 上的 <active_agent> 识别子会话,routing 规则支持 scope,sections 支持 subagentExclude (子会话去掉 memory_policy 与 chrome_profile_bridge)。 - pi-hermes-memory:子会话只读,记忆/skill 写入工具返回错误,后台整理、 纠错检测、会话 flush 与索引写入全部跳过。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
763 lines
29 KiB
TypeScript
763 lines
29 KiB
TypeScript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
applyToolCatalog,
|
|
isSubagentCustomPrompt,
|
|
loadRoutingRules,
|
|
loadSectionsConfig,
|
|
loadToolCatalog,
|
|
orderSections,
|
|
readPreamble,
|
|
renderRouting,
|
|
resolvePreamblePath,
|
|
spliceCover,
|
|
stripJsoncComments,
|
|
validateRoutingConfig,
|
|
validateSectionsConfig,
|
|
type MutablePromptOptions,
|
|
type RoutingRule,
|
|
} from "../extensions/system-prompt-config.ts";
|
|
import systemPromptExtension, {
|
|
createSystemPromptComposer,
|
|
getSystemPromptDumpPath,
|
|
} from "../extensions/system-prompt.ts";
|
|
import { guardExtensionApi } from "../extensions/prompt-guard.ts";
|
|
import {
|
|
CONTEXT_MODE_ANCHOR_PREFIX,
|
|
filterContextModeMessages,
|
|
} from "../extensions/context-mode-filter.ts";
|
|
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts";
|
|
import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
|
|
|
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const promptConfigDir = join(repositoryRoot, "config", "prompt");
|
|
|
|
const BUNDLE_PREAMBLE =
|
|
"You are an expert coding assistant operating inside pi, a coding agent harness. " +
|
|
"You help users by reading files, executing commands, editing code, and writing new files.";
|
|
|
|
// ── JSONC parsing ───────────────────────────────────────────────────────────
|
|
|
|
test("stripJsoncComments removes comments but keeps // inside strings", () => {
|
|
const source = `{
|
|
// line comment
|
|
"url": "https://example.com//path", /* block comment */
|
|
"escaped": "a\\"//not a comment"
|
|
}`;
|
|
assert.deepEqual(JSON.parse(stripJsoncComments(source)), {
|
|
url: "https://example.com//path",
|
|
escaped: 'a"//not a comment',
|
|
});
|
|
});
|
|
|
|
// ── Config files load and validate ──────────────────────────────────────────
|
|
|
|
test("config/prompt files load with the expected shapes", () => {
|
|
const catalog = loadToolCatalog();
|
|
assert.equal(catalog.version, 1);
|
|
assert.ok(Object.keys(catalog.tools).length > 0);
|
|
|
|
const routing = loadRoutingRules();
|
|
assert.equal(routing.version, 1);
|
|
assert.ok(routing.rules.length > 0);
|
|
for (const rule of routing.rules) assert.equal(typeof rule.text, "string");
|
|
|
|
const sections = loadSectionsConfig();
|
|
assert.equal(sections.version, 1);
|
|
assert.deepEqual(sections.order, [
|
|
"memory_policy",
|
|
"remote_ssh",
|
|
"chrome_profile_bridge",
|
|
"tool_routing",
|
|
]);
|
|
assert.deepEqual(sections.subagentExclude, ["memory_policy", "chrome_profile_bridge"]);
|
|
});
|
|
|
|
test("routing scope validation: main/subagent accepted, other values rejected", () => {
|
|
assert.doesNotThrow(() =>
|
|
validateRoutingConfig({
|
|
version: 1,
|
|
rules: [
|
|
{ text: "both" },
|
|
{ scope: "main", text: "m" },
|
|
{ scope: "subagent", text: "s" },
|
|
],
|
|
}),
|
|
);
|
|
assert.throws(
|
|
() => validateRoutingConfig({ version: 1, rules: [{ scope: "everyone", text: "x" }] }),
|
|
/scope must be "main" or "subagent"/,
|
|
);
|
|
});
|
|
|
|
test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => {
|
|
assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), {
|
|
version: 1,
|
|
order: ["tool_routing"],
|
|
subagentExclude: [],
|
|
});
|
|
assert.throws(
|
|
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: "memory_policy" }),
|
|
/subagentExclude.*array of section names/,
|
|
);
|
|
assert.throws(
|
|
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: ["Bad Name"] }),
|
|
/not a valid section name/,
|
|
);
|
|
});
|
|
|
|
test("readPreamble reads the bundle preamble from config/prompt/", () => {
|
|
assert.equal(
|
|
resolvePreamblePath(),
|
|
join(promptConfigDir, "preamble.md"),
|
|
);
|
|
assert.equal(readPreamble(), BUNDLE_PREAMBLE);
|
|
});
|
|
|
|
// ── Catalog completeness ────────────────────────────────────────────────────
|
|
|
|
const CORE_TOOL_NAMES = [
|
|
"read",
|
|
"bash",
|
|
"edit",
|
|
"write",
|
|
"grep",
|
|
"find",
|
|
"ls",
|
|
"powershell",
|
|
"tool_search",
|
|
];
|
|
|
|
function requiredToolNames(): Set<string> {
|
|
const required = new Set(CORE_TOOL_NAMES);
|
|
for (const group of BUNDLE_GROUP_DEFINITIONS) {
|
|
for (const name of group.tools) required.add(name);
|
|
}
|
|
for (const name of BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled) required.add(name);
|
|
return required;
|
|
}
|
|
|
|
test("tools.jsonc covers every tool the bundle can expose, and nothing else", () => {
|
|
const required = requiredToolNames();
|
|
const catalog = loadToolCatalog();
|
|
for (const name of required) {
|
|
assert.ok(name in catalog.tools, `tool "${name}" is missing from config/prompt/tools.jsonc`);
|
|
}
|
|
for (const name of Object.keys(catalog.tools)) {
|
|
assert.ok(required.has(name), `tools.jsonc entry "${name}" is not a bundle-exposed tool`);
|
|
}
|
|
});
|
|
|
|
// ── Forbidden phrases ───────────────────────────────────────────────────────
|
|
|
|
test("prompt config carries no retired guidance phrasing", () => {
|
|
const forbidden = [
|
|
"ls, grep, find",
|
|
"read, grep, or edit",
|
|
"read/grep",
|
|
"replace_text",
|
|
"Read/edit files",
|
|
"ctx_fetch_and_index then ctx_search",
|
|
];
|
|
for (const file of ["preamble.md", "tools.jsonc", "routing.jsonc"]) {
|
|
const source = readFileSync(join(promptConfigDir, file), "utf8");
|
|
for (const phrase of forbidden) {
|
|
assert.ok(!source.includes(phrase), `config/prompt/${file} must not contain "${phrase}"`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── applyToolCatalog ────────────────────────────────────────────────────────
|
|
|
|
test("applyToolCatalog applies snippet/guideline overrides and reports unregistered tools", () => {
|
|
const catalog = {
|
|
version: 1 as const,
|
|
tools: {
|
|
bash: { snippet: "custom bash line" },
|
|
edit: { guidelines: ["rule one", "rule two"] },
|
|
read: { inherit: true as const },
|
|
write: { snippet: null },
|
|
},
|
|
};
|
|
const options: MutablePromptOptions = {
|
|
selectedTools: ["bash", "edit", "read", "write", "mystery_tool"],
|
|
toolSnippets: { bash: "orig bash", write: "orig write", read: "orig read" },
|
|
toolGuidelines: { edit: ["old rule"] },
|
|
};
|
|
|
|
const missing = applyToolCatalog(options, catalog);
|
|
|
|
assert.deepEqual(missing, ["mystery_tool"]);
|
|
assert.equal(options.toolSnippets?.bash, "custom bash line");
|
|
assert.equal(options.toolSnippets?.read, "orig read", "inherit entries keep upstream text");
|
|
assert.ok(!("write" in (options.toolSnippets ?? {})), "snippet: null hides the tools entry");
|
|
assert.deepEqual(options.toolGuidelines?.edit, ["rule one", "rule two"]);
|
|
});
|
|
|
|
// ── renderRouting ───────────────────────────────────────────────────────────
|
|
|
|
test("renderRouting honours whenAll, whenAny and unconditional rules", () => {
|
|
const rules: RoutingRule[] = [
|
|
{ whenAll: ["a", "b"], text: "all" },
|
|
{ whenAny: ["x", "y"], text: "any" },
|
|
{ text: "always" },
|
|
{ whenAll: ["a"], whenAny: ["b", "z"], text: "both" },
|
|
];
|
|
|
|
assert.equal(renderRouting(rules, ["a", "b", "x"]), "- all\n- any\n- always\n- both");
|
|
assert.equal(renderRouting(rules, ["a"]), "- always", "whenAny must match at least one");
|
|
assert.equal(renderRouting(rules, []), "- always");
|
|
assert.equal(
|
|
renderRouting(rules, undefined),
|
|
"- all\n- any\n- always\n- both",
|
|
"absent selectedTools means every tool is active",
|
|
);
|
|
assert.equal(renderRouting([], ["a"]), "");
|
|
});
|
|
|
|
test("renderRouting filters scoped rules by audience, unscoped rules render for both", () => {
|
|
const rules: RoutingRule[] = [
|
|
{ text: "both audiences" },
|
|
{ scope: "main", text: "main only" },
|
|
{ scope: "subagent", text: "subagent only" },
|
|
];
|
|
|
|
assert.equal(renderRouting(rules, undefined, "main"), "- both audiences\n- main only");
|
|
assert.equal(renderRouting(rules, undefined, "subagent"), "- both audiences\n- subagent only");
|
|
// The omitted-audience default stays "main" for callers that predate scopes.
|
|
assert.equal(renderRouting(rules, undefined), "- both audiences\n- main only");
|
|
});
|
|
|
|
test("isSubagentCustomPrompt matches the <active_agent> contract only on customPrompt", () => {
|
|
assert.ok(isSubagentCustomPrompt('a\n<active_agent name="Explore"/>'));
|
|
assert.ok(isSubagentCustomPrompt("<active_agent name='x'/>"));
|
|
assert.ok(!isSubagentCustomPrompt("plain main-session prompt"));
|
|
assert.ok(!isSubagentCustomPrompt(undefined));
|
|
assert.ok(!isSubagentCustomPrompt(42));
|
|
});
|
|
|
|
test("bundled ssh_connect rules differ per audience", () => {
|
|
const routing = loadRoutingRules();
|
|
const tools = ["ssh_connect", "ssh_bash"];
|
|
|
|
const main = renderRouting(routing.rules, tools, "main");
|
|
assert.match(main, /the user explicitly names an imported host/);
|
|
assert.doesNotMatch(main, /the delegated task explicitly names/);
|
|
|
|
const subagent = renderRouting(routing.rules, tools, "subagent");
|
|
assert.match(subagent, /the delegated task explicitly names an imported host/);
|
|
assert.doesNotMatch(subagent, /the user explicitly names an imported host/);
|
|
});
|
|
|
|
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
|
|
const routing = loadRoutingRules();
|
|
|
|
const literal = renderRouting(routing.rules, ["find", "grep", "read"]);
|
|
assert.match(literal, /Use find to identify likely files or directories/);
|
|
assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/);
|
|
|
|
const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]);
|
|
assert.match(ssh, /only when the user explicitly names an imported host/);
|
|
assert.match(ssh, /command-local directory change/);
|
|
|
|
const chrome = renderRouting(routing.rules, ["chrome_snapshot", "ctx_execute_file"]);
|
|
assert.match(chrome, /immediately analyze the returned workspace-relative capture path/);
|
|
|
|
const minimal = renderRouting(routing.rules, ["read"]);
|
|
assert.doesNotMatch(minimal, /codegraph_explore first/);
|
|
assert.doesNotMatch(minimal, /For literal search/);
|
|
assert.doesNotMatch(minimal, /ssh_connect only when/);
|
|
assert.match(minimal, /Stop searching once the current evidence is sufficient/);
|
|
});
|
|
|
|
// ── orderSections ───────────────────────────────────────────────────────────
|
|
|
|
test("orderSections applies the configured order and keeps unlisted sections last", () => {
|
|
const sections = {
|
|
late_unlisted: "L",
|
|
tool_routing: "R",
|
|
memory_policy: "M",
|
|
early_unlisted: "E",
|
|
};
|
|
assert.deepEqual(
|
|
Object.keys(orderSections(sections, ["memory_policy", "tool_routing"])),
|
|
["memory_policy", "tool_routing", "late_unlisted", "early_unlisted"],
|
|
);
|
|
assert.deepEqual(orderSections({}, ["memory_policy"]), {});
|
|
});
|
|
|
|
// ── spliceCover (moved from the retired cover extension) ────────────────────
|
|
|
|
function renderedPrompt(preamble = "PI DEFAULT PREAMBLE") {
|
|
return [
|
|
preamble,
|
|
"<tools>",
|
|
"- read: reads files",
|
|
"</tools>",
|
|
"<rules>",
|
|
"- Be concise",
|
|
"</rules>",
|
|
"<project_context>",
|
|
'<project_instructions path="/work/AGENTS.md">',
|
|
"Nested content with a </project_context>-looking line is not a tag.",
|
|
"</project_instructions>",
|
|
"</project_context>",
|
|
"<cwd>",
|
|
"/work",
|
|
"</cwd>",
|
|
].join("\n");
|
|
}
|
|
|
|
function tagsOf(text: string): string[] {
|
|
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
|
|
}
|
|
|
|
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
|
|
const rendered = renderedPrompt();
|
|
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
|
|
|
|
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
|
|
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
|
|
assert.ok(spliced.endsWith(originalTail));
|
|
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
|
|
assert.deepEqual(tagsOf(spliced), ["tools", "rules", "project_context", "cwd"]);
|
|
});
|
|
|
|
test("spliceCover tolerates a trailing newline and a whitespace-only preamble", () => {
|
|
const rendered = renderedPrompt();
|
|
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
|
|
const blank = spliceCover(rendered, " \n");
|
|
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
|
|
});
|
|
|
|
test("spliceCover degenerates to the preamble when the prompt has no sections", () => {
|
|
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
|
|
assert.equal(spliceCover("", "NEW"), "NEW");
|
|
});
|
|
|
|
// ── Composer: modern structured path ────────────────────────────────────────
|
|
|
|
interface FakePromptOptions extends MutablePromptOptions {
|
|
forceSystemPrompt?: string;
|
|
customPrompt?: string;
|
|
}
|
|
|
|
/** Simplified re-render of Pi's prompt from the mutable options object. */
|
|
function renderPiPrompt(options: FakePromptOptions): string {
|
|
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt;
|
|
// Like the real host: a non-empty customPrompt owns the preamble section.
|
|
const preamble =
|
|
typeof options.customPrompt === "string" && options.customPrompt.length > 0
|
|
? options.customPrompt
|
|
: "PI DEFAULT PREAMBLE";
|
|
const parts = [preamble];
|
|
const snippets = Object.entries(options.toolSnippets ?? {});
|
|
if (snippets.length > 0) {
|
|
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`);
|
|
}
|
|
const rules = [
|
|
...(options.promptGuidelines ?? []),
|
|
...Object.entries(options.toolGuidelines ?? {}).flatMap(([name, lines]) =>
|
|
lines.map((line) => `${name}: ${line}`),
|
|
),
|
|
];
|
|
if (rules.length > 0) parts.push(`<rules>\n${rules.join("\n")}\n</rules>`);
|
|
parts.push("<cwd>\n/work\n</cwd>");
|
|
for (const [name, text] of Object.entries(options.sections ?? {})) {
|
|
parts.push(`<${name}>\n${text}\n</${name}>`);
|
|
}
|
|
return parts.join("\n");
|
|
}
|
|
|
|
function makeEvent(options: FakePromptOptions | undefined) {
|
|
return {
|
|
systemPromptOptions: options,
|
|
get systemPrompt() {
|
|
return options ? renderPiPrompt(options) : "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>";
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeUiCtx() {
|
|
const notices: string[] = [];
|
|
const ctx = {
|
|
hasUI: true,
|
|
ui: { notify: (message: string) => notices.push(message) },
|
|
};
|
|
return { notices, ctx: ctx as never };
|
|
}
|
|
|
|
test("composer splices the preamble, applies the catalog, and orders sections", () => {
|
|
const composer = createSystemPromptComposer();
|
|
const options: FakePromptOptions = {
|
|
selectedTools: ["read", "bash", "edit", "codegraph_explore", "tool_search"],
|
|
toolSnippets: {
|
|
read: "reads files",
|
|
bash: "runs commands",
|
|
edit: "edits",
|
|
codegraph_explore: "explores",
|
|
tool_search: "searches",
|
|
},
|
|
toolGuidelines: { edit: ["upstream edit rule"] },
|
|
sections: { memory_policy: "policy text" },
|
|
};
|
|
const { notices, ctx } = makeUiCtx();
|
|
|
|
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
|
|
const output = result?.systemPrompt;
|
|
assert.ok(output);
|
|
|
|
assert.ok(
|
|
output.startsWith(BUNDLE_PREAMBLE),
|
|
"the bundle preamble replaces Pi's default preamble",
|
|
);
|
|
assert.match(output, /<tool_routing>\n- Use codegraph_explore first/);
|
|
assert.match(output, /search files with the find\/grep tools, not bash/);
|
|
assert.match(output, /LINE#HASH anchors/);
|
|
assert.ok(
|
|
!output.includes("upstream edit rule"),
|
|
"the edit guidelines entry replaces upstream rules",
|
|
);
|
|
assert.ok(
|
|
output.indexOf("<memory_policy>") < output.indexOf("<tool_routing>"),
|
|
"sections.jsonc orders memory_policy before tool_routing",
|
|
);
|
|
assert.deepEqual(notices, []);
|
|
assert.equal(composer.getLastComposed(), output);
|
|
});
|
|
|
|
test("composer degrades to text splicing when an earlier extension froze the prompt", () => {
|
|
const composer = createSystemPromptComposer();
|
|
const options: FakePromptOptions = {
|
|
selectedTools: ["read", "bash"],
|
|
forceSystemPrompt: "FROZEN PROMPT\n<frozen>\nkept\n</frozen>",
|
|
};
|
|
const { notices, ctx } = makeUiCtx();
|
|
const event = makeEvent(options);
|
|
|
|
const first = composer.handleBeforeAgentStart(event as never, ctx);
|
|
composer.handleBeforeAgentStart(event as never, ctx);
|
|
|
|
assert.match(notices[0] ?? "", /单一入口规则/);
|
|
assert.equal(notices.length, 1, "the violation warning is reported once per process");
|
|
assert.match(first?.systemPrompt ?? "", /<tool_routing>/);
|
|
assert.match(first?.systemPrompt ?? "", /<frozen>\nkept\n<\/frozen>/);
|
|
assert.ok(
|
|
(first?.systemPrompt ?? "").startsWith(BUNDLE_PREAMBLE),
|
|
"the preamble is still spliced onto the frozen prompt",
|
|
);
|
|
});
|
|
|
|
test("composer keeps a customPrompt-owned preamble (subagent / SYSTEM.md) intact", () => {
|
|
const composer = createSystemPromptComposer();
|
|
const customPrompt = 'You are the Explore agent.\n<active_agent name="Explore"/>';
|
|
const options: FakePromptOptions = {
|
|
customPrompt,
|
|
selectedTools: ["read"],
|
|
toolSnippets: { read: "reads files" },
|
|
};
|
|
const { ctx } = makeUiCtx();
|
|
const event = {
|
|
systemPromptOptions: options,
|
|
systemPrompt: `${customPrompt}\n\n<tools>\n- read: reads files\n</tools>`,
|
|
};
|
|
|
|
const result = composer.handleBeforeAgentStart(event as never, ctx);
|
|
const output = result?.systemPrompt ?? "";
|
|
|
|
assert.ok(output.startsWith(customPrompt), "the agent's own preamble is kept verbatim");
|
|
assert.ok(
|
|
output.includes('<active_agent name="Explore"/>'),
|
|
"the <active_agent> identity the permission system reads survives",
|
|
);
|
|
assert.ok(
|
|
!output.includes(BUNDLE_PREAMBLE),
|
|
"the bundle preamble must not be spliced over a customPrompt-owned preamble",
|
|
);
|
|
assert.match(output, /<tool_routing>/, "routing still degrades to an appended section");
|
|
});
|
|
|
|
test("composer treats an <active_agent> customPrompt as a subagent session", () => {
|
|
const composer = createSystemPromptComposer();
|
|
const customPrompt =
|
|
'You are a subagent.\n<active_agent name="Explore"/>\n# Environment\nWorking directory: /work';
|
|
const options: FakePromptOptions = {
|
|
customPrompt,
|
|
selectedTools: ["read", "ssh_connect"],
|
|
toolSnippets: { read: "reads files", ssh_connect: "connects" },
|
|
sections: {
|
|
memory_policy: "memory guidance for main sessions",
|
|
chrome_profile_bridge: "chrome bridge preamble",
|
|
remote_ssh: "remote ssh preamble",
|
|
},
|
|
};
|
|
const { ctx } = makeUiCtx();
|
|
|
|
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
|
|
const output = result?.systemPrompt ?? "";
|
|
|
|
assert.ok(output.startsWith(customPrompt), "the child preamble stays untouched");
|
|
assert.doesNotMatch(output, /<memory_policy>/, "subagentExclude removes memory_policy");
|
|
assert.doesNotMatch(output, /<chrome_profile_bridge>/, "subagentExclude removes chrome_profile_bridge");
|
|
assert.match(output, /<remote_ssh>/, "unexcluded sections are kept");
|
|
assert.match(output, /the delegated task explicitly names an imported host/, "subagent-scope routing renders");
|
|
assert.doesNotMatch(output, /the user explicitly names an imported host/, "main-scope routing stays out");
|
|
});
|
|
|
|
test("a main session keeps excluded sections and main-scope routing even with a literal <active_agent> tag", () => {
|
|
const composer = createSystemPromptComposer();
|
|
// The tag appears inside a contributed section (project docs can carry the
|
|
// literal text) but there is no customPrompt — this is a main session.
|
|
const options: FakePromptOptions = {
|
|
selectedTools: ["read", "ssh_connect"],
|
|
toolSnippets: { read: "reads files", ssh_connect: "connects" },
|
|
sections: {
|
|
memory_policy: 'docs mention <active_agent name="Guide"/>',
|
|
chrome_profile_bridge: "chrome bridge preamble",
|
|
},
|
|
};
|
|
const { ctx } = makeUiCtx();
|
|
|
|
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
|
|
const output = result?.systemPrompt ?? "";
|
|
|
|
assert.ok(output.startsWith(BUNDLE_PREAMBLE), "the bundle preamble still splices for a main session");
|
|
assert.match(output, /<memory_policy>/, "main sessions are unaffected by subagentExclude");
|
|
assert.match(output, /<chrome_profile_bridge>/);
|
|
assert.match(output, /the user explicitly names an imported host/, "main-scope routing renders");
|
|
assert.doesNotMatch(output, /the delegated task explicitly names/, "subagent-scope routing stays out");
|
|
});
|
|
|
|
test("composer falls back to text appends when the host exposes no prompt options", () => {
|
|
const composer = createSystemPromptComposer();
|
|
const { ctx } = makeUiCtx();
|
|
const event = { systemPrompt: "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>" };
|
|
|
|
const result = composer.handleBeforeAgentStart(event as never, ctx);
|
|
const output = result?.systemPrompt ?? "";
|
|
assert.ok(output.startsWith(BUNDLE_PREAMBLE));
|
|
assert.match(output, /<tool_routing>/);
|
|
assert.match(output, /<tools>\n- x\n<\/tools>/);
|
|
});
|
|
|
|
test("composer reports unregistered tools once per distinct name set", () => {
|
|
const composer = createSystemPromptComposer();
|
|
const { notices, ctx } = makeUiCtx();
|
|
const options = (): FakePromptOptions => ({
|
|
selectedTools: ["read", "ghost_tool"],
|
|
toolSnippets: { read: "r" },
|
|
});
|
|
|
|
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
|
|
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
|
|
assert.equal(notices.length, 1);
|
|
assert.match(notices[0], /未在 config\/prompt\/tools\.jsonc 登记:.*ghost_tool/);
|
|
|
|
composer.handleBeforeAgentStart(
|
|
makeEvent({ selectedTools: ["other_tool"], toolSnippets: {} }) as never,
|
|
ctx,
|
|
);
|
|
assert.equal(notices.length, 2, "a different missing-tool set notifies again");
|
|
});
|
|
|
|
test("composer warns once and returns nothing when the prompt config fails to load", () => {
|
|
const composer = createSystemPromptComposer({
|
|
loadToolCatalog: () => {
|
|
throw new Error("invalid jsonc");
|
|
},
|
|
});
|
|
const { notices, ctx } = makeUiCtx();
|
|
const options: FakePromptOptions = { selectedTools: ["read"], toolSnippets: { read: "r" } };
|
|
|
|
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
|
|
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
|
|
assert.equal(notices.length, 1);
|
|
assert.match(notices[0], /invalid jsonc/);
|
|
});
|
|
|
|
// ── prompt guard ────────────────────────────────────────────────────────────
|
|
|
|
function makeRecordingPi() {
|
|
const tools: Array<{ name: string; description?: string }> = [];
|
|
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
const pi = {
|
|
registerTool(def: { name: string; description?: string }) {
|
|
tools.push(def);
|
|
},
|
|
on(name: string, handler: (event: unknown, ctx: unknown) => unknown) {
|
|
handlers.set(name, handler);
|
|
},
|
|
someOtherApi: () => "passthrough",
|
|
};
|
|
return { pi, tools, handlers };
|
|
}
|
|
|
|
test("prompt guard applies catalog description overrides at registration", () => {
|
|
const { pi, tools } = makeRecordingPi();
|
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
|
|
|
|
guarded.registerTool({ name: "edit", description: "upstream edit" } as never);
|
|
guarded.registerTool({ name: "grep", description: "upstream grep" } as never);
|
|
|
|
const override = loadToolCatalog().tools.edit;
|
|
assert.ok(override && !("inherit" in override) && typeof override.description === "string");
|
|
assert.equal(tools[0].description, override.description);
|
|
assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description");
|
|
});
|
|
|
|
test("prompt guard strips a returned systemPrompt but keeps other fields", async () => {
|
|
const { pi, handlers } = makeRecordingPi();
|
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
|
|
|
|
guarded.on("before_agent_start", (async () => ({
|
|
systemPrompt: "forced by third party",
|
|
message: "kept",
|
|
})) as never);
|
|
|
|
const result = (await handlers.get("before_agent_start")?.({}, {})) as Record<string, unknown>;
|
|
assert.deepEqual(result, { message: "kept" });
|
|
});
|
|
|
|
test("prompt guard runs context results through the provided contextFilter", async () => {
|
|
const { pi, handlers } = makeRecordingPi();
|
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
|
|
contextFilter: (before, after) => after.slice(0, before.length),
|
|
});
|
|
|
|
guarded.on("context", (async (event: { messages: Array<{ role: string; content: string }> }) => {
|
|
event.messages.push({ role: "user", content: "injected" });
|
|
return { messages: event.messages };
|
|
}) as never);
|
|
|
|
const event = { messages: [{ role: "user", content: "real" }] };
|
|
const result = (await handlers.get("context")?.(event, {})) as {
|
|
messages: Array<{ content: string }>;
|
|
};
|
|
assert.deepEqual(result.messages.map((m) => m.content), ["real"]);
|
|
});
|
|
|
|
test("prompt guard returns the handler result untouched when the filter changes nothing", async () => {
|
|
const { pi, handlers } = makeRecordingPi();
|
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
|
|
contextFilter: filterContextModeMessages,
|
|
});
|
|
const expected = { messages: [{ role: "user", content: "plain" }] };
|
|
|
|
guarded.on("context", (async () => expected) as never);
|
|
|
|
const result = await handlers.get("context")?.({ messages: expected.messages.slice() }, {});
|
|
assert.equal(result, expected, "a no-op filter must preserve result identity");
|
|
});
|
|
|
|
test("prompt guard passes unrelated API members through to the real pi object", () => {
|
|
const { pi } = makeRecordingPi();
|
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
|
|
assert.equal((guarded as unknown as { someOtherApi: () => string }).someOtherApi(), "passthrough");
|
|
});
|
|
|
|
// ── context-mode filter ─────────────────────────────────────────────────────
|
|
|
|
test("context-mode filter drops only the routing-anchor part", () => {
|
|
const before = [{ role: "user", content: "real" }];
|
|
const anchor = `${CONTEXT_MODE_ANCHOR_PREFIX} ctx_batch_execute > ctx_execute`;
|
|
const after = [
|
|
...before,
|
|
{ role: "user", content: `${anchor}\n\n<active_memory>\nx\n</active_memory>` },
|
|
];
|
|
const filtered = filterContextModeMessages(before, after);
|
|
assert.equal(filtered.length, 2);
|
|
assert.equal(filtered[1].content, "<active_memory>\nx\n</active_memory>");
|
|
});
|
|
|
|
test("context-mode filter removes the appended message when only the anchor remains", () => {
|
|
const before = [{ role: "user", content: "real" }];
|
|
const after = [
|
|
...before,
|
|
{ role: "user", content: `${CONTEXT_MODE_ANCHOR_PREFIX} only anchor` },
|
|
];
|
|
assert.deepEqual(filterContextModeMessages(before, after), before);
|
|
});
|
|
|
|
test("context-mode filter returns the same reference when nothing changed", () => {
|
|
const before = [{ role: "user", content: "real" }];
|
|
const after = [...before, { role: "user", content: "no anchor here" }];
|
|
assert.equal(filterContextModeMessages(before, after), after);
|
|
assert.equal(filterContextModeMessages(after, after), after);
|
|
});
|
|
|
|
test("context-mode filter ignores appended non-user or non-string messages", () => {
|
|
const before = [{ role: "user", content: "real" }];
|
|
const after = [
|
|
...before,
|
|
{ role: "assistant", content: `${CONTEXT_MODE_ANCHOR_PREFIX} not ours` },
|
|
{ role: "user", content: [{ type: "text", text: "structured" }] },
|
|
];
|
|
assert.equal(filterContextModeMessages(before, after), after);
|
|
});
|
|
|
|
// ── Pinned upstream text ────────────────────────────────────────────────────
|
|
|
|
test("context-mode upstream still appends the single anchor-bearing user message", async () => {
|
|
const packageJson = JSON.parse(
|
|
await readFile(join(repositoryRoot, "package.json"), "utf8"),
|
|
) as { dependencies: Record<string, string> };
|
|
assert.match(
|
|
packageJson.dependencies["context-mode"] ?? "",
|
|
/^\d+\.\d+\.\d+$/,
|
|
"the context-filter pin requires an exact upstream version",
|
|
);
|
|
const upstream = await readFile(
|
|
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),
|
|
"utf8",
|
|
);
|
|
assert.ok(upstream.includes('parts.push("context-mode active. Hierarchy:'));
|
|
assert.ok(upstream.includes('_pendingContext = extraParts.join("\\n\\n")'));
|
|
assert.ok(upstream.includes("event.messages.push({"));
|
|
assert.ok(upstream.includes('role: "user"'));
|
|
});
|
|
|
|
// ── /dump-system-prompt ─────────────────────────────────────────────────────
|
|
|
|
test("dump-system-prompt writes the last composed prompt under .pi-debug", async () => {
|
|
let beforeAgentStart: ((event: unknown, ctx: unknown) => unknown) | undefined;
|
|
let command: { handler: (args: string, ctx: unknown) => Promise<void> } | undefined;
|
|
const pi = {
|
|
on(name: string, handler: typeof beforeAgentStart) {
|
|
if (name === "before_agent_start") beforeAgentStart = handler;
|
|
},
|
|
registerCommand(name: string, definition: typeof command) {
|
|
if (name === "dump-system-prompt") command = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
systemPromptExtension(pi);
|
|
assert.ok(beforeAgentStart);
|
|
assert.ok(command);
|
|
|
|
const { ctx } = makeUiCtx();
|
|
beforeAgentStart(
|
|
makeEvent({ selectedTools: ["read"], toolSnippets: { read: "r" } }) as never,
|
|
ctx,
|
|
);
|
|
|
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-system-prompt-"));
|
|
try {
|
|
await command.handler("", {
|
|
cwd,
|
|
getSystemPrompt: () => "fallback prompt",
|
|
ui: { notify() {} },
|
|
});
|
|
const dumped = await readFile(getSystemPromptDumpPath(cwd), "utf8");
|
|
assert.ok(dumped.startsWith(BUNDLE_PREAMBLE));
|
|
assert.match(dumped, /<tool_routing>/);
|
|
} finally {
|
|
await rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|