From 7ceeeff523c15d1142b7c605d76b7783ea4a9200 Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:09:01 +0800 Subject: [PATCH] feat: add scoped navigation and compact UI responses --- CHANGELOG.md | 4 + README.md | 26 ++++ ...56\346\265\201\350\257\264\346\230\216.md" | 6 + docs/codex_worklog.md | 22 +++ scripts/test-mcp-client.ts | 17 ++- scripts/verify-roslyn-gateway.mjs | 2 +- skills/wincode/SKILL.md | 26 ++-- skills/wincode/references/code.md | 13 +- skills/wincode/references/diagnostics.md | 6 +- skills/wincode/references/ui.md | 5 + src/Adapters/LocalTextAdapter.ts | 2 + src/Core/CodeNavigation.ts | 125 ++++++++++++++++++ src/Core/CodeQueries.ts | 4 + src/Core/Context.ts | 7 +- src/Core/LocalTextScanner.ts | 77 ++++++++--- src/Core/ToolRouter.ts | 9 ++ src/Gateway/CodeTools.ts | 30 +++++ src/Gateway/ConnectionGuide.ts | 16 +++ src/Gateway/ContextRangeCoverage.ts | 13 +- src/Gateway/ContextResponse.ts | 30 +++-- src/Gateway/McpServer.ts | 4 +- src/Gateway/UiCompact.ts | 56 ++++++++ src/Gateway/UiResponse.ts | 9 +- src/Gateway/UiTools.ts | 16 ++- src/index.ts | 9 ++ tests/context-coverage.test.ts | 22 ++- tests/context-efficiency.test.ts | 66 ++++++++- tests/fixed-workspace.test.ts | 25 +++- tests/local-text.test.ts | 5 + tests/request-admission.test.ts | 4 +- tests/resource-cleanup.test.ts | 38 ++++-- tests/tool-contracts.test.ts | 9 +- tests/ui-code-candidates.test.ts | 43 ++++++ tests/ui-code-runtime.test.ts | 18 +++ 34 files changed, 683 insertions(+), 81 deletions(-) create mode 100644 src/Core/CodeNavigation.ts create mode 100644 src/Gateway/ConnectionGuide.ts create mode 100644 src/Gateway/UiCompact.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1d485..5e811d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.15.0 (unreleased) +- Add bounded literal text search and file outlines with executable source follow-ups, exclusive/deduplicated scopes and file-level lexical/scan diagnostics. Retain local-text evidence boundaries and existing filesystem/cancellation/byte limits. +- Add final-output context summaries and observed EOF correction requests without claiming the original range was covered. File-head excerpts now support bounded continuation as well as symbol excerpts. +- Return an independent STDIO launch recipe on WORKSPACE_MISMATCH and via `--print-connection --workspace `, without registration, project startup or rebinding. Add opt-in compact UI responses with unchanged snapshot IDs/images, shared C# candidates and live-control expansion requests; default full output remains compatible. + - Add per-Host design-time intermediate outputs, original project exclusion/import preservation, conservative generated-input filtering, and owned output cleanup. Internal Host input policy is now 2; missing/old policy handshakes are rejected and their processes reaped. PR #37 and the cache-test correction in PR #38 are merged; main `d51f3e1` passed Node 22/24 and all three CodeQL checks on 2026-09-11. No GitHub Release has been published. The full Roslyn workflow still needs testing in actual agent clients. - Preserve `PROJECT_LOAD_FAILED` when original project evaluation rejects malformed project XML. Validate Configuration and TargetFramework as literal directory segments before Host admission, and verify normalized design-time output containment inside the owning UUID namespace. - Replace the prototype comparison acceptance entry with verification of the current published Host and production client. Use the actual Host UUID for blockers and ownership checks; fail on selected-case errors, empty selection, changed delivery, cleanup failures or surviving observed processes. CI now includes simultaneous A/B/A startup and the production semantic/concurrency/input matrix, including prebuilt custom outputs and two target frameworks. diff --git a/README.md b/README.md index a908f9b..8646c07 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,20 @@ Add each argument as a separate entry, without extra surrounding quotes even whe For Skill installation and client configuration, see the [Skill and MCP setup guide](WinCode-Skill制作与MCP配置指南.md). +### Navigation and connection guidance + +To obtain a separate project's STDIO configuration without starting its Gateway or changing client settings: + +```powershell +node C:/path/to/WinCode/dist/index.js --print-connection --workspace C:/path/to/project +``` + +`WORKSPACE_MISMATCH` returns the same `connectionGuide`: absolute command/argument entries and a workspace verification call. It describes a default local-text connection; existing Roslyn, development and Tray options are not copied. Directory existence is checked on actual startup. Refresh the corresponding client connection after rebuilding to load the new tools/schema. + +For ordinary code navigation, call `wincode_search_text` with a literal `query` and exclusive `scopePaths`, or `wincode_file_outline` with a literal `file`. Search returns one match per line; outlines return observed line/byte counts and text declarations. Both include `nextRequest` arguments for `wincode_prepare_context` and bounded file-level diagnostics. They use local text regardless of the semantic provider. Paths, scan/output budgets and cancellation remain enforced; zero matches do not prove absence outside the scanned scope. + +Context `summary` gives the displayed scope, complete/partial/missing counts and next action. An EOF error now reports actual line count and, where an intersection exists, a corrected read request while preserving the original coverage gap. UI inspect/review optionally accept `responseFormat: "compact"`: retain node IDs/hierarchy/states and the image, omit per-node geometry/class names, share repeated C# candidates through `candidateIds`, and provide live-UI `expansionRequests` for full detail. Default UI output remains `full`; compact counts do not establish defects or binding causality. + ### Optional tray and manual memory release Automatic Roslyn release is **off**. This version provides no idle timer or automatic-release switch. A loaded semantic workspace stays warm for successive Agent calls. To release it when you decide it is no longer needed: @@ -158,6 +172,8 @@ The 2026-09-08 check of the current Codex connection against TavernDesk source p | Tool | Purpose | | --- | --- | +| `wincode_search_text` | Search literal text within exclusive files/directories; return locations, bounded previews and follow-up reads. | +| `wincode_file_outline` | Read one file's observed line/byte counts and bounded local declarations with follow-up reads. | | `workspace_open` | Confirm or recover the fixed workspace and return a bounded summary; reject other roots. | | `wincode_list_directory` | Browse a specific workspace directory with entry, depth and output limits. | | `wincode_analyze_workspace` | Parse solution structure and declared `.sln`/`.csproj` project references. | @@ -337,6 +353,14 @@ UI 并发检查、实际 Agent 客户端中的完整 Roslyn 操作流程,以 Skill 安装和客户端配置方法见 [Skill 与 MCP 配置指南](WinCode-Skill制作与MCP配置指南.md)。 +### 代码导航与连接引导 + +使用 `node C:/path/to/WinCode/dist/index.js --print-connection --workspace C:/path/to/project` 可以输出目标项目的独立 STDIO 配置,不启动其 Gateway、不修改客户端设置。`WORKSPACE_MISMATCH` 也返回同一 `connectionGuide`,包含绝对命令、独立参数和工作区核对调用。配置默认 local-text,不复制已有 Roslyn、开发或托盘选项,目录存在性在实际启动时检查。构建后刷新对应客户端连接,才能使用新工具及 Schema。 + +日常定位用 `wincode_search_text` 的字面量 `query` 和排他的 `scopePaths`;查看文件行数、字节数和声明,用 `wincode_file_outline({file: ...})`。两者都返回可交给 `wincode_prepare_context` 的 `nextRequest`,并指出具体失败文件。它们始终提供本地文本线索;路径、扫描和最终输出预算、取消机制继续生效,零匹配不证明范围外没有相关代码。 + +上下文 `summary` 汇总展示范围、完整/部分/缺失文件数和下一步。EOF 越界报告实际行数,有有效交集时给出修正读取请求,原始覆盖缺口仍保留。UI inspect/review 可显式传 `responseFormat: "compact"`:保留控件 ID、层级、状态及图片,省略节点几何和类名,以 `candidateIds` 共享重复 C# 候选,并通过 `expansionRequests` 重新查询完整控件信息。UI 默认格式仍为 full,统计不自动判定缺陷或绑定原因。 + ### 可选托盘与手动释放内存 **自动释放保持关闭**,本版没有 idle 定时器或自动释放开关。Roslyn 加载后会保留,优先保障 Agent 连续工作;确实不再需要时,由你在设置里主动释放。 @@ -401,6 +425,8 @@ Skill 安装和客户端配置方法见 [Skill 与 MCP 配置指南](WinCode-Ski | 工具名称 | 功能描述 | | --- | --- | +| `wincode_search_text` | 在排他文件/目录范围内查字面量,返回位置、有界预览和续读请求。 | +| `wincode_file_outline` | 返回单文件实际行数/字节数、有界声明概览和续读请求。 | | `workspace_open` | 确认或恢复本连接的固定工作区,返回有长度限制的摘要;拒绝其他根目录。 | | `wincode_list_directory` | 按指定目录浏览,限制条目、深度与整份输出。 | | `wincode_analyze_workspace` | 解析解决方案结构及 `.sln`/`.csproj` 中声明的项目引用。 | diff --git "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" index 2a15210..de0a8a8 100644 --- "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" +++ "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" @@ -145,12 +145,16 @@ flowchart LR ### 3.3 输出预算位于最后一公里 +普通代码导航由 CodeNavigation 复用 LocalTextScanner:字面量搜索在排他目录/文件范围内进行,文件概览读取实际行数、字节数及文本声明,均返回 prepare_context 续读请求。路径范围先整体校验,实际读取再检查真实路径;扫描预算与最终 JSON 预算分别生效。LocalTextScanner 保留具名文件问题和省略计数,不把词法不确定性隐藏成完整结果。导航不启动语义 Host,也不改变所配置的提供方。 + `maxTokens` 当前按 UTF-16 字符数 / 4 估算,最终 MCP 文本块的 JSON 转义、元数据及 legacy 附加文本共同占预算。它不是模型 tokenizer 的精确结果。 ContextResponse 在最终裁剪后重新计算范围覆盖,区分读取阶段不足和响应预算不足,并给出缺失区间或后续请求。符号窗口没有解析方法结束边界,`symbolCoverage=unknown` 不能被显示的几行正文替代。 ## 4. 桌面取证与源码候选的数据流 +Gateway 可显式输出 compact 格式:UiCompact 保留快照节点 ID、树结构与状态,省略节点几何/类名,把重复 C# 候选提取为 candidateIds 引用的共享表;截图仍属于同一快照。展开请求是新的实时 UI 查询,不是原快照续页;几何详情和查询唯一性由 full 响应重新验证。默认 full 契约保留,序列化裁剪使用独立副本。 + ```mermaid flowchart TB Req["PID / HWND / query / capture"] --> Validate["Gateway + UiContracts 参数校验"] @@ -205,6 +209,8 @@ flowchart TB ### 5.2 固定工作区与同根恢复 +WORKSPACE_MISMATCH 的 connectionGuide 与 CLI `--print-connection` 由同一纯配置生成器提供绝对命令、参数和核对步骤;不读取其他客户端设置、不注册或启动进程、不切换工作区。目标路径存在性仍由真正的连接初始化验证。 + 启动时捕获并保护 config.workspaceRoot,内部 setRoot 与 openWorkspace 也校验固定根。显式 CLI 路径须为绝对路径;缺省绑定 cwd。初始化前验证目录已存在且路径无链接,其他根或 junction 别名不能作为切换入口。这不是对抗并发文件系统替换的原子沙盒。 独立实例指向同一物理项目时,设计时输出按 Host UUID 隔离。原 editorconfig/AssemblyAttributes.cs 写入竞争已有本地生产回归:真实 A/B/A 三个 MCP 进程同时冷加载,项目引用、嵌套根、外部构建、取消/崩溃及兄弟查询分别验证。分阶段启动同根 Host 的 N3 诊断结果仍不用于证明并发隔离;共享持久缓存和目标窗口属于另外的边界。 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index 6403a40..2fea420 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -1311,3 +1311,25 @@ - 文档提交 `26d0a42` 的 [CI 34575235538](https://github.com/linnnn89/WinCode/actions/runs/34575235538) 中,Node 24 和三项 CodeQL 通过,Node 22 在 owner-death 测试结束时失败。Roslyn Host 59 项、Gateway 22 项均通过;owner-death 主场景记录 9 个已观察进程、survivors=[]、success=true。 - 失败来自随后执行的兜底清理检查:`terminateObserved` 启动的 PowerShell 子进程触发 8000 ms 超时,报告 `spawnSync powershell.exe ETIMEDOUT`,导致整组测试按既有规则失败。现有记录不能确定超时发生在 PowerShell 启动还是命令执行阶段,也不能证明运行环境抖动就是根因。报告已下载到本地 `test-tmp/pr39-ci-26d0a42-attempt1`。 - 与已通过的 main `d51f3e1` 比较,生产代码和相关测试脚本完全一致。核对了 Node child_process 超时说明,并检索 GitHub runner-images 的相关记录,未找到可直接确认本次根因的同类案例。先记录失败并重新验证,以检查是否为偶发超时;不更改生产代码、测试断言、清理范围或超时时间,不将重跑通过称为根因已修复。合并仍要求当前 PR 提交的全部必需检查通过。 + +## 2026-09-11 — Agent 导航、上下文恢复与 UI 精简迭代 + +- 按用户要求把实际 New-tavern 审查中的操作摩擦分成四阶段实施:上下文恢复与摘要、限定范围的代码导航、独立连接引导、UI 精简与展开。改动位于 WinCode 工作区;没有增加依赖或修改应用数据库。保留已有文档工作,本轮未提交或推送。 +- 上下文 EOF 错误保留原始缺口,附实际行数及有效交集的续读请求;起点已超过 EOF 或文件不存在时不盲目重试。最终序列化后生成范围明确的 summary,普通文件开头片段也可续读。原有 512-token 字符估算预算、半行覆盖和未知任务覆盖语义保留。 +- 新增 wincode_search_text / wincode_file_outline:字面量搜索、排他文件/目录范围、重叠文件去重、同次读取的行数/字节数和声明概览、可执行的源码续读参数。复用有界 LocalTextScanner;越界范围在读取前拒绝,实际链接再核对真实路径,词法/编码/文件大小等问题按文件报告并保留省略数。导航不启动语义 Host。 +- WORKSPACE_MISMATCH 返回 connectionGuide;CLI --print-connection --workspace 输出同一默认 local-text 配置。真实 STDIO 验证从 A 的错误响应直接启动 B,并分别读到 A/B 标记;打印配置不创建目标缓存,不注册或重启客户端。 +- UI 增加显式 compact 格式,默认 full 保持兼容。控件 ID、层级、状态及图片保留,节点几何和类名可用 expansionRequests 恢复,重复 C# 候选用 candidateIds 共享。同一六控件夹具中 full=13165、compact=8750 字符,约减少 33.5%;图片字节和节点 ID 不变。这不是普遍 token/延迟收益或长期性能结论。 +- 各阶段先执行失败场景再实现:EOF 缺少实际行数/续读、导航工具缺失/词法失败不具名、生产错误缺少可用连接配置、UI 精简仍返回几何及重复候选。相关集合分别通过 36、59、8、37 项检查,集合有重叠,不相加。新增两个 it 场景,其余扩展既有测试及生产 STDIO 驱动。 +- 首次完整 core 检查:452/454,通过的实现之外有两处旧测试仍期望 15 个工具;更新为 17。记录:test-tmp/check/2026-09-11T07-58-37-514Z-core/report.json。第二次:453/454,唯一失败为旧恢复测试把整个仓库扫描计入 4 秒阈值,实际 4108 ms;记录:test-tmp/check/2026-09-11T08-02-02-436Z-core/report.json。将该测试改为隔离项目、真实 MCP 调用和实际恢复状态断言,定向通过 1/1;确认排空时 inFlight=0、监听器恢复、后续健康确认不再次排空。没有放宽时间阈值,也没有第三次全量重跑;原始完整检查报告仍保留失败状态。 +- 首次 desktop 集合 34/35:新增断言误认为按钮只有一个节点,UIA 实际还返回内部文本。改为对照独立原生计数,定向重跑真实 WPF 流程通过 1/1:compact 候选续读、full 展开恢复真实几何、定位判定方法、隔离副本修改/重新编译后同按钮由禁用变为可用;原仓库夹具哈希保持。记录:test-tmp/check/2026-09-11T08-08-23-380Z-desktop/report.json,原始失败未改写。 +- 后续验证通过:最终类型检查;新生产 STDIO 的 17 工具/Schema、搜索到源码、文件概览与 EOF 修正;17 个错误契约场景(test-tmp/error-contracts/run-10DlYB/report.json);桌面 owner-death(test-tmp/owner-death/run-0toZoN/report.json);WinForms/Named Pipe 托盘(test-tmp/tray/run-WQWlW7/report.json);实际 Roslyn 托盘工作流(test-tmp/tray-workflow/run-TuKjVN/report.json)。未重新跑已通过的完整集合,也未把分项通过写成单次全量全绿。 +- Gateway/原生 Release 构建完成,交付清单生成与核验 matched=true,contentId=6bc3f0e6aae0d0726a801897581801b8a27e019e3ad71234f49463f2346af106。生产新实例 buildId=866c93db7b87c8a5a5f5975f8c81ad601b01c02477a562b837414fbd9f344ba3,Schema=96d30bd506af37f08afcc02025ed3254d9ba2b9bd6f71732a31ad1a8f749f876。构建基于 8e4b70c 加当前未提交源码,版本仍为 0.15.0 未发布。 +- 四份受管 Skill 已备份后同步,安装入口保留精简写法,skill check matched=true;备份在 C:/Users/6/.codex/skills/.wincode-backup-948552f4-4d0b-4c9d-b2ce-3ba47349bba0。当前 Codex 连接仍为实例 4a6e5836-c683-4602-8ddf-696ebcd7d94e、旧 buildId=ff308c972a7296bce88891287958c73c1c06fd4766c9c6ecab075f302272dad1、15 工具且无 UI responseFormat。需要正常刷新连接才能加载新 Schema;未修改 MCP 配置或自动重启。Node 22/远端 CI 和当前 Codex 新接口使用尚未验证。 + +## 2026-09-11 — 导航与 UI 功能提交前复核 + +- 按用户确认开始收尾。将原目录 31 个修改文件和 3 个新文件完整保存为本地快照 da2d394,备份分支 codex/backup-navigation-before-delivery-20260911;从 main de14850 建立独立 worktree 和 codex/navigation-delivery-20260911,迁移后 Git tree 与快照一致。原目录保留,依赖和精确 SDK 复用本机现有安装。 +- 复核搜索范围、真实路径、扫描与序列化预算、取消传播、EOF 续读、UI 精简/展开和默认 full 兼容。未发现需要增加产品代码修改的缺陷;两份 Skill 手册的 inputPolicy 仍写 1,与 WorkspaceSession 和 RoslynAdapter 的实际版本 2 不符,已改正。未增加依赖、测试或架构层次。 +- 本地 Node 24.19.0:Gateway 构建和类型检查通过;上下文、导航、扫描、工具契约与 UI 候选集合 86/86;生产固定工作区集合 8/8;修订后的 MCP 恢复场景 1/1;真实 WPF 源码修改与重编译流程 1/1。正式 STDIO 的 17 工具、搜索/概览/EOF 续读、错误契约 17 场景通过。原生三组件经锁定 restore 和正式 Release 发布,交付清单校验通过。原始失败记录保留;本轮未重复完整本地 core/desktop 集合,完整兼容性检查交给最终 PR 提交的 CI。分阶段回执:test-tmp/navigation-delivery/report.json。 +- 实际 Codex 连接已在本次核对前刷新:实例 44103150-ef65-41e1-9f61-db8162ef752f,17 个工具、buildId=866c93db7b87c8a5a5f5975f8c81ad601b01c02477a562b837414fbd9f344ba3,与独立 worktree 构建相同。通过该连接完成限定目录搜索、文件概览、按返回请求读取 25/25 行;针对本轮启动的隔离 WPF 窗口完成 compact 读取及 full 展开,原生查询唯一,控件状态和几何恢复正确。回执:test-tmp/navigation-delivery/live-client.json。未改 MCP 配置;源码和 Schema 无后续变化,不要求再次重启当前连接。 +- 安装的 Skill 已通过既有脚本备份并同步,仅更新两份手册的策略版本,入口仍为 22 行;备份 .wincode-backup-946fa519-9d5c-4008-ba14-e3ac6fdf27dd,校验 matched=true。最终 PR-head 的 Node 22/24、三项 CodeQL 和合并后 main 检查仍待运行;不沿用 PR #39 的成功结果。 diff --git a/scripts/test-mcp-client.ts b/scripts/test-mcp-client.ts index 066526f..119688e 100644 --- a/scripts/test-mcp-client.ts +++ b/scripts/test-mcp-client.ts @@ -52,6 +52,20 @@ await server.start(); assert.deepEqual(hello.toolContract.tool.inputSchema, schema); assert.equal(hello.toolContract.tool.schemaHash, contractHash(schema)); for (const key of ['scopeFiles', 'symbol', 'lineRanges']) assert.ok(Object.hasOwn(schema.properties!, key)); + const found = await call('wincode_search_text', { query: 'RuntimeProbeTarget', scopePaths: ['Target.ts'] }); + assert.deepEqual(found.matches.map((item: any) => [item.file, item.line]), [['Target.ts', 50]]); + const discovered = await call('wincode_prepare_context', found.matches[0].nextRequest); + assert.equal(discovered.coverage.allRequestedCovered, true); + assert.ok(discovered.evidence[0].snippet.includes(lines[49])); + const outline = await call('wincode_file_outline', { file: 'Target.ts' }); + assert.equal(outline.fileLineCount, 60); + assert.equal(outline.sizeBytes, Buffer.byteLength(lines.join('\n'))); + assert.equal(outline.symbols[0].line, 50); + const pastEof = await call('wincode_prepare_context', { task: 'Read tail', lineRanges: [{ file: 'Target.ts', startLine: 58, endLine: 70 }] }); + assert.equal(pastEof.coverage.allRequestedCovered, false); + const corrected = await call('wincode_prepare_context', pastEof.coverage.details[0].nextRequest); + assert.equal(corrected.evidence[0].snippet, lines.slice(57).join('\n')); + assert.equal(corrected.summary.status, 'complete'); const symbol = await call('wincode_prepare_context', { task: 'Inspect runtime target', scopeFiles: ['Target.ts'], symbol: 'RuntimeProbeTarget' }); assert.ok(symbol.evidence.some((item: any) => item.file === 'Target.ts' && item.line === 50 && item.snippet.includes('RUNTIME_TARGET_BODY'))); const range = await call('wincode_prepare_context', { task: 'Inspect runtime target', lineRanges: [{ file: 'Target.ts', startLine: 50, endLine: 50 }] }); @@ -68,7 +82,8 @@ await server.start(); upstreams: false, gui: false, codexConnectionVerified: false, version: hello.version, runtime: hello.runtime, schemaHash: hello.toolContract.schemaHash, toolCount: tools.length, resourceCleanup: { observation: 'before shutdown; not proof of process exit', value: hello.health?.resourceCleanup ?? null }, - checks: ['initialize', 'tools/list', 'hello schema agreement', 'symbol body at line 50', 'exact range body', 'unknown fields ignored', 'known field type rejected', 'stable instance'] }, null, 2)); + checks: ['initialize', 'tools/list', 'hello schema agreement', 'literal search to source', 'file outline', 'EOF correction to source', + 'symbol body at line 50', 'exact range body', 'unknown fields ignored', 'known field type rejected', 'stable instance'] }, null, 2)); } finally { try { await client.close(); } finally { try { await transport?.close(); } finally { diff --git a/scripts/verify-roslyn-gateway.mjs b/scripts/verify-roslyn-gateway.mjs index 10d9228..9338ffe 100644 --- a/scripts/verify-roslyn-gateway.mjs +++ b/scripts/verify-roslyn-gateway.mjs @@ -129,7 +129,7 @@ try { assert.equal(initial.health.text.semanticConfigured, false); report.scenarios.push('explicit production CLI selects Roslyn; hello does not load a project'); const listed = await client.listTools(); - assert.equal(listed.tools.length, 15); + assert.equal(listed.tools.length, 17); assert.ok(listed.tools.find(tool => tool.name === 'wincode_find_references').inputSchema.properties.symbolLocation); report.scenarios.push('existing tools expose the validated optional symbolLocation contract'); const target = await integerTarget(); diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index f993cfd..8549eca 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -1,22 +1,22 @@ --- name: wincode -description: 使用 WinCode MCP 分析 Windows/.NET 工作区,或读取桌面窗口、截图与 XAML 源码候选。 +description: 使用 WinCode MCP 读取 Windows/.NET 项目源码、引用和变更影响,按需查看桌面 UI。 --- # WinCode -源码契约:0.15.0(连接固定启动工作区,其他根返回 WORKSPACE_MISMATCH);手册修订:2026-09-10。此版本号不代表当前连接已升级,以实际 Schema 为准。外部 Serena 已退役。仅维护时用 node scripts/sync-skill.mjs <安装目录绝对路径> 核对安装内容。 +适用于 WinCode 0.15.0。使用已连接的 MCP 工具,名称和参数以该连接实际暴露的 Schema 为准。 -默认以本地文本模式启动,source=local-text;明确配置 Roslyn 后,才通过 WinCode.Code.Host 提供 C# 语义证据。搜索返回的 location 可作为引用、影响分析和重构工具的 symbolLocation;不要猜测定位、复用旧快照或使用已退役的 namePath。内部 reload/cancel 不是 MCP 工具字段。配置与验收边界见代码手册。 +只读取与当前任务有关的手册: -仅按当前任务读取对应手册,不预读全部文件: -- 代码、上下文、引用、影响分析:[code](references/code.md)。 -- 窗口发现、截图、UI 源码候选:[ui](references/ui.md)。 -- 连接失败、运行状态、审计提醒:[diagnostics](references/diagnostics.md)。 +- [代码与工作区](references/code.md):源码搜索、上下文、引用、影响分析和 Roslyn 配置。 +- [窗口与 UI](references/ui.md):窗口选择、截图、控件读取和源码候选。 +- [诊断与恢复](references/diagnostics.md):工具不可用、版本、运行状态及错误恢复。 -使用客户端已连接的 WinCode MCP 工具;名称前缀以实际暴露为准。 -参数采用兼容容忍模式:未声明字段会被忽略,不表示相应功能已生效;已声明字段仍校验类型、必填项和范围。按对应手册的规范字段表构造请求,使用真正的 JSON 数字/布尔值,不传字符串替代。以当前连接 tools/list 的 schema 为准;手册比连接新时,不反复尝试旧实例未支持的参数。 -工具不可用时读诊断手册,不用临时脚本绕过 MCP 或审计。 -源码操作前核对所选连接的工作区。workspace_open 只确认或恢复启动根,健康同根确认保留 Host,不是强制重启或清理完成屏障。WORKSPACE_MISMATCH 表示连接不属于目标项目;选择对应连接,不得忽略错误继续声称正在操作另一项目,也不自动改配置或重试。hello.health.workspaceBinding 标明固定根及来源(argument/cwd/configuration);已知根一致时直接查询,不例行重复打开。每实例最多 32 个未完成业务请求,hello/tools/list 共享 4 个轻量槽;原始参数含未知字段按 UTF-8 JSON 限制为 64 KiB。SERVER_BUSY 表示本次尚未执行,按需稍后重试,不自动重放或重启;REQUEST_TIMEOUT 包括排队耗时,不证明业务没有执行。 -按需获取小结果,不例行探测、遍历全仓、截图或重复枚举。 -保留降级、截断与歧义,不把源码候选当作确定的运行时映射。 +连接固定到启动工作区;已知根一致时直接查询,不例行重复打开。`WORKSPACE_MISMATCH` 时选择目标项目的连接,可参考 `connectionGuide`;`workspace_open` 只能确认或恢复原工作区。 + +默认 `local-text` 提供文本线索;显式启用 Roslyn 才有 C# 语义证据。需要精确引用时先搜索声明,再传回完整 `location`,不猜定位或复用过期快照。 + +日常导航用 `wincode_search_text` 限定目录查字面量、`wincode_file_outline` 查看行数和声明,再把返回的 `nextRequest` 交给 `wincode_prepare_context`。先看 `summary` 的范围和缺口,再核对正文与覆盖率。UI 首轮可显式用 `responseFormat:"compact"`,需要几何或更多信息时按 `expansionRequests` 展开;仅使用本连接已声明的能力。 + +优先按文件、符号和行范围获取小结果。参数遵循手册与实际 Schema;保留截断、降级和歧义,UI 源码候选不等于已验证的运行时映射。`SERVER_BUSY` 或超时后先按诊断手册处理,不自动重放请求或重启连接。 diff --git a/skills/wincode/references/code.md b/skills/wincode/references/code.md index b7beaa7..bcd251a 100644 --- a/skills/wincode/references/code.md +++ b/skills/wincode/references/code.md @@ -41,7 +41,7 @@ allowProjectEvaluation 表示允许 MSBuild 设计时求值执行项目 targets `additionalInputs` 是可选启动配置,默认空数组。例如构建读取 schema.yaml 和 build-inputs/custom.rules,可填 ["schema.yaml","build-inputs/custom.rules"]。最多 32 个固定工作区相对文件路径,数组 JSON 最长 4096 个 UTF-16 字符;不接受根外/绝对路径、重复项、目录、通配符或链接。缺失项报 INPUT_UNAVAILABLE,不静默删除;创建或恢复文件后再显式搜索。其他项目连接分别配置自己的列表。修改列表需更新启动配置并重启 Gateway,普通 MCP 参数不能添加输入或获取项目执行许可。 -Host 通过独立进程的 JSON 行协议 v2 工作,非 MCP tools/call:启动参数为 `--allow-project-evaluation ROOT PROJECT CONFIGURATION FRAMEWORK [ADDITIONAL_INPUTS_JSON]`;加载后 ready 帧给出 protocolVersion=2、snapshot 及 inputPolicy={version:1,additionalInputs:[...]}。Gateway 必须核对实际列表;旧 Host 缺少输入策略确认或列表不一致时拒绝接入,即使同为协议 v2 也不能假定兼容。项目求值可能执行 targets,不自动 restore;本维护验收只使用获准的生成夹具。协议及启动方式以源码 `tools/WinCode.Code.Host/Program.cs` 注释为准,尚非稳定公共接口。 +Host 通过独立进程的 JSON 行协议 v2 工作,非 MCP tools/call:启动参数为 `--allow-project-evaluation ROOT PROJECT CONFIGURATION FRAMEWORK [ADDITIONAL_INPUTS_JSON]`;加载后 ready 帧给出 protocolVersion=2、snapshot 及 inputPolicy={version:2,additionalInputs:[...]}。Gateway 必须核对实际列表;旧 Host 缺少输入策略确认或列表不一致时拒绝接入,即使同为协议 v2 也不能假定兼容。项目求值可能执行 targets,不自动 restore;本维护验收只使用获准的生成夹具。协议及启动方式以源码 `tools/WinCode.Code.Host/Program.cs` 注释为准,尚非稳定公共接口。 | 内部 operation | 请求与结果 | | --- | --- | @@ -61,6 +61,15 @@ Host 监听变化并在查询前后比较内容指纹,变化时丢弃结果并 ## 规范字段 +先定位再阅读:`wincode_search_text({query:"Save(",scopePaths:["src"]})` 返回每个匹配行的文件、行号、1 起始 UTF-16 列号和预览。`nextRequest` 是下一次 `wincode_prepare_context` 的完整参数,不是自动执行指令。`wincode_file_outline({file:"src/Service.cs"})` 给出同次读取的 `fileLineCount`、`sizeBytes`、声明及续读请求。声明边界仍是文本模式,不代表整个方法或语义身份。 + +| 导航工具 | 必填字段 | 可选字段及范围 | +| --- | --- | --- | +| `wincode_search_text` | `query`:非空白单行字面量,最长 256;不接受正则表达式 | `scopePaths`:1–20 个字面文件或目录,默认工作区;`caseSensitive`:布尔值,默认 false;`maxResults`:整数 1–200,默认 50;`maxOutputChars`:整数 2048–32768,默认 8000 | +| `wincode_file_outline` | `file`:工作区内字面文件 | `maxSymbols`:整数 1–200,默认 100;`maxOutputChars`:整数 2048–32768,默认 8000 | + +导航路径最长 1024,允许工作区内绝对路径,拒绝通配符、父目录逃逸和范围外链接。搜索范围排他、重叠文件去重;单文件 256 KiB、合计 8 MiB、最多 5000 枚举项,并遵守操作取消和扫描时限。默认跳过生成目录及 test-tmp。支持 C#/TS/TSX/JS/JSX/Python 源码,以及 XAML/XML、项目/解决方案、JSON/Markdown/文本、YAML/config/PowerShell/mjs/cjs 文件;声明概览仅解析前六种源码,其余返回 `declarationsSupported:false`。扫描限制和最终 JSON 输出限制都可能使结果不完整。`foundItems` 是有界扫描发现数,`returnedItems` 是实际展示数;`fileIssues` 最多 20 条,`fileIssuesOmitted` 表示省略数。词法失败的文件在 `fileIssues` 中具名;零结果不能证明未扫描范围没有匹配。 + 兼容容忍模式允许额外字段,但会忽略它们,不能据“调用成功”判断参数已经生效。例如 `scopeFile`、`scope_files` 均不是 `scopeFiles`,`symbolName` 不能代替查符号工具的 `query`。未知字段不能补足缺失必填项;已知字段填错类型、空白必填值或违反范围规则仍会报错。下面列出的名称区分大小写,未列出的参数不应发送。 | 工具 | 必填字段 | 可选字段及类型 | @@ -107,6 +116,8 @@ local-text 的 queryComplete=true 仅表示该次有界文本扫描完成,仍 ## 按任务取证 +上下文先看 `summary`:`scope` 区分 requested-lines、packed-files 和 displayed-snippets,`status` 只描述这个范围的 complete/partial/empty/unknown;`nextAction` 指向续读、文件问题或缩小范围,不承诺整个审查已完成。`line-range-out-of-bounds` 保留原始请求的未覆盖状态,并报告实际 `fileLineCount`;若请求与文件仍有交集,`coverage.details[].nextRequest` 返回有效交集。起点已超过 EOF 或文件不存在时不生成盲目重试。普通文件开头片段也带有界续读请求。 + 怀疑源码与连接不同步时,先调用 wincode_hello_world({toolName:"wincode_prepare_context"}),对照本连接 tools/list 的参数及 schemaHash,并记录 runtime.instanceId/build.buildId。旧实例没有这些字段时明确为旧契约,不再反复尝试新参数。构建后需要客户端重连;build.status=unknown 不能当成当前源码已运行。test:e2e 只证明它自己启动的隔离 stdio 进程。 lineRanges 查看最终 coverage.allRequestedCovered、completeLines 和 details 中的 missingRanges/nextRequest。末行 endLineComplete=false 时从该整行补取,不能把“行号落入区间”算作完整正文。nextRequest 可能提高预算;maximum-budget-without-progress 表示不要反复提交同一请求。明细缺省还需检查 omittedItemCount。scopeFiles/symbol 的 coverage=null,taskCoverage=null;queryComplete 或非空片段均不证明整个方法覆盖。 diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index 9dbd957..4eaa4cb 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -1,5 +1,7 @@ # 诊断与审计 +跨项目入口:新构建的 `WORKSPACE_MISMATCH` 响应包含 `connectionGuide`,其中 `configuration.command/args` 是独立 STDIO 连接配置,`verification` 给出连接后检查工作区的调用。也可运行 `node /dist/index.js --print-connection --workspace <目标绝对路径>` 输出同一配置;不创建缓存、不注册或重启客户端、不启动项目 Host。配置默认 local-text,不复制已有 Roslyn、开发或托盘选项;目录存在性在实际连接启动时校验。选择已有正确连接优先,建立新连接仍遵守用户授权。刷新连接后再使用新增导航工具或 UI 精简参数,磁盘重建和 Skill 同步不会热替换旧 MCP Schema。 + 0.15.0 的 WORKSPACE_MISMATCH 是固定工作区拒绝:检查 activeWorkspace/requestedWorkspace,选择对应项目连接。错误发生在工作区资源变更之前,不表示旧根已切换或需要清空缓存。hello.health.workspaceBinding 给出固定根及启动来源;argument 是显式 CLI 参数,cwd 是启动目录回退,configuration 是嵌入式配置。显式 --workspace 必须有绝对目录值;已有连接不会因磁盘重建或配置保存自行更新。 0.15.0 的 health.admission 返回 business/status 的 active、executing、waiting、accepted、completed、rejected、cancelled、timedOut、peakActive,以及累计 waitMs/executionMs 和 maxWaitMs。每实例最多 32 个未完成业务请求、4 个共享轻量状态请求;内层互斥保持 FIFO,运行中取消须在实际清理后归还容量。workspace_open 占用业务容量,但不计入它自己等待排空的 inFlight。状态不等待慢查询或同根恢复;tools/list 满额以协议错误 data.errorCode=SERVER_BUSY 表达。 @@ -60,7 +62,7 @@ Code Host 内部协议 v2 的失败包含 success=false、errorCode 和 error, Roslyn 的已知领域错误通过 MCP 的 isError=true 和 JSON 文本 success=false/errorCode/errorMessage 返回;失败的 JSON 文本与 structuredContent 一致,仍保留领域差异。健康状态下同根 workspace_open 保留 Host 和 snapshot;它不是强制冷启动命令,也不是等待在途业务清理完成的屏障。普通同根确认取消不进入恢复;客户端取消先返回时,后台操作仍可能正在清理,不能把客户端 Promise 结束当作 Host 已退出。HOST_RESTART_REQUIRED(SDK/监听状态)应对当前路径执行 workspace_open,此时才关闭旧 Host,再由显式搜索重新选择 SDK。INPUTS_CHANGED/SNAPSHOT_STALE 按 search_again 重新搜索,普通打开不清除已知重载要求。启动时的项目、Configuration、TFM 和可执行文件配置固定于 Adapter;修改客户端启动配置后须正常重建连接,workspace_open 不热应用配置文件。清理失败则按 WORKSPACE_RECOVERY_REQUIRED 的 restart_gateway 处理,不能通过再次打开恢复。HOST_TIMEOUT/HOST_CRASHED 后旧定位不可用,下一次显式搜索才启动新 Host;不会重放失败引用。 -INPUT_UNAVAILABLE/HOST_UNAVAILABLE 先检查明确的配置文件、SDK/Host/项目路径,以及 additionalInputs 中的文件是否存在;补充文件缺失时,重载也会失败,恢复文件后再显式搜索。不要为恢复查询而静默移除真实构建输入。HOST_VERSION_MISMATCH 先核对 Code Host 与 Gateway 的版本、Release 配置和协议;不要继续使用混合交付。HOST_PROTOCOL_ERROR 同时检查协议 v2、inputPolicy.version=1 和实际补充列表;旧 Host 没有确认新策略时不能绕过。LEGACY_SYMBOL_ID 要求重新搜索 Roslyn 身份;UNSUPPORTED_SYMBOL_LOCATION 表示该实例未配置 Roslyn;SYMBOL_MISMATCH 表示名称和定位不一致。INPUT_BUDGET_EXCEEDED 区分枚举规模与受跟踪输入字节限制,先缩小受支持范围,不能接受截断指纹。内部 BUSY 表示队列已满,DUPLICATE_REQUEST 要求新的 id;CANCELLED 是目标终止结果,取消确认不替代它。OUTSIDE_WORKSPACE/UNSUPPORTED_LINK 拒绝越界或链接路径,不放松校验来恢复。 +INPUT_UNAVAILABLE/HOST_UNAVAILABLE 先检查明确的配置文件、SDK/Host/项目路径,以及 additionalInputs 中的文件是否存在;补充文件缺失时,重载也会失败,恢复文件后再显式搜索。不要为恢复查询而静默移除真实构建输入。HOST_VERSION_MISMATCH 先核对 Code Host 与 Gateway 的版本、Release 配置和协议;不要继续使用混合交付。HOST_PROTOCOL_ERROR 同时检查协议 v2、inputPolicy.version=2 和实际补充列表;旧 Host 没有确认新策略时不能绕过。LEGACY_SYMBOL_ID 要求重新搜索 Roslyn 身份;UNSUPPORTED_SYMBOL_LOCATION 表示该实例未配置 Roslyn;SYMBOL_MISMATCH 表示名称和定位不一致。INPUT_BUDGET_EXCEEDED 区分枚举规模与受跟踪输入字节限制,先缩小受支持范围,不能接受截断指纹。内部 BUSY 表示队列已满,DUPLICATE_REQUEST 要求新的 id;CANCELLED 是目标终止结果,取消确认不替代它。OUTSIDE_WORKSPACE/UNSUPPORTED_LINK 拒绝越界或链接路径,不放松校验来恢复。 维护接口变更时,同步检查 Gateway 工具定义、相应 references 手册、实际客户端 Schema 和已安装四份受管文件;更新源码手册后运行 skill:sync,再以 skill:check 校验。仍须单独确认 MCP 实例的版本/构建/Schema,不能用手册同步代替重连。公共接口尚未发布时,只记录实验边界,不提前把新参数加入 MCP 规范字段表。 @@ -87,7 +89,7 @@ Gateway 通过子进程私有环境传递所属 PID;两个 .NET Host 在项目 ## 手动 Roslyn 释放与可选托盘(0.14.0) -自动释放关闭,本版不创建 idle timer。用户可按 README 手动启动独立 Tray,并给希望管理的 Gateway 启动参数添加 --tray 后刷新连接。托盘只管理已注册的实例,不扫描/终止外部客户端或目标应用;MCP 仍为原有 15 个工具,没有让 Agent 自动代替用户释放的管理工具。默认不启用托盘连接、不设置自启动。 +自动释放关闭,本版不创建 idle timer。用户可按 README 手动启动独立 Tray,并给希望管理的 Gateway 启动参数添加 --tray 后刷新连接。托盘只管理已注册的实例,不扫描/终止外部客户端或目标应用;当前新增两个只读导航工具后共 17 个公开工具名称,仍没有让 Agent 自动代替用户释放的管理工具。默认不启用托盘连接、不设置自启动。 手动释放遇到业务在途、语义排队/收尾、工作区确认或恢复门时拒绝,不自动延后执行。释放完成后新请求继续;旧 symbolLocation 返回 SNAPSHOT_STALE,显式重新搜索取得当前定位。保留 Gateway、watcher、缓存与最后诊断。清理失败进入 restart_gateway 恢复门,不能靠反复点击清除错误。local-text 没有可释放的 Roslyn。 diff --git a/skills/wincode/references/ui.md b/skills/wincode/references/ui.md index 2e6c790..7f627db 100644 --- a/skills/wincode/references/ui.md +++ b/skills/wincode/references/ui.md @@ -14,6 +14,7 @@ UI 工具同样占用每实例 32 个业务受理槽;既有 UI/健康探测互 | `wincode_ui_inspect.pid` | 可选正整数;与 `hwnd` 至少提供一个 | | `hwnd` | 可选非空字符串,十六进制如 `"0x123ABC"` 或十进制字符串;不能传 JSON 数字 | | `capture` | 可选字符串 `none/original/annotated`,默认 `none` | +| `responseFormat` | inspect/review 可选 `full/compact`,默认 full;只影响 Gateway 输出,不改变原生取证或执行 UI 操作 | | `maxDepth` / `maxNodes` | 可选整数,分别为 1–50(默认 6)、1–5000(默认 300) | | `backgroundOnly` | 可选布尔值,默认 `false`;为 `true` 时必须同时提供 `pid` 和 `hwnd` | | `readStates` | 可选布尔值,默认 `false`;只读状态,不执行动作或读取输入值 | @@ -24,6 +25,10 @@ UI 工具同样占用每实例 32 个业务受理槽;既有 UI/健康探测互 ## 选择目标与取证 +首轮可以显式传 `responseFormat:"compact"`;默认 `full` 保持兼容。精简格式保留全部已返回控件的 ID、层级、名称、AutomationId、状态和同一截图,省略节点 bounds/relativeBounds/className;`summary` 仅统计已返回快照中的无名称按钮、禁用控件和树缺口,不把这些观察自动判为缺陷。需要坐标或更完整信息时使用 `expansionRequests` 中的 tool/arguments;它们重新观察实时 UI,ID 可能变化,查询仍须检查唯一性和完整性。 + +精简 `codeEvidence.candidates` 是去重后的候选表,`clues[].candidateIds` 引用其 id,候选自身的 `nextRequest` 只保存一份;full 格式仍是 `clues[].candidates`。两种格式都保留运行时/源码身份未验证、歧义、截断与扫描边界。不要把 candidate id 当成源码的持久身份。 + 已知准确 PID/HWND 就直接使用;未知时调用 wincode_ui_list_windows,以 processName(不带 .exe)、pid 或 titleContains 缩小范围,maxWindows 建议 10。筛选同时满足。候选歧义时先确认目标,标题不证明源码归属;失效句柄才重新发现。 对用户指定窗口调用 wincode_ui_inspect,例如(编号必须替换为真实结果): diff --git a/src/Adapters/LocalTextAdapter.ts b/src/Adapters/LocalTextAdapter.ts index b35348d..afdc50f 100644 --- a/src/Adapters/LocalTextAdapter.ts +++ b/src/Adapters/LocalTextAdapter.ts @@ -57,6 +57,7 @@ export class LocalTextAdapter { analysisCompleteness: scan.complete ? 'degraded' : 'incomplete', limitations: [...(scan.error ? [`查询不完整: ${scan.error}`] : []), ...LOCAL_TEXT_LIMITATIONS], queryComplete: scan.complete, queryError: scan.error, truncated: scan.truncated, + fileIssues: scan.fileIssues, fileIssuesOmitted: scan.fileIssuesOmitted, uniqueTypeMatch: scan.complete && !scan.truncated && stats.uniqueTypeMatch, typeMatchCount: stats.typeMatchCount, }; checkOperation(operation); @@ -89,6 +90,7 @@ export class LocalTextAdapter { analysisCompleteness: scan.complete ? 'degraded' : 'incomplete', limitations: [...(scan.error ? [`查询不完整: ${scan.error}`] : []), ...LOCAL_TEXT_LIMITATIONS], queryComplete: scan.complete, queryError: scan.error, truncated: scan.truncated, + fileIssues: scan.fileIssues, fileIssuesOmitted: scan.fileIssuesOmitted, }; checkOperation(operation); return result; diff --git a/src/Core/CodeNavigation.ts b/src/Core/CodeNavigation.ts new file mode 100644 index 0000000..0c788dd --- /dev/null +++ b/src/Core/CodeNavigation.ts @@ -0,0 +1,125 @@ +import path from 'node:path'; +import type { WinCodeConfig } from './Config.js'; +import { scanLocalFiles, type LocalScanResult } from './LocalTextScanner.js'; +import { parseTextDeclarations } from './TextDeclarations.js'; +import { boundedInteger, isWorkspacePathInside } from './WorkspaceContracts.js'; +import { checkOperation, type OperationContext } from './OperationContext.js'; + +export interface TextSearchOptions { + query: string; scopePaths?: string[]; caseSensitive?: boolean; maxResults?: number; maxOutputChars?: number; +} +export interface FileOutlineOptions { file: string; maxSymbols?: number; maxOutputChars?: number; } + +const declarationExtensions = ['.cs', '.ts', '.tsx', '.js', '.jsx', '.py']; +const textExtensions = [...declarationExtensions, '.xaml', '.xml', '.csproj', '.sln', '.slnx', '.props', '.targets', + '.json', '.md', '.txt', '.yml', '.yaml', '.config', '.ps1', '.mjs', '.cjs']; + +/** Admission performs lexical checks only; the shared scanner separately validates real paths. */ +export function navigationPath(value: unknown, root: string): string { + if (typeof value !== 'string' || !value.trim() || value.length > 1024 || /[\x00-\x1f*?]/.test(value) || + value.replace(/\\/g, '/').split('/').includes('..') || + value.slice(path.parse(value).root.length).includes(':')) throw new Error('Use a literal in-workspace path without globs or parent traversal.'); + const full = path.resolve(root, value); + if (!isWorkspacePathInside(root, full, true)) throw new Error('Path is outside-workspace.'); + return path.relative(root, full).replace(/\\/g, '/') || '.'; +} + +export function validateTextSearch(options: TextSearchOptions, root: string) { + if (typeof options.query !== 'string' || !options.query.trim() || options.query.length > 256 || /[\r\n\0]/.test(options.query)) + throw new Error('query must be a non-empty single-line literal of at most 256 characters.'); + const scopes = options.scopePaths ?? ['.']; + if (!Array.isArray(scopes) || !scopes.length || scopes.length > 20) throw new Error('scopePaths accepts 1-20 literal files or directories.'); + if (options.caseSensitive !== undefined && typeof options.caseSensitive !== 'boolean') throw new Error('caseSensitive must be a boolean.'); + return { scopePaths: [...new Set(scopes.map(scope => navigationPath(scope, root)))], + maxResults: boundedInteger(options.maxResults, 50, 1, 200, 'maxResults'), + maxOutputChars: boundedInteger(options.maxOutputChars, 8000, 2048, 32768, 'maxOutputChars') }; +} + +export function validateFileOutline(options: FileOutlineOptions, root: string) { + const file = navigationPath(options.file, root); + if (!textExtensions.includes(path.extname(file).toLowerCase())) throw new Error('file must use a supported source or text extension.'); + return { file, maxSymbols: boundedInteger(options.maxSymbols, 100, 1, 200, 'maxSymbols'), + maxOutputChars: boundedInteger(options.maxOutputChars, 8000, 2048, 32768, 'maxOutputChars') }; +} + +function nextRead(file: string, line: number, fileLineCount: number) { + return { task: 'Inspect the located source; text matches and declarations are not semantic identity.', + lineRanges: [{ file: file.replace(/\\/g, '/'), startLine: Math.max(1, line - 8), endLine: Math.min(fileLineCount, line + 16) }] }; +} + +export interface NavigationResponse { + source: 'local-text'; scope: 'literal-text' | 'file-declarations'; + query?: string; caseSensitive?: boolean; scopePaths?: string[]; scopePathsOmitted?: number; + file?: string; fileLineCount?: number | null; sizeBytes?: number | null; declarationsSupported?: boolean; + matches?: unknown[]; symbols?: unknown[]; + nextRequest?: ReturnType; + scanComplete: boolean; queryComplete: boolean; queryError?: string; truncated: boolean; + fileIssues: LocalScanResult['fileIssues']; fileIssuesOmitted: number; + scannedFiles: number; scannedBytes: number; foundItems: number; returnedItems: number; + nextAction: string; limitations: string[]; +} + +function scanMetadata(scan: LocalScanResult) { + return { source: 'local-text' as const, scanComplete: scan.complete, queryComplete: scan.complete, + queryError: scan.error, truncated: scan.truncated, fileIssues: scan.fileIssues, fileIssuesOmitted: scan.fileIssuesOmitted, + scannedFiles: scan.filesRead, scannedBytes: scan.bytesRead, foundItems: scan.items.length, returnedItems: scan.items.length, + nextAction: scan.fileIssues.length ? 'inspect_file_issues' : scan.items.length ? 'follow_next_request' : 'refine_query_or_scope', + limitations: ['Text evidence only; scan completion does not establish semantic or task coverage.'] }; +} + +/** Budget the serialized transport form, retaining total counts when any details are omitted. */ +function boundedResponse(data: NavigationResponse, maxCharacters: number): NavigationResponse { + const items = data.matches ?? data.symbols!; + while (JSON.stringify(data).length > maxCharacters) { + data.truncated = true; + data.queryComplete = false; + data.nextAction = 'narrow_scope_or_increase_output_budget'; + if (data.fileIssues.length > 1) { data.fileIssues.pop(); data.fileIssuesOmitted++; } + else if ((data.scopePaths?.length ?? 0) > 1) { data.scopePaths!.pop(); data.scopePathsOmitted = (data.scopePathsOmitted ?? 0) + 1; } + else if (items.length) { items.pop(); data.returnedItems = items.length; } + else if (data.fileIssues.length) { data.fileIssues.pop(); data.fileIssuesOmitted++; } + else if (data.scopePaths?.length) { data.scopePaths.pop(); data.scopePathsOmitted = (data.scopePathsOmitted ?? 0) + 1; } + else if (data.nextRequest) delete data.nextRequest; + else if (data.limitations.length) data.limitations.pop(); + else throw new Error('Navigation metadata exceeds the output budget; use a shorter path or increase maxOutputChars.'); + } + return data; +} + +export async function searchText(config: WinCodeConfig, options: TextSearchOptions, operation?: OperationContext) { + const { scopePaths, maxResults, maxOutputChars } = validateTextSearch(options, config.workspaceRoot); + // Escaping keeps regex punctuation literal; matching itself cannot introduce user regex backtracking. + const literal = new RegExp(options.query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), options.caseSensitive ? '' : 'i'); + const scan = await scanLocalFiles(config.workspaceRoot, config.timeouts.fileScanMs, textExtensions, maxResults, + function* (content, file) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + checkOperation(operation); + const match = literal.exec(lines[index]); + if (!match) continue; + const start = Math.max(0, match.index - 80); + const end = Math.min(lines[index].length, match.index + options.query.length + 80); + yield { file: file.replace(/\\/g, '/'), line: index + 1, column: match.index + 1, + preview: lines[index].slice(start, end), previewStartColumn: start + 1, + previewTruncated: start > 0 || end < lines[index].length, nextRequest: nextRead(file, index + 1, lines.length) }; + } + }, scopePaths, operation); + return boundedResponse({ ...scanMetadata(scan), scope: 'literal-text', query: options.query, + caseSensitive: options.caseSensitive ?? false, scopePaths, matches: scan.items }, maxOutputChars); +} + +export async function fileOutline(config: WinCodeConfig, options: FileOutlineOptions, operation?: OperationContext) { + const { file, maxSymbols, maxOutputChars } = validateFileOutline(options, config.workspaceRoot); + let fileLineCount: number | null = null, sizeBytes: number | null = null; + const declarationsSupported = declarationExtensions.includes(path.extname(file).toLowerCase()); + const scan = await scanLocalFiles(config.workspaceRoot, config.timeouts.fileScanMs, textExtensions, maxSymbols, + (content, relativeFile, extension, byteLength) => { + fileLineCount = content.split(/\r?\n/).length; + sizeBytes = byteLength; + return declarationsSupported ? parseTextDeclarations(content, relativeFile, extension, () => checkOperation(operation)) + .map(symbol => ({ ...symbol, file: symbol.file.replace(/\\/g, '/'), nextRequest: nextRead(file, symbol.line, fileLineCount!) })) : []; + }, file, operation); + return boundedResponse({ ...scanMetadata(scan), scope: 'file-declarations', file, fileLineCount, sizeBytes, + ...(fileLineCount !== null ? { nextRequest: nextRead(file, 1, fileLineCount) } : {}), + declarationsSupported, symbols: scan.items }, maxOutputChars); +} diff --git a/src/Core/CodeQueries.ts b/src/Core/CodeQueries.ts index 73ee108..e4f589a 100644 --- a/src/Core/CodeQueries.ts +++ b/src/Core/CodeQueries.ts @@ -58,6 +58,8 @@ export interface SymbolReference { } export interface FindSymbolsResult { + fileIssues?: { path: string; reason: string }[]; + fileIssuesOmitted?: number; semanticContext?: SemanticContext; query: string; kindFilter?: string; @@ -74,6 +76,8 @@ export interface FindSymbolsResult { } export interface FindReferencesResult { + fileIssues?: { path: string; reason: string }[]; + fileIssuesOmitted?: number; semanticContext?: SemanticContext; symbolName: string; totalReferences: number; diff --git a/src/Core/Context.ts b/src/Core/Context.ts index a7be361..ac5d75c 100644 --- a/src/Core/Context.ts +++ b/src/Core/Context.ts @@ -73,7 +73,7 @@ export interface PreparedContextResult { evidence: ContextEvidence[]; relatedFiles: { path: string; included: boolean; reason: string }[]; omittedFiles: string[]; - fileIssues: { path: string; reason: string }[]; + fileIssues: { path: string; reason: string; fileLineCount?: number }[]; queryComplete: boolean; truncated: boolean; evidenceInsufficient: boolean; @@ -592,7 +592,10 @@ export class ContextManager { if (stat.size >= 500_000) { issues.push({ path: rel, reason: 'file-too-large' }); return null; } const content = sourceContent ?? await fs.readFile(fullPath, { encoding: 'utf8', signal: operation?.signal }); const lines = content.split(/\r?\n/); - if (range && range.endLine > lines.length) { issues.push({ path: rel, reason: 'line-range-out-of-bounds' }); return null; } + if (range && range.endLine > lines.length) { + issues.push({ path: rel, reason: 'line-range-out-of-bounds', fileLineCount: lines.length }); + return null; + } const symbol = symbols.find((s) => this.normalizeRel(s.file) === this.normalizeRel(rel) && Number.isInteger(s.line) && s.line! > 0 && s.line! <= lines.length); const center = symbol?.line && symbol.line > 0 ? symbol.line - 1 : 0; diff --git a/src/Core/LocalTextScanner.ts b/src/Core/LocalTextScanner.ts index c21a0b1..7e49ef9 100644 --- a/src/Core/LocalTextScanner.ts +++ b/src/Core/LocalTextScanner.ts @@ -3,14 +3,22 @@ import path from 'node:path'; import { checkOperation, rethrowOperationError, type OperationContext } from './OperationContext.js'; /** 扫描完成仅指文本扫描范围,不代表语义引用完整。 */ -export interface LocalScanResult { items: T[]; complete: boolean; truncated: boolean; error?: string; } +export interface LocalScanResult { + items: T[]; complete: boolean; truncated: boolean; error?: string; + fileIssues: { path: string; reason: string }[]; fileIssuesOmitted: number; + filesRead: number; bytesRead: number; +} /** 保留原有全局预算、路径边界、编码检查和取消传播;不同目录共用同一个扫描预算。 */ export async function scanLocalFiles(root: string, timeoutMs: number, extensions: string[], maxResults: number, - extract: (content: string, relPath: string, ext: string) => Iterable, relativePath?: string, operation?: OperationContext + extract: (content: string, relPath: string, ext: string, sizeBytes: number) => Iterable, relativePath?: string | string[], operation?: OperationContext ): Promise> { const items: T[] = []; const reasons = new Set(); + const fileIssues: LocalScanResult['fileIssues'] = []; + let fileIssuesOmitted = 0; + let filesRead = 0; + const visited = new Set(); const deadline = Date.now() + timeoutMs; const fileLimit = 256 * 1024; const totalLimit = 8 * 1024 * 1024; @@ -18,9 +26,11 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi let entriesVisited = 0; let stopped = false; let truncated = false; - const ignoredDirs = new Set(['node_modules', 'bin', 'obj', 'dist', '.git', '.vs', 'trash', '.cache', '.deps', '.packages', '.dotnet', '.dotnet_cli_home']); - const mark = (reason: string, bounded = false, stop = false): void => { + const ignoredDirs = new Set(['node_modules', 'bin', 'obj', 'dist', '.git', '.vs', 'trash', '.cache', '.deps', '.packages', '.dotnet', '.dotnet_cli_home', 'test-tmp']); + const mark = (reason: string, bounded = false, stop = false, file = '.'): void => { reasons.add(reason); + if (fileIssues.length < 20) fileIssues.push({ path: file.replace(/\\/g, '/'), reason }); + else fileIssuesOmitted++; truncated ||= bounded; stopped ||= stop; }; @@ -35,24 +45,30 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi }; let realRoot: string; try { realRoot = await fs.realpath(root); } - catch { return { items, complete: false, truncated: false, error: 'Local scan: read-error' }; } + catch { return { items, complete: false, truncated: false, error: 'Local scan: read-error', + fileIssues: [{ path: '.', reason: 'read-error' }], fileIssuesOmitted: 0, filesRead, bytesRead }; } const read = async (fullPath: string): Promise => { if (!canContinue()) return; const ext = path.extname(fullPath).toLowerCase(); if (!extensions.includes(ext)) return; + const file = path.relative(root, fullPath).replace(/\\/g, '/'); + const issue = (reason: string, bounded = false, stop = false) => mark(reason, bounded, stop, file); try { // Also guards a scoped path whose intermediate directory is a junction. const actualPath = await fs.realpath(fullPath); - if (!isInside(actualPath, realRoot)) { mark('invalid-scope'); return; } + if (!isInside(actualPath, realRoot)) { issue('invalid-scope'); return; } + const key = process.platform === 'win32' ? actualPath.toLowerCase() : actualPath; + if (visited.has(key)) return; + visited.add(key); if (!canContinue()) return; const handle = await fs.open(actualPath, 'r'); try { const stat = await handle.stat(); - if (!stat.isFile()) { mark('read-error'); return; } - if (stat.size > fileLimit) { mark('file-byte-limit', true); return; } + if (!stat.isFile()) { issue('read-error'); return; } + if (stat.size > fileLimit) { issue('file-byte-limit', true); return; } const remaining = totalLimit - bytesRead; - if (remaining <= 0) { mark('total-byte-limit', true, true); return; } + if (remaining <= 0) { issue('total-byte-limit', true, true); return; } // One extra byte detects a file growing after stat, without an unbounded readFile. const buffer = Buffer.alloc(Math.min(fileLimit + 1, remaining)); let used = 0; @@ -64,27 +80,30 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi if (chunk.bytesRead === 0) { eof = true; break; } } if (!canContinue()) return; - if (used > fileLimit) { mark('file-byte-limit', true); return; } - if (!eof && used === remaining) { mark('total-byte-limit', true, true); return; } + if (used > fileLimit) { issue('file-byte-limit', true); return; } + if (!eof && used === remaining) { issue('total-byte-limit', true, true); return; } let content: string; try { content = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, used)); - if (content.includes('\0')) { mark('encoding'); return; } - } catch { mark('encoding'); return; } - for (const item of extract(content, path.relative(root, fullPath), ext)) { + if (content.includes('\0')) { issue('encoding'); return; } + } catch { issue('encoding'); return; } + filesRead++; + for (const item of extract(content, path.relative(root, fullPath), ext, used)) { if (!canContinue()) break; items.push(item); - if (items.length >= maxResults) { mark('result-limit', true, true); break; } + if (items.length >= maxResults) { issue('result-limit', true, true); break; } } } finally { await handle.close(); } } catch (error) { rethrowOperationError(error, operation); - mark((error as NodeJS.ErrnoException)?.code === 'TEXT_LEXICAL_UNCERTAINTY' ? 'lexical-uncertainty' : 'read-error'); + issue((error as NodeJS.ErrnoException)?.code === 'TEXT_LEXICAL_UNCERTAINTY' ? 'lexical-uncertainty' : 'read-error'); } }; const walk = async (dir: string): Promise => { if (!canContinue()) return; try { + const actualPath = await fs.realpath(dir); + if (!isInside(actualPath, realRoot)) { mark('invalid-scope', false, false, path.relative(root, dir)); return; } // Stream directory entries so an enormous directory does not allocate an unbounded array. const handle = await fs.opendir(dir); for await (const entry of handle) { @@ -93,15 +112,29 @@ export async function scanLocalFiles(root: string, timeoutMs: number, extensi const fullPath = path.join(dir, entry.name); if (entry.isDirectory() && !ignoredDirs.has(entry.name)) await walk(fullPath); else if (entry.isFile()) await read(fullPath); - else if (entry.isSymbolicLink()) mark('symlink-skipped'); + else if (entry.isSymbolicLink()) mark('symlink-skipped', false, false, path.relative(root, fullPath)); } - } catch (error) { rethrowOperationError(error, operation); mark('read-error'); } + } catch (error) { rethrowOperationError(error, operation); mark('read-error', false, false, path.relative(root, dir)); } }; if (relativePath) { - const scopedPath = path.resolve(root, relativePath); - if (path.isAbsolute(relativePath) || !isInside(scopedPath, root)) mark('invalid-scope'); - else await read(scopedPath); + const scopes = Array.isArray(relativePath) ? relativePath : [relativePath]; + // Validate the whole selection before reading any of it. + if (scopes.some(scope => path.isAbsolute(scope) || !isInside(path.resolve(root, scope), root))) mark('invalid-scope'); + else for (const scope of scopes) { + if (!canContinue()) break; + const scopedPath = path.resolve(root, scope); + try { + const actualPath = await fs.realpath(scopedPath); + if (!isInside(actualPath, realRoot)) { mark('invalid-scope', false, false, scope); continue; } + if ((await fs.stat(actualPath)).isDirectory()) { + if (Array.isArray(relativePath)) await walk(scopedPath); + else mark('not-file', false, false, scope); + } + else await read(scopedPath); + } catch (error) { rethrowOperationError(error, operation); mark('read-error', false, false, scope); } + } } else await walk(root); - return { items, complete: reasons.size === 0, truncated, + checkOperation(operation); + return { items, complete: reasons.size === 0, truncated, fileIssues, fileIssuesOmitted, filesRead, bytesRead, error: reasons.size ? `Local scan: ${[...reasons].join(', ')}` : undefined }; } diff --git a/src/Core/ToolRouter.ts b/src/Core/ToolRouter.ts index 99a4e41..d33ff4b 100644 --- a/src/Core/ToolRouter.ts +++ b/src/Core/ToolRouter.ts @@ -4,6 +4,7 @@ import { CacheManager, KnownCacheStats } from './Cache.js'; import { RequestAdmission } from './RequestAdmission.js'; import { WorkspaceManager, WorkspaceOpenOptions, WorkspaceDirectoryOptions } from './Workspace.js'; import { ContextManager, PreparedContextOptions } from './Context.js'; +import { searchText, fileOutline, type TextSearchOptions, type FileOutlineOptions } from './CodeNavigation.js'; import { RepomixAdapter } from '../Adapters/RepomixAdapter.js'; import { LocalTextAdapter } from '../Adapters/LocalTextAdapter.js'; import { RoslynAdapter } from '../Adapters/RoslynAdapter.js'; @@ -248,6 +249,14 @@ export class ToolRouter { return this.runCode(signal, operation => this.context.prepareContext(options, operation)); } + searchText(options: TextSearchOptions, signal?: AbortSignal) { + return this.runCode(signal, operation => searchText(this.config, options, operation)); + } + + fileOutline(options: FileOutlineOptions, signal?: AbortSignal) { + return this.runCode(signal, operation => fileOutline(this.config, options, operation)); + } + analyzeWorkspace(maxDepth?: number, signal?: AbortSignal) { return this.runCode(signal, operation => this.architecture.analyze(maxDepth, operation)); } diff --git a/src/Gateway/CodeTools.ts b/src/Gateway/CodeTools.ts index f6e6789..bd47bd0 100644 --- a/src/Gateway/CodeTools.ts +++ b/src/Gateway/CodeTools.ts @@ -2,6 +2,7 @@ import { defineTool, jsonResult } from './ToolDefinition.js'; import { contextResponse } from './ContextResponse.js'; import { validateContextScope, type PreparedContextOptions } from '../Core/Context.js'; import type { SymbolLocation } from '../Core/CodeQueries.js'; +import { validateTextSearch, validateFileOutline, type TextSearchOptions, type FileOutlineOptions } from '../Core/CodeNavigation.js'; /** 引用、影响与重构共用同一位置校验契约。 */ const symbolLocationSchema = { @@ -16,6 +17,35 @@ const symbolLocationSchema = { }; export const CODE_TOOLS = [ + defineTool({ + name: 'wincode_search_text', + description: 'Searches a literal single-line string in bounded workspace files/directories without a semantic Host. Returns one match per line, UTF-16 columns, clipped previews and executable prepare_context nextRequest arguments. Inspect queryComplete, fileIssues and omitted counts; zero matches do not prove absence outside the scanned scope.', + annotations: { readOnlyHint: true }, + inputSchema: { type: 'object', additionalProperties: true, required: ['query'], properties: { + query: { type: 'string', minLength: 1, maxLength: 256, description: 'Literal text, not a regular expression; no newlines.' }, + scopePaths: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1024 }, + description: 'Exclusive literal files or directories; defaults to the workspace. Prefer a narrow scope. In-workspace absolute paths are accepted; no globs or parent traversal.' }, + caseSensitive: { type: 'boolean', default: false }, + maxResults: { type: 'integer', minimum: 1, maximum: 200, default: 50 }, + maxOutputChars: { type: 'integer', minimum: 2048, maximum: 32768, default: 8000, description: 'Budget for the entire serialized JSON text, not model tokens.' }, + } }, + }, { + validate: (args, { router }) => { validateTextSearch(args, router.config.workspaceRoot); }, + execute: async (args, { router, signal }) => jsonResult(await router.searchText(args, signal)), + }), + defineTool({ + name: 'wincode_file_outline', + description: 'Reads one bounded UTF-8 source/text file, reports its observed line count/byte size and local declaration outline with prepare_context nextRequest arguments. No method-end or semantic identity claim. Other supported text formats return metadata with declarationsSupported=false. Lexical failures identify the affected file.', + annotations: { readOnlyHint: true }, + inputSchema: { type: 'object', additionalProperties: true, required: ['file'], properties: { + file: { type: 'string', minLength: 1, maxLength: 1024, description: 'Literal file inside the workspace; absolute paths accepted. No globs or parent traversal.' }, + maxSymbols: { type: 'integer', minimum: 1, maximum: 200, default: 100 }, + maxOutputChars: { type: 'integer', minimum: 2048, maximum: 32768, default: 8000 }, + } }, + }, { + validate: (args, { router }) => { validateFileOutline(args, router.config.workspaceRoot); }, + execute: async (args, { router, signal }) => jsonResult(await router.fileOutline(args, signal)), + }), defineTool({ name: 'wincode_prepare_context', description: 'Returns compact file evidence with actual source ranges and omission metadata. Explicit lineRanges report final complete-line coverage and recoverable missing ranges after serialization; a partial last line is not covered. Other requests do not establish full method/task coverage. maxTokens budgets ALL response text using characters/4 (not a model tokenizer). Set includeFullText for packed bodies; legacy opts into JSON plus Markdown.', diff --git a/src/Gateway/ConnectionGuide.ts b/src/Gateway/ConnectionGuide.ts new file mode 100644 index 0000000..69bc1f0 --- /dev/null +++ b/src/Gateway/ConnectionGuide.ts @@ -0,0 +1,16 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** A reviewable launch recipe only: no client discovery, registration, process spawn or workspace change. */ +export function connectionGuide(workspace: string) { + const root = path.resolve(workspace); + return { + workspace: root, + configuration: { command: process.execPath, + args: [fileURLToPath(new URL('../../dist/index.js', import.meta.url)), '--workspace', root] }, + verification: { tool: 'wincode_hello_world', arguments: {}, expectedWorkspace: root }, + codeProvider: 'local-text', + nextAction: 'Select an existing connection for this workspace, or configure a separate STDIO connection using configuration. Then verify its workspace.', + limitations: 'This recipe does not check directory existence or create a connection. It uses default local-text settings; project-specific Roslyn, development and Tray options are not copied.', + }; +} diff --git a/src/Gateway/ContextRangeCoverage.ts b/src/Gateway/ContextRangeCoverage.ts index f27c568..79af2b1 100644 --- a/src/Gateway/ContextRangeCoverage.ts +++ b/src/Gateway/ContextRangeCoverage.ts @@ -39,7 +39,8 @@ export function rangeCoverage(data: PreparedContextResult, originalEvidence: Pre endLine: Math.min(item.endLine, requested.endLine), endLineComplete: item.endLine > requested.endLine || item.endLineComplete === true, })); - const issue = originalIssues.find(item => item.path === requested.file)?.reason; + const fileIssue = originalIssues.find(item => item.path === requested.file); + const issue = fileIssue?.reason; const missingRanges: Array = []; for (let line = requested.startLine; line <= requested.endLine; line++) { if (finalComplete.has(line)) continue; @@ -60,8 +61,16 @@ export function rangeCoverage(data: PreparedContextResult, originalEvidence: Pre const retryBudget = retryNeedsMoreBudget ? Math.min(65536, data.metrics.budgetTokens * 2) : data.metrics.budgetTokens; const retryAdvances = retry && (retry.startLine > requested.startLine || retry.endLine < requested.endLine); const retryBlocked = retry && retryNeedsMoreBudget && retryBudget <= data.metrics.budgetTokens && !retryAdvances; + // Keep the original gap intact; suggest only its intersection with the observed file. + const correctedEnd = issue === 'line-range-out-of-bounds' ? fileIssue?.fileLineCount : undefined; + const correction = correctedEnd !== undefined && requested.startLine <= correctedEnd ? { + task: 'Read the valid portion of the requested range; the original request exceeded EOF.', + lineRanges: [{ file: requested.file, startLine: requested.startLine, endLine: Math.min(requested.endLine, correctedEnd) }], + maxTokens: data.metrics.budgetTokens, + } : undefined; return { file: requested.file, + ...(fileIssue?.fileLineCount !== undefined ? { fileLineCount: fileIssue.fileLineCount } : {}), requested: { startLine: requested.startLine, endLine: requested.endLine }, returned, completeRanges: groupLines([...finalComplete]), missingRanges, status, ...(retryBlocked ? { retryBlockedReason: 'maximum-budget-without-progress', @@ -70,7 +79,7 @@ export function rangeCoverage(data: PreparedContextResult, originalEvidence: Pre task: 'Read the missing source lines.', lineRanges: [{ file: requested.file, startLine: retry.startLine, endLine: retry.endLine }], maxTokens: retryBudget, - } } : {}), + } } : correction ? { nextRequest: correction } : {}), }; }); return { diff --git a/src/Gateway/ContextResponse.ts b/src/Gateway/ContextResponse.ts index 16f8b24..600c171 100644 --- a/src/Gateway/ContextResponse.ts +++ b/src/Gateway/ContextResponse.ts @@ -21,16 +21,16 @@ export function contextResponse(context: PreparedContextResult, responseFormat: // A symbol window has no parsed end boundary. Continue from the final serialized // tail, including a partial last line, without calling the whole method complete. const evidence = data.evidence.map(item => { - if (item.locationKind !== 'symbol') return item; + if (!['symbol', 'file-start'].includes(item.locationKind)) return item; const startLine = item.endLine + (item.endLineComplete === true ? 1 : 0); const endLine = Math.min(startLine + 79, item.fileLineCount ?? 0); const needsMoreBudget = item.endLineComplete !== true; const maxTokens = needsMoreBudget ? Math.min(65536, data.metrics.budgetTokens * 2) : data.metrics.budgetTokens; const blocked = needsMoreBudget && maxTokens <= data.metrics.budgetTokens && startLine <= item.startLine; - return { ...item, symbolCoverage: 'unknown', + return { ...item, ...(item.locationKind === 'symbol' ? { symbolCoverage: 'unknown' } : {}), ...(blocked ? { nextAction: 'Use a file reader for the remaining long line; maximum response budget reached.' } : endLine >= startLine ? { nextRequest: { - task: 'Read following source lines; the symbol end boundary remains unknown.', + task: 'Read following source lines; this continuation does not establish whole-task coverage.', lineRanges: [{ file: item.file, startLine, endLine }], maxTokens, } } : {}), }; @@ -54,13 +54,27 @@ export function contextResponse(context: PreparedContextResult, responseFormat: data.evidenceInsufficient = data.metrics.returnedFiles === 0 || (data.metrics.includeFullText && !data.packedContent); data.omittedFiles = [...omitted].slice(0, omittedListLimit); if (omitted.size > omittedListLimit) metadataTruncated = true; + const coverage = rangeCoverage(data, originalEvidence, originalIssues, requestedRanges, coverageDetailLimit); + const counts = { complete: 0, partial: 0, omitted: 0, unknown: 0 }; + for (const file of selectedFiles) counts[bodyStatus(file)]++; + const hasNextRequest = coverage?.details.some(item => 'nextRequest' in item) || evidence.some(item => 'nextRequest' in item); + const summary = { + scope: coverage ? 'requested-lines' : data.metrics.includeFullText ? 'packed-files' : 'displayed-snippets', + status: returned.length === 0 && !counts.unknown ? 'empty' : counts.unknown ? 'unknown' : + counts.partial || counts.omitted || !data.queryComplete ? 'partial' : 'complete', + selectedFiles: selectedFiles.length, completeFiles: counts.complete, partialFiles: counts.partial, + missingFiles: counts.omitted, unknownFiles: counts.unknown, + nextAction: hasNextRequest ? 'follow_next_request' : data.fileIssues.length ? 'inspect_file_issues' : + !data.queryComplete ? 'narrow_scope' : counts.unknown ? 'inspect_packer_limitations' : + counts.partial || counts.omitted ? 'read_missing_files' : 'inspect_evidence', + }; const { formattedContent, executiveSummary, packedContent, packedFileSpans, requestedLineRanges, ...base } = data; - const common = { ...base, + const common = { summary, ...base, evidence, bodyStatusScope: data.metrics.includeFullText ? 'packed-file' : 'displayed-snippet', relatedFiles: data.relatedFiles.map(item => ({...item, included: returned.includes(item.path), bodyStatus: bodyStatus(item.path)})), responseFormat, metadataTruncated, limitationsOmitted, omittedFileCount: omitted.size, - coverage: rangeCoverage(data, originalEvidence, originalIssues, requestedRanges, coverageDetailLimit), taskCoverage: null }; + coverage, taskCoverage: null }; if (responseFormat === 'compact') { return [{ type: 'text', text: JSON.stringify({ ...common, // A packed body has its own file delimiters. Snippet ranges must not describe that body. @@ -68,14 +82,14 @@ export function contextResponse(context: PreparedContextResult, responseFormat: ...(data.metrics.includeFullText ? { packedContent: packedContent || '' } : {}), }) }]; } - const summary = `# AI Agent Context Snapshot\nTask: ${data.task}`; - const markdown = [summary, ...data.guidance, + const markdownSummary = `# AI Agent Context Snapshot\nTask: ${data.task}`; + const markdown = [markdownSummary, ...data.guidance, ...data.evidence.map(item => `### ${item.file}:${item.startLine}-${item.endLine}\nReason: ${item.reason}\n\n\`\`\`\n${item.snippet}\n\`\`\``), ...(packedContent ? ['## Packed snapshot', packedContent] : []), ...data.limitations, ...(data.truncated ? ['Output is partial; inspect truncation and omission metadata.'] : []), ].join('\n\n'); - return [{ type: 'text', text: JSON.stringify({ ...common, executiveSummary: summary }) }, { type: 'text', text: markdown }]; + return [{ type: 'text', text: JSON.stringify({ ...common, executiveSummary: markdownSummary }) }, { type: 'text', text: markdown }]; } function measured() { diff --git a/src/Gateway/McpServer.ts b/src/Gateway/McpServer.ts index 9de5e23..48dce92 100644 --- a/src/Gateway/McpServer.ts +++ b/src/Gateway/McpServer.ts @@ -7,6 +7,7 @@ import { ADMISSION_LIMITS, RequestLease, ServerBusyError } from '../Core/Request import { checkOperation } from '../Core/OperationContext.js'; import { CodeQueryError } from '../Core/CodeQueries.js'; import { WorkspaceMismatchError } from '../Core/WorkspaceContracts.js'; +import { connectionGuide } from './ConnectionGuide.js'; import { ToolRegistry } from './ToolRegistry.js'; import { toolErrorResult, codeRecoveryAction, type ToolExecutionContext } from './ToolDefinition.js'; @@ -83,7 +84,8 @@ export class WinCodeMcpServer { { workStarted: false, retryable: true, lane: error.lane, admission: error.admission }); if (error instanceof WorkspaceMismatchError) return toolErrorResult(error.errorCode, error.message, 'select_workspace_connection', - { activeWorkspace: error.activeWorkspace, requestedWorkspace: error.requestedWorkspace }); + { activeWorkspace: error.activeWorkspace, requestedWorkspace: error.requestedWorkspace, + connectionGuide: connectionGuide(error.requestedWorkspace) }); // 同根恢复失败必须携带真实恢复状态;取消不能掩盖已发生的部分状态变更。 const recovery = error instanceof WorkspaceRecoveryRequiredError ? error.recovery : this.router.workspaceRecoveryState; const details = recovery ? { workspaceRecovery: recovery } : {}; diff --git a/src/Gateway/UiCompact.ts b/src/Gateway/UiCompact.ts new file mode 100644 index 0000000..15ac84a --- /dev/null +++ b/src/Gateway/UiCompact.ts @@ -0,0 +1,56 @@ +import type { UiNode } from '../Core/UiContracts.js'; +import type { UiReviewResult } from '../CompositeTools/UiReview.js'; +import type { UiCodeCandidate } from '../Core/UiCodeMapper.js'; + +/** Keep IDs and hierarchy intact so the image remains tied to the same observation. */ +export function compactUi(result: Omit) { + const nodes: UiNode[] = []; + const compactNode = (node: UiNode): UiNode => { + nodes.push(node); + const { bounds: _bounds, relativeBounds: _relativeBounds, className: _className, ...kept } = node; + return { ...kept, children: node.children.map(compactNode) }; + }; + const tree = result.tree ? compactNode(result.tree) : undefined; + const unnamed = nodes.filter(node => node.controlType === 'Button' && !node.name?.trim()); + const disabled = nodes.filter(node => node.isEnabled === false); + const validSelector = (value: string | undefined) => Boolean(value?.trim() && value.length <= 256 && !/[\x00-\x1f]/.test(value)); + const expansionRequests: Array<{ tool: string; arguments: Record }> = []; + if (result.pid && result.hwnd && tree) { + const selected = [...new Map([tree, ...unnamed, ...disabled].map(node => [node.id, node])).values()].slice(0, 4); + for (const node of selected) { + const query = validSelector(node.automationId) ? { automationId: node.automationId } : + validSelector(node.name) ? { name: node.name } : undefined; + if (!query && node !== tree) continue; + expansionRequests.push({ tool: 'wincode_ui_inspect', arguments: { + pid: result.pid, hwnd: result.hwnd, backgroundOnly: true, capture: 'none', responseFormat: 'full', + ...(query ? { query } : {}), + } }); + } + } + const code = result.codeEvidence; + const candidates: Array = []; + const ids = new Map(); + const clues = code?.clues.map(({ candidates: repeated, ...clue }) => ({ ...clue, + candidateIds: repeated.map(candidate => { + const key = JSON.stringify(candidate); + let id = ids.get(key); + if (id === undefined) { id = candidates.length + 1; ids.set(key, id); candidates.push({ id, ...candidate }); } + return id; + }), + })); + return { + summary: { observedNodes: nodes.length, unnamedButtons: unnamed.length, disabledControls: disabled.length, + treeComplete: result.treeComplete ?? null, truncateReason: result.truncateReason ?? null, + omittedNodeFields: ['bounds', 'relativeBounds', 'className'], + nextAction: !result.success ? 'inspect_error' : result.treeComplete === false ? 'expand_relevant_controls' : 'inspect_evidence', + scope: 'returned-snapshot-only' }, + ...result, tree, responseFormat: 'compact', + ...(result.queryResult ? { queryResult: { ...result.queryResult, matches: result.queryResult.matches.map(node => ({ + id: node.id, parentId: node.parentId, automationId: node.automationId, name: node.name, controlType: node.controlType, + isEnabled: node.isEnabled, isOffscreen: node.isOffscreen, states: node.states, propertyIssues: node.propertyIssues, + })) } } : {}), + ...(code ? { codeEvidence: { ...code, clues, candidates } } : {}), + expansionRequests, + expansionNotice: 'Expansion queries observe live UI again; IDs may change and selectors may be ambiguous. Counts are observations, not defect or binding-causality judgments.', + }; +} diff --git a/src/Gateway/UiResponse.ts b/src/Gateway/UiResponse.ts index f9437f5..41b9fe3 100644 --- a/src/Gateway/UiResponse.ts +++ b/src/Gateway/UiResponse.ts @@ -1,19 +1,22 @@ import { jsonResult } from './ToolDefinition.js'; import { UI_INSPECT_DEFAULTS } from '../Core/UiContracts.js'; import type { UiReviewResult } from '../CompositeTools/UiReview.js'; +import { compactUi } from './UiCompact.js'; /** Preserve snapshot, source-evidence and image budgets at the MCP serialization boundary. */ -export function uiResponse(result: UiReviewResult) { +export function uiResponse(result: UiReviewResult, responseFormat: 'full' | 'compact' = 'full') { // Extract image data for MCP image block; omit base64 payload from text JSON const imageBase64 = result.annotatedPngBase64 || result.screenshotPngBase64; const { annotatedPngBase64: _omittedAnnotated, screenshotPngBase64: _omittedScreenshot, - ...cleanResult + ...body } = result; + const cleanResult = structuredClone(body); + const textPayload = { - ...cleanResult, + ...(responseFormat === 'compact' ? compactUi(cleanResult) : cleanResult), hasScreenshot: Boolean(imageBase64), }; let text = JSON.stringify(textPayload); diff --git a/src/Gateway/UiTools.ts b/src/Gateway/UiTools.ts index 4dc9d98..5d8e074 100644 --- a/src/Gateway/UiTools.ts +++ b/src/Gateway/UiTools.ts @@ -12,17 +12,20 @@ function validateInspect(args: UiInspectRequest): void { if (args.hwnd !== undefined && !args.hwnd.trim()) throw new Error('hwnd must be non-empty.'); } -type UiReviewArgs = UiInspectRequest & { candidateFiles: string[]; candidateCodeFiles?: string[]; textQueries?: string[] }; +type UiInspectArgs = UiInspectRequest & { responseFormat?: 'full' | 'compact' }; +type UiReviewArgs = UiInspectArgs & { candidateFiles: string[]; candidateCodeFiles?: string[]; textQueries?: string[] }; const invalidArguments = (errorMessage: string) => jsonResult({ schemaVersion: '1.0', protocolVersion: '1.0', success: false, errorCode: 'INVALID_ARGUMENT', errorMessage }, false, true); -const inspectDefinition = defineTool({ +const inspectDefinition = defineTool({ name: 'wincode_ui_inspect', description: 'Inspects a Windows desktop application window using UI Automation. Returns a bounded control tree (JSON) and optional annotated screenshot (MCP image content). Requires either pid or hwnd.', inputSchema: { type: 'object', additionalProperties: true, properties: { + responseFormat: { type: 'string', enum: ['full', 'compact'], default: 'full', + description: 'compact keeps snapshot IDs/hierarchy/states and image, omits per-node geometry/className, shares code candidates and adds a summary plus live-UI expansion requests. full preserves the complete response shape.' }, pid: { type: 'integer', minimum: 1, @@ -78,7 +81,10 @@ const inspectDefinition = defineTool({ }, { invalidArguments, validate: validateInspect, requestBudget: 'ui', - execute: async (args, { router, signal }) => uiResponse(await router.inspectUi({ ...args, hwnd: args.hwnd?.trim() }, signal)), + execute: async (args, { router, signal }) => { + const { responseFormat, ...input } = args; + return uiResponse(await router.inspectUi({ ...input, hwnd: input.hwnd?.trim() }, signal), responseFormat); + }, }); const uiInspectTool = inspectDefinition.tool; @@ -141,8 +147,8 @@ export const UI_TOOLS = [ }, requestBudget: 'ui', execute: async (args, { router, signal }) => { - const { candidateFiles, candidateCodeFiles, textQueries, ...input } = args; - return uiResponse(await router.reviewUi({ ...input, hwnd: input.hwnd?.trim() }, candidateFiles, signal, textQueries, candidateCodeFiles)); + const { candidateFiles, candidateCodeFiles, textQueries, responseFormat, ...input } = args; + return uiResponse(await router.reviewUi({ ...input, hwnd: input.hwnd?.trim() }, candidateFiles, signal, textQueries, candidateCodeFiles), responseFormat); }, }), ]; diff --git a/src/index.ts b/src/index.ts index f373870..09d9326 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { WinCodeMcpServer } from './Gateway/McpServer.js'; import fs from 'node:fs/promises'; import path from 'node:path'; import { resolveTrayEndpoint, TrayClient } from './Gateway/TrayClient.js'; +import { connectionGuide } from './Gateway/ConnectionGuide.js'; async function main() { let workspaceRoot = process.cwd(); @@ -20,6 +21,14 @@ async function main() { } } + if (args.includes('--print-connection')) { + if (workspaceRootSource !== 'argument') throw new Error('--print-connection requires --workspace with an absolute target path.'); + if (args.some(arg => !['--print-connection', '--workspace', '-w', workspaceRoot].includes(arg))) + throw new Error('--print-connection accepts only --workspace; add other startup options explicitly to the generated configuration.'); + process.stdout.write(JSON.stringify(connectionGuide(workspaceRoot), null, 2) + '\n'); + return; + } + const config = getDefaultConfig(workspaceRoot); config.workspaceRootSource = workspaceRootSource; // 此文件是用户显式选择的启动配置,不从目标仓库自动发现或接受 MCP 参数指定执行程序。 diff --git a/tests/context-coverage.test.ts b/tests/context-coverage.test.ts index c23a829..157eabe 100644 --- a/tests/context-coverage.test.ts +++ b/tests/context-coverage.test.ts @@ -152,6 +152,10 @@ it('budget-dropped file bodies remain missing in the original multi-file coverag assert.equal(data.coverage.requestedItems, 8); assert.equal(data.coverage.completeItems + data.coverage.partialItems + data.coverage.missingItems, 8); assert.equal(data.coverage.allRequestedCovered, false); + assert.equal(data.summary.scope, 'requested-lines'); + assert.equal(data.summary.status, 'partial'); + assert.equal(data.summary.selectedFiles, 8); + assert.equal(data.summary.missingFiles, data.coverage.missingItems); })); it('a long-line retry raises its budget and stops suggesting the same request at the maximum', async () => fixture(async (root, manager) => { @@ -166,7 +170,7 @@ it('a long-line retry raises its budget and stops suggesting the same request at assert.equal(maximum.coverage.details[0].retryBlockedReason, 'maximum-budget-without-progress'); })); -it('EOF and missing-file requests retain their original gap reasons and do not propose blind retries', async () => fixture(async (root, manager) => { +it('EOF recovery reads the valid remainder while missing files never propose blind retries', async () => fixture(async (root, manager) => { await fs.writeFile(path.join(root, 'Short.ts'), 'one\ntwo\nthree'); const context = await manager.prepareContext({ task: 'Read source', lineRanges: [ { file: 'Short.ts', startLine: 2, endLine: 5 }, @@ -179,7 +183,21 @@ it('EOF and missing-file requests retain their original gap reasons and do not p assert.equal(data.coverage.allRequestedCovered, false); assert.equal(data.coverage.details[0].missingRanges[0].reason, 'line-range-out-of-bounds'); assert.equal(data.coverage.details[1].missingRanges[0].reason, 'not-found'); - assert.ok(data.coverage.details.every((detail: any) => detail.nextRequest === undefined)); + assert.equal(data.fileIssues[0].fileLineCount, 3); + assert.equal(data.summary.status, 'empty'); + assert.equal(data.summary.nextAction, 'follow_next_request'); + const correction = data.coverage.details[0].nextRequest; + assert.deepEqual(correction.lineRanges, [{ file: 'Short.ts', startLine: 2, endLine: 3 }]); + const continued = response(await manager.prepareContext(correction)); + assert.equal(continued.evidence[0].snippet, 'two\nthree'); + assert.equal(continued.coverage.allRequestedCovered, true); + assert.equal(continued.summary.status, 'complete'); + assert.equal(data.coverage.details[1].nextRequest, undefined); + const pastEof = response(await manager.prepareContext({ task: 'Read source', lineRanges: [ + { file: 'Short.ts', startLine: 8, endLine: 10 }, + ] })); + assert.equal(pastEof.fileIssues[0].fileLineCount, 3); + assert.equal(pastEof.coverage.details[0].nextRequest, undefined, 'an entirely invalid range must not jump to unrelated source'); })); it('coverage totals survive pruning of multi-file request details and error metadata', async () => fixture(async (_root, manager) => { diff --git a/tests/context-efficiency.test.ts b/tests/context-efficiency.test.ts index 2053cca..9c88ac3 100644 --- a/tests/context-efficiency.test.ts +++ b/tests/context-efficiency.test.ts @@ -86,7 +86,7 @@ it('precise retrieval remains bounded after response serialization', async () => }); }); -async function fixture(run: (root: string, router: ToolRouter, call: (args: Record) => Promise) => Promise) { +async function fixture(run: (root: string, router: ToolRouter, call: (args: Record, name?: string) => Promise) => Promise) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-context-')); const config = getDefaultConfig(root); @@ -101,7 +101,7 @@ async function fixture(run: (root: string, router: ToolRouter, call: (args: Reco i === 49 ? 'export function SaveTarget() { return "TARGET_BODY"; }' : `// fixture line ${i + 1}`).join('\n')); router.text.findSymbolsDetailed = async () => ({ symbols: [], limitations: [], queryComplete: true } as any); await Promise.all([client.connect(clientTransport), (server as any).server.connect(serverTransport)]); - await run(root, router, args => client.callTool({ name: 'wincode_prepare_context', arguments: args })); + await run(root, router, (args, name = 'wincode_prepare_context') => client.callTool({ name, arguments: args })); } finally { await client.close(); await server.stop(); @@ -121,6 +121,68 @@ function payload(result: any) { return { data, texts }; } +it('scoped navigation finds a literal call, outlines its file and follows source without reading outside links', async t => fixture(async (root, _router, call) => { + await fs.mkdir(path.join(root, 'src')); + const source = 'public class Session {\n public void Save() {\n Cache[0].Save("marker");\n }\n}'; + await fs.writeFile(path.join(root, 'src/Session.cs'), source); + await fs.writeFile(path.join(root, 'Decoy.cs'), 'Cache[0].Save("OUT_OF_SCOPE");'); + const decode = (result: any) => { + assert.notEqual(result.isError, true, JSON.stringify(result)); + return JSON.parse(result.content[0].text); + }; + const found = decode(await call({ query: 'Cache[0].Save(', scopePaths: ['src', 'src/Session.cs'] }, 'wincode_search_text')); + assert.equal(found.queryComplete, true); + assert.equal(found.matches.length, 1, 'overlapping scopes must not duplicate matches'); + assert.equal(found.matches[0].file, 'src/Session.cs'); + assert.equal(found.matches[0].line, 3); + assert.equal(found.matches[0].column, 2); + assert.ok(!JSON.stringify(found).includes('OUT_OF_SCOPE')); + const read = payload(await call(found.matches[0].nextRequest)).data; + assert.equal(read.evidence[0].snippet, source); + const outline = decode(await call({ file: path.join(root, 'src/Session.cs') }, 'wincode_file_outline')); + assert.equal(outline.fileLineCount, 5); + assert.equal(outline.sizeBytes, Buffer.byteLength(source)); + assert.deepEqual(outline.symbols.map((s: any) => [s.name, s.line]), [['Session', 1], ['Save', 2]]); + const method = payload(await call(outline.symbols[1].nextRequest)).data; + assert.ok(method.evidence[0].snippet.includes('public void Save()')); + const none = decode(await call({ query: 'Cache0XSave(', scopePaths: ['src'] }, 'wincode_search_text')); + assert.equal(none.matches.length, 0, 'query metacharacters must be literal'); + await fs.writeFile(path.join(root, 'src/Broken.ts'), 'const text = `unterminated'); + const broken = decode(await call({ file: 'src/Broken.ts' }, 'wincode_file_outline')); + assert.equal(broken.fileLineCount, 1); + assert.equal(broken.queryComplete, false); + assert.ok(broken.fileIssues.some((i: any) => i.path === 'src/Broken.ts' && i.reason === 'lexical-uncertainty')); + await fs.writeFile(path.join(root, 'src/Large.cs'), 'x'.repeat(256 * 1024 + 1)); + const external = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode-navigation-outside-')); + try { + await fs.writeFile(path.join(external, 'Secret.cs'), 'Cache[0].Save("MUST_NOT_READ");'); + await fs.symlink(external, path.join(root, 'src/linked'), process.platform === 'win32' ? 'junction' : 'dir'); + const opened: string[] = []; + const realOpen = fs.open; + const open = t.mock.method(fs, 'open', async (...args: Parameters) => { + opened.push(String(args[0])); + return realOpen(...args); + }); + const linked = decode(await call({ query: 'Cache[0].Save(', scopePaths: ['src/linked'] }, 'wincode_search_text')); + assert.equal(linked.queryComplete, false); + assert.ok(linked.fileIssues.some((i: any) => i.reason === 'invalid-scope')); + assert.ok(!opened.some(file => file.startsWith(external)), 'an outside file must never be opened'); + opened.length = 0; + const invalid = await call({ query: 'Cache', scopePaths: ['src', '../outside'] }, 'wincode_search_text'); + assert.equal(invalid.isError, true); + assert.equal(opened.length, 0, 'all scopes must validate before any file is opened'); + open.mock.restore(); + const boundedResult = await call({ query: 'x', scopePaths: ['src'], maxOutputChars: 2048 }, 'wincode_search_text'); + const bounded = decode(boundedResult); + assert.ok(boundedResult.content[0].text.length <= 2048); + assert.equal(bounded.queryComplete, false); + assert.ok(bounded.fileIssues.some((i: any) => i.path === 'src/Large.cs' && i.reason === 'file-byte-limit')); + } finally { + assert.equal(path.dirname(external), path.resolve(os.tmpdir())); + await fs.rm(external, { recursive: true, force: true }); + } +})); + it('compact MCP response returns file evidence once and accounts for its entire output', async () => fixture(async (_root, _router, call) => { const { data, texts } = payload(await call({ task: '查看文件', candidateFiles: ['Service.ts'], maxTokens: 2000 })); assert.equal(texts.length, 1); diff --git a/tests/fixed-workspace.test.ts b/tests/fixed-workspace.test.ts index 2fa8d22..e57937a 100644 --- a/tests/fixed-workspace.test.ts +++ b/tests/fixed-workspace.test.ts @@ -158,7 +158,7 @@ it('a startup junction is rejected even when the cache is configured on a separa } finally { await router.dispose(); } })); -it('the production CLI reports cwd fallback and binds it without requiring workspace_open', async () => fixture(async (a, b) => { +it('a production mismatch supplies a usable second connection without rebinding the first', async () => fixture(async (a, b) => { const client = new Client({ name: 'fixed-cwd-acceptance', version: '1' }); const transport = new StdioClientTransport({ command: process.execPath, args: [path.resolve('dist/index.js')], cwd: a, stderr: 'pipe' }); @@ -169,6 +169,29 @@ it('the production CLI reports cwd fallback and binds it without requiring works assert.equal(hello.workspace, a); const rejected = await client.callTool({ name: 'workspace_open', arguments: { path: b } }); assert.equal(body(rejected).errorCode, 'WORKSPACE_MISMATCH'); + const guide = body(rejected).connectionGuide; + assert.equal(guide.workspace, b); + assert.equal(guide.configuration.command, process.execPath); + assert.deepEqual(guide.configuration.args, [path.resolve('dist/index.js'), '--workspace', b]); + const printed = spawnSync(process.execPath, [path.resolve('dist/index.js'), '--print-connection', '--workspace', b], + { cwd: a, input: '', encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 262144 }); + assert.equal(printed.status, 0, printed.stderr); + assert.deepEqual(JSON.parse(printed.stdout), guide); + await assert.rejects(fs.stat(path.join(b, '.cache')), { code: 'ENOENT' }); + const second = new Client({ name: 'guided-second-connection', version: '1' }); + try { + await second.connect(new StdioClientTransport({ ...guide.configuration, cwd: a, stderr: 'pipe' })); + const secondHello = body(await second.callTool({ name: guide.verification.tool, arguments: guide.verification.arguments })); + assert.equal(secondHello.workspace, guide.verification.expectedWorkspace); + assert.notEqual(secondHello.runtime.instanceId, hello.runtime.instanceId); + for (const [connection, expected, excluded] of [[client, 'ONLY_A', 'ONLY_B'], [second, 'ONLY_B', 'ONLY_A']] as const) { + const result = body(await connection.callTool({ name: 'wincode_prepare_context', arguments: { + task: 'Read the connected project', lineRanges: [{ file: 'Api.cs', startLine: 1, endLine: 1 }], + } })); + assert.ok(result.evidence[0].snippet.includes(expected)); + assert.ok(!result.evidence[0].snippet.includes(excluded)); + } + } finally { await second.close(); } } finally { await client.close(); } })); diff --git a/tests/local-text.test.ts b/tests/local-text.test.ts index bde3b65..f2c6cfa 100644 --- a/tests/local-text.test.ts +++ b/tests/local-text.test.ts @@ -207,6 +207,11 @@ it('does not report a complete empty scan or cache results when lexical boundari assert.deepEqual(result.symbols, []); assert.equal(result.queryComplete, false); assert.match(result.queryError!, /lexical-uncertainty/); + assert.deepEqual(result.fileIssues, [{ path: 'Broken.ts', reason: 'lexical-uncertainty' }]); + await fs.writeFile(path.join(root, 'Valid.ts'), 'export class Target {}'); + const narrowed = await adapter.findSymbolsDetailed('Target', undefined, 'Valid.ts'); + assert.equal(narrowed.queryComplete, true); + assert.deepEqual(narrowed.fileIssues, []); assert.equal(cached.size, 0); })); diff --git a/tests/request-admission.test.ts b/tests/request-admission.test.ts index 906b82c..74f0ed2 100644 --- a/tests/request-admission.test.ts +++ b/tests/request-admission.test.ts @@ -204,7 +204,7 @@ it('cancelled startup waiters are removed and passive requests stay available du const controllers = Array.from({ length: 8 }, () => new AbortController()); const pending = controllers.map(c => call('wincode_find_code_symbol', { query: 'Api' }, c.signal).catch(e => e)); await until(() => router.admission.snapshot().sharedWaiters === 8, 'startup waiters must be observable'); - assert.notEqual((await call('wincode_hello_world')).isError, true); assert.equal((await client.listTools()).tools.length, 15); + assert.notEqual((await call('wincode_hello_world')).isError, true); assert.equal((await client.listTools()).tools.length, 17); controllers.forEach(c => c.abort()); await Promise.all(pending); await until(() => router.admission.pendingCount === 0, 'cancelled startup calls must release capacity'); assert.equal(router.admission.snapshot().sharedWaiters, 0); @@ -305,7 +305,7 @@ it('128-call burst admits 32, rejects overflow before execution, and preserves F const health = body(await call('wincode_hello_world')).health; assert.equal(health.admission.business.active, 32); assert.equal(health.admission.business.waiting, 31); assert.equal(health.admission.business.executing, 1); - assert.equal((await client.listTools()).tools.length, 15); + assert.equal((await client.listTools()).tools.length, 17); assert.equal(body(await call('workspace_open', { path: path.join(root, 'other') })).errorCode, 'WORKSPACE_MISMATCH'); assert.equal(body(await call('workspace_open', { path: root })).errorCode, 'SERVER_BUSY'); assert.equal((await router.releaseRoslynMemory()).status, 'busy'); diff --git a/tests/resource-cleanup.test.ts b/tests/resource-cleanup.test.ts index 3dab2e2..612c5af 100644 --- a/tests/resource-cleanup.test.ts +++ b/tests/resource-cleanup.test.ts @@ -11,6 +11,7 @@ import { getDefaultConfig } from '../src/Core/Config.js'; import { ToolRouter } from '../src/Core/ToolRouter.js'; import { WinCodeMcpServer } from '../src/Gateway/McpServer.js'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; const execAsync = promisify(exec); @@ -112,22 +113,39 @@ describe('resource-cleanup', () => { } }); - it('McpServer: workspace_open does not increment in-flight and completes promptly without self-wait', async t => { - const config = getDefaultConfig(root); + it('McpServer: workspace_open recovers through MCP without waiting on its own request', async t => { + const workspaceRoot = path.join(testCacheDir, 'server_ws_root'); + await fs.mkdir(workspaceRoot); + await fs.writeFile(path.join(workspaceRoot, 'package.json'), '{"name":"workspace-recovery-fixture"}'); + const config = getDefaultConfig(workspaceRoot); config.cacheDir = path.join(testCacheDir, 'server_ws_open'); + config.adapters.flaui.enabled = false; + config.adapters.repomix.useCli = false; const router = new ToolRouter(config); const server = new WinCodeMcpServer(router); - t.after(() => server.stop()); + const client = new Client({ name: 'workspace-recovery-mcp', version: '1' }); + t.after(async () => { try { await client.close(); } finally { await server.stop(); } }); await router.initialize(); + const [left, right] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(left), (server as any).server.connect(right)]); await (router as any).watch.stop(); - - const t0 = Date.now(); - await router.openWorkspace(root); - const elapsed = Date.now() - t0; + const observed: number[] = []; + const waitForIdle = router.waitForIdle.bind(router); + t.mock.method(router, 'waitForIdle', async (...args: Parameters) => { + observed.push(router.inFlightRequests); + assert.equal(router.inFlightRequests, 0, 'the recovery request must not count itself as business to drain'); + return waitForIdle(...args); + }); + const result = await client.callTool({ name: 'workspace_open', arguments: { path: workspaceRoot } }); + assert.notEqual(result.isError, true, JSON.stringify(result)); + assert.deepEqual(observed, [0], 'exercise the actual recovery drain path'); assert.strictEqual(router.inFlightRequests, 0, 'Recovery must not leave in-flight request dangling'); - assert.ok(elapsed < 4000, `Recovery must not wait out drain timeout, took ${elapsed}ms`); - - await router.openWorkspace(root); + const health = await router.getRuntimeHealth(); + assert.equal(health.workspaceWatch.active, true); + assert.equal(health.workspaceRecovery, null); + const confirmation = await client.callTool({ name: 'workspace_open', arguments: { path: workspaceRoot } }); + assert.notEqual(confirmation.isError, true); + assert.deepEqual(observed, [0], 'healthy confirmation must not re-enter recovery or drain'); }); it('CacheManager: pruneDiskCache counts overflow size, enforces maxDiskBytes, respects grace period & memory protection', async () => { diff --git a/tests/tool-contracts.test.ts b/tests/tool-contracts.test.ts index 68efc18..609acdc 100644 --- a/tests/tool-contracts.test.ts +++ b/tests/tool-contracts.test.ts @@ -46,6 +46,7 @@ const examples: Record> = { wincode_list_directory: { path: '.', maxDepth: 1 }, wincode_hello_world: { greeting: 'hello' }, wincode_analyze_workspace: { maxDepth: 2 }, wincode_prepare_context: { task: 'Inspect Target', scopeFiles: ['Target.ts'] }, wincode_find_code_symbol: { query: 'Target', kind: 'class' }, wincode_find_references: { symbolName: 'Target', relativePath: 'Target.ts' }, + wincode_search_text: { query: 'Target', scopePaths: ['Target.ts'] }, wincode_file_outline: { file: 'Target.ts' }, analyze_change_impact: { target: 'Target' }, wincode_analyze_change_impact: { target: 'Target' }, wincode_diagnose_project: {}, wincode_plan_refactoring: { target: 'Target', goal: 'Improve reliability' }, wincode_safe_move_to_trash: { filePath: 'Target.ts', reason: 'fixture' }, @@ -62,6 +63,8 @@ const expectedCalls: Record = { wincode_analyze_workspace: { method: 'analyzeWorkspace', args: [2, ''] }, wincode_prepare_context: { method: 'prepareContext', args: [{ task: 'Inspect Target', scopeFiles: ['Target.ts'] }, ''] }, wincode_find_code_symbol: { method: 'findCodeSymbols', args: ['Target', 'class', ''] }, + wincode_search_text: { method: 'searchText', args: [{ query: 'Target', scopePaths: ['Target.ts'] }, ''] }, + wincode_file_outline: { method: 'fileOutline', args: [{ file: 'Target.ts' }, ''] }, wincode_find_references: { method: 'findCodeReferences', args: ['Target', 'Target.ts', ''] }, analyze_change_impact: { method: 'analyzeChangeImpact', args: ['Target', ''] }, wincode_analyze_change_impact: { method: 'analyzeChangeImpact', args: ['Target', ''] }, @@ -73,14 +76,14 @@ const expectedCalls: Record = { wincode_ui_review: { method: 'reviewUi', args: [{ pid: 5, hwnd: undefined }, ['View.xaml'], '', ['Save'], ['View.cs']] }, }; -it('calls all 15 published tools and the hidden alias; unknown fields do not reach use cases', async () => fixture(async (client, router) => { +it('calls all published tools and the hidden alias; unknown fields do not reach use cases', async () => fixture(async (client, router) => { await fs.writeFile(path.join(router.config.workspaceRoot, 'Target.ts'), 'export class Target {}'); const prepared = await router.prepareContext(examples.wincode_prepare_context as any); const calls: Array<{ method: string; args: unknown[] }> = []; const stub = (method: string, result: unknown) => { (router as any)[method] = async (...args: unknown[]) => { calls.push({ method, args }); return structuredClone(result); }; }; - for (const method of ['openWorkspace', 'listDirectory', 'analyzeWorkspace', 'findCodeSymbols', 'findCodeReferences', 'diagnoseProject', 'planRefactoring']) + for (const method of ['openWorkspace', 'listDirectory', 'analyzeWorkspace', 'findCodeSymbols', 'findCodeReferences', 'diagnoseProject', 'planRefactoring', 'searchText', 'fileOutline']) stub(method, { success: true }); stub('prepareContext', prepared); stub('moveToTrash', { success: true }); @@ -90,7 +93,7 @@ it('calls all 15 published tools and the hidden alias; unknown fields do not rea stub('inspectUi', { success: true }); stub('reviewUi', { success: true }); const published = (await client.listTools()).tools; - assert.equal(published.length, 15); + assert.equal(published.length, 17); assert.ok(!published.some(tool => tool.name === 'wincode_workspace_open')); assert.deepEqual(new Set([...published.map(tool => tool.name), 'wincode_workspace_open']), new Set(Object.keys(examples))); const responses = new Map(); diff --git a/tests/ui-code-candidates.test.ts b/tests/ui-code-candidates.test.ts index e88c23f..cd1a272 100644 --- a/tests/ui-code-candidates.test.ts +++ b/tests/ui-code-candidates.test.ts @@ -7,6 +7,7 @@ import { mapUiCodeCandidates, validateCandidateCodeFiles } from '../src/Core/UiC import { mapUiSources } from '../src/Core/UiSourceMapper.js'; import { reviewUi } from '../src/CompositeTools/UiReview.js'; import { UiNode, UiInspectResult } from '../src/Core/UiContracts.js'; +import { uiResponse } from '../src/Gateway/UiResponse.js'; const tree: UiNode = { id: 1, parentId: null, automationId: 'NavCharacters', isEnabled: false, children: [] }; async function fixture(run: (root: string) => Promise) { @@ -48,6 +49,48 @@ it('a disabled button yields bounded XAML to command-assignment navigation witho assert.ok(!JSON.stringify(evidence).includes(root)); })); +it('compact UI evidence preserves snapshot IDs and image while sharing command candidates and usable expansion requests', async t => fixture(async root => { + const children: UiNode[] = Array.from({ length: 6 }, (_, i) => ({ id: i + 2, parentId: 1, automationId: `Button${i}`, + controlType: 'Button', name: i === 0 ? '' : `Action ${i}`, isEnabled: i !== 0, children: [], + className: 'Button', bounds: { x: i * 90, y: 30, width: 80, height: 30 }, relativeBounds: { x: i * 90, y: 30, width: 80, height: 30 } })); + await fs.writeFile(path.join(root, 'View.xaml'), '\n' + children.map(n => + `