feat(codegraph): codegraph_explore 改为自有 stdio JSON-RPC 客户端直连 codegraph serve

一次性 `codegraph explore` CLI 不会同步索引,新增符号在手动 sync 前
无法被搜到。改为按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,
由组合包自有的逐行 JSON-RPC 客户端(extensions/codegraph-client.ts,
不依赖 MCP adapter/SDK)收发,serve 内置文件监听自动同步索引,删除
mtime 结果缓存。子进程闲置 10 分钟或会话结束关闭,子进程死亡时透明
重试一次;用户拒绝初始化在会话内记住;用户中止调用不关闭子进程;
所有失败以文本打回引导改用 find/grep/read/LSP。新增模拟服务与真实
CLI 集成测试。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-20 02:02:47 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent da9908fb6e
commit dc4fe92e27
6 changed files with 990 additions and 104 deletions
+4 -4
View File
@@ -23,7 +23,7 @@
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。 - `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。
- `pi-web-search/`:完全自维护的统一在线搜索扩展。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名;`web_search` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择并经统一参数翻译调用,`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存,路由失败或被选厂商报错按可用性顺序降级;`web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily。`clients/` 为纯 `fetch` 直连:Tavily 走 `api.tavily.com`(必须有 key),Keenable 复刻上游 client 保留 keyless `/public` 端点与 SSRF 防护,Exa 直接对 `mcp.exa.ai/mcp` 发 JSON-RPC(缓存 `mcp-session-id`,404 自动重建会话,key 只走 `x-api-key` 头),不使用 `pi-mcp-adapter` 或任何上游扩展包。 - `pi-web-search/`:完全自维护的统一在线搜索扩展。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名;`web_search` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择并经统一参数翻译调用,`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存,路由失败或被选厂商报错按可用性顺序降级;`web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily。`clients/` 为纯 `fetch` 直连:Tavily 走 `api.tavily.com`(必须有 key),Keenable 复刻上游 client 保留 keyless `/public` 端点与 SSRF 防护,Exa 直接对 `mcp.exa.ai/mcp` 发 JSON-RPC(缓存 `mcp-session-id`,404 自动重建会话,key 只走 `x-api-key` 头),不使用 `pi-mcp-adapter` 或任何上游扩展包。
- `configure.sh search` 与 `scripts/search-auth.mjs`:以交互或参数方式把 `tavily` / `exa` / `keenable` 三条 `api_key` 凭据写入 Pi 的 `auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`);加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 导入。`pi-web-search/auth.ts` 每次调用读取并复刻上游解析语义(`!command` 执行、`$VAR`/`${VAR}` 插值、credential `env` 优先于进程 env、`$$`/`$!` 转义);显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。 - `configure.sh search` 与 `scripts/search-auth.mjs`:以交互或参数方式把 `tavily` / `exa` / `keenable` 三条 `api_key` 凭据写入 Pi 的 `auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`);加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 导入。`pi-web-search/auth.ts` 每次调用读取并复刻上游解析语义(`!command` 执行、`$VAR`/`${VAR}` 插值、credential `env` 优先于进程 env、`$$`/`$!` 转义);显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,以 `execFile` argv 形式调用机器级 `codegraph` CLI(每次调用为短命进程,无常驻 server/daemon);未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝或失败则把结果打回让模型改用 `find`/`grep`/`read`/LSP;结果按 `codegraph.db` mtime 做有界缓存,索引一变即失效。 - `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp -p <root>` 子进程,经 `extensions/codegraph-client.ts` 的组合包自有 stdio 逐行 JSON-RPC 客户端直连(不依赖 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭,调用中子进程死亡则换新客户端透明重试一次;未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝记入会话级 declined 不再重复询问;所有失败(未授权、init 失败、spawn ENOENT、调用失败)都以文本打回让模型改用 `find`/`grep`/`read`/LSP,绝不抛出。`codegraph serve` 可能启动的上游 daemon(`.codegraph/daemon.sock`)在子进程退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。
- `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。 - `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 - `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
@@ -87,9 +87,9 @@
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。 - Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。
- `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。 - `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。 - `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
- CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。 - CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录,拒绝结果在本会话内记住不再询问。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。运行时每个索引根惰性常驻一个 `codegraph serve --mcp` 子进程(闲置 10 分钟关闭、会话结束统一关闭),其启动的上游 daemon 退出后可多驻留 ≤5 分钟属上游行为。
- `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每次调用通过 `execFile`/`pi.exec` 以 argv 形式运行 `codegraph explore`(带超时、不经 shell),结果按 `.codegraph/codegraph.db` mtime 缓存;CLI 缺失、init 被拒绝或执行失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。 - `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每个索引根由 `codegraph serve --mcp -p <root>` 子进程服务,经组合包自有 stdio JSON-RPC 客户端收发(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 的文件监听自动同步索引,无结果缓存。子进程死亡导致调用失败时换新客户端透明重试一次;CLI 缺失、init 被拒绝或服务调用失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。
- 组合包不加载任何 MCP adapter;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。 - 组合包不加载任何 MCP adapter 或 MCP SDK;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;CodeGraph 由 `extensions/codegraph-client.ts` 以 stdio 逐行 JSON-RPC 直连 `codegraph serve --mcp`。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。
- 在线搜索对模型只暴露 `web_search` / `web_fetch` 两个统一工具名,不得重新引入按厂商命名的工具;`web_search` 必须经 `router.ts` 的主模型分类选择 provider,`web_fetch` 固定优先 Exa。Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 auth.json 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。 - 在线搜索对模型只暴露 `web_search` / `web_fetch` 两个统一工具名,不得重新引入按厂商命名的工具;`web_search` 必须经 `router.ts` 的主模型分类选择 provider,`web_fetch` 固定优先 Exa。Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 auth.json 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。
- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`configure.sh search` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。 - 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`configure.sh search` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。
- `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。 - `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。
+14 -10
View File
@@ -6,7 +6,7 @@
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。 - 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
- `context-mode@1.0.169`:通过 Pi adapter 提供 `ctx_*` 工具、上下文隔离和会话连续性。 - `context-mode@1.0.169`:通过 Pi adapter 提供 `ctx_*` 工具、上下文隔离和会话连续性。
- 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。 - 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,每次调用以短命 `codegraph explore` CLI 进程完成,无常驻 server/daemon;未初始化项目经确认后才 `codegraph init`,结果按索引库 mtime 缓存。 - `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。 - `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。 - `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
- `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。 - `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。
@@ -120,7 +120,7 @@ Tool Search 常驻,保证 policy 指示与模型可见工具一致。
`config/pi-hermes-memory.json`。 `config/pi-hermes-memory.json`。
CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认, CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认,
确认后才运行 `codegraph init` 并自动重试本次探索;不需要手动执行任何初始化命令。 确认后才运行 `codegraph init` 并自动重试本次探索,拒绝一次后本会话不再询问;不需要手动执行任何初始化命令。
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。 默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
@@ -273,17 +273,21 @@ key 值支持 auth.json 原生解析语义:`$VAR` / `${VAR}` 环境变量插
### CodeGraph 是原生工具 ### CodeGraph 是原生工具
`extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP:每次调用以 argv 形式 `extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP adapter/SDK:每个索引根
直接执行机器级 `codegraph explore`(上游保证其输出与 `codegraph_explore` MCP 工具一致), 惰性启动一个常驻 `codegraph serve --mcp -p <root>` 子进程,由组合包自有的
进程随调用结束,不维护常驻 server 或 daemon。`projectPath` 缺省为会话 cwd,沿目录向上寻找 `extensions/codegraph-client.ts` 以逐行 JSON-RPC over stdio 直连(与 `pi-web-search` 直连 Exa
最近的 `.codegraph/codegraph.db`;`maxFiles` 透传 `--max-files`。 同一思路),`initialize` → `tools/list` → 每次调用一次 `tools/call`。`projectPath` 缺省为会话 cwd,
沿目录向上寻找最近的 `.codegraph/codegraph.db` 决定索引根;调用参数只透传上游
`inputSchema` 声明的属性。
结果按 `(索引根, query, maxFiles)` 缓存在扩展内,并以索引库 mtime 作为有效性判据:任何 与一次性 `codegraph explore` CLI 不同,serve 进程内置文件监听会自动同步索引,新符号数秒内
重建或同步都会更新 mtime 使旧缓存自动失效,模型对同一区域反复探索时重复调用立即返回。 可搜到,因此扩展不再做结果缓存。子进程闲置 10 分钟或会话结束时关闭;调用因子进程死亡失败时
换新客户端透明重试一次。serve 可能拉起 CodeGraph 自己的 daemon(`.codegraph/daemon.sock`),
子进程退出后最多再驻留 5 分钟——这是上游行为,组合包不管理它。
若目标目录没有索引,工具先弹出确认;用户同意后运行 `codegraph init` 并自动重试本次调用, 若目标目录没有索引,工具先弹出确认;用户同意后运行 `codegraph init` 并自动重试本次调用,
拒绝或 CLI 不可用/执行失败时把结果打回并提示改用 `find`/`grep`/`read`/LSP,不影响其他扩展。 拒绝一次后本会话不再询问;CLI 不可用或服务调用失败时把结果打回并提示改用
索引状态可在项目内自行检查: `find`/`grep`/`read`/LSP,不影响其他扩展。索引状态可在项目内自行检查:
```bash ```bash
codegraph status codegraph status
+378
View File
@@ -0,0 +1,378 @@
/**
* Minimal stdio JSON-RPC 2.0 client for `codegraph serve --mcp -p <root>`.
*
* Same approach as pi-web-search/clients/exa.ts (plain JSON-RPC, no MCP
* adapter or SDK): `codegraph serve --mcp` speaks one JSON object per line on
* stdio. Unlike the one-shot `codegraph explore` CLI, the serve process runs a
* file watcher that keeps the index synced, so newly written symbols become
* searchable without a manual `codegraph sync`.
*
* One client exists per index root; `extensions/codegraph.ts` owns the map,
* idle closing, and the one-shot respawn retry.
*/
import {
spawn as nodeSpawn,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
const PROTOCOL_VERSION = "2025-06-18";
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
const STDERR_TAIL_LINES = 20;
const CLOSE_GRACE_MS = 1_000;
export type CodegraphSpawn = (
command: string,
args: string[],
options: SpawnOptions,
) => ChildProcess;
export interface CodegraphRpcClientOptions {
/** Index root — passed to `codegraph serve --mcp -p <root>` and used as cwd. */
root: string;
/** Test seam: replace the child_process spawn. */
spawn?: CodegraphSpawn;
/** Default "codegraph". */
command?: string;
/** Default ["serve", "--mcp", "-p", root]. */
args?: string[];
/** Per-request timeout; default 120 s. */
requestTimeoutMs?: number;
}
interface PendingRequest {
resolve: (result: unknown) => void;
reject: (error: Error) => void;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function contentText(result: Record<string, unknown>): string {
const content = Array.isArray(result.content)
? (result.content as Record<string, unknown>[])
: [];
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text as string)
.join("\n")
.trim();
}
export class CodegraphRpcClient {
private readonly spawnFn: CodegraphSpawn;
private readonly command: string;
private readonly args: string[];
private readonly timeoutMs: number;
private child: ChildProcess | undefined;
private stdoutBuffer = "";
private stderrLines: string[] = [];
private pending = new Map<number, PendingRequest>();
private nextId = 1;
private startPromise: Promise<void> | undefined;
/** null = upstream tool declares no inputSchema.properties → forward all args. */
private schemaProps: ReadonlySet<string> | null = null;
private deadFlag = false;
private closedFlag = false;
private deathFinalized = false;
private exitDetail = "";
private deathReason = "codegraph serve 子进程已退出";
private readonly options: CodegraphRpcClientOptions;
constructor(options: CodegraphRpcClientOptions) {
this.options = options;
this.spawnFn = options.spawn ?? nodeSpawn;
this.command = options.command ?? "codegraph";
this.args =
options.args ?? ["serve", "--mcp", "-p", options.root];
this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
/** True once the child has exited or errored — the handler respawns instead. */
get dead(): boolean {
return this.deadFlag;
}
/** Property names of the upstream codegraph_explore inputSchema. */
schemaPropertyNames(): readonly string[] {
return this.schemaProps === null ? [] : [...this.schemaProps];
}
/** Spawn + initialize + tools/list. Memoized: concurrent callers share it. */
start(): Promise<void> {
if (!this.startPromise) {
this.startPromise = this.doStart();
// A dead child during startup rejects this promise; attach a no-op
// branch so it never surfaces as an unhandled rejection when a
// racing close() kills the child with nobody awaiting start().
this.startPromise.catch(() => {});
}
return this.startPromise;
}
private async doStart(): Promise<void> {
let child: ChildProcess;
try {
child = this.spawnFn(this.command, this.args, {
cwd: this.options.root,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (error) {
this.markDead(
`无法启动 codegraph serve:${error instanceof Error ? error.message : String(error)}`,
);
throw this.deathError();
}
this.child = child;
this.attach(child);
await this.request("initialize", {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "my-pi-codegraph", version: "1.0.0" },
});
this.notify("notifications/initialized");
const list = (await this.request("tools/list", {})) as Record<
string,
unknown
>;
const tools = Array.isArray(list.tools) ? list.tools : [];
const tool = tools.find(
(entry) => isRecord(entry) && entry.name === "codegraph_explore",
);
if (!tool) {
throw new Error("codegraph serve 未提供 codegraph_explore 工具");
}
const properties =
isRecord(tool) && isRecord(tool.inputSchema)
? tool.inputSchema.properties
: undefined;
this.schemaProps = isRecord(properties)
? new Set(Object.keys(properties))
: null;
}
/**
* `tools/call codegraph_explore`. Forwards only the argument properties the
* upstream inputSchema declares (e.g. `projectPath` is forwarded only when
* upstream lists it). Returns the raw result object; the handler passes
* `result.content` through verbatim.
*/
async callExplore(
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
await this.start();
const forwarded: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (this.schemaProps === null || this.schemaProps.has(key)) {
forwarded[key] = value;
}
}
const result = await this.request(
"tools/call",
{ name: "codegraph_explore", arguments: forwarded },
signal,
);
if (isRecord(result) && result.isError) {
throw new Error(
`codegraph_explore 返回错误:${contentText(result) || "unknown"}`,
);
}
return isRecord(result) ? result : {};
}
/** End stdin, then SIGTERM after a 1 s grace. Idempotent. */
async close(): Promise<void> {
if (this.closedFlag) return;
this.closedFlag = true;
const child = this.child;
this.failAll(new Error("codegraph serve 客户端已关闭"));
if (!child) return;
try {
child.stdin?.end();
} catch {
// stdin already closed.
}
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
try {
child.kill("SIGTERM");
} catch {
// Already gone.
}
resolve();
}, CLOSE_GRACE_MS);
timer.unref();
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
child.once("error", () => {
clearTimeout(timer);
resolve();
});
});
}
// ── internals ────────────────────────────────────────────────────────────
private attach(child: ChildProcess): void {
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
this.stdoutBuffer += chunk;
for (;;) {
const newline = this.stdoutBuffer.indexOf("\n");
if (newline < 0) break;
const line = this.stdoutBuffer.slice(0, newline).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
if (line) this.handleLine(line);
}
});
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
for (const line of chunk.split(/\r?\n/u)) {
if (!line.trim()) continue;
this.stderrLines.push(line);
if (this.stderrLines.length > STDERR_TAIL_LINES) {
this.stderrLines.shift();
}
}
});
child.once("error", (error) => {
// Spawn failure (e.g. ENOENT) — the message carries the detail.
this.markDead(`无法启动 codegraph serve:${error.message}`);
});
child.once("exit", (code, signal) => {
this.deadFlag = true;
this.exitDetail =
signal !== null ? `signal ${signal}` : `code ${code}`;
});
// Reject pending on "close" (stdio drained), not "exit" — otherwise the
// final stderr lines may not have been delivered yet.
child.once("close", () => {
this.markDead(`codegraph serve 子进程退出(${this.exitDetail})`);
});
}
private handleLine(line: string): void {
let message: unknown;
try {
message = JSON.parse(line);
} catch {
return; // Not JSON — server chatter; ignore.
}
if (!isRecord(message)) return;
// Server → client request: we implement no methods; reply -32601.
if (typeof message.method === "string" && message.id !== undefined) {
this.sendRaw({
jsonrpc: "2.0",
id: message.id,
error: { code: -32601, message: "Method not found" },
});
return;
}
// Response to one of our requests.
if (message.id !== undefined) {
const entry = this.pending.get(Number(message.id));
if (!entry) return;
if (message.error !== undefined) {
const detail = isRecord(message.error)
? (message.error.message ?? JSON.stringify(message.error))
: String(message.error);
entry.reject(new Error(`codegraph serve ${String(detail)}`));
} else {
entry.resolve(message.result);
}
}
// Anything else (server notifications) is ignored.
}
private request(
method: string,
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<unknown> {
if (this.deadFlag || this.closedFlag) {
return Promise.reject(this.deathError());
}
const stdin = this.child?.stdin;
if (!this.child || !stdin || stdin.destroyed) {
return Promise.reject(new Error("codegraph serve 子进程不可用"));
}
if (signal?.aborted) {
return Promise.reject(new Error("codegraph serve 请求已中止"));
}
const id = this.nextId++;
return new Promise<unknown>((resolve, reject) => {
let onAbort: (() => void) | undefined;
const finish = (action: () => void): void => {
clearTimeout(timer);
if (onAbort) signal?.removeEventListener("abort", onAbort);
this.pending.delete(id);
action();
};
const timer = setTimeout(() => {
finish(() =>
reject(new Error(`codegraph serve 请求 ${method} 超时`)),
);
}, this.timeoutMs);
timer.unref();
onAbort = () => {
this.notify("notifications/cancelled", {
requestId: id,
reason: "aborted",
});
finish(() => reject(new Error("codegraph serve 请求已中止")));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
this.pending.set(id, {
resolve: (result) => finish(() => resolve(result)),
reject: (error) => finish(() => reject(error)),
});
this.sendRaw({ jsonrpc: "2.0", id, method, params });
});
}
private notify(method: string, params?: Record<string, unknown>): void {
this.sendRaw({ jsonrpc: "2.0", method, params });
}
private sendRaw(message: Record<string, unknown>): void {
try {
this.child?.stdin?.write(`${JSON.stringify(message)}\n`);
} catch {
// stdin already closed — the exit handler will fail pending calls.
}
}
private markDead(reason: string): void {
if (this.deathFinalized) return;
this.deathFinalized = true;
this.deadFlag = true;
const tail = this.stderrLines.join("\n").trim();
this.deathReason = tail ? `${reason};stderr: ${tail}` : reason;
this.failAll(this.deathError());
}
private deathError(): Error {
return new Error(
this.closedFlag && !this.deadFlag
? "codegraph serve 客户端已关闭"
: this.deathReason,
);
}
private failAll(error: Error): void {
const pending = [...this.pending.values()];
this.pending.clear();
for (const entry of pending) entry.reject(error);
}
}
Binary file not shown.
+473 -89
View File
@@ -1,49 +1,124 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import {
execFileSync,
spawn,
type ChildProcess,
} from "node:child_process";
import {
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test"; import test from "node:test";
import { import {
buildExploreArgs, CodegraphRpcClient,
type CodegraphRpcClientOptions,
} from "../extensions/codegraph-client.ts";
import {
createCodegraphExploreHandler, createCodegraphExploreHandler,
findIndexedRoot, findIndexedRoot,
isNotIndexedOutput,
type CodegraphExec, type CodegraphExec,
type CodegraphExploreClient,
type ExploreParams, type ExploreParams,
} from "../extensions/codegraph.ts"; } from "../extensions/codegraph.ts";
async function makeIndexedProject(): Promise<{ root: string; cleanup: () => Promise<void> }> { const FIXTURE = fileURLToPath(
new URL("./fixtures/fake-codegraph-mcp.mjs", import.meta.url),
);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ── fixtures ────────────────────────────────────────────────────────────────
async function makeIndexedProject(): Promise<{
root: string;
cleanup: () => Promise<void>;
}> {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
await mkdir(join(root, ".codegraph")); await mkdir(join(root, ".codegraph"));
await writeFile(join(root, ".codegraph", "codegraph.db"), ""); await writeFile(join(root, ".codegraph", "codegraph.db"), "");
return { root, cleanup: () => rm(root, { recursive: true, force: true }) }; return { root, cleanup: () => rm(root, { recursive: true, force: true }) };
} }
function mockCtx(cwd: string, confirm?: () => Promise<boolean>) { /** Spawn the .mjs fixture under process.execPath with the given env. */
function fixtureSpawn(extraEnv: Record<string, string>, children?: ChildProcess[]) {
return (_command: string, _args: string[], options: object) => {
const child = spawn(process.execPath, [FIXTURE], {
...(options as object),
env: { ...process.env, ...extraEnv },
});
children?.push(child);
return child;
};
}
function fixtureClient(
root: string,
extraEnv: Record<string, string> = {},
overrides: Partial<CodegraphRpcClientOptions> = {},
children?: ChildProcess[],
): CodegraphRpcClient {
return new CodegraphRpcClient({
root,
requestTimeoutMs: 5_000,
spawn: fixtureSpawn(
{ FAKE_CG_MODE: "ok", ...extraEnv },
children,
),
...overrides,
});
}
function mockCtx(
cwd: string,
ui?: { confirm?: () => Promise<boolean>; notify?: (m: string, t?: string) => void },
) {
return { return {
cwd, cwd,
hasUI: confirm !== undefined, hasUI: ui !== undefined,
ui: { confirm: confirm ?? (async () => false) }, ui: {
confirm: ui?.confirm ?? (async () => false),
notify: ui?.notify ?? (() => {}),
},
} as never; } as never;
} }
function mockExec(handler: (command: string, args: string[]) => { code?: number; stdout?: string; stderr?: string }) { function mockExec(
handler: (command: string, args: string[]) => {
code?: number;
stdout?: string;
stderr?: string;
},
) {
const calls: Array<{ command: string; args: string[] }> = []; const calls: Array<{ command: string; args: string[] }> = [];
const exec: CodegraphExec = async (command, args) => { const exec: CodegraphExec = async (command, args) => {
calls.push({ command, args }); calls.push({ command, args });
const result = handler(command, args); const result = handler(command, args);
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", killed: false }; return {
code: result.code ?? 0,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
killed: false,
};
}; };
return { exec, calls }; return { exec, calls };
} }
function makeHandler(exec: CodegraphExec) { function makeHandler(
return { exec: CodegraphExec,
handle: createCodegraphExploreHandler(exec, new Map(), new Map()), createClient: (root: string) => CodegraphExploreClient,
}; ) {
return createCodegraphExploreHandler({ exec, createClient });
} }
// ── findIndexedRoot (unchanged behavior) ────────────────────────────────────
test("findIndexedRoot locates the nearest ancestor index", async () => { test("findIndexedRoot locates the nearest ancestor index", async () => {
const { root, cleanup } = await makeIndexedProject(); const { root, cleanup } = await makeIndexedProject();
try { try {
@@ -65,71 +140,229 @@ test("findIndexedRoot returns undefined when no index exists", async () => {
} }
}); });
test("buildExploreArgs maps params to CLI argv", () => { // ── CodegraphRpcClient against the fixture ──────────────────────────────────
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", () => { test("client handshake + explore returns upstream content verbatim", async () => {
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 { root, cleanup } = await makeIndexedProject();
const { exec, calls } = mockExec(() => ({ stdout: "explore output" })); const client = fixtureClient(root);
const { handle } = makeHandler(exec);
try { try {
const first = await handle({ query: "auth" }, mockCtx(root)); await client.start();
assert.equal(first.content[0].text, "explore output"); const result = await client.callExplore({
const second = await handle({ query: "auth" }, mockCtx(root)); query: "alphaOne",
assert.equal(second.content[0].text, "explore output"); maxFiles: 5,
assert.equal(calls.length, 1, "identical query on unchanged index must hit the cache"); projectPath: "/some/dir",
});
// Simulate a reindex: bump the db mtime. const content = result.content as Array<{ type: string; text: string }>;
await utimes(join(root, ".codegraph", "codegraph.db"), new Date(), new Date(Date.now() + 5000)); const args = JSON.parse(content[0].text.slice("explore:".length));
await handle({ query: "auth" }, mockCtx(root)); assert.deepEqual(args, {
assert.equal(calls.length, 2, "mtime change must invalidate the cached result"); query: "alphaOne",
maxFiles: 5,
projectPath: "/some/dir",
});
} finally { } finally {
await client.close();
await cleanup(); await cleanup();
} }
}); });
test("uninitialized project asks the user and indexes on approval", async () => { test("client forwards only properties the upstream schema declares", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const { root, cleanup } = await makeIndexedProject();
const { exec, calls } = mockExec((_command, args) => { const client = fixtureClient(root, {
if (args[0] === "init") { FAKE_CG_SCHEMA: '["query","maxFiles"]',
// Emulate codegraph init creating the index.
return { stdout: "indexed" };
}
return { stdout: "explore output" };
}); });
const { handle } = makeHandler(exec);
try { try {
const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); await client.start();
assert.equal(result.content[0].text, "explore output"); const result = await client.callExplore({
assert.deepEqual( query: "q",
calls.map(({ args }) => args[0]), maxFiles: 3,
["init", "explore"], projectPath: "/should/not/forward",
); });
assert.deepEqual(calls[0].args, ["init", root]); const content = result.content as Array<{ text: string }>;
const args = JSON.parse(content[0].text.slice("explore:".length));
assert.deepEqual(args, { query: "q", maxFiles: 3 });
} finally { } finally {
await client.close();
await cleanup();
}
});
test("client start fails when the server lacks codegraph_explore", async () => {
const { root, cleanup } = await makeIndexedProject();
const client = fixtureClient(root, { FAKE_CG_MODE: "no-tool" });
try {
await assert.rejects(client.start(), /未提供 codegraph_explore 工具/);
} finally {
await client.close();
await cleanup();
}
});
test("client abort sends notifications/cancelled and rejects", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const logFile = join(dir, "rpc.log");
const client = fixtureClient(dir, {
FAKE_CG_MODE: "hang",
FAKE_CG_LOG: logFile,
});
try {
await client.start();
const controller = new AbortController();
const call = client.callExplore({ query: "q" }, controller.signal);
setTimeout(() => controller.abort(), 50);
await assert.rejects(call);
await client.close();
const lines = (await readFile(logFile, "utf8"))
.trim()
.split("\n")
.map((l) => JSON.parse(l));
const cancelled = lines.find(
(m) => m.method === "notifications/cancelled",
);
assert.ok(cancelled, "server must see notifications/cancelled");
assert.equal(cancelled.params.requestId, 3);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("client request timeout rejects a hung call", async () => {
const { root, cleanup } = await makeIndexedProject();
const client = fixtureClient(
root,
{ FAKE_CG_MODE: "hang" },
{ requestTimeoutMs: 300 },
);
try {
await client.start();
await assert.rejects(client.callExplore({ query: "q" }), /超时/);
} finally {
await client.close();
await cleanup();
}
});
test("client exit rejects the pending call with the stderr tail", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const client = fixtureClient(dir, {
FAKE_CG_MODE: "crash-first",
FAKE_CG_MARKER: join(dir, "crash.marker"),
});
try {
await client.start();
await assert.rejects(
client.callExplore({ query: "q" }),
/index corrupted/,
);
assert.equal(client.dead, true);
} finally {
await client.close();
await rm(dir, { recursive: true, force: true });
}
});
test("client close() terminates the child process", async () => {
const { root, cleanup } = await makeIndexedProject();
const children: ChildProcess[] = [];
const client = fixtureClient(root, {}, {}, children);
try {
await client.start();
await client.close();
assert.equal(children.length, 1);
assert.ok(
children[0].exitCode !== null || children[0].signalCode !== null,
"child must be gone after close()",
);
} finally {
await client.close();
await cleanup();
}
});
// ── handler ─────────────────────────────────────────────────────────────────
test("indexed project explores through the serve child", async () => {
const { root, cleanup } = await makeIndexedProject();
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => fixtureClient(r));
try {
const result = await handler.handle({ query: "auth" }, mockCtx(root));
const content = result.content as Array<{ text: string }>;
assert.match(content[0].text, /^explore:\{/);
assert.equal(calls.length, 0, "indexed project never runs codegraph init");
} finally {
await handler.close();
await cleanup();
}
});
test("declined init is remembered for the rest of the session", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
let confirmCalls = 0;
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => fixtureClient(r));
const ctx = mockCtx(root, {
confirm: async () => {
confirmCalls += 1;
return false;
},
});
try {
const first = await handler.handle({ query: "a" }, ctx);
const second = await handler.handle({ query: "b" }, ctx);
assert.match(first.content[0].text ?? "", /find\/grep\/read\/LSP/);
assert.match(second.content[0].text ?? "", /find\/grep\/read\/LSP/);
assert.equal(confirmCalls, 1, "decline is remembered — no second prompt");
assert.equal(calls.length, 0, "declined init must not spawn the CLI");
} finally {
await handler.close();
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("uninitialized project bounces to built-in tools when declined", async () => { test("no UI bounces to built-in tools without prompting", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" })); const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
const { handle } = makeHandler(exec); const handler = makeHandler(exec, (r) => fixtureClient(r));
try { try {
const result = await handle({ query: "auth" }, mockCtx(root, async () => false)); const result = await handler.handle({ query: "auth" }, mockCtx(root));
assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/); assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/);
assert.equal(calls.length, 0, "declined init must not spawn the CLI at all"); assert.equal(calls.length, 0);
} finally { } finally {
await handler.close();
await rm(root, { recursive: true, force: true });
}
});
test("uninitialized project inits on approval then explores", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec, calls } = mockExec((_command, args) => {
if (args[0] === "init") return { stdout: "indexed" };
return { stdout: "unreachable" };
});
// Emulate codegraph init materializing the index.
const execWithSideEffect: CodegraphExec = async (command, args, options) => {
const result = await exec(command, args, options);
if (args[0] === "init") {
await mkdir(join(root, ".codegraph"));
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
}
return result;
};
const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r));
try {
const result = await handler.handle(
{ query: "auth" },
mockCtx(root, { confirm: async () => true }),
);
const content = result.content as Array<{ text: string }>;
assert.match(content[0].text, /^explore:\{/);
assert.deepEqual(
calls.map(({ args }) => args[0]),
["init"],
);
assert.deepEqual(calls[0].args, ["init", root]);
} finally {
await handler.close();
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
@@ -138,21 +371,28 @@ test("concurrent calls on an uninitialized project share one init decision", asy
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
let confirmCalls = 0; let confirmCalls = 0;
const { exec, calls } = mockExec(() => ({ stdout: "ok" })); const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
const pending = new Map<string, Promise<string | undefined>>(); const execWithSideEffect: CodegraphExec = async (command, args, options) => {
const handle = createCodegraphExploreHandler(exec, new Map(), pending); const result = await exec(command, args, options);
if (args[0] === "init") {
await mkdir(join(root, ".codegraph"));
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
}
return result;
};
const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r));
try { try {
const ctx = mockCtx(root, {
confirm: async () => {
confirmCalls += 1;
return true;
},
});
const [a, b] = await Promise.all([ const [a, b] = await Promise.all([
handle({ query: "one" }, mockCtx(root, async () => { handler.handle({ query: "one" }, ctx),
confirmCalls += 1; handler.handle({ query: "two" }, ctx),
return true;
})),
handle({ query: "two" }, mockCtx(root, async () => {
confirmCalls += 1;
return true;
})),
]); ]);
assert.equal(a.content[0].text, "ok"); assert.equal(a.content[0].type, "text");
assert.equal(b.content[0].text, "ok"); assert.equal(b.content[0].type, "text");
assert.equal(confirmCalls, 1, "parallel callers must not double-prompt"); assert.equal(confirmCalls, 1, "parallel callers must not double-prompt");
assert.equal( assert.equal(
calls.filter(({ args }) => args[0] === "init").length, calls.filter(({ args }) => args[0] === "init").length,
@@ -160,49 +400,193 @@ test("concurrent calls on an uninitialized project share one init decision", asy
"parallel callers must share a single init", "parallel callers must share a single init",
); );
} finally { } finally {
await handler.close();
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("init failure is reported instead of exploring", async () => { test("init failure bounces with exit code instead of exploring", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec } = mockExec((_command, args) => const { exec } = mockExec((_command, args) =>
args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" }, args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" },
); );
const { handle } = makeHandler(exec); const handler = makeHandler(exec, (r) => fixtureClient(r));
try { try {
const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); const result = await handler.handle(
assert.match(result.content[0].text ?? "", /codegraph init 失败/); { query: "auth" },
mockCtx(root, { confirm: async () => true }),
);
assert.match(result.content[0].text ?? "", /codegraph init 失败(退出码 1)/);
} finally { } finally {
await handler.close();
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("a dead serve child is transparently replaced once", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
await mkdir(join(dir, ".codegraph"));
await writeFile(join(dir, ".codegraph", "codegraph.db"), "");
const env = {
FAKE_CG_MODE: "crash-first",
FAKE_CG_MARKER: join(dir, "crash.marker"),
};
let created = 0;
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => {
created += 1;
return fixtureClient(r, env);
});
try {
const result = await handler.handle({ query: "auth" }, mockCtx(dir));
const content = result.content as Array<{ text: string }>;
assert.match(content[0].text, /^explore:\{/);
assert.equal(created, 2, "the crashed client is replaced exactly once");
} finally {
await handler.close();
await rm(dir, { recursive: true, force: true });
}
});
test("a persistently crashing child bounces after one retry", async () => {
const { root, cleanup } = await makeIndexedProject();
let created = 0;
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => {
created += 1;
return fixtureClient(r, { FAKE_CG_MODE: "crash" });
});
try {
const result = await handler.handle({ query: "auth" }, mockCtx(root));
assert.match(result.content[0].text ?? "", /CodeGraph 服务调用失败/);
assert.equal(created, 2, "one retry, then bounce");
} finally {
await handler.close();
await cleanup();
}
});
test("missing CLI surfaces an install hint instead of throwing", async () => { test("missing CLI surfaces an install hint instead of throwing", async () => {
const { root, cleanup } = await makeIndexedProject(); const { root, cleanup } = await makeIndexedProject();
const exec: CodegraphExec = async () => { const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, () => ({
dead: false,
start: async () => {
throw new Error("spawn codegraph ENOENT"); throw new Error("spawn codegraph ENOENT");
}; },
const { handle } = makeHandler(exec); callExplore: async () => ({}),
close: async () => {},
schemaPropertyNames: () => [],
}));
try { try {
const result = await handle({ query: "auth" }, mockCtx(root)); const result = await handler.handle({ query: "auth" }, mockCtx(root));
assert.match(result.content[0].text ?? "", /无法执行 codegraph CLI/); assert.match(result.content[0].text ?? "", /未找到 codegraph CLI/);
assert.match(result.content[0].text ?? "", /install\.sh/);
} finally { } finally {
await handler.close();
await cleanup(); await cleanup();
} }
}); });
test("projectPath overrides the session cwd", async () => { test("projectPath targets a different indexed root with its own client", async () => {
const { root, cleanup } = await makeIndexedProject(); const main = await makeIndexedProject();
const other = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const other = await makeIndexedProject();
const { exec, calls } = mockExec(() => ({ stdout: "ok" })); const roots: string[] = [];
const { handle } = makeHandler(exec); const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => {
roots.push(r);
return fixtureClient(r);
});
try { try {
const params: ExploreParams = { query: "auth", projectPath: root }; await handler.handle({ query: "a" }, mockCtx(main.root));
await handle(params, mockCtx(other)); await handler.handle(
assert.deepEqual(calls[0].args, ["explore", "-p", root, "auth"]); { query: "b", projectPath: other.root } satisfies ExploreParams,
mockCtx(main.root),
);
assert.deepEqual(roots, [main.root, other.root]);
} finally { } finally {
await cleanup(); await handler.close();
await rm(other, { recursive: true, force: true }); await main.cleanup();
await other.cleanup();
} }
}); });
test("schema drift notifies once per process", async () => {
const { root, cleanup } = await makeIndexedProject();
const notices: string[] = [];
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) =>
fixtureClient(r, { FAKE_CG_SCHEMA: '["query","maxFiles"]' }),
);
const ctx = mockCtx(root, {
confirm: async () => true,
notify: (message: string) => {
notices.push(message);
},
});
try {
await handler.handle({ query: "a" }, ctx);
await handler.handle({ query: "b" }, ctx);
assert.equal(notices.length, 1);
assert.match(notices[0], /参数已变化/);
} finally {
await handler.close();
await cleanup();
}
});
// ── real CLI integration ────────────────────────────────────────────────────
const codegraphAvailable = (() => {
try {
execFileSync("codegraph", ["--version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
})();
test(
"real codegraph serve --mcp keeps the index synced via its watcher",
{ skip: !codegraphAvailable },
async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-real-"));
const client = new CodegraphRpcClient({ root: dir });
try {
execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
await writeFile(
join(dir, "a.ts"),
"export function alphaOne() { return 1; }\n",
);
execFileSync("codegraph", ["init", dir], {
cwd: dir,
stdio: "ignore",
timeout: 120_000,
});
const first = await client.callExplore({ query: "alphaOne" });
const firstText = (first.content as Array<{ text?: string }>)
.map((c) => c.text ?? "")
.join("\n");
assert.match(firstText, /alphaOne/);
await writeFile(
join(dir, "a.ts"),
"export function alphaOne() { return 1; }\nexport function betaTwo() { return 2; }\n",
);
const deadline = Date.now() + 15_000;
let found = false;
while (Date.now() < deadline && !found) {
const res = await client.callExplore({ query: "betaTwo" });
found = (res.content as Array<{ text?: string }>).some((c) =>
(c.text ?? "").includes("betaTwo"),
);
if (!found) await sleep(500);
}
assert.ok(found, "watcher should make betaTwo searchable within 15 s");
} finally {
await client.close();
await rm(dir, { recursive: true, force: true });
}
},
);
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Fake `codegraph serve --mcp`: newline-delimited JSON-RPC 2.0 on stdio, for
* tests of extensions/codegraph-client.ts.
*
* Env:
* FAKE_CG_MODE "ok" (default) | "no-tool" | "crash-first" | "crash" | "hang"
* no-tool: tools/list returns no codegraph_explore
* crash-first: on the first tools/call, write FAKE_CG_MARKER
* and exit(1) — a second spawned process sees
* the marker and serves normally
* crash: exit(1) on every tools/call
* hang: never reply to tools/call
* FAKE_CG_SCHEMA JSON array of inputSchema property names
* (default ["query","maxFiles","projectPath"])
* FAKE_CG_MARKER marker file path for crash-first
* FAKE_CG_LOG file to append every inbound message as a JSON line
*/
import fs from "node:fs";
import readline from "node:readline";
const mode = process.env.FAKE_CG_MODE || "ok";
const marker = process.env.FAKE_CG_MARKER;
const logFile = process.env.FAKE_CG_LOG;
const schemaProps = (() => {
try {
return JSON.parse(process.env.FAKE_CG_SCHEMA || "");
} catch {
return ["query", "maxFiles", "projectPath"];
}
})();
function log(entry) {
if (logFile) fs.appendFileSync(logFile, JSON.stringify(entry) + "\n");
}
function send(msg) {
process.stdout.write(JSON.stringify(msg) + "\n");
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
const text = line.trim();
if (!text) return;
let msg;
try {
msg = JSON.parse(text);
} catch {
return;
}
if (!msg || typeof msg !== "object") return;
log(msg);
if (typeof msg.method !== "string") return;
switch (msg.method) {
case "initialize":
send({
jsonrpc: "2.0",
id: msg.id,
result: {
protocolVersion: "2025-06-18",
capabilities: {},
serverInfo: { name: "fake-codegraph", version: "0.0.0" },
},
});
return;
case "tools/list": {
const tools =
mode === "no-tool"
? []
: [
{
name: "codegraph_explore",
description: "fake explore",
inputSchema: {
type: "object",
properties: Object.fromEntries(
schemaProps.map((name) => [name, { type: "string" }]),
),
required: ["query"],
},
},
];
send({ jsonrpc: "2.0", id: msg.id, result: { tools } });
return;
}
case "tools/call": {
const shouldCrash =
mode === "crash" ||
(mode === "crash-first" && marker && !fs.existsSync(marker));
if (shouldCrash) {
if (mode === "crash-first") fs.writeFileSync(marker, "crashed");
// Synchronous write — process.exit() can truncate pending pipe output.
fs.writeSync(2, "fatal: codegraph index corrupted\n");
process.exit(1);
}
if (mode === "hang") return; // Never reply — timeout/abort test.
const args = (msg.params && msg.params.arguments) || {};
send({
jsonrpc: "2.0",
id: msg.id,
result: {
content: [{ type: "text", text: `explore:${JSON.stringify(args)}` }],
},
});
return;
}
default:
// Notifications (initialized, cancelled) need no reply; unknown
// requests get -32601.
if (msg.id !== undefined) {
send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32601, message: "Method not found" },
});
}
}
});