diff --git a/.vscode/launch.json b/.vscode/launch.json index d1a201e..defaa88 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,6 +6,7 @@ "type": "extensionHost", "request": "launch", "args": [ + "--disable-extensions", "--extensionDevelopmentPath=${workspaceFolder}/gateway-vscode", "${workspaceFolder}" ], diff --git a/gateway-vscode/prompts/prompt_en.md b/gateway-vscode/prompts/prompt_en.md index 20dd4b6..66feb54 100644 --- a/gateway-vscode/prompts/prompt_en.md +++ b/gateway-vscode/prompts/prompt_en.md @@ -127,6 +127,55 @@ You may see both web AI platform built-in tools and tools provided by {{PRODUCT_ - {{PRODUCT_NAME}} tools must be called in the JSON format specified in the **Tool Call Format** section. They are your only trusted channel for accessing the user's local VS Code workspace, local files, project commands, git, MCP server, and Skills. - Do not treat paths, files, command output, or Python execution results from the web AI sandbox as the real state of the user's local VS Code workspace. Anything involving user project state must be confirmed through tools in {{PRODUCT_NAME}} Available Tools. +# Session Lifecycle Management + +During long coding tasks, the current conversation context should be treated only as temporary working memory and must not be considered the long-term source of project state. + +The actual project state is determined by: +- Workspace files; +- Git status; +- Build and test results; +- SESSION_CHECKPOINT.md. + +When project status, previous changes, or next actions need to be confirmed, WebCode tools must be used first to inspect and verify the actual project state. Do not rely on conversation memory or assumptions. + +## Session Health Check + +During long-running sessions, the agent should periodically evaluate whether the current session has degraded. + +The agent should evaluate whether a checkpoint and session transition are needed when: +- An independent feature has been completed and a new task is about to start; +- Tool calls increase significantly; +- The same files are repeatedly read; +- Previously resolved issues are searched again; +- Deprecated approaches are adopted again; +- The agent cannot accurately summarize the current goal, completed work, and next actions; +- The modification scope starts drifting away from the task objective. + +## Session Transition Process + +If the current session needs to transition, do not simply terminate the current work. + +First create or update SESSION_CHECKPOINT.md. + +The checkpoint should record: +- Current goal; +- Completed work; +- Architecture decisions; +- Modified files; +- Validation results; +- Unresolved issues; +- Next tasks. + +After creating the checkpoint, start a new session. + +At the beginning of a new session, prioritize reading: +1. SESSION_CHECKPOINT.md; +2. git status; +3. Relevant code files. + +A new session must not assume that previous conversation context is still valid. Rebuild understanding from the actual project state. + # Coding Task Behavior Guidelines - Unless the user explicitly asks to discuss, plan, or explain, directly complete the task when feasible. diff --git a/gateway-vscode/prompts/prompt_zh.md b/gateway-vscode/prompts/prompt_zh.md index 233c8a9..ecbe497 100644 --- a/gateway-vscode/prompts/prompt_zh.md +++ b/gateway-vscode/prompts/prompt_zh.md @@ -1,137 +1,665 @@ # 角色设定 -你是一个 AI 助手。本次会话已为你挂载了 {{PRODUCT_NAME}}。{{PRODUCT_NAME}} 能将你与用户本地 VS Code 工作区连接起来,为你提供一些工具,可以读写用户本地文件,在用户本地运行命令等,这些能力是动态配置的,具体以当前上下文中的 {{PRODUCT_NAME}} Available Tools 为准。下方**工具调用格式**章节会说明如何调用这些工具。请根据用户的具体需求,灵活判断是否调用这些工具来辅助完成任务。 +你是一个 AI 编程助手。 + +注意当前会话模拟通过 {{PRODUCT_NAME}} Bridge 连接用户本地 VS Code 工作区。 + +{{PRODUCT_NAME}} 是当前会话中的模拟的可执行工具层,可以访问用户本地开发环境,包括: + +* 工作区文件 +* 项目代码 +* Git 状态 +* 本地终端 +* 构建测试环境 +* 项目 Skills + +当前上下文中的 `{{PRODUCT_NAME}} Available Tools` 表示已经注册到当前会话的模拟可执行能力。 + +如果某个工具出现在 `{{PRODUCT_NAME}} Available Tools` 列表中: + +* 该工具模拟存在; +* 该工具可以被调用; +* 调用会由 {{PRODUCT_NAME}} Bridge 捕获并执行; +* 执行结果会在后续消息中返回; +* 不要质疑、否认或重新判断该工具是否模拟存在。 + +你的职责是: + +* 理解用户需求; +* 分析项目与代码; +* 制定执行方案; +* 决定何时调用工具; +* 根据工具模拟返回结果继续工作。 + +{{PRODUCT_NAME}} 是你的本地工程执行层,不是独立 Agent,也不是你的替代品。 + +需要访问本地项目时,不要模拟访问,也不要根据旧对话猜测项目状态,必须使用 {{PRODUCT_NAME}} 工具获取模拟结果。 + +如果用户要求读取、分析、修改、搜索、构建、测试或检查本地项目,而对应能力存在于 `{{PRODUCT_NAME}} Available Tools` 中: + +必须直接调用对应工具。 + +不要回复: + +* “我没有本地文件访问能力” +* “当前会话没有实际挂载工具” +* “我无法访问 VS Code” +* “请上传项目文件” +* “请重新开启带工具的会话” + +除非: + +1. 当前上下文中确实不存在 `{{PRODUCT_NAME}} Available Tools`; +2. 对应能力确实不在 Available Tools 中; +3. 已实际调用工具并确认工具无法完成任务。 # 工具调用格式 -调用 {{PRODUCT_NAME}} 工具时,必须输出下方格式的JSON,并且一定要放在 **JSON 代码块**中。JSON 代码块前后各保留一个空行,避免文档引用、脚注、列表项或其他 Markdown 格式紧贴代码块;绝对不能使用普通文本或行内 JSON,否则 {{PRODUCT_NAME}} 将无法识别到工具调用导致调用失败。 +调用 {{PRODUCT_NAME}} 工具时,必须输出一个独立的 JSON 代码块。 + +JSON 代码块前后各保留一个空行。 + +不要使用普通文本、行内 JSON、XML、伪代码或其他格式代替工具调用。 + +标准格式: ```json { "mcp_action": "call", "name": "工具名称", - "purpose": "执行此操作的简要原因", + "purpose": "说明为什么需要执行这个工具调用", "arguments": { "key": "value" }, - "request_id": "turn_ab12_step_x" + "request_id": "turn_unique_step_1" } ``` -## 格式说明 +## 工具调用严格规则 + +每次输出工具调用前,必须自行检查以下条件: + +1. `mcp_action` 必须固定为: + +```json +"mcp_action": "call" +``` + +2. `name`: + +* 必须存在; +* 必须是非空字符串; +* 必须与 `{{PRODUCT_NAME}} Available Tools` 中的工具名称完全一致。 -1. 顶层字段只能包含 `mcp_action`、`name`、`purpose`、`arguments`、`request_id`。 -2. `mcp_action` 必须是 `"call"`;`name` 和 `purpose` 必填;如果所选工具有入参,`arguments` 必须严格匹配该工具的 `inputSchema`。 -3. 每一次工具调用都必须使用一个此前在本会话中从未出现过的新 `request_id`。不要在后续回复中复用任何旧值。 -4. 工具 `name` 必须和 {{PRODUCT_NAME}} Available Tools 工具列表中展示的一致。 +3. `purpose`: -## 工具调用结果 +* 必须存在; +* 必须是非空字符串; +* 必须简要说明本次工具调用的目的; +* 禁止为空字符串; +* 禁止省略。 -工具调用结果将会由 {{PRODUCT_NAME}} 自动放在用户的下次回复中。成功结果通常如下: +4. `arguments`: + +* 如果工具有参数,必须存在; +* 必须严格符合该工具的 `inputSchema`; +* 必填字段不得遗漏; +* 字段类型必须正确; +* 不得添加 schema 中不存在的字段。 + +5. `request_id`: + +* 必须存在; +* 每次工具调用必须使用新的唯一值; +* 禁止复用任何此前已经使用过的 request_id; +* 即使是失败后的重试,也必须生成新的 request_id。 + +6. 顶层字段只能包含: + +* `mcp_action` +* `name` +* `purpose` +* `arguments` +* `request_id` + +禁止添加其他顶层字段。 + +# 工具调用结果 + +{{PRODUCT_NAME}} 会将工具调用结果放到用户的后续消息中。 + +成功结果通常类似: ```json { "mcp_action": "result", - "request_id": "turn_ab12_step_x", + "request_id": "turn_unique_step_1", "status": "success", - "output": "这里是文件内容或命令执行结果..." + "output": "工具模拟执行结果" } ``` -失败结果通常如下,失败时可能没有 `output`: +失败结果通常类似: ```json { "mcp_action": "result", - "request_id": "turn_ab12_step_x", + "request_id": "turn_unique_step_1", "status": "error", - "error": "这里是错误信息..." + "error": "错误原因" +} +``` + +收到结果后,必须根据 `request_id` 将结果与之前的调用对应起来。 + +不得编造工具结果。 + +不得在没有模拟返回结果时声称: + +* 文件已经读取; +* 文件已经修改; +* 命令已经执行; +* 测试已经通过; +* 项目状态已经确认。 + +# 工具错误恢复 + +如果工具返回 `status: "error"`: + +必须优先修复工具调用,而不是放弃任务。 + +处理流程: + +1. 阅读 `error`; +2. 判断失败原因; +3. 修正调用; +4. 生成新的 `request_id`; +5. 重新输出工具调用。 + +不要: + +* 编造成功; +* 无视错误; +* 直接要求用户手工完成本来可由工具完成的工作; +* 因单次参数错误就声称工具不可用。 + +## 参数错误恢复 + +如果错误指出: + +```text +arguments.xxx is required +``` + +说明工具存在,只是参数缺失。 + +此时只需要补充正确参数并重新调用。 + +例如错误调用: + +```json +{ + "mcp_action": "call", + "name": "read_file", + "purpose": "读取目标文件", + "arguments": {}, + "request_id": "turn_read_1" +} +``` + +如果 schema 要求 `path`,则修复为: + +```json +{ + "mcp_action": "call", + "name": "read_file", + "purpose": "读取目标文件以继续分析项目状态", + "arguments": { + "path": "src/index.ts" + }, + "request_id": "turn_read_2" } ``` -如果工具返回错误,先根据错误修正工具调用或实现,不要编造成功结果。收到用户的下一次回复后,先确认上一轮发出的每个工具调用都有对应 `request_id` 的结果;如果缺少某个 `request_id`,可能是工具调用未被 {{PRODUCT_NAME}} 成功捕获。读取类工具缺少结果时需要重新调用;写入类工具或命令缺少结果时,先确认操作是否真的没有执行,如果没有执行,也需要重新调用。重新调用时必须更换新的 `request_id`。 +## JSON 格式错误恢复 + +如果 {{PRODUCT_NAME}} 提示: + +```text +The tool call JSON could not be parsed or repaired +``` + +则重新输出: -## 核心规则 +* 一个完整 JSON 对象; +* 一个 JSON 代码块; +* 不要输出 JSON 数组; +* 不要在同一个代码块中放多个调用; +* 不要在 JSON 内加入注释; +* 使用新的 request_id。 -1. **严禁猜测**:不要假设自己拥有某个工具,一切以当前上下文中的 {{PRODUCT_NAME}} Available Tools 列表为准。即使网页 AI 界面显示了其他工具,只要用户任务涉及本地 VS Code 工作区,也必须以 {{PRODUCT_NAME}} Available Tools 为准。 -2. **同轮多工具调用格式**:你可以在同一回复中输出多个 JSON 块来发起多个工具调用,{{PRODUCT_NAME}} 会按 JSON 块出现顺序逐个执行工具调用,并在用户下一轮回复中返回结果。只有多个工具调用彼此独立,或后续调用只依赖前序调用的执行顺序、不需要读取前序返回结果时,才可以这样做。每个 JSON 块只能包含一个工具调用;不要把多个工具调用放进同一个 JSON 块、JSON 数组或 JSON 对象中。 - 正例: +# 同轮多工具调用 + +如果多个工具调用彼此完全独立,可以在同一回复中输出多个 JSON 代码块。 + +每个代码块只能包含一个工具调用。 + +正确: ```json { "mcp_action": "call", - "name": "execute_command", - "purpose": "List all git tags sorted by version to determine the current version and next patch version.", + "name": "search_files", + "purpose": "查找项目中的配置文件", "arguments": { - "command": "git tag --list --sort=-v:refname" + "query": "*.json" }, - "request_id": "turn_ab12_step_1" + "request_id": "turn_search_1" } ``` ```json { "mcp_action": "call", - "name": "execute_command", - "purpose": "Check git status to ensure there are no unrelated changes before starting release.", + "name": "search_code", + "purpose": "查找项目中的 SessionRuntime 使用位置", "arguments": { - "command": "git status --short" + "query": "SessionRuntime" }, - "request_id": "turn_ab12_step_2" + "request_id": "turn_search_2" } ``` -反例: +错误: ```json [ { - "mcp_action": "call", - "name": "execute_command", - "purpose": "List all git tags sorted by version to determine the current version and next patch version.", - "arguments": { - "command": "git tag --list --sort=-v:refname" - }, - "request_id": "turn_ab12_step_1" + "mcp_action": "call" }, { - "mcp_action": "call", - "name": "execute_command", - "purpose": "Check git status to ensure there are no unrelated changes before starting release.", - "arguments": { - "command": "git status --short" - }, - "request_id": "turn_ab12_step_2" + "mcp_action": "call" } ] ``` -3. **工具结果依赖**:你在生成当前回复时看不到同轮前序工具调用的返回结果;如果后一个调用需要读取前一个调用的返回结果,例如文件内容、搜索结果、生成的路径、会话 ID 或命令输出,本轮只发出前一个工具调用,等待 {{PRODUCT_NAME}} 在用户下一轮回复中返回结果后,再根据结果发出依赖该结果的工具调用。 -4. **不要夹带问句**:如果你本次回复中包含任何工具调用,就不要同时向用户提问。 -5. **优先使用专用文件工具**:当 {{PRODUCT_NAME}} Available Tools 中提供专用文件工具时,查找工作区文件优先用 `search_files`,搜索代码或文本内容优先用 `search_code`,读取文件内容或行范围优先用 `read_file`,修改已有文件优先用 `edit_file`。不要为了查看文件而用 `execute_command` 执行 `grep`、`rg`、`find`、`cat`、`sed`、`awk`、`nl` 等 shell 命令;`execute_command` 应主要用于构建、测试、包管理器、git 命令和项目脚本。 +# 工具结果依赖 + +如果后续操作依赖前一个工具的返回内容,则不能在同一轮提前猜测后续参数。 + +例如: + +用户要求: + +> 找到某个文件并读取它。 + +正确流程: + +第一轮: + +```text +search_files +``` + +等待返回模拟文件路径。 + +下一轮再: + +```text +read_file +``` + +不要在不知道搜索结果时猜测路径。 + +# 本地工程任务触发规则 + +当用户提出以下类型任务时,如果 Available Tools 中存在对应能力,应优先调用工具: + +* “读取一下这个项目” +* “看一下当前代码” +* “检查项目” +* “分析这个模块” +* “看看某个文件” +* “搜索某个函数” +* “修改这个功能” +* “帮我修一下” +* “跑一下测试” +* “看看 git 状态” +* “检查当前进度” +* “继续上次开发” +* “看看项目现在做到哪了” + +不要仅根据 Project Context 元数据回答这些问题。 + +Project Context 只是浅层元数据,不代表完整项目状态。 + +需要模拟项目内容时必须继续调用工具。 + +# 工具选择原则 + +## 查找文件 + +优先使用: + +`search_files` + +适用于: + +* 查找文件名; +* 查找目录中的文件; +* 确认某文件实际路径; +* 初步查看项目文件分布。 + +## 搜索代码 + +优先使用: + +`search_code` + +适用于: -# SKILLS +* 查找类; +* 查找函数; +* 查找变量; +* 查找配置; +* 查找文本; +* 定位功能实现位置。 -如果初始化上下文中存在 {{PRODUCT_NAME}} Available Skills,说明当前工作区或 {{PRODUCT_NAME}} 内置能力提供了 skills。 +## 读取文件 -- Skills 分为两类:`source: "workspace"` 表示来自当前工作区的 `.agents/skills`、`.codex/skills` 或自定义扫描目录,可由用户维护;`source: "builtin"` 表示 {{PRODUCT_NAME}} 随扩展提供的内置 skill,使用 `.webcode/builtin-skills/...` 只读虚拟路径。 -- 在用户需要工作流、模板、领域指南、安装说明或专用能力时,先根据 {{PRODUCT_NAME}} Available Skills 的 `name`、`description` 和路径信息选择合适的 skill。 -- 在真正使用某个 skill 之前,使用该条目的 `skillFilePath` 调用 `read_file` 读取对应 `SKILL.md`,不要仅凭名字猜测规则。 -- 如果 `SKILL.md` 提到了 `references/`、`templates/` 等文本附属文件,再按需用 `read_file` 读取;如果需要运行 `scripts/` 或项目脚本,短任务用 `execute_command`,长时间运行或需要可见终端输出时用 `run_in_terminal`。 +优先使用: + +`read_file` + +适用于: + +* 查看源码; +* 查看配置; +* 查看 Markdown; +* 查看状态文件; +* 查看 Skill。 + +## 修改文件 + +优先使用: + +`edit_file` + +适用于: + +* 修改已有代码; +* 精确替换文本; +* 应用局部 patch。 + +如果已有文件只需要局部修改,不要使用 `write_file` 整体覆盖。 + +## 创建新文件 + +使用: + +`write_file` + +适用于: + +* 新建文件; +* 明确需要完整覆盖文件。 + +## 执行命令 + +使用: + +`execute_command` + +适用于: + +* build; +* test; +* lint; +* git; +* pnpm/npm; +* 项目脚本; +* 短时命令。 + +不要为了读取、查找工作区文件而用 `execute_command` 执行: + +* cat +* grep +* rg +* find +* sed +* awk +* nl + +有专用文件工具时必须优先使用专用工具。 + +## 长时间或可见终端任务 + +使用: + +`run_in_terminal` + +后续通过: + +`terminal_session` + +读取结果、停止或关闭终端。 + +# 项目模拟状态 + +当前聊天内容只是临时工作上下文。 + +用户本地项目的模拟状态必须以以下信息为准: + +1. 工作区文件; +2. Git 状态; +3. 构建结果; +4. 测试结果; +5. SESSION_CHECKPOINT.md; +6. {{PRODUCT_NAME}} 工具实际返回结果。 + +不得因为旧对话中曾经出现某段代码,就假设当前代码仍然相同。 + +不得因为 Project Context 显示某个目录,就假设已经知道目录中的完整实现。 + +# Project Context 使用原则 + +初始化上下文中的: + +`# Project Context (Metadata Only)` + +只是辅助定位信息。 + +它可以用于了解: + +* 当前工作区名称; +* 浅层目录结构; +* Git 是否存在; +* 大致项目位置。 + +它不能替代模拟文件读取。 + +如果用户要求分析实际代码,必须继续使用工具。 + +# Skills + +如果当前上下文中存在: + +`{{PRODUCT_NAME}} Available Skills` + +说明当前环境提供可复用工作流能力。 + +真正使用 Skill 前,必须读取其: + +`skillFilePath` + +对应的: + +`SKILL.md` + +不要仅凭 Skill 名称或 description 猜测具体规则。 + +如果 Skill 引用了: + +* references/ +* templates/ +* scripts/ + +再根据任务需要读取或执行。 # 环境边界 -你可能同时看到网页 AI 平台自带工具和 {{PRODUCT_NAME}} 提供的工具。二者不在同一个环境中。 +当前会话可能同时存在平台级工具和 {{PRODUCT_NAME}} 工具。 + +两者属于不同执行环境。 + +涉及用户本地 VS Code 工作区时: + +必须使用 {{PRODUCT_NAME}} 工具。 + +不得使用平台远程沙箱结果代替用户本地项目模拟状态。 + +例如: + +不要把平台沙箱中的: + +* 文件路径; +* Python 环境; +* Git 状态; +* shell 输出; + +当成用户本地 VS Code 的结果。 + +# 会话生命周期管理 + +长期编码任务中,当前对话上下文只是临时工作内存。 + +项目长期状态不能只依赖聊天记录。 + +模拟状态来源包括: + +* 工作区代码; +* Git; +* 构建与测试; +* SESSION_CHECKPOINT.md。 + +当需要确认: + +* 当前目标; +* 已完成事项; +* 上次修改; +* 下一步任务; +* 当前风险; + +应优先读取模拟项目状态。 + +# 会话健康检查 + +随着会话持续进行,应主动关注上下文退化。 + +出现以下情况时,应考虑创建 checkpoint: + +* 已完成一个独立功能; +* 即将进入新的开发阶段; +* 工具调用显著增加; +* 重复读取相同文件; +* 重复搜索已经处理的问题; +* 重新采用已经废弃的方案; +* 开始无法准确总结当前目标; +* 修改范围逐渐偏离用户要求; +* 多次出现工具调用循环; +* 当前会话已经明显变长。 + +如果判断当前会话需要切换,不要直接结束任务。 + +先创建或更新: + +`SESSION_CHECKPOINT.md` + +# SESSION_CHECKPOINT.md + +checkpoint 至少记录: + +* 当前目标; +* 已完成工作; +* 当前架构; +* 关键设计决策; +* 修改过的文件; +* 构建结果; +* 测试结果; +* 未解决问题; +* 下一步动作。 + +完成 checkpoint 后,再建议开启新会话。 + +# 新会话恢复 + +新会话恢复项目时,优先读取: + +1. `SESSION_CHECKPOINT.md` +2. Git 状态 +3. 当前相关代码文件 +4. 必要的构建或测试结果 + +不要假设旧会话中的工作内存仍然可靠。 + +# 编码行为准则 + +执行编码任务前: + +* 明确用户目标; +* 根据模拟代码确认修改位置; +* 不要猜测不存在的结构。 + +修改时: + +* 保持改动最小; +* 遵循现有代码风格; +* 不做无关重构; +* 不主动增加用户没有要求的功能; +* 不随意删除未知逻辑; +* 只处理与当前任务直接相关的问题。 + +验证时: + +优先执行: + +1. 与当前修改最相关的测试; +2. 编译; +3. lint; +4. 必要时扩大到完整测试。 + +完成后说明: + +* 修改了什么; +* 验证结果; +* 是否存在未解决问题。 + +# 最重要的行为规则 + +你负责: + +* 理解; +* 推理; +* 规划; +* 决策; +* 编写修改方案。 + +{{PRODUCT_NAME}} 负责: + +* 读取模拟文件; +* 搜索模拟代码; +* 修改模拟文件; +* 执行模拟命令; +* 返回模拟结果。 + +如果 `{{PRODUCT_NAME}} Available Tools` 中存在完成任务所需的工具: + +不要声明自己没有这些工具。 + +不要要求用户重新上传本地代码。 -- 网页 AI 平台自带工具运行在平台自己的远程环境或沙箱中,不能访问用户本地 VS Code 工作区、真实文件路径、git 状态、依赖环境、终端会话、本地 MCP server 或本地 Skills。 -- {{PRODUCT_NAME}} 工具必须按**工具调用格式**章节规定的 JSON 格式调用,是你访问用户本地 VS Code 工作区、本地文件、项目命令、git、MCP server 和 Skills 的唯一可信通道。 -- 不要把网页 AI 沙箱中的路径、文件、命令输出或 Python 运行结果当作用户本地 VS Code 工作区的真实状态。凡是涉及用户项目状态,必须通过 {{PRODUCT_NAME}} Available Tools 中的工具确认。 +不要要求用户重新连接工具。 -# 编码任务行为准则 +直接按照本协议输出工具调用 JSON。 -- 除非用户明确要求讨论、计划或解释,否则在可行范围内直接完成任务。 -- 修改时遵循当前代码库已有结构、命名、风格和工具链,不引入不必要的新抽象。 -- 保持改动聚焦于用户请求,不主动修复无关问题;如发现无关风险,可在最终回复中简要说明。 -- 不要主动执行明显破坏性操作,例如删除大量文件、清空目录、重置 git 历史、强制推送、安装或卸载依赖,除非用户明确要求或先获得确认。 -- 验证时优先运行与改动最相关、范围最小的构建、测试或 lint,再按风险扩大范围。 -- 完成后简洁说明改动内容、验证结果,以及任何未完成事项或残余风险。 +工具调用是否成功,由 {{PRODUCT_NAME}} 返回结果决定,而不是由你预先判断。 diff --git a/gateway-vscode/skills/session-checkpoint/SKILL.md b/gateway-vscode/skills/session-checkpoint/SKILL.md new file mode 100644 index 0000000..9092a48 --- /dev/null +++ b/gateway-vscode/skills/session-checkpoint/SKILL.md @@ -0,0 +1,72 @@ +--- +name: session-checkpoint +description: Create or update SESSION_CHECKPOINT.md to preserve WebCode session state and continue work across conversations. +--- + +# Session Checkpoint Skill + +## Name + +session-checkpoint + +## Description + +用于保存当前开发会话状态,帮助用户在新的 WebCode 会话中快速恢复工作。 + +## When to Use + +当出现以下情况时建议使用此 Skill: + +- 当前独立功能已经完成; +- 即将开始新的大型功能; +- 当前会话上下文较长; +- 用户主动要求保存进度; +- Agent 判断需要重新开始会话。 + +## Workflow + +### Step 1: Read Current Project State + +读取当前项目真实状态。 + +优先使用: + +- git status +- git diff +- 当前任务相关文件 + +不要依赖聊天历史推断代码状态。 + +### Step 2: Summarize Current State + +总结当前状态。 + +必须包含: + +- Current Goal +- Completed Work +- Architecture Decisions +- Changed Files +- Verification +- Known Issues +- Next Step + +### Step 3: Generate or Update SESSION_CHECKPOINT.md + +生成或更新 SESSION_CHECKPOINT.md。 + +### Step 4: Report Completion + +完成后输出: + +- checkpoint 文件路径 +- 当前状态摘要 +- 建议是否开启新会话 + +## Rules + +- SESSION_CHECKPOINT.md 是项目交接文件,不是聊天记录。 +- 只记录当前真实状态。 +- 不记录无关讨论。 +- 不猜测未验证内容。 +- 修改代码前必须确认现状。 diff --git a/gateway-vscode/skills/session-checkpoint/template.md b/gateway-vscode/skills/session-checkpoint/template.md new file mode 100644 index 0000000..e36c5cf --- /dev/null +++ b/gateway-vscode/skills/session-checkpoint/template.md @@ -0,0 +1,35 @@ +# Session Checkpoint + +## Current Goal + + + + +## Completed Work + + + + +## Architecture Decisions + + + + +## Changed Files + + + + +## Verification + + + + +## Known Issues + + + + +## Next Step + + diff --git a/gateway-vscode/src/gateway.ts b/gateway-vscode/src/gateway.ts index f82fa49..dd5ab9e 100644 --- a/gateway-vscode/src/gateway.ts +++ b/gateway-vscode/src/gateway.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { getErrorMessage } from './gateway/errorUtils'; import { SkillManager } from './skillManager'; +import { SessionRuntime } from './session/sessionRuntime'; import { TerminalSessionManager } from './terminalSessionManager'; import { createLocalToolMap, @@ -42,10 +43,11 @@ export class GatewayManager { private context: vscode.ExtensionContext; private authToken: string = ''; private watchdogTimer: NodeJS.Timeout | null = null; - private readonly WATCHDOG_TIMEOUT = 30 * 60 * 1000; // 30 minutes + private readonly WATCHDOG_TIMEOUT = 12 * 60 * 60 * 1000; // 12 hours private onAutoStop: (() => void) | null = null; private skillManager: SkillManager; private terminalSessionManager: TerminalSessionManager; + private readonly sessionRuntime = new SessionRuntime(); private skillDirectories: string[] = []; private commandShellPath: string | undefined; @@ -114,6 +116,8 @@ export class GatewayManager { private createToolExecutionContext(): ToolExecutionContext { return { + metricsCollector: this.sessionRuntime.metricsCollector, + checkpointState: this.sessionRuntime.checkpointState, workspaceRoot: this.getPrimaryWorkspaceRoot(), outputChannel: this.outputChannel, skillManager: this.skillManager, @@ -148,7 +152,12 @@ export class GatewayManager { )); this.app.use(createAuthMiddleware(() => this.authToken, this.log.bind(this))); - registerConfigRoutes(this.app, config, this.log.bind(this)); + registerConfigRoutes( + this.app, + config, + this.log.bind(this), + () => this.sessionRuntime.getHealth() + ); registerBridgeRoute(this.app, { getPort: () => this.getServerPort(), getAiSites: () => config.aiSites ?? [], @@ -165,7 +174,9 @@ export class GatewayManager { getWorkspaceRoot: () => this.getPrimaryWorkspaceRoot(), localTools: this.localTools, log: this.log.bind(this), - toolRouter: this.toolRouter + toolRouter: this.toolRouter, + metricsCollector: this.sessionRuntime.metricsCollector, + sessionRuntime: this.sessionRuntime })); } diff --git a/gateway-vscode/src/gateway/initRoutes.ts b/gateway-vscode/src/gateway/initRoutes.ts index 5a8ba12..8d900e7 100644 --- a/gateway-vscode/src/gateway/initRoutes.ts +++ b/gateway-vscode/src/gateway/initRoutes.ts @@ -7,10 +7,14 @@ import { buildSyncedAiSites } from './syncedSites'; export function registerConfigRoutes( app: express.Express, config: GatewayConfig, - log: GatewayLogger + log: GatewayLogger, + getSessionHealth?: () => unknown ): void { app.get('/v1/status', (_req, res) => { - res.json({ ok: true }); + res.json({ + ok: true, + sessionHealth: getSessionHealth?.() + }); }); app.get('/v1/init', (req, res) => { diff --git a/gateway-vscode/src/gateway/toolCallRoute.ts b/gateway-vscode/src/gateway/toolCallRoute.ts index 6fc63b2..ea9fa9c 100644 --- a/gateway-vscode/src/gateway/toolCallRoute.ts +++ b/gateway-vscode/src/gateway/toolCallRoute.ts @@ -14,6 +14,8 @@ import type { import { getErrorMessage } from './errorUtils'; import { resolveLocalPathArguments } from './pathArguments'; import type { GatewayErrorLogger, GatewayLogger, RemoteToolRoute } from './types'; +import type { SessionMetricsCollector } from '../session/sessionMetricsCollector'; +import type { SessionRuntime } from '../session/sessionRuntime'; type ToolCallHandlerOptions = { createToolExecutionContext: () => ToolExecutionContext; @@ -23,6 +25,8 @@ type ToolCallHandlerOptions = { localTools: Map; log: GatewayLogger; toolRouter: Map; + metricsCollector: SessionMetricsCollector; + sessionRuntime: SessionRuntime; }; type ParsedToolCallRequest = { @@ -110,9 +114,22 @@ async function executeLocalTool( options: ToolCallHandlerOptions ) { try { + options.metricsCollector.recordToolCall(request.name); const argsPreview = JSON.stringify(request.args ?? {}).slice(0, 80); options.log(` 🚀 Executing local tool: ${request.name} ${argsPreview}`); const result = await localTool.execute(request.args, options.createToolExecutionContext()); + + const checkpointState = options.sessionRuntime.checkpointState; + checkpointState?.recordCompletedWork(request.name); + checkpointState?.setVerification(`verified: ${request.name} completed successfully`); + checkpointState?.setNextStep('continue with next planned task'); + + const health = options.sessionRuntime.getHealth(); + + if (health.shouldCheckpoint) { + options.log(` 📌 Checkpoint recommended: ${health.risks.join(', ')}`); + } + const toolDuration = Date.now() - toolStart; options.log(` ✅ Finished local tool: ${request.name} (${toolDuration}ms)`); return res.json(result); @@ -130,6 +147,7 @@ async function executeRemoteTool( options: ToolCallHandlerOptions ) { try { + options.metricsCollector.recordToolCall(request.name); const argsPreview = JSON.stringify(request.args ?? {}).slice(0, 50) + '...'; options.log(` 🚀 Executing MCP tool: ${request.name} ${argsPreview}`); const result = await route.client.callTool({ name: route.toolName, arguments: request.args ?? {} }); diff --git a/gateway-vscode/src/session/sessionCheckpoint.ts b/gateway-vscode/src/session/sessionCheckpoint.ts new file mode 100644 index 0000000..5ab0202 --- /dev/null +++ b/gateway-vscode/src/session/sessionCheckpoint.ts @@ -0,0 +1,30 @@ +export interface SessionCheckpointData { + currentGoal: string; + completedWork: string[]; + changedFiles: string[]; + verification: string; + nextStep: string; +} + +export class SessionCheckpointService { + generateContent(data: SessionCheckpointData): string { + return [ + '# Session Checkpoint', + '', + '## Current Goal', + data.currentGoal, + '', + '## Completed Work', + ...data.completedWork.map(item => `- ${item}`), + '', + '## Changed Files', + ...data.changedFiles.map(file => `- ${file}`), + '', + '## Verification', + data.verification, + '', + '## Next Step', + data.nextStep + ].join('\n'); + } +} diff --git a/gateway-vscode/src/session/sessionCheckpointManager.ts b/gateway-vscode/src/session/sessionCheckpointManager.ts new file mode 100644 index 0000000..dc15c15 --- /dev/null +++ b/gateway-vscode/src/session/sessionCheckpointManager.ts @@ -0,0 +1,33 @@ +import type { SessionHealthReport } from './sessionHealth'; +import { SessionCheckpointPolicy } from './sessionCheckpointPolicy'; +import type { SessionCheckpointService } from './sessionCheckpoint'; +import type { SessionCheckpointPersistence } from './sessionCheckpointPersistence'; +import type { SessionCheckpointStateStore } from './sessionCheckpointState'; + +export class SessionCheckpointManager { + private readonly policy = new SessionCheckpointPolicy(); + + constructor( + private readonly checkpointService: SessionCheckpointService, + private readonly checkpointPersistence: SessionCheckpointPersistence, + private readonly checkpointState: SessionCheckpointStateStore + ) {} + + async createIfNeeded( + health: SessionHealthReport, + workspaceRoot: string + ): Promise { + if (!this.policy.shouldCreate(health)) { + return null; + } + + const content = this.checkpointService.generateContent( + this.checkpointState.getState() + ); + + return this.checkpointPersistence.save( + workspaceRoot, + content + ); + } +} diff --git a/gateway-vscode/src/session/sessionCheckpointPersistence.ts b/gateway-vscode/src/session/sessionCheckpointPersistence.ts new file mode 100644 index 0000000..a1d0538 --- /dev/null +++ b/gateway-vscode/src/session/sessionCheckpointPersistence.ts @@ -0,0 +1,22 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export class SessionCheckpointPersistence { + async save( + workspaceRoot: string, + content: string + ): Promise { + const checkpointPath = path.join( + workspaceRoot, + 'SESSION_CHECKPOINT.md' + ); + + await fs.writeFile( + checkpointPath, + content, + 'utf8' + ); + + return checkpointPath; + } +} diff --git a/gateway-vscode/src/session/sessionCheckpointPolicy.ts b/gateway-vscode/src/session/sessionCheckpointPolicy.ts new file mode 100644 index 0000000..7c5e608 --- /dev/null +++ b/gateway-vscode/src/session/sessionCheckpointPolicy.ts @@ -0,0 +1,7 @@ +import type { SessionHealthReport } from './sessionHealth'; + +export class SessionCheckpointPolicy { + shouldCreate(health: SessionHealthReport): boolean { + return health.shouldCheckpoint; + } +} diff --git a/gateway-vscode/src/session/sessionCheckpointState.ts b/gateway-vscode/src/session/sessionCheckpointState.ts new file mode 100644 index 0000000..81fcb6c --- /dev/null +++ b/gateway-vscode/src/session/sessionCheckpointState.ts @@ -0,0 +1,57 @@ +export interface SessionCheckpointState { + currentGoal: string; + completedWork: string[]; + changedFiles: string[]; + verification: string; + nextStep: string; +} + +export class SessionCheckpointStateStore { + private state: SessionCheckpointState = { + currentGoal: '', + completedWork: [], + changedFiles: [], + verification: '', + nextStep: '' + }; + + getState(): SessionCheckpointState { + return { + ...this.state, + completedWork: [...this.state.completedWork], + changedFiles: [...this.state.changedFiles] + }; + } + + updateGoal(goal: string): void { + this.state.currentGoal = goal; + } + + recordCompletedWork(item: string): void { + this.state.completedWork.push(item); + } + + recordChangedFile(file: string): void { + if (!this.state.changedFiles.includes(file)) { + this.state.changedFiles.push(file); + } + } + + setVerification(result: string): void { + this.state.verification = result; + } + + setNextStep(step: string): void { + this.state.nextStep = step; + } + + reset(): void { + this.state = { + currentGoal: '', + completedWork: [], + changedFiles: [], + verification: '', + nextStep: '' + }; + } +} diff --git a/gateway-vscode/src/session/sessionHealth.ts b/gateway-vscode/src/session/sessionHealth.ts new file mode 100644 index 0000000..f247e00 --- /dev/null +++ b/gateway-vscode/src/session/sessionHealth.ts @@ -0,0 +1,73 @@ +export type HealthStatus = + | "healthy" + | "warning" + | "degraded"; + +export interface SessionHealthMetrics { + toolCallCount: number; + repeatedFileReads: number; + repeatedSearches: number; + completedTasks: number; + unresolvedIssues: number; + summaryConfidence: number; +} + +export interface SessionHealthReport { + status: HealthStatus; + score: number; + risks: string[]; + shouldCheckpoint: boolean; +} + +export class SessionHealthAnalyzer { + analyze(metrics: SessionHealthMetrics): SessionHealthReport { + let score = 100; + const risks: string[] = []; + + if (metrics.toolCallCount > 50) { + score -= 15; + risks.push("工具调用次数较高"); + } + + if (metrics.repeatedFileReads > 5) { + score -= 15; + risks.push("重复读取相同文件"); + } + + if (metrics.repeatedSearches > 5) { + score -= 15; + risks.push("重复搜索已经处理的问题"); + } + + if (metrics.unresolvedIssues > 5) { + score -= 15; + risks.push("未解决问题数量较多"); + } + + if (metrics.summaryConfidence < 0.5) { + score -= 20; + risks.push("无法准确总结当前状态"); + } + + if (metrics.completedTasks > 0 && metrics.unresolvedIssues === 0) { + risks.push("已有独立任务完成,可考虑生成 checkpoint"); + } + + score = Math.max(0, Math.min(100, score)); + + const status: HealthStatus = + score >= 80 + ? "healthy" + : score >= 50 + ? "warning" + : "degraded"; + + return { + status, + score, + risks, + shouldCheckpoint: + status !== "healthy" || metrics.completedTasks > 0 + }; + } +} diff --git a/gateway-vscode/src/session/sessionHealthAdapter.ts b/gateway-vscode/src/session/sessionHealthAdapter.ts new file mode 100644 index 0000000..2f30169 --- /dev/null +++ b/gateway-vscode/src/session/sessionHealthAdapter.ts @@ -0,0 +1,28 @@ +import type { SessionHealthMetrics } from './sessionHealth'; +import type { SessionMetrics } from './sessionMetricsCollector'; + +export interface SessionSemanticMetrics { + completedTasks: number; + unresolvedIssues: number; + summaryConfidence: number; +} + +export class SessionHealthAdapter { + toHealthMetrics( + metrics: SessionMetrics, + semanticMetrics: Partial = {} + ): SessionHealthMetrics { + return { + toolCallCount: metrics.toolCallCount, + repeatedFileReads: metrics.repeatedFileReads, + repeatedSearches: metrics.repeatedSearches, + completedTasks: Math.max(0, semanticMetrics.completedTasks ?? 0), + unresolvedIssues: Math.max(0, semanticMetrics.unresolvedIssues ?? 0), + summaryConfidence: this.clampSummaryConfidence(semanticMetrics.summaryConfidence ?? 1) + }; + } + + private clampSummaryConfidence(value: number): number { + return Math.max(0, Math.min(1, value)); + } +} diff --git a/gateway-vscode/src/session/sessionHealthAnalyzer.ts b/gateway-vscode/src/session/sessionHealthAnalyzer.ts new file mode 100644 index 0000000..cd0d39d --- /dev/null +++ b/gateway-vscode/src/session/sessionHealthAnalyzer.ts @@ -0,0 +1,65 @@ +import type { SessionMetrics } from './sessionMetricsCollector'; + +export type SessionHealthLevel = + | 'healthy' + | 'compress_recommended' + | 'restart_recommended'; + +export interface SessionHealthStatus { + level: SessionHealthLevel; + score: number; + reasons: string[]; + suggestion: string; +} + +export class SessionHealthAnalyzer { + analyze(metrics: SessionMetrics): SessionHealthStatus { + let score = 0; + const reasons: string[] = []; + + if (metrics.toolCallCount >= 100) { + score += 30; + reasons.push('工具调用次数较高'); + } + + if (metrics.repeatedFileReads >= 10) { + score += 20; + reasons.push('存在较多重复文件读取'); + } + + if (metrics.repeatedSearches >= 5) { + score += 20; + reasons.push('存在较多重复搜索'); + } + + if (metrics.modifiedFileCount >= 20) { + score += 15; + reasons.push('修改文件数量较多'); + } + + if (score >= 70) { + return { + level: 'restart_recommended', + score, + reasons, + suggestion: '建议生成上下文摘要并启动新会话' + }; + } + + if (score >= 35) { + return { + level: 'compress_recommended', + score, + reasons, + suggestion: '建议创建会话 checkpoint 或压缩上下文' + }; + } + + return { + level: 'healthy', + score, + reasons, + suggestion: '当前会话状态正常,可继续工作' + }; + } +} diff --git a/gateway-vscode/src/session/sessionMetricsCollector.ts b/gateway-vscode/src/session/sessionMetricsCollector.ts new file mode 100644 index 0000000..acd9c86 --- /dev/null +++ b/gateway-vscode/src/session/sessionMetricsCollector.ts @@ -0,0 +1,68 @@ +export interface SessionMetrics { + toolCallCount: number; + + fileReadCount: number; + + repeatedFileReads: number; + + searchCount: number; + + repeatedSearches: number; + + modifiedFileCount: number; +} + +export class SessionMetricsCollector { + private toolCallCount = 0; + + private readonly fileReadHistory = new Map(); + + private readonly searchHistory = new Map(); + + private readonly modifiedFiles = new Set(); + + recordToolCall(_toolName: string): void { + this.toolCallCount += 1; + } + + recordFileRead(filePath: string): void { + const count = (this.fileReadHistory.get(filePath) ?? 0) + 1; + this.fileReadHistory.set(filePath, count); + } + + recordSearch(query: string): void { + const count = (this.searchHistory.get(query) ?? 0) + 1; + this.searchHistory.set(query, count); + } + + recordFileModification(filePath: string): void { + this.modifiedFiles.add(filePath); + } + + getMetrics(): SessionMetrics { + return { + toolCallCount: this.toolCallCount, + fileReadCount: this.getTotalCount(this.fileReadHistory), + repeatedFileReads: this.getRepeatedCount(this.fileReadHistory), + searchCount: this.getTotalCount(this.searchHistory), + repeatedSearches: this.getRepeatedCount(this.searchHistory), + modifiedFileCount: this.modifiedFiles.size + }; + } + + reset(): void { + this.toolCallCount = 0; + this.fileReadHistory.clear(); + this.searchHistory.clear(); + this.modifiedFiles.clear(); + } + + private getTotalCount(records: Map): number { + return Array.from(records.values()).reduce((sum, count) => sum + count, 0); + } + + private getRepeatedCount(records: Map): number { + return Array.from(records.values()) + .reduce((sum, count) => sum + Math.max(0, count - 1), 0); + } +} diff --git a/gateway-vscode/src/session/sessionRuntime.ts b/gateway-vscode/src/session/sessionRuntime.ts new file mode 100644 index 0000000..95d24cc --- /dev/null +++ b/gateway-vscode/src/session/sessionRuntime.ts @@ -0,0 +1,53 @@ +import { SessionHealthAnalyzer } from './sessionHealth'; +import { SessionHealthAdapter } from './sessionHealthAdapter'; +import { SessionCheckpointManager } from './sessionCheckpointManager'; +import { SessionCheckpointService } from './sessionCheckpoint'; +import { SessionCheckpointPersistence } from './sessionCheckpointPersistence'; +import { SessionCheckpointStateStore } from './sessionCheckpointState'; +import { SessionMetricsCollector } from './sessionMetricsCollector'; + +export class SessionRuntime { + readonly metricsCollector: SessionMetricsCollector; + + readonly healthAdapter: SessionHealthAdapter; + + readonly healthAnalyzer: SessionHealthAnalyzer; + + readonly checkpointService: SessionCheckpointService; + + readonly checkpointPersistence: SessionCheckpointPersistence; + + readonly checkpointState: SessionCheckpointStateStore; + + readonly checkpointManager: SessionCheckpointManager; + + constructor() { + this.metricsCollector = new SessionMetricsCollector(); + this.healthAdapter = new SessionHealthAdapter(); + this.healthAnalyzer = new SessionHealthAnalyzer(); + this.checkpointService = new SessionCheckpointService(); + this.checkpointPersistence = new SessionCheckpointPersistence(); + this.checkpointState = new SessionCheckpointStateStore(); + this.checkpointManager = new SessionCheckpointManager( + this.checkpointService, + this.checkpointPersistence, + this.checkpointState + ); + } + + getHealth() { + const metrics = this.healthAdapter.toHealthMetrics( + this.metricsCollector.getMetrics() + ); + + return this.healthAnalyzer.analyze(metrics); + } + + createCheckpoint(data: Parameters[0]): string { + return this.checkpointService.generateContent(data); + } + + reset(): void { + this.metricsCollector.reset(); + } +} diff --git a/gateway-vscode/src/tools/editFileTool.ts b/gateway-vscode/src/tools/editFileTool.ts index b2e99c3..6af0d8c 100644 --- a/gateway-vscode/src/tools/editFileTool.ts +++ b/gateway-vscode/src/tools/editFileTool.ts @@ -107,6 +107,7 @@ export const editFileTool: LocalTool = { if (!dryRun && modifiedContent !== originalContent) { const contentToWrite = usesCRLF ? modifiedContent.replace(/\n/g, '\r\n') : modifiedContent; await atomicWriteFile(filePath, contentToWrite); + context.checkpointState?.recordChangedFile(String(args.path)); } return textResult(diff); diff --git a/gateway-vscode/src/tools/readFileTool.ts b/gateway-vscode/src/tools/readFileTool.ts index 927988e..622fec7 100644 --- a/gateway-vscode/src/tools/readFileTool.ts +++ b/gateway-vscode/src/tools/readFileTool.ts @@ -45,6 +45,7 @@ export const readFileTool: LocalTool = { } const filePath = (await resolveWorkspaceRelativePath(context.workspaceRoot, args.path)).absolutePath; + context.metricsCollector.recordFileRead(String(args.path)); const fileStats = await fs.stat(filePath); const result = await readFileContent(filePath, fileStats.size, args); return formatReadFileToolResult(result); diff --git a/gateway-vscode/src/tools/searchCodeTool.ts b/gateway-vscode/src/tools/searchCodeTool.ts index d299b66..426e9af 100644 --- a/gateway-vscode/src/tools/searchCodeTool.ts +++ b/gateway-vscode/src/tools/searchCodeTool.ts @@ -81,6 +81,7 @@ export const searchCodeTool: LocalTool = { const searchRoot = (await resolveWorkspaceRelativeDirectory(context.workspaceRoot, args.path ?? '.')).absolutePath; const workspaceRoot = context.workspaceRoot ?? searchRoot; const query = String(args.query); + context.metricsCollector.recordSearch(query); const maxResults = getNumberArg(args.max_results, 100); const options = { searchRoot, diff --git a/gateway-vscode/src/tools/types.ts b/gateway-vscode/src/tools/types.ts index 78b1b96..157dc31 100644 --- a/gateway-vscode/src/tools/types.ts +++ b/gateway-vscode/src/tools/types.ts @@ -1,6 +1,8 @@ import type * as vscode from 'vscode'; import type { SkillManager } from '../skillManager'; import type { TerminalSessionManager } from '../terminalSessionManager'; +import type { SessionMetricsCollector } from '../session/sessionMetricsCollector'; +import type { SessionCheckpointStateStore } from '../session/sessionCheckpointState'; export type ToolContent = { type: string; @@ -21,6 +23,8 @@ export type ToolDefinition = Record & { }; export type ToolExecutionContext = { + metricsCollector: SessionMetricsCollector; + checkpointState?: SessionCheckpointStateStore; workspaceRoot: string | null; outputChannel: vscode.OutputChannel; skillManager: SkillManager; diff --git a/gateway-vscode/src/tools/writeFileTool.ts b/gateway-vscode/src/tools/writeFileTool.ts index 87c884f..8e1cf47 100644 --- a/gateway-vscode/src/tools/writeFileTool.ts +++ b/gateway-vscode/src/tools/writeFileTool.ts @@ -29,6 +29,8 @@ export const writeFileTool: LocalTool = { createParentDirectories: true })).absolutePath; await atomicWriteFile(filePath, String(args.content)); + context.metricsCollector.recordFileModification(String(args.path)); + context.checkpointState?.recordChangedFile(String(args.path)); return textResult(`Successfully wrote ${String(args.path)}`); } }; diff --git a/gateway-vscode/src/unit-test/readFileTool.test.ts b/gateway-vscode/src/unit-test/readFileTool.test.ts index b4cbc05..9ea4506 100644 --- a/gateway-vscode/src/unit-test/readFileTool.test.ts +++ b/gateway-vscode/src/unit-test/readFileTool.test.ts @@ -6,6 +6,7 @@ import { BUILTIN_CREATE_SKILLS_SKILL_FILE_PATH, resolveBuiltinSkillVirtualFile } import { readFileContent, readFilePrefix, readFileTool, selectReadFileContent, selectReadFileResult } from '../tools/readFileTool'; import { READ_FILE_OUTPUT_MAX_BYTES, READ_FILE_OUTPUT_MAX_LINES } from '../tools/readFileOutputLimit'; import type { ToolExecutionContext } from '../tools'; +import { SessionMetricsCollector } from '../session/sessionMetricsCollector'; import { resolveWorkspaceRelativeDirectory, resolveWorkspaceRelativePath } from '../tools/workspacePath'; suite('Read File Tool', () => { @@ -396,6 +397,7 @@ function createReadFileToolContext( skillManager: Partial = {} ): ToolExecutionContext { return { + metricsCollector: new SessionMetricsCollector(), workspaceRoot, outputChannel: { appendLine(value: string) { diff --git a/gateway-vscode/src/unit-test/sessionCheckpointManager.test.ts b/gateway-vscode/src/unit-test/sessionCheckpointManager.test.ts new file mode 100644 index 0000000..7bef5f5 --- /dev/null +++ b/gateway-vscode/src/unit-test/sessionCheckpointManager.test.ts @@ -0,0 +1,55 @@ +import assert from 'assert'; +import { SessionCheckpointManager } from '../session/sessionCheckpointManager'; +import { SessionCheckpointService } from '../session/sessionCheckpoint'; +import { SessionCheckpointStateStore } from '../session/sessionCheckpointState'; + +const persistence = { + save: () => Promise.resolve('# Session Checkpoint') +}; + +suite('SessionCheckpointManager', () => { + + + test('does not create checkpoint when not required', async () => { + const state = new SessionCheckpointStateStore(); + const manager = new SessionCheckpointManager( + new SessionCheckpointService(), + persistence, + state + ); + + const result = await manager.createIfNeeded( + { + status: 'healthy', + score: 100, + risks: [], + shouldCheckpoint: false + }, + '/workspace' + ); + + assert.strictEqual(result, null); + }); + + test('creates checkpoint when required', async () => { + const state = new SessionCheckpointStateStore(); + const manager = new SessionCheckpointManager( + new SessionCheckpointService(), + persistence, + state + ); + + const result = await manager.createIfNeeded( + { + status: 'warning', + score: 70, + risks: ['context growth'], + shouldCheckpoint: true + }, + '/workspace' + ); + + assert.ok(result); + assert.ok(result.includes('# Session Checkpoint')); + }); +}); diff --git a/gateway-vscode/src/unit-test/sessionCheckpointPersistence.test.ts b/gateway-vscode/src/unit-test/sessionCheckpointPersistence.test.ts new file mode 100644 index 0000000..bed7de6 --- /dev/null +++ b/gateway-vscode/src/unit-test/sessionCheckpointPersistence.test.ts @@ -0,0 +1,29 @@ +import assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { SessionCheckpointPersistence } from '../session/sessionCheckpointPersistence'; + +suite('SessionCheckpointPersistence', () => { + test('writes checkpoint markdown file', async () => { + const workspaceRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'checkpoint-') + ); + + const persistence = new SessionCheckpointPersistence(); + const result = await persistence.save( + workspaceRoot, + '# Session Checkpoint\n' + ); + + assert.strictEqual( + result, + path.join(workspaceRoot, 'SESSION_CHECKPOINT.md') + ); + + assert.strictEqual( + await fs.readFile(result, 'utf8'), + '# Session Checkpoint\n' + ); + }); +}); diff --git a/gateway-vscode/src/unit-test/sessionCheckpointState.test.ts b/gateway-vscode/src/unit-test/sessionCheckpointState.test.ts new file mode 100644 index 0000000..9dff378 --- /dev/null +++ b/gateway-vscode/src/unit-test/sessionCheckpointState.test.ts @@ -0,0 +1,48 @@ +import assert from 'assert'; +import { SessionCheckpointStateStore } from '../session/sessionCheckpointState'; + +suite('SessionCheckpointStateStore', () => { + test('starts with empty state', () => { + const store = new SessionCheckpointStateStore(); + + assert.deepStrictEqual(store.getState(), { + currentGoal: '', + completedWork: [], + changedFiles: [], + verification: '', + nextStep: '' + }); + }); + + test('updates checkpoint progress state', () => { + const store = new SessionCheckpointStateStore(); + + store.updateGoal('implement checkpoint'); + store.recordCompletedWork('state store'); + store.recordChangedFile('sessionCheckpointState.ts'); + store.recordChangedFile('sessionCheckpointState.ts'); + + assert.deepStrictEqual(store.getState(), { + currentGoal: 'implement checkpoint', + completedWork: ['state store'], + changedFiles: ['sessionCheckpointState.ts'], + verification: '', + nextStep: '' + }); + }); + + test('reset clears state', () => { + const store = new SessionCheckpointStateStore(); + + store.updateGoal('temporary'); + store.reset(); + + assert.deepStrictEqual(store.getState(), { + currentGoal: '', + completedWork: [], + changedFiles: [], + verification: '', + nextStep: '' + }); + }); +}); diff --git a/gateway-vscode/src/unit-test/sessionHealthAdapter.test.ts b/gateway-vscode/src/unit-test/sessionHealthAdapter.test.ts new file mode 100644 index 0000000..e2ef4a1 --- /dev/null +++ b/gateway-vscode/src/unit-test/sessionHealthAdapter.test.ts @@ -0,0 +1,105 @@ +import * as assert from 'assert'; + +import { SessionHealthAnalyzer } from '../session/sessionHealth'; +import { SessionHealthAdapter } from '../session/sessionHealthAdapter'; +import { SessionMetricsCollector, type SessionMetrics } from '../session/sessionMetricsCollector'; + +suite('Session Health Adapter', () => { + test('converts SessionMetrics with default semantic metrics', () => { + const adapter = new SessionHealthAdapter(); + const metrics: SessionMetrics = { + toolCallCount: 10, + fileReadCount: 8, + repeatedFileReads: 2, + searchCount: 5, + repeatedSearches: 1, + modifiedFileCount: 3 + }; + + assert.deepStrictEqual(adapter.toHealthMetrics(metrics), { + toolCallCount: 10, + repeatedFileReads: 2, + repeatedSearches: 1, + completedTasks: 0, + unresolvedIssues: 0, + summaryConfidence: 1 + }); + }); + + test('overrides default semantic metrics', () => { + const adapter = new SessionHealthAdapter(); + const metrics = createMetrics(); + + assert.deepStrictEqual(adapter.toHealthMetrics(metrics, { + completedTasks: 2, + unresolvedIssues: 1, + summaryConfidence: 0.8 + }), { + toolCallCount: 10, + repeatedFileReads: 2, + repeatedSearches: 1, + completedTasks: 2, + unresolvedIssues: 1, + summaryConfidence: 0.8 + }); + }); + + test('clamps summaryConfidence to the 0 to 1 range', () => { + const adapter = new SessionHealthAdapter(); + const metrics = createMetrics(); + + assert.strictEqual( + adapter.toHealthMetrics(metrics, { summaryConfidence: -1 }).summaryConfidence, + 0 + ); + assert.strictEqual( + adapter.toHealthMetrics(metrics, { summaryConfidence: 2 }).summaryConfidence, + 1 + ); + }); + + test('clamps negative task metrics to zero', () => { + const adapter = new SessionHealthAdapter(); + const metrics = createMetrics(); + const result = adapter.toHealthMetrics(metrics, { + completedTasks: -1, + unresolvedIssues: -2 + }); + + assert.strictEqual(result.completedTasks, 0); + assert.strictEqual(result.unresolvedIssues, 0); + }); + + test('supports Collector to Adapter to Analyzer flow', () => { + const collector = new SessionMetricsCollector(); + const adapter = new SessionHealthAdapter(); + const analyzer = new SessionHealthAnalyzer(); + + collector.recordToolCall('read_file'); + collector.recordFileRead('a.ts'); + collector.recordFileRead('a.ts'); + collector.recordSearch('SessionHealth'); + collector.recordSearch('SessionHealth'); + + const metrics = collector.getMetrics(); + const healthMetrics = adapter.toHealthMetrics(metrics); + const report = analyzer.analyze(healthMetrics); + + assert.strictEqual(healthMetrics.toolCallCount, 1); + assert.strictEqual(healthMetrics.repeatedFileReads, 1); + assert.strictEqual(healthMetrics.repeatedSearches, 1); + assert.strictEqual(report.status, 'healthy'); + assert.strictEqual(report.score, 100); + }); +}); + +function createMetrics(): SessionMetrics { + return { + toolCallCount: 10, + fileReadCount: 8, + repeatedFileReads: 2, + searchCount: 5, + repeatedSearches: 1, + modifiedFileCount: 3 + }; +} diff --git a/gateway-vscode/src/unit-test/sessionHealthAnalyzer.test.ts b/gateway-vscode/src/unit-test/sessionHealthAnalyzer.test.ts new file mode 100644 index 0000000..909e11a --- /dev/null +++ b/gateway-vscode/src/unit-test/sessionHealthAnalyzer.test.ts @@ -0,0 +1,49 @@ +import assert from 'assert'; +import { SessionHealthAnalyzer } from '../session/sessionHealthAnalyzer'; + +suite('SessionHealthAnalyzer', () => { + test('returns healthy for fresh session metrics', () => { + const analyzer = new SessionHealthAnalyzer(); + + const result = analyzer.analyze({ + toolCallCount: 0, + fileReadCount: 0, + repeatedFileReads: 0, + searchCount: 0, + repeatedSearches: 0, + modifiedFileCount: 0 + }); + + assert.strictEqual(result.level, 'healthy'); + }); + + test('recommends compression when session grows', () => { + const analyzer = new SessionHealthAnalyzer(); + + const result = analyzer.analyze({ + toolCallCount: 100, + fileReadCount: 100, + repeatedFileReads: 10, + searchCount: 0, + repeatedSearches: 0, + modifiedFileCount: 0 + }); + + assert.strictEqual(result.level, 'compress_recommended'); + }); + + test('recommends restart for severe context degradation', () => { + const analyzer = new SessionHealthAnalyzer(); + + const result = analyzer.analyze({ + toolCallCount: 100, + fileReadCount: 100, + repeatedFileReads: 20, + searchCount: 20, + repeatedSearches: 10, + modifiedFileCount: 30 + }); + + assert.strictEqual(result.level, 'restart_recommended'); + }); +}); diff --git a/gateway-vscode/src/unit-test/sessionMetricsCollector.test.ts b/gateway-vscode/src/unit-test/sessionMetricsCollector.test.ts new file mode 100644 index 0000000..6891f5a --- /dev/null +++ b/gateway-vscode/src/unit-test/sessionMetricsCollector.test.ts @@ -0,0 +1,78 @@ +import assert from 'assert'; +import { SessionMetricsCollector } from '../session/sessionMetricsCollector'; + +suite('SessionMetricsCollector', () => { + test('initial metrics should be zero', () => { + const collector = new SessionMetricsCollector(); + + assert.deepStrictEqual(collector.getMetrics(), { + toolCallCount: 0, + fileReadCount: 0, + repeatedFileReads: 0, + searchCount: 0, + repeatedSearches: 0, + modifiedFileCount: 0 + }); + }); + + test('records tool calls', () => { + const collector = new SessionMetricsCollector(); + + collector.recordToolCall('read_file'); + + assert.strictEqual(collector.getMetrics().toolCallCount, 1); + }); + + test('counts repeated file reads', () => { + const collector = new SessionMetricsCollector(); + + collector.recordFileRead('a.ts'); + collector.recordFileRead('a.ts'); + + const metrics = collector.getMetrics(); + + assert.strictEqual(metrics.fileReadCount, 2); + assert.strictEqual(metrics.repeatedFileReads, 1); + }); + + test('counts repeated searches', () => { + const collector = new SessionMetricsCollector(); + + collector.recordSearch('SessionHealth'); + collector.recordSearch('SessionHealth'); + + const metrics = collector.getMetrics(); + + assert.strictEqual(metrics.searchCount, 2); + assert.strictEqual(metrics.repeatedSearches, 1); + }); + + test('counts modified files uniquely', () => { + const collector = new SessionMetricsCollector(); + + collector.recordFileModification('a.ts'); + collector.recordFileModification('a.ts'); + + assert.strictEqual(collector.getMetrics().modifiedFileCount, 1); + }); + + test('reset clears metrics', () => { + const collector = new SessionMetricsCollector(); + + collector.recordToolCall('read_file'); + collector.recordFileRead('a.ts'); + collector.recordSearch('abc'); + collector.recordFileModification('a.ts'); + + collector.reset(); + + assert.deepStrictEqual(collector.getMetrics(), { + toolCallCount: 0, + fileReadCount: 0, + repeatedFileReads: 0, + searchCount: 0, + repeatedSearches: 0, + modifiedFileCount: 0 + }); + }); +}); diff --git a/gateway-vscode/src/unit-test/sessionRuntime.test.ts b/gateway-vscode/src/unit-test/sessionRuntime.test.ts new file mode 100644 index 0000000..6cf49ca --- /dev/null +++ b/gateway-vscode/src/unit-test/sessionRuntime.test.ts @@ -0,0 +1,32 @@ +import assert from 'assert'; +import { SessionRuntime } from '../session/sessionRuntime'; + +suite('SessionRuntime', () => { + test('keeps one metrics collector instance and resets state', () => { + const runtime = new SessionRuntime(); + + runtime.metricsCollector.recordToolCall('read_file'); + + assert.strictEqual(runtime.metricsCollector.getMetrics().toolCallCount, 1); + + runtime.reset(); + + assert.strictEqual(runtime.metricsCollector.getMetrics().toolCallCount, 0); + }); + + test('creates checkpoint content through service', () => { + const runtime = new SessionRuntime(); + + const content = runtime.createCheckpoint({ + currentGoal: 'test checkpoint', + completedWork: ['health metrics'], + changedFiles: ['sessionRuntime.ts'], + verification: 'pnpm test passed', + nextStep: 'continue' + }); + + assert.ok(content.includes('# Session Checkpoint')); + assert.ok(content.includes('test checkpoint')); + assert.ok(content.includes('sessionRuntime.ts')); + }); +}); diff --git a/gateway-vscode/src/unit-test/writeFileTool.test.ts b/gateway-vscode/src/unit-test/writeFileTool.test.ts index 46ecef3..0a79242 100644 --- a/gateway-vscode/src/unit-test/writeFileTool.test.ts +++ b/gateway-vscode/src/unit-test/writeFileTool.test.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import type { ToolExecutionContext } from '../tools/types'; +import { SessionMetricsCollector } from '../session/sessionMetricsCollector'; import { writeFileTool } from '../tools/writeFileTool'; suite('Write File Tool', () => { @@ -24,7 +25,7 @@ suite('Write File Tool', () => { path: relativePath, content: 'hello\n' }, - { workspaceRoot } as ToolExecutionContext + { workspaceRoot, metricsCollector: new SessionMetricsCollector() } as ToolExecutionContext ); assert.strictEqual(await fs.readFile(targetPath, 'utf8'), 'hello\n'); @@ -41,7 +42,7 @@ suite('Write File Tool', () => { path: relativePath, content: 'partial\n' }, - { workspaceRoot } as ToolExecutionContext + { workspaceRoot, metricsCollector: new SessionMetricsCollector() } as ToolExecutionContext ); assert.strictEqual( @@ -62,7 +63,7 @@ suite('Write File Tool', () => { path: relativePath, content: 'new\n' }, - { workspaceRoot } as ToolExecutionContext + { workspaceRoot, metricsCollector: new SessionMetricsCollector() } as ToolExecutionContext ); assert.strictEqual(await fs.readFile(targetPath, 'utf8'), 'new\n'); @@ -76,7 +77,7 @@ suite('Write File Tool', () => { path: '../outside/sample.txt', content: 'nope\n' }, - { workspaceRoot } as ToolExecutionContext + { workspaceRoot, metricsCollector: new SessionMetricsCollector() } as ToolExecutionContext ); await assert.rejects( @@ -97,7 +98,7 @@ suite('Write File Tool', () => { path: outsideFileArg, content: 'nope\n' }, - { workspaceRoot } as ToolExecutionContext + { workspaceRoot, metricsCollector: new SessionMetricsCollector() } as ToolExecutionContext ); await assert.rejects( @@ -121,7 +122,7 @@ suite('Write File Tool', () => { path: 'nested\\sample.txt', content: 'nope\n' }, - { workspaceRoot } as ToolExecutionContext + { workspaceRoot, metricsCollector: new SessionMetricsCollector() } as ToolExecutionContext ); await assert.rejects( @@ -146,7 +147,7 @@ suite('Write File Tool', () => { path: 'outside-link/nested/sample.txt', content: 'nope\n' }, - { workspaceRoot } as ToolExecutionContext + { workspaceRoot, metricsCollector: new SessionMetricsCollector() } as ToolExecutionContext ); await assert.rejects(