mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat: codegraph_explore 改为原生 CLI 工具并移除 /plugin_init
codegraph 有一等公民 CLI 且上游保证其输出与 codegraph_explore MCP 工具一致,走共享 MCP adapter 只是多余的传输层。改为原生 Pi 工具后 每次调用以短命 codegraph explore 进程完成,去掉常驻 server/daemon; 未初始化项目经 ctx.ui.confirm 确认后 init 并自动重试,拒绝或失败时 打回并引导模型回退 find/grep/read/LSP;结果按 codegraph.db mtime 做有界缓存,索引一变即失效。共享 MCP adapter 只保留 Exa;工具名 不变,权限策略、Tool Search 常驻表、路由规则与分组不受影响。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
97c2e75dfe
commit
6fe6cb29ae
@@ -0,0 +1,208 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildExploreArgs,
|
||||
createCodegraphExploreHandler,
|
||||
findIndexedRoot,
|
||||
isNotIndexedOutput,
|
||||
type CodegraphExec,
|
||||
type ExploreParams,
|
||||
} from "../extensions/codegraph.ts";
|
||||
|
||||
async function makeIndexedProject(): Promise<{ root: string; cleanup: () => Promise<void> }> {
|
||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
await mkdir(join(root, ".codegraph"));
|
||||
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
|
||||
return { root, cleanup: () => rm(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
function mockCtx(cwd: string, confirm?: () => Promise<boolean>) {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: confirm !== undefined,
|
||||
ui: { confirm: confirm ?? (async () => false) },
|
||||
} as never;
|
||||
}
|
||||
|
||||
function mockExec(handler: (command: string, args: string[]) => { code?: number; stdout?: string; stderr?: string }) {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: CodegraphExec = async (command, args) => {
|
||||
calls.push({ command, args });
|
||||
const result = handler(command, args);
|
||||
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", killed: false };
|
||||
};
|
||||
return { exec, calls };
|
||||
}
|
||||
|
||||
function makeHandler(exec: CodegraphExec) {
|
||||
return {
|
||||
handle: createCodegraphExploreHandler(exec, new Map(), new Map()),
|
||||
};
|
||||
}
|
||||
|
||||
test("findIndexedRoot locates the nearest ancestor index", async () => {
|
||||
const { root, cleanup } = await makeIndexedProject();
|
||||
try {
|
||||
const nested = join(root, "src", "deep");
|
||||
await mkdir(nested, { recursive: true });
|
||||
assert.equal(await findIndexedRoot(nested), root);
|
||||
assert.equal(await findIndexedRoot(root), root);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("findIndexedRoot returns undefined when no index exists", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
try {
|
||||
assert.equal(await findIndexedRoot(root), undefined);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildExploreArgs maps params to CLI argv", () => {
|
||||
assert.deepEqual(buildExploreArgs({ query: "auth flow" }, "/repo"), ["explore", "-p", "/repo", "auth flow"]);
|
||||
assert.deepEqual(
|
||||
buildExploreArgs({ query: "q", maxFiles: 5, projectPath: "/ignored" }, "/repo"),
|
||||
["explore", "-p", "/repo", "--max-files", "5", "q"],
|
||||
);
|
||||
});
|
||||
|
||||
test("isNotIndexedOutput detects the upstream marker", () => {
|
||||
assert.equal(isNotIndexedOutput("✗ CodeGraph isn't available here — no .codegraph/ index exists in /x."), true);
|
||||
assert.equal(isNotIndexedOutput("Found 3 symbols"), false);
|
||||
});
|
||||
|
||||
test("explore runs the CLI and caches by database mtime", async () => {
|
||||
const { root, cleanup } = await makeIndexedProject();
|
||||
const { exec, calls } = mockExec(() => ({ stdout: "explore output" }));
|
||||
const { handle } = makeHandler(exec);
|
||||
try {
|
||||
const first = await handle({ query: "auth" }, mockCtx(root));
|
||||
assert.equal(first.content[0].text, "explore output");
|
||||
const second = await handle({ query: "auth" }, mockCtx(root));
|
||||
assert.equal(second.content[0].text, "explore output");
|
||||
assert.equal(calls.length, 1, "identical query on unchanged index must hit the cache");
|
||||
|
||||
// Simulate a reindex: bump the db mtime.
|
||||
await utimes(join(root, ".codegraph", "codegraph.db"), new Date(), new Date(Date.now() + 5000));
|
||||
await handle({ query: "auth" }, mockCtx(root));
|
||||
assert.equal(calls.length, 2, "mtime change must invalidate the cached result");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("uninitialized project asks the user and indexes on approval", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
const { exec, calls } = mockExec((_command, args) => {
|
||||
if (args[0] === "init") {
|
||||
// Emulate codegraph init creating the index.
|
||||
return { stdout: "indexed" };
|
||||
}
|
||||
return { stdout: "explore output" };
|
||||
});
|
||||
const { handle } = makeHandler(exec);
|
||||
try {
|
||||
const result = await handle({ query: "auth" }, mockCtx(root, async () => true));
|
||||
assert.equal(result.content[0].text, "explore output");
|
||||
assert.deepEqual(
|
||||
calls.map(({ args }) => args[0]),
|
||||
["init", "explore"],
|
||||
);
|
||||
assert.deepEqual(calls[0].args, ["init", root]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uninitialized project bounces to built-in tools when declined", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
|
||||
const { handle } = makeHandler(exec);
|
||||
try {
|
||||
const result = await handle({ query: "auth" }, mockCtx(root, async () => false));
|
||||
assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/);
|
||||
assert.equal(calls.length, 0, "declined init must not spawn the CLI at all");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent calls on an uninitialized project share one init decision", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
let confirmCalls = 0;
|
||||
const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
|
||||
const pending = new Map<string, Promise<string | undefined>>();
|
||||
const handle = createCodegraphExploreHandler(exec, new Map(), pending);
|
||||
try {
|
||||
const [a, b] = await Promise.all([
|
||||
handle({ query: "one" }, mockCtx(root, async () => {
|
||||
confirmCalls += 1;
|
||||
return true;
|
||||
})),
|
||||
handle({ query: "two" }, mockCtx(root, async () => {
|
||||
confirmCalls += 1;
|
||||
return true;
|
||||
})),
|
||||
]);
|
||||
assert.equal(a.content[0].text, "ok");
|
||||
assert.equal(b.content[0].text, "ok");
|
||||
assert.equal(confirmCalls, 1, "parallel callers must not double-prompt");
|
||||
assert.equal(
|
||||
calls.filter(({ args }) => args[0] === "init").length,
|
||||
1,
|
||||
"parallel callers must share a single init",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("init failure is reported instead of exploring", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
const { exec } = mockExec((_command, args) =>
|
||||
args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" },
|
||||
);
|
||||
const { handle } = makeHandler(exec);
|
||||
try {
|
||||
const result = await handle({ query: "auth" }, mockCtx(root, async () => true));
|
||||
assert.match(result.content[0].text ?? "", /codegraph init 失败/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("missing CLI surfaces an install hint instead of throwing", async () => {
|
||||
const { root, cleanup } = await makeIndexedProject();
|
||||
const exec: CodegraphExec = async () => {
|
||||
throw new Error("spawn codegraph ENOENT");
|
||||
};
|
||||
const { handle } = makeHandler(exec);
|
||||
try {
|
||||
const result = await handle({ query: "auth" }, mockCtx(root));
|
||||
assert.match(result.content[0].text ?? "", /无法执行 codegraph CLI/);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("projectPath overrides the session cwd", async () => {
|
||||
const { root, cleanup } = await makeIndexedProject();
|
||||
const other = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
|
||||
const { handle } = makeHandler(exec);
|
||||
try {
|
||||
const params: ExploreParams = { query: "auth", projectPath: root };
|
||||
await handle(params, mockCtx(other));
|
||||
assert.deepEqual(calls[0].args, ["explore", "-p", root, "auth"]);
|
||||
} finally {
|
||||
await cleanup();
|
||||
await rm(other, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -32,8 +32,13 @@ test("all package extensions load together without global registration conflicts
|
||||
allowScripts: Record<string, boolean>;
|
||||
};
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./extensions/codegraph.ts"),
|
||||
"the bundle must expose the native codegraph_explore tool",
|
||||
);
|
||||
assert.equal(
|
||||
packageJson.pi.extensions.includes("./extensions/plugin-init.ts"),
|
||||
"the bundle must expose the project plugin initializer command",
|
||||
false,
|
||||
"the retired plugin initializer command must not be loaded",
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
|
||||
|
||||
+1
-11
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
|
||||
import { createMcpConfig, EXA_TOOLS } from "../extensions/mcp-config.ts";
|
||||
|
||||
test("one MCP adapter config contains both Exa and CodeGraph", () => {
|
||||
test("the shared MCP adapter config only connects Exa", () => {
|
||||
assert.deepEqual(createMcpConfig({}), {
|
||||
settings: {
|
||||
disableProxyTool: true,
|
||||
@@ -21,16 +21,6 @@ test("one MCP adapter config contains both Exa and CodeGraph", () => {
|
||||
approveTools: false,
|
||||
exposeResources: false,
|
||||
},
|
||||
codegraph: {
|
||||
command: "codegraph",
|
||||
args: ["serve", "--mcp"],
|
||||
lifecycle: "keep-alive",
|
||||
directTools: ["codegraph_explore"],
|
||||
includeTools: ["codegraph_explore"],
|
||||
toolPrefix: "none",
|
||||
approveTools: false,
|
||||
exposeResources: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join, parse } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import pluginInitExtension, {
|
||||
findMissingPluginInitializers,
|
||||
initializeMissingPlugins,
|
||||
validatePluginInitCwd,
|
||||
} from "../extensions/plugin-init.ts";
|
||||
|
||||
test("initializes missing CodeGraph state after preflighting the CLI", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
const calls: Array<{ command: string; args: string[]; cwd: string }> = [];
|
||||
try {
|
||||
const targets = await findMissingPluginInitializers(cwd);
|
||||
assert.deepEqual(targets.map(({ name }) => name), ["CodeGraph"]);
|
||||
|
||||
await initializeMissingPlugins(cwd, targets, async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ command: "codegraph", args: ["--version"], cwd },
|
||||
{ command: "codegraph", args: ["init", cwd], cwd },
|
||||
]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the CLI's database file as the authoritative initialization marker", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
await mkdir(join(cwd, ".codegraph"));
|
||||
assert.deepEqual(
|
||||
(await findMissingPluginInitializers(cwd)).map(({ name }) => name),
|
||||
["CodeGraph"],
|
||||
"a bare state directory must not count as initialized",
|
||||
);
|
||||
|
||||
await writeFile(join(cwd, ".codegraph", "codegraph.db"), "");
|
||||
const targets = await findMissingPluginInitializers(cwd);
|
||||
assert.deepEqual(targets, []);
|
||||
|
||||
await initializeMissingPlugins(cwd, targets, async (command, args) => {
|
||||
calls.push(`${command} ${args.join(" ")}`);
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
});
|
||||
assert.deepEqual(calls, ["codegraph --version"]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unsafe project roots", () => {
|
||||
assert.throws(() => validatePluginInitCwd(parse(homedir()).root), /文件系统根目录/);
|
||||
assert.throws(() => validatePluginInitCwd(homedir()), /用户主目录/);
|
||||
});
|
||||
|
||||
test("preflights the CLI before creating project state", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
const targets = await findMissingPluginInitializers(cwd);
|
||||
await assert.rejects(
|
||||
initializeMissingPlugins(cwd, targets, async (command, args) => {
|
||||
calls.push(`${command} ${args.join(" ")}`);
|
||||
return { code: 1, stdout: "", stderr: "not installed" };
|
||||
}),
|
||||
/CodeGraph 检测失败:not installed/,
|
||||
);
|
||||
assert.deepEqual(calls, ["codegraph --version"]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin_init confirms the target and hot-reloads after initialization", async () => {
|
||||
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
||||
const calls: string[] = [];
|
||||
const pi = {
|
||||
registerCommand(name: string, definition: typeof command) {
|
||||
assert.equal(name, "plugin_init");
|
||||
command = definition;
|
||||
},
|
||||
exec: async (program: string, args: string[]) => {
|
||||
calls.push(`${program} ${args.join(" ")}`);
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
pluginInitExtension(pi);
|
||||
assert.ok(command);
|
||||
const registeredCommand = command!;
|
||||
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
let reloaded = false;
|
||||
let confirmation = "";
|
||||
try {
|
||||
await registeredCommand.handler("", {
|
||||
cwd,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
confirm: async (_title: string, message: string) => {
|
||||
confirmation = message;
|
||||
return true;
|
||||
},
|
||||
notify() {},
|
||||
},
|
||||
reload: async () => {
|
||||
reloaded = true;
|
||||
},
|
||||
});
|
||||
assert.match(confirmation, new RegExp(cwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.deepEqual(calls, [
|
||||
"codegraph --version",
|
||||
`codegraph init ${cwd}`,
|
||||
]);
|
||||
assert.equal(reloaded, true);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin_init reports the failure and does not reload when initialization fails", async () => {
|
||||
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
||||
const pi = {
|
||||
registerCommand(_name: string, definition: typeof command) {
|
||||
command = definition;
|
||||
},
|
||||
exec: async (program: string, args: string[]) => ({
|
||||
code: program === "codegraph" && args[0] === "init" ? 1 : 0,
|
||||
stdout: "",
|
||||
stderr: "mock failure",
|
||||
}),
|
||||
} as unknown as ExtensionAPI;
|
||||
pluginInitExtension(pi);
|
||||
assert.ok(command);
|
||||
const registeredCommand = command!;
|
||||
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
let reloaded = false;
|
||||
let error = "";
|
||||
try {
|
||||
await registeredCommand.handler("", {
|
||||
cwd,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
confirm: async () => true,
|
||||
notify(message: string, level: string) {
|
||||
if (level === "error") error = message;
|
||||
},
|
||||
},
|
||||
reload: async () => {
|
||||
reloaded = true;
|
||||
},
|
||||
});
|
||||
assert.match(error, /CodeGraph 初始化失败:mock failure/);
|
||||
assert.equal(reloaded, false);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin_init stops without executing or reloading when confirmation is cancelled", async () => {
|
||||
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
||||
let executed = false;
|
||||
const pi = {
|
||||
registerCommand(_name: string, definition: typeof command) {
|
||||
command = definition;
|
||||
},
|
||||
exec: async () => {
|
||||
executed = true;
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
pluginInitExtension(pi);
|
||||
const registeredCommand = command!;
|
||||
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
let reloaded = false;
|
||||
try {
|
||||
await registeredCommand.handler("", {
|
||||
cwd,
|
||||
hasUI: true,
|
||||
ui: { confirm: async () => false, notify() {} },
|
||||
reload: async () => {
|
||||
reloaded = true;
|
||||
},
|
||||
});
|
||||
assert.equal(executed, false);
|
||||
assert.equal(reloaded, false);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user