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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user