diff --git a/Cargo.lock b/Cargo.lock index a7c4b85ed7..34cdd836a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,6 +1423,7 @@ dependencies = [ "flate2", "fs2", "git2", + "globset", "ignore", "libc", "log", diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 0cc7db9155..1a42c9841d 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -42,7 +42,7 @@ flowchart TB | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 | | Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复和重命名当前 Session,读取 transcript,切换当前 Session 的 Agent mode/model,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复和重命名当前 Session,读取 transcript,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session delete/fork 同样不在当前协议中 | 因此当前交付的是一条窄的、显式启用的 Shared TUI deployment,不是通用本机 Server。具体 `EventQueue` 仍由 Core 产品装配;IPC 只把当前 TUI 必需的强类型操作和事件映射到同一个 Runtime owner,没有事件重放或公开协议承诺。 @@ -196,7 +196,7 @@ sequenceDiagram S-->>C: initialized(health + interactive_tui) C->>S: create or restore Session S-->>C: Session control + Session facts - C->>S: rename or update current Session + C->>S: rename, update, or reload current Session context C->>S: submit/cancel Turn or answer Permission/UserInput S-->>C: Session-filtered authoritative events else invalid @@ -204,11 +204,11 @@ sequenceDiagram end ``` -当前私有协议(v5)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v6)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| -| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、当前 Session rename、Agent mode/model update | Session delete/fork、跨 workspace attach、transcript 分页、模型目录/默认值和 Agent/Subagent 管理 | +| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、当前 Session rename、Agent mode/model update、声明式上下文 reload | Session delete/fork、跨 workspace attach、transcript 分页、模型目录/默认值和 Agent/Subagent 管理 | | Turn submit/cancel | replay、cursor、resume event stream | | pending/respond Permission、submit UserInput answers | observer、controller transfer、多 Session multiplex | | 连接断开清理、Session-filtered events | detach/observer/controller transfer、SDK callbacks、GUI/Remote/Peer/ACP/Headless wire | @@ -228,6 +228,7 @@ sequenceDiagram - 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session,也不能修改其名称、Agent mode 或 model。 - Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 - Session rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终更新保持权威并拒绝无效值。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试;用户恢复 Session 并核对当前值后再决定是否重试。模式与模型目录仍是同版本第一方产品事实,不加入 IPC。 +- 声明式上下文 reload 只失效当前 Session 的 instructions 缓存,并按目标复用 Skill Registry 刷新;它可在活动 Turn 中执行但不改写该 Turn,generation 保护保证下一条消息重建上下文。它不引入 watcher、热替换或第二套 Runtime owner。 - Shared TUI 的模型选择器复用 Client 已有的只读产品配置来显示同版本模型目录;它只把选中的 model ID 通过 `update current Session model` 交给 Runtime。Client 不持有 Session 写入权,也不通过 IPC 管理模型目录或默认值。 - Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。 - Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。 diff --git a/docs/architecture/cache-friendly-message-structure.md b/docs/architecture/cache-friendly-message-structure.md index 8cacf07331..2f02f66b85 100644 --- a/docs/architecture/cache-friendly-message-structure.md +++ b/docs/architecture/cache-friendly-message-structure.md @@ -244,6 +244,15 @@ What usually breaks reuse: - explicit prompt-cache invalidation - context compression, which resets prompt cache after rewriting history +The user-facing `/reload instructions` command intentionally invalidates only +the current Session's `UserContext` cache. The active turn is not rewritten; +workspace instructions are read again when the next message is assembled. +Plain `/reload` combines that operation with the independently owned Skill +Registry refresh, while `/reload skills` leaves `UserContext` intact. +An in-memory generation guard rejects a user-context build that started before +the invalidation from repopulating the cache after it, so active-turn reloads +preserve the same next-message guarantee. + ### 6. Conversation history What it is: diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 2ad4496d35..9922e7f7ae 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -256,12 +256,12 @@ Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付 | 形态 | 默认部署 | 当前 Shared 范围 | |---|---|---| -| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session rename/Agent mode/model、Turn submit/cancel、Permission 和 UserInput | +| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、Turn submit/cancel、Permission 和 UserInput | | `bitfun exec` / CI | Embedded | 不接受 Shared;保持独立进程、stdout/stderr 和退出码语义 | | ACP / SDK Host / GUI / Remote / Peer | 各自既有部署 | 不消费 TUI IPC,也不因本开关改变生命周期 | Shared TUI 不提供 Session delete/fork、模型目录/默认值、Agent/Subagent 管理、MCP/扩展、账号同步、用量、observer、replay 或 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 -Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:`/rename ` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示 `/rename `;在 slash menu 中选择它只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权;单个连接已有活动 Turn 时拒绝重复提交以及 Session rename/mode/model update;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 +Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:`/rename ` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model;`/reload [skills|instructions]` 刷新下一条消息使用的声明式上下文。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示 `/rename ` 和 `/reload`;在 slash menu 中选择 rename 只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权;单个连接已有活动 Turn 时拒绝重复提交以及 Session rename/mode/model update,但允许 reload 只影响下一条消息;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 部署差异由 CLI Runtime client 封装。Embedded 以 Rust 类型直接调用 `AgentRuntime`,不初始化 IPC 或执行 JSON 编解码;Shared 将同一业务请求映射为一个有界本机 frame,Client/Server 各自只编码一次,再交给同一 Runtime owner。多 TUI 复用一个 Runtime 进程,连接和队列保持有界,不按 TUI 数量复制 Session owner。详细的 4+1 视图、帧上限和并发边界见 [`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。 @@ -535,10 +535,14 @@ Hook C0:脱敏发现 -> 精确命令预览 | 指纹确认 -> 原子发布本 规则文件优先复用项目已有文件,不复制出第二份内容。若不同生态规则冲突,导入报告必须展示目标文件、 优先级和冲突段,不能自动拼接。 -当前 Workspace Instructions 只消费真实工作区根:本地和 Remote 共用 `WorkspaceFileSystem` 读取, -`AGENTS.override.md` 文件存在时替代同目录 `AGENTS.md`(空文件也不回退),`CLAUDE.md` 继续作为独立来源按既有顺序追加。 -运行时尚无稳定的嵌套活动目录事实,因此不声明 root-to-cwd 级联;全局规则、Claude rules/import、OpenCode -`instructions` glob/URL、变化监听和冲突报告也不属于当前实现。 +当前 Workspace Instructions 只消费真实工作区根,本地和 Remote 共用同一个解析器与 `WorkspaceFileSystem` 端口。 +固定顺序是:`AGENTS.override.md`(存在时替代 `AGENTS.md`,空文件也不回退)、根 `CLAUDE.md` 或 +`.claude/CLAUDE.md`、`CLAUDE.local.md`、不带 `paths` front matter 的 `.claude/rules/**/*.md`,最后是项目根与 +`.opencode` 中 `opencode.json/jsonc` 的本地 `instructions` 文件或 glob。Claude `@import` 只跟随工作区内文件, +深度上限为 5,并对重复和循环引用去重;所有目录遍历都跳过符号链接。运行时尚无稳定的嵌套活动目录事实,因此 +不声明 root-to-cwd 级联。递归扫描跳过 VCS、依赖与构建目录,并对扫描节点、文件数量、单文件和总内容字节设置固定 +上限,避免宽 glob 阻塞本地或 Remote 工作区。Claude path-scoped rules、全局规则、OpenCode 远程 URL、变化监听和冲突 +报告也不属于当前实现。 现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现已经保留来源身份和全局/项目使用范围,并在 GUI/TUI 展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。 @@ -554,8 +558,12 @@ Skill Registry 还保留来源资产声明的隐式调用意图:Claude `SKILL. 分组以及 `\$` 转义;缺失的位置参数保留原占位符,模板没有未转义占位符时才追加 `ARGUMENTS:` 段。该展开器只处理 字符串,不执行命令、脚本或动态变量。未携带 `arguments` 的旧工具调用保持原 Skill 正文不变。 -这项能力不新增导入记录、来源图、后台 watcher 或第二套刷新生命周期。工作区查询继续按现有 Registry 路径扫描,用户 -缓存继续使用已有刷新入口,CLI 的 `/reload-skills` 仍是明确的手动刷新方式;运行期不承诺对所有来源做文件监听或热重载。 +这项能力不新增导入记录、来源图、后台 watcher 或第二套刷新生命周期。用户只需要一个手动入口:`/reload` 同时刷新 +Skill Registry 并失效当前 Session 的 Workspace Instructions 缓存;`/reload skills` 与 `/reload instructions` 用于只刷新 +一类内容。Desktop、Embedded CLI 与 Shared TUI 共用同一 core 协调入口,但 Skill Registry 刷新和 Session +`UserContext` 缓存失效仍由各自既有 owner 完成。指令变更从下一条消息开始生效;运行期不承诺文件监听或当前生成中的 +消息热替换。缓存 generation 会拒绝活动 Turn 在失效之后写回的旧构建结果;旧 `/reload-skills` 输入仅作为隐藏兼容别名 +映射到 `/reload skills`,不增加第二个命令入口。 本切片也不实现 `allowed-tools`、`context`、`fork`、`agent`、`model`、命名参数、动态 shell/runtime 变量、URL、祖先目录 级联、插件 Runtime 或 OpenCode 复杂 Hook。后续只有在存在稳定消费方和独立安全边界时才扩展这些语义。 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index ea2ef55660..a92e55dd7b 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -198,7 +198,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| -| Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | Workspace Instructions 归属模块保存有序来源引用 | 本地内容按 L1 合并并保留来源;主动获取远程 URL 前确认 | 远程或单文件失败只排除该来源。 | +| Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | Workspace Instructions 归属模块保存有序来源引用 | 当前实现项目根与 `.opencode` 配置中的本地精确文件/glob;全局与远程 URL 仍是目标 | 无效 JSONC 或 glob 只排除对应配置项;文件 I/O 失败时当前构建不缓存并在下一条消息重试。 | | Agents / Modes | JSON、Markdown、description、mode、prompt、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、permission、disable、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图 | 当前支持 Subagent 安全子集;首次按行为、来源、模型和工具范围确认,fresh single-run 调用 | primary/mode、permission、variant/options、采样、steps 与续接保持诊断或阻断,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | Skill 归属模块复用按需加载并补齐规则顺序 | 说明和索引按需加载;URL、脚本或外部依赖按 L2 确认 | URL 或可执行资源失败只降级对应 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | @@ -217,6 +217,12 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 规则内容尽量原地引用,不复制成第二份文件。组合结果保留原始段落来源和顺序。OpenCode 与 BitFun 原生规则 同时存在时,配置视图展示实际进入模型的顺序;不能把冲突文本自动改写成“合并后的真相”。 +当前 runtime-free 子集不建立通用配置来源图:Workspace Instructions owner 在每个 Session 首次需要 user context 时读取 +项目根 `opencode.json`、`opencode.jsonc`、`.opencode/opencode.json` 和 `.opencode/opencode.jsonc` 中的 +`instructions` 数组,只接受工作区内相对精确文件与 glob,确定性排序后追加到既有 `AGENTS`/Claude 来源之后。 +绝对路径、`~`、越出工作区的路径、符号链接和 URL 都不会加载。文件变更不启动 watcher;用户通过统一的 +`/reload instructions`(或默认 `/reload`)失效当前 Session 的 `UserContext` 缓存,下一条消息重新读取。 + ### 5.2 Agents、Modes 与 Skills 兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。当前已实现范围按是否能保持行为等价划分: diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index ac362da702..4d8b491bab 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -104,9 +104,9 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | OpenCode 扩展项 | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | 细节 | |---|---|---|---|---|---|---| | 配置层级与合并 | 融合现有能力 | 部分实现:Command/Subagent/MCP 本地来源顺序 | 可完整适配 | OC-R1 | 当前三类 provider 复用私有本地路径顺序与监听根;后续按 remote、global、自定义文件、project、`.opencode`、内联和组织配置补齐有序来源并保留最终来源 | [来源与合并](opencode-config-assets-adapter-design.md#3-配置层级与来源) | -| JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 未实现 | 可主要适配 | OC-R1 | 有效配置保持 OpenCode 解码语义;未知字段保留和非安全字段局部恢复属于 BitFun 鲁棒性增强,安全/执行字段无效时不激活受影响结果 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | +| JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 部分实现:项目 `instructions` 的 JSON/JSONC | 可主要适配 | OC-R1 | 当前只解析项目根和 `.opencode` 配置中的本地 instruction 文件/glob;完整配置 schema、环境变量与文件变量引用仍未实现 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | -| Rules / Instructions | 转换参数 | 未实现 | 可完整适配 | OC-R1 | R1 映射本地/已缓存内容;需要主动联网的远程 instruction 在 R2 通过归属模块保护后获取 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | +| Rules / Instructions | 转换参数 | 部分实现:项目本地文件与 glob | 可完整适配 | OC-R1 | 当前由 Workspace Instructions owner 读取项目 `instructions` 的精确文件和 glob;全局、远程 URL、完整来源合并与变化监听仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集 | 可主要适配 | OC-R1 | 已支持全局/项目 Markdown、JSON/JSONC 的 subagent/all、prompt、description、disable/hidden、精确模型与工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;primary/mode、permission、variant/options、采样与续接明确降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 部分实现:现有 Registry 发现与来源/覆盖展示 | 可完整适配 | OC-R2 | 已沿用现有根顺序发现 `.opencode` 等用户/项目 Skill,并在 GUI/TUI 展示来源、使用范围和覆盖结果;尚未复现 OpenCode 按需加载、完整配置合并及 allow/deny/ask 顺序,也未并入外部来源策略模块 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 1ecf1a16f9..537a71ed9b 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -6,7 +6,7 @@ export const forbiddenContentRules = [ reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', patterns: [ { - regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|RenameSession|UpdateSessionMode|UpdateSessionModel|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|Self|AgentDialogTurnRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|Self|AgentContextReloadRequest|AgentDialogTurnRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, message: 'agent-runtime-ipc may not add replay, observer, controller-transfer, deletion, fork, or other operations beyond the reviewed Shared TUI slice', }, diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index c6da3979d3..607aed8168 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -77,7 +77,7 @@ pub(crate) enum ActionHandler { Sessions, RenameSession, Skills, - ReloadSkills, + Reload, McpServers, Tools, Extensions, @@ -115,7 +115,7 @@ pub(crate) enum ActionHandler { pub(crate) const SHARED_TUI_EMBEDDED_HANDOFF: &str = "Exit all Shared TUI clients, wait up to 30 seconds for their Runtime to stop, then use default Embedded `bitfun chat`"; pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/rename ` to rename the current Session, `/agent`, Tab, or Shift+Tab to change its Agent mode, and `/models` to change its model. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/rename ` to rename the current Session, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { pub(crate) const fn available_in_shared_tui(self, context: ActionContext) -> bool { @@ -135,6 +135,7 @@ impl ActionHandler { | Self::OpenAgentSelector | Self::SwitchAgent | Self::SwitchAgentReverse + | Self::Reload | Self::Exit | Self::OpenPalette | Self::SubmitInput @@ -404,13 +405,13 @@ static ACTION_SPECS: &[ActionSpec] = &[ slash_on_startup: true, }, ActionSpec { - id: "reload_skills", - name: "Reload skills", - aliases: &["/reload-skills"], - description: "Re-scan skill directories without restarting", + id: "reload", + name: "Reload context", + aliases: &["/reload"], + description: "Reload skills and instructions; optionally choose one target", contexts: CHAT, availability: ActionAvailability::Always, - handler: ActionHandler::ReloadSkills, + handler: ActionHandler::Reload, default_bindings: &[], fallback_bindings: &[], shortcut_field: None, @@ -1838,6 +1839,7 @@ mod tests { assert!(SHARED_TUI_HELP_NOTE.contains("one Session")); assert!(SHARED_TUI_HELP_NOTE.contains("`/models`")); assert!(SHARED_TUI_HELP_NOTE.contains("`/rename `")); + assert!(SHARED_TUI_HELP_NOTE.contains("`/reload [skills|instructions]`")); assert!(SHARED_TUI_HELP_NOTE.contains("Agent/Subagent management")); assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); } @@ -1881,6 +1883,14 @@ mod tests { assert!(slash_ids.contains(&"select_model")); assert!(palette_ids.contains(&"select_model")); assert!(slash_ids.contains(&"rename_session")); + assert!(slash_ids.contains(&"reload")); + assert!(!slash_ids.contains(&"reload_skills")); + + let reload = + action_for_alias("/reload", ActionContext::Chat).expect("unified reload action"); + assert_eq!(reload.id, "reload"); + assert!(reload.available(state)); + assert!(action_for_alias("/reload-skills", ActionContext::Chat).is_none()); let help = ResolvedKeymap::new(&ShortcutsConfig::default()).help_text(state); assert!(help.contains("Switch Agent")); diff --git a/src/apps/cli/src/agent/context_reload_client.rs b/src/apps/cli/src/agent/context_reload_client.rs new file mode 100644 index 0000000000..32c19897a6 --- /dev/null +++ b/src/apps/cli/src/agent/context_reload_client.rs @@ -0,0 +1,212 @@ +use anyhow::Result; +use bitfun_agent_runtime_ipc::{RuntimeIpcClient, RuntimeIpcOperation}; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; +use bitfun_runtime_ports::AgentContextReloadRequest; + +use super::runtime_client::expect_unit; + +/// CLI-private adapter that keeps context reload identical across Embedded and +/// Shared TUI deployments without expanding the primary Agent Runtime client. +pub(crate) enum CliContextReloadClient { + Embedded(CoreAgentRuntimeCompatibility), + Shared(RuntimeIpcClient), +} + +impl CliContextReloadClient { + pub(crate) fn embedded(compatibility: CoreAgentRuntimeCompatibility) -> Self { + Self::Embedded(compatibility) + } + + pub(crate) fn shared(client: RuntimeIpcClient) -> Self { + Self::Shared(client) + } + + pub(crate) async fn reload(&self, request: AgentContextReloadRequest) -> Result<()> { + match self { + Self::Embedded(compatibility) => compatibility + .reload_session_context(request) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())), + Self::Shared(client) => { + let result = client + .request(RuntimeIpcOperation::ReloadSessionContext { request }) + .await?; + expect_unit(result, "reload_session_context") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use bitfun_agent_runtime_ipc::{ + RuntimeInstanceIdentity, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, + RuntimeIpcOperationResult, RuntimeIpcRequestHandler, RuntimeIpcServer, + RuntimeIpcServerConfig, PROTOCOL_VERSION, + }; + use bitfun_runtime_ports::{ + AgentContextReloadTarget, AgentSessionCreateRequest, AgentSessionCreateResult, + }; + use serde_json::Map; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + use tempfile::tempdir; + use tokio::sync::broadcast; + + struct ReloadHandler { + calls: Mutex>, + reload_calls: AtomicUsize, + events: broadcast::Sender, + } + + impl ReloadHandler { + fn new() -> Self { + let (events, _) = broadcast::channel(4); + Self { + calls: Mutex::new(Vec::new()), + reload_calls: AtomicUsize::new(0), + events, + } + } + } + + #[async_trait] + impl RuntimeIpcRequestHandler for ReloadHandler { + async fn execute( + &self, + operation: RuntimeIpcOperation, + ) -> std::result::Result { + self.calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(operation.clone()); + + match operation { + RuntimeIpcOperation::CreateSession { request } => { + let mut session = AgentSessionCreateResult::new( + "shared-reload-session", + request.session_name, + request.agent_type, + ); + session.workspace_path = request.workspace_path; + Ok(RuntimeIpcOperationResult::SessionCreated { session }) + } + RuntimeIpcOperation::ReloadSessionContext { .. } + if self.reload_calls.fetch_add(1, Ordering::SeqCst) > 0 => + { + Err(RuntimeIpcError { + code: RuntimeIpcErrorCode::Internal, + message: "shared reload failed".to_string(), + }) + } + _ => Ok(RuntimeIpcOperationResult::Unit), + } + } + + fn subscribe_events( + &self, + _session_id: &str, + ) -> std::result::Result, RuntimeIpcError> { + Ok(self.events.subscribe()) + } + } + + #[tokio::test] + async fn shared_reload_sends_the_typed_ipc_request_and_propagates_remote_errors() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = RuntimeInstanceIdentity::for_workspace( + workspace.path(), + "bitfun", + "test", + "context-reload", + PROTOCOL_VERSION, + ) + .expect("runtime identity"); + let handler = Arc::new(ReloadHandler::new()); + let server = RuntimeIpcServer::bind_with_handler( + runtime_root.path(), + identity, + RuntimeIpcServerConfig { + server_version: "context-reload-test".to_string(), + idle_timeout: Duration::from_millis(50), + handshake_timeout: Duration::from_secs(2), + request_timeout: Duration::from_secs(2), + max_connections: 2, + }, + handler.clone(), + ) + .await + .expect("bind shared server"); + let discovery = server.discovery_record().clone(); + let server_task = tokio::spawn(server.serve()); + let ipc_client = RuntimeIpcClient::connect( + runtime_root.path(), + &discovery, + "context-reload-test", + env!("CARGO_PKG_VERSION"), + Duration::from_secs(2), + Duration::from_secs(2), + ) + .await + .expect("connect shared client"); + let created = ipc_client + .request(RuntimeIpcOperation::CreateSession { + request: AgentSessionCreateRequest { + session_name: "Reload test".to_string(), + agent_type: "agentic".to_string(), + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: Map::new(), + }, + }) + .await + .expect("create controlled shared session"); + assert!(matches!( + created, + RuntimeIpcOperationResult::SessionCreated { .. } + )); + + let client = CliContextReloadClient::shared(ipc_client.clone()); + let request = AgentContextReloadRequest { + session_id: "shared-reload-session".to_string(), + target: AgentContextReloadTarget::All, + }; + client + .reload(request.clone()) + .await + .expect("reload shared context"); + assert!(handler + .calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains(&RuntimeIpcOperation::ReloadSessionContext { + request: request.clone(), + })); + + let error = client + .reload(request) + .await + .expect_err("remote reload error must propagate"); + assert!( + error.to_string().contains("shared reload failed"), + "{error}" + ); + + drop(client); + drop(ipc_client); + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("shared server exits") + .expect("shared server task") + .expect("shared server result"); + } +} diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index f43aec75a2..a777221952 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -3,4 +3,5 @@ /// Session operations use the shared Agent Runtime SDK. Event consumption /// remains in the chat and exec mode loops. pub(crate) mod agentic_system; +pub(crate) mod context_reload_client; pub(crate) mod runtime_client; diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 55504f656a..49d1cda851 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -1275,7 +1275,7 @@ fn project_routed_permission_event( } } -fn expect_unit(result: RuntimeIpcOperationResult, operation: &str) -> Result<()> { +pub(super) fn expect_unit(result: RuntimeIpcOperationResult, operation: &str) -> Result<()> { match result { RuntimeIpcOperationResult::Unit => Ok(()), _ => Err(unexpected_shared_result(operation)), diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index e02dd2770b..0cbd9dc212 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -41,6 +41,7 @@ use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, OnceLock}; +use agent::context_reload_client::CliContextReloadClient; use agent::runtime_client::CliAgentRuntimeClient; use config::CliConfig; use hook_import::HookAction; @@ -855,17 +856,23 @@ async fn run_interactive( .await?, ) }; - let agent = if let Some(runtime) = &runtime { - Arc::new(CliAgentRuntimeClient::new( - runtime.as_ref(), - Some(workspace_path.clone()), - )) + let (agent, context_reload) = if let Some(runtime) = &runtime { + ( + Arc::new(CliAgentRuntimeClient::new( + runtime.as_ref(), + Some(workspace_path.clone()), + )), + CliContextReloadClient::embedded(runtime.compatibility().clone()), + ) } else { let client = shared_runtime::connect_or_start(&workspace_path).await?; - Arc::new(CliAgentRuntimeClient::new_shared( - client, - Some(workspace_path.clone()), - )) + ( + Arc::new(CliAgentRuntimeClient::new_shared( + client.clone(), + Some(workspace_path.clone()), + )), + CliContextReloadClient::shared(client), + ) }; let compatibility = runtime .as_ref() @@ -924,7 +931,14 @@ async fn run_interactive( // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); let config = startup_page.config().clone(); - let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent, compatibility); + let mut chat_mode = ChatMode::new( + config, + agent_type, + workspace, + agent, + context_reload, + compatibility, + ); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); } @@ -1327,6 +1341,8 @@ async fn run_interactive_with_session( runtime.as_ref(), Some(workspace_path), )); + let compatibility = runtime.compatibility().clone(); + let context_reload = CliContextReloadClient::embedded(compatibility.clone()); let sessions = agent.list_sessions().await?; let agent_type = sessions .iter() @@ -1344,7 +1360,8 @@ async fn run_interactive_with_session( agent_type, workspace, agent, - Some(runtime.compatibility().clone()), + context_reload, + Some(compatibility), ) .with_restore_session(session_id); let run_result = chat_mode.run(Some(terminal)); diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 3945d7422f..373cbce8be 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -28,6 +28,7 @@ use crate::actions::{ removed_management_command_hint, slash_actions, ActionContext, ActionHandler, ActionSpec, ActionState, ResolvedKeymap, SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, }; +use crate::agent::context_reload_client::CliContextReloadClient; use crate::agent::runtime_client::{CliAgentRuntimeClient, SessionUpdateError}; use crate::chat_state::ChatState; use crate::config::CliConfig; @@ -208,7 +209,7 @@ struct PendingSessionUpdate { } const SESSION_UPDATE_SLOW_NOTICE: Duration = Duration::from_secs(15); -const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, turns, the current Session name, current Session Agent mode, and current Session model; model management remains Embedded, along with local extension, MCP, account-sync, and Agent/Subagent management."; +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, turns, the current Session name, current Session Agent mode, current Session model, and declarative context via /reload [skills|instructions]; model management remains Embedded, along with local extension, MCP, account-sync, and Agent/Subagent management."; #[derive(Default)] struct NonKeyEventOutcome { @@ -233,6 +234,7 @@ pub(crate) struct ChatMode { agent_type: String, workspace: Option, agent: Arc, + context_reload: CliContextReloadClient, compatibility: Option, /// User-level default resolved from shared config for this TUI run. auto_approve_ask_default: bool, @@ -288,6 +290,7 @@ impl ChatMode { agent_type: String, workspace: Option, agent: Arc, + context_reload: CliContextReloadClient, compatibility: Option, ) -> Self { let keymap = ResolvedKeymap::new(&config.shortcuts); @@ -297,6 +300,7 @@ impl ChatMode { agent_type, workspace, agent, + context_reload, compatibility, auto_approve_ask_default: false, auto_approve_ask_override: None, diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index a845881bf0..87d64d6b2e 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -8,36 +8,41 @@ impl ChatMode { chat_view.show_skill_menu(); } - /// Re-scan skill directories from disk and rebuild the registry cache. - /// - /// Mirrors Claude Code 2.1.152 `/reload-skills`. Safe to call at any - /// time — does not require `is_processing` to be false because the - /// registry swap is atomic and a held `SkillInfo` reference is not - /// kept across the call. - fn reload_skills_from_disk( + fn reload_context( &self, + target: bitfun_runtime_ports::AgentContextReloadTarget, chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let registry = SkillRegistry::global(); - let workspace = self.agent.workspace_path_buf(); - let outcome = tokio::task::block_in_place(|| { - // refresh() is the global re-scan entry point; the workspace - // arg of refresh_for_workspace is currently a no-op upstream, - // so we call refresh() directly and re-resolve the workspace - // count afterwards. - rt_handle.block_on(async { - registry.refresh().await; - registry - .get_resolved_skills_for_workspace(Some(workspace.as_path()), None) - .await - }) - }); - - let count = outcome.len(); - chat_state.add_system_message(format!("Reloaded {} skill(s) from disk.", count)); - chat_view.set_status(Some(format!("Skills reloaded ({} available)", count))); + use bitfun_runtime_ports::{AgentContextReloadRequest, AgentContextReloadTarget}; + + let request = AgentContextReloadRequest { + session_id: chat_state.core_session_id.clone(), + target, + }; + let outcome = + tokio::task::block_in_place(|| rt_handle.block_on(self.context_reload.reload(request))); + + match outcome { + Ok(_) => { + let message = match target { + AgentContextReloadTarget::All => { + "Reloaded skills. Instructions will be reread for the next message." + } + AgentContextReloadTarget::Skills => "Reloaded skills.", + AgentContextReloadTarget::Instructions => { + "Instructions will be reread for the next message." + } + }; + chat_state.add_system_message(message.to_string()); + chat_view.set_status(Some(message.to_string())); + } + Err(error) => { + chat_state.add_system_message(format!("Could not reload context: {error}")); + chat_view.set_status(Some("Context reload failed".to_string())); + } + } } fn show_available_skill_list( diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 24ee2326ca..68f1698392 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -2,6 +2,36 @@ fn session_update_blocks_typed_submission(pending_for_current_session: bool, inp pending_for_current_session && !input.trim().starts_with('/') } +fn parse_reload_target( + arguments: &str, +) -> std::result::Result { + use bitfun_runtime_ports::AgentContextReloadTarget; + + match arguments.trim().to_ascii_lowercase().as_str() { + "" => Ok(AgentContextReloadTarget::All), + "skills" => Ok(AgentContextReloadTarget::Skills), + "instructions" => Ok(AgentContextReloadTarget::Instructions), + _ => Err("Usage: /reload [skills|instructions]"), + } +} + +fn parse_reload_invocation( + command_name: &str, + arguments: &str, +) -> Option> { + if command_name.eq_ignore_ascii_case("reload") { + return Some(parse_reload_target(arguments)); + } + if command_name.eq_ignore_ascii_case("reload-skills") { + return Some(if arguments.trim().is_empty() { + Ok(bitfun_runtime_ports::AgentContextReloadTarget::Skills) + } else { + Err("Usage: /reload-skills (or /reload skills)") + }); + } + None +} + fn pending_session_update_blocks_runtime_action( shared_tui: bool, pending_for_current_session: bool, @@ -263,15 +293,17 @@ impl ChatMode { } let token = parts[0]; - let command_name = token.trim_start_matches('/'); + let entered_command_name = token.trim_start_matches('/'); let arguments = command .get(token.len()..) .map(str::trim_start) .unwrap_or(""); + let command_name = entered_command_name; let selected_native_once = consume_selected_native_command_once( &mut self.selected_native_command_once, command_name, ); + let reload_invocation = parse_reload_invocation(entered_command_name, arguments); if command_name == "auto" { let action_id = match arguments.trim() { "on" | "enable" => "toggle_auto_approve:on", @@ -298,6 +330,14 @@ impl ChatMode { if command_name.eq_ignore_ascii_case("worktree") { return self.handle_worktree_command(arguments, chat_view, chat_state, rt_handle); } + if command_name.eq_ignore_ascii_case("reload-skills") { + return self.handle_reload_invocation( + reload_invocation.expect("legacy reload alias requires a parsed invocation"), + chat_view, + chat_state, + rt_handle, + ); + } let builtin_alias = format!("/{command_name}"); let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); if self.agent.is_shared() { @@ -310,6 +350,14 @@ impl ChatMode { } return self.start_session_rename(arguments, chat_view, chat_state, rt_handle); } + if action.handler == ActionHandler::Reload { + return self.handle_reload_invocation( + reload_invocation.expect("reload action requires a parsed invocation"), + chat_view, + chat_state, + rt_handle, + ); + } return self.dispatch_action(action, state, chat_view, chat_state, rt_handle); } chat_state.add_system_message(format!( @@ -434,6 +482,14 @@ impl ChatMode { match route { CommandRoute::Builtin => { let action = builtin_action.expect("route requires an available built-in action"); + if action.handler == ActionHandler::Reload { + return self.handle_reload_invocation( + reload_invocation.expect("reload action requires a parsed invocation"), + chat_view, + chat_state, + rt_handle, + ); + } self.dispatch_action( action, self.action_state(chat_state.is_processing, false), @@ -485,6 +541,23 @@ impl ChatMode { } } + fn handle_reload_invocation( + &mut self, + target: std::result::Result, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + match target { + Ok(target) => self.reload_context(target, chat_view, chat_state, rt_handle), + Err(usage) => { + chat_view.set_status(Some(usage.to_string())); + chat_state.add_system_message(usage.to_string()); + } + } + Ok(None) + } + fn external_command_projection(&self, command_name: &str) -> Option { external_command_projections( self.external_source_snapshot.as_ref()?, @@ -821,8 +894,13 @@ impl ChatMode { ActionHandler::Skills => { self.show_skill_selector(chat_view, chat_state, rt_handle); } - ActionHandler::ReloadSkills => { - self.reload_skills_from_disk(chat_view, chat_state, rt_handle); + ActionHandler::Reload => { + self.reload_context( + bitfun_runtime_ports::AgentContextReloadTarget::All, + chat_view, + chat_state, + rt_handle, + ); } ActionHandler::McpServers => { self.show_mcp_selector(chat_view, chat_state, rt_handle); diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index ebf33907d4..2691dbfde2 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -18,10 +18,10 @@ mod tests { merge_external_agent_mutation_snapshot, native_command_choice_is_active, native_command_reconfirmation_is_required, native_hook_help_text, parse_external_agent_review_action, parse_external_control_action, - parse_external_tool_review_action, parse_hook_management_action, - pending_session_update_blocks_runtime_action, previous_session_update_status, - render_external_hook_catalog, render_native_hook_overview, requested_session_name, - retain_selected_native_command_for_input, selected_command_prefill, + parse_external_tool_review_action, parse_hook_management_action, parse_reload_invocation, + parse_reload_target, pending_session_update_blocks_runtime_action, + previous_session_update_status, render_external_hook_catalog, render_native_hook_overview, + requested_session_name, retain_selected_native_command_for_input, selected_command_prefill, session_command_help_note, session_update_allowed, session_update_blocks_typed_submission, session_update_completion_should_exit, shared_session_change_is_blocked, CommandRoute, ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, @@ -48,8 +48,38 @@ mod tests { NativeHookFileView, NativeHookHandlerView, NativeHookOverview, NativeHookRuleView, }; use bitfun_product_domains::external_sources::ExternalSourceScope; + use bitfun_runtime_ports::AgentContextReloadTarget; use std::collections::{BTreeMap, BTreeSet}; + #[test] + fn reload_command_uses_one_closed_optional_target() { + assert_eq!( + parse_reload_target("").unwrap(), + AgentContextReloadTarget::All + ); + assert_eq!( + parse_reload_target(" SKILLS ").unwrap(), + AgentContextReloadTarget::Skills + ); + assert_eq!( + parse_reload_target("Instructions").unwrap(), + AgentContextReloadTarget::Instructions + ); + assert!(parse_reload_target("mcp").is_err()); + assert!(parse_reload_target("skills instructions").is_err()); + + assert_eq!( + parse_reload_invocation("reload-skills", "") + .unwrap() + .unwrap(), + AgentContextReloadTarget::Skills + ); + assert!(parse_reload_invocation("reload-skills", "instructions") + .unwrap() + .is_err()); + assert!(parse_reload_invocation("other", "").is_none()); + } + fn external_command( name: &str, selected_candidate_id: Option<&str>, @@ -1698,6 +1728,7 @@ mod tests { assert!(SHARED_TUI_CHAT_STATUS.contains("current Session Agent mode")); assert!(SHARED_TUI_CHAT_STATUS.contains("current Session model")); assert!(SHARED_TUI_CHAT_STATUS.contains("current Session name")); + assert!(SHARED_TUI_CHAT_STATUS.contains("/reload [skills|instructions]")); assert!(SHARED_TUI_CHAT_STATUS.contains("Agent/Subagent management")); assert!(SHARED_TUI_CHAT_STATUS.contains("model management remains Embedded")); } diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 5507e521f0..dcd6feeee1 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -11,6 +11,7 @@ use bitfun_agent_runtime_ipc::{ RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcStreamInvalidationReason, RuntimeSessionRenameRequest, PROTOCOL_VERSION, }; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::runtime_ownership::CoreRuntimeOwnership; use bitfun_events::{AgenticEvent, ToolEventData}; use bitfun_services_core::runtime_ownership::RuntimeDeployment; @@ -33,6 +34,7 @@ type SessionEventSenders = Mutex, question_sessions: Arc>>, @@ -41,7 +43,11 @@ pub(crate) struct SharedRuntimeHandler { } impl SharedRuntimeHandler { - pub(crate) fn build(runtime: AgentRuntime, workspace: &Path) -> Result { + pub(crate) fn build( + runtime: AgentRuntime, + compatibility: CoreAgentRuntimeCompatibility, + workspace: &Path, + ) -> Result { let mut agent_events = runtime .subscribe_events() .map_err(runtime_error_message) @@ -178,6 +184,7 @@ impl SharedRuntimeHandler { Ok(Self { runtime, + compatibility, workspace: dunce::canonicalize(workspace) .context("canonicalize Shared Runtime workspace")?, events, @@ -273,10 +280,14 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { Ok(RuntimeIpcOperationResult::Unit) } RuntimeIpcOperation::RenameSession { request } => { - self.runtime - .rename_session(owned_session_rename_request(&self.workspace, request)) + rename_owned_session(&self.runtime, &self.workspace, request).await?; + Ok(RuntimeIpcOperationResult::Unit) + } + RuntimeIpcOperation::ReloadSessionContext { request } => { + self.compatibility + .reload_session_context(request) .await - .map_err(runtime_ipc_error)?; + .map_err(core_ipc_error)?; Ok(RuntimeIpcOperationResult::Unit) } RuntimeIpcOperation::SubmitTurn { request } => { @@ -400,6 +411,17 @@ fn owned_session_rename_request( } } +async fn rename_owned_session( + runtime: &AgentRuntime, + workspace: &Path, + request: RuntimeSessionRenameRequest, +) -> std::result::Result<(), RuntimeIpcError> { + runtime + .rename_session(owned_session_rename_request(workspace, request)) + .await + .map_err(runtime_ipc_error) +} + fn subscribe_session_events( events: &SessionEventSenders, available: &watch::Sender, @@ -663,6 +685,7 @@ pub(crate) async fn run_service(workspace: PathBuf, expected_identity: String) - .await?; let handler = Arc::new(SharedRuntimeHandler::build( runtime.agent_runtime().clone(), + runtime.compatibility().clone(), &workspace, )?); let server = RuntimeIpcServer::bind_with_handler( @@ -967,14 +990,31 @@ fn runtime_ipc_error(error: RuntimeError) -> RuntimeIpcError { } } +fn core_ipc_error(error: bitfun_core::util::errors::BitFunError) -> RuntimeIpcError { + let code = match &error { + bitfun_core::util::errors::BitFunError::Validation(_) + | bitfun_core::util::errors::BitFunError::NotFound(_) => { + RuntimeIpcErrorCode::InvalidRequest + } + bitfun_core::util::errors::BitFunError::SessionInUse { .. } => { + RuntimeIpcErrorCode::SessionInUse + } + _ => RuntimeIpcErrorCode::Unavailable, + }; + RuntimeIpcError { + code, + message: error.to_string(), + } +} + #[cfg(test)] mod tests { use super::{ await_permission_route, connect_existing, index_user_question, invalidate_event_stream, owned_session_rename_request, permission_event_session, permission_targets_session, - project_subagent_link_route, project_user_question_route, publish_event, route_agent_event, - runtime_ipc_error, subscribe_session_events, SessionEventSenders, SharedRuntimeHandler, - EVENT_BUFFER, + project_subagent_link_route, project_user_question_route, publish_event, + rename_owned_session, route_agent_event, runtime_ipc_error, subscribe_session_events, + SessionEventSenders, EVENT_BUFFER, }; use bitfun_agent_runtime::sdk::{ AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult, @@ -985,10 +1025,7 @@ mod tests { PermissionRequest, PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, RuntimeError, }; - use bitfun_agent_runtime_ipc::{ - RuntimeIpcErrorCode, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcRequestHandler, RuntimeSessionRenameRequest, - }; + use bitfun_agent_runtime_ipc::{RuntimeIpcErrorCode, RuntimeSessionRenameRequest}; use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -1134,26 +1171,17 @@ mod tests { .await .expect("embedded rename"); - let (event_stream_available, _) = watch::channel(true); - let handler = SharedRuntimeHandler { - runtime, - workspace: canonical_workspace, - events: Arc::new(Mutex::new(HashMap::new())), - question_sessions: Arc::new(Mutex::new(HashMap::new())), - subagent_routes: Arc::new(Mutex::new(HashMap::new())), - event_stream_available, - }; - let result = handler - .execute(RuntimeIpcOperation::RenameSession { - request: RuntimeSessionRenameRequest { - session_id: "session-1".to_string(), - session_name: "Auth refactor".to_string(), - }, - }) - .await - .expect("shared rename"); + rename_owned_session( + &runtime, + &canonical_workspace, + RuntimeSessionRenameRequest { + session_id: "session-1".to_string(), + session_name: "Auth refactor".to_string(), + }, + ) + .await + .expect("shared rename"); - assert_eq!(result, RuntimeIpcOperationResult::Unit); assert_eq!( port.rename_requests.lock().unwrap().as_slice(), &[expected.clone(), expected] diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 11364948a1..eaf67c6639 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1068,7 +1068,7 @@ impl StartupPage { ActionHandler::NavigateBack => self.navigate_back(), ActionHandler::ClearConversation | ActionHandler::RenameSession - | ActionHandler::ReloadSkills + | ActionHandler::Reload | ActionHandler::Tools | ActionHandler::Extensions | ActionHandler::NativeHooks diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index 66b87581ec..8ec2278540 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -283,6 +283,30 @@ fn primary_cli_session_client_uses_only_the_runtime_sdk_boundary() { } } +#[test] +fn chat_context_reload_keeps_deployment_choice_behind_a_cli_adapter() { + const CHAT_MODE: &str = include_str!("../src/modes/chat.rs"); + const CHAT_CAPABILITIES: &str = include_str!("../src/modes/chat/capabilities.rs"); + const RELOAD_CLIENT: &str = include_str!("../src/agent/context_reload_client.rs"); + + assert!( + CHAT_MODE.contains("context_reload: CliContextReloadClient"), + "ChatMode must submit reload through one CLI-owned deployment adapter" + ); + assert!( + !CHAT_CAPABILITIES.contains("is_shared()") + && !CHAT_CAPABILITIES.contains("reload_shared_session_context") + && !CHAT_CAPABILITIES.contains("self.compatibility"), + "TUI capability code must not branch context reload by Runtime deployment" + ); + assert!( + RELOAD_CLIENT.contains(".reload_session_context(request)") + && RELOAD_CLIENT + .contains(".request(RuntimeIpcOperation::ReloadSessionContext { request })"), + "the private adapter must delegate directly to the existing Embedded and Shared owners" + ); +} + #[test] fn primary_cli_runtime_client_covers_interactive_permission_and_local_turn_operations() { const PRIMARY_CLIENT: &str = include_str!("../src/agent/runtime_client.rs"); diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index e680b2e014..a911bbb251 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1686,6 +1686,18 @@ pub async fn update_session_model( .map_err(|error| format!("Failed to update session model: {}", error.into_message())) } +#[tauri::command] +pub async fn reload_session_context( + runtime: State<'_, DesktopRuntimeContext>, + request: bitfun_runtime_ports::AgentContextReloadRequest, +) -> Result<(), String> { + runtime + .session_application() + .reload_session_context(request) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn update_session_title( runtime: State<'_, DesktopRuntimeContext>, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index ab48c12a14..6e33603c13 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1321,6 +1321,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "reload_global_config", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "reload_session_context", + RemoteWorkspacePolicy::RemoteRouted, + ), ("reload_subagents", RemoteWorkspacePolicy::LegacyUnaudited), // One-click self-hosted relay (SSH to user host). WorkspaceAgnostic: uses // an SSH connection id, not the open project workspace. See diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 724608a0d0..93d040a755 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1121,6 +1121,7 @@ pub async fn run() { hide_main_window_after_close_request, api::agentic_api::create_session, api::agentic_api::update_session_model, + api::agentic_api::reload_session_context, api::agentic_api::update_session_title, api::agentic_api::ensure_coordinator_session, api::agentic_api::start_dialog_turn, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 78bafd5cd4..eb1b7ca536 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -26,6 +26,7 @@ use bitfun_core::service::session_usage::SessionUsageReport; use bitfun_core::service::token_usage::TokenUsageService; use bitfun_core::service::workspace::WorkspaceService; use bitfun_core::util::errors::BitFunError; +use bitfun_runtime_ports::AgentContextReloadRequest; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; @@ -253,6 +254,16 @@ impl DesktopSessionApplication { &self.agent_runtime } + pub(crate) async fn reload_session_context( + &self, + request: AgentContextReloadRequest, + ) -> DesktopSessionApplicationResult<()> { + self.compatibility + .reload_session_context(request) + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + } + async fn resolved_scope( &self, request: DesktopSessionScopeRequest, diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md index 7f5a54515a..819139dd59 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -15,7 +15,7 @@ ## 边界 - 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 operation 范围为 Health、Session list/create/restore(restore 结果包含 transcript)、当前 Session rename 和 Agent mode/model update、Turn submit/cancel、pending/respond Permission 和 UserInput answers。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 delete、fork、replay、observer、controller transfer、Tool/MCP/Hook 管理或其他产品配置。 +- 封闭 operation 范围为 Health、Session list/create/restore(restore 结果包含 transcript)、当前 Session rename 和 Agent mode/model update、声明式上下文 reload、Turn submit/cancel、pending/respond Permission 和 UserInput answers。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 delete、fork、replay、observer、controller transfer、Tool/MCP/Hook 管理或其他产品配置。 - 可以复用稳定 Event、Product Domain 和 Runtime Port DTO。禁止依赖 `bitfun-core`、Agent Runtime 实现、SDK Host、services、Tauri、terminal、tool runtime 或远程 transport。 - 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 - 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index 3a5c3e17f9..bb92c2e91f 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -23,7 +23,8 @@ session controller leases, event delivery, connection bounds, and cleanup. It is - Export only the exact workspace-private API needed by the CLI adapter. Do not publish this crate or expose its wire as an SDK contract. - The closed operation budget is Health, Session list/create/restore (including transcript), current-Session rename and Agent mode/model update, - Turn submit/cancel, pending/respond Permission, and UserInput answers. Disconnect cleanup is internal lifecycle, not a detach operation. + declarative context reload, Turn submit/cancel, pending/respond Permission, and UserInput answers. Context reload may run during an active + Turn, does not rewrite that Turn, and guards the cache so the next message reads invalidated instructions. Disconnect cleanup is internal lifecycle, not a detach operation. Model catalogs and defaults remain product configuration outside this wire. Do not add delete, fork, replay, observer, controller transfer, Tool/MCP/Hook management, or other product configuration incidentally. - Stable Event, Product Domain, and Runtime Port DTOs may be reused. Do not diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index a780791425..4c138f1d15 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -1,9 +1,9 @@ use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; use bitfun_runtime_ports::{ - AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, - AgentSessionSummary, AgentTurnCancellationRequest, AgentTurnCancellationResult, - SessionTranscript, + AgentContextReloadRequest, AgentDialogTurnRequest, AgentSessionCreateRequest, + AgentSessionCreateResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, + AgentSessionModelUpdateRequest, AgentSessionSummary, AgentTurnCancellationRequest, + AgentTurnCancellationResult, SessionTranscript, }; use serde::{Deserialize, Serialize}; @@ -56,6 +56,9 @@ pub enum RuntimeIpcOperation { RenameSession { request: RuntimeSessionRenameRequest, }, + ReloadSessionContext { + request: AgentContextReloadRequest, + }, SubmitTurn { request: AgentDialogTurnRequest, }, @@ -82,6 +85,7 @@ impl RuntimeIpcOperation { Self::UpdateSessionMode { request } => Some(&request.session_id), Self::UpdateSessionModel { request } => Some(&request.session_id), Self::RenameSession { request } => Some(&request.session_id), + Self::ReloadSessionContext { request } => Some(&request.session_id), Self::SubmitTurn { request } => Some(&request.session_id), Self::CancelTurn { request } => Some(&request.session_id), Self::PendingPermissions { session_id } @@ -97,6 +101,7 @@ impl RuntimeIpcOperation { Self::UpdateSessionMode { .. } | Self::UpdateSessionModel { .. } | Self::RenameSession { .. } + | Self::ReloadSessionContext { .. } | Self::SubmitTurn { .. } | Self::CancelTurn { .. } | Self::PendingPermissions { .. } diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 8a95b586c2..4dd1dd536e 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,7 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 5; +pub const PROTOCOL_VERSION: u32 = 6; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs index 78ac0d9a18..c0c59d4230 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -679,6 +679,7 @@ fn operation_has_side_effects(operation: &RuntimeIpcOperation) -> bool { | RuntimeIpcOperation::UpdateSessionMode { .. } | RuntimeIpcOperation::UpdateSessionModel { .. } | RuntimeIpcOperation::RenameSession { .. } + | RuntimeIpcOperation::ReloadSessionContext { .. } | RuntimeIpcOperation::SubmitTurn { .. } | RuntimeIpcOperation::CancelTurn { .. } | RuntimeIpcOperation::RespondPermission { .. } diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index bbcc60ac88..59afd1d21b 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -6,8 +6,9 @@ use crate::{ use bitfun_product_domains::tool_permissions::PermissionReply; use bitfun_runtime_ports::{ - AgentDialogTurnRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, - AgentSubmissionSource, DialogSubmissionPolicy, + AgentContextReloadRequest, AgentContextReloadTarget, AgentDialogTurnRequest, + AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSubmissionSource, + DialogSubmissionPolicy, }; use serde_json::{json, Map}; @@ -110,7 +111,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 5); + assert_eq!(PROTOCOL_VERSION, 6); let operation = RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { @@ -138,6 +139,27 @@ fn protocol_round_trips_the_current_session_rename_operation() { assert!(decoded.requires_controller()); } +#[test] +fn protocol_round_trips_context_reload_as_a_controller_operation() { + let operation = RuntimeIpcOperation::ReloadSessionContext { + request: AgentContextReloadRequest { + session_id: "session-1".to_string(), + target: AgentContextReloadTarget::Instructions, + }, + }; + + let encoded = serde_json::to_value(&operation).expect("serialize context reload"); + assert_eq!(encoded["operation"], "reload_session_context"); + assert_eq!(encoded["request"]["sessionId"], "session-1"); + assert_eq!(encoded["request"]["target"], "instructions"); + let decoded: RuntimeIpcOperation = + serde_json::from_value(encoded).expect("deserialize context reload"); + + assert_eq!(decoded, operation); + assert_eq!(decoded.session_id(), Some("session-1")); + assert!(decoded.requires_controller()); +} + #[test] fn session_mode_operation_rejects_unknown_envelope_fields() { let unknown_field = json!({ diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index e14d0995f9..443b530202 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -17,7 +17,7 @@ use bitfun_product_domains::external_subagents::{ ExternalSubagentProviderSnapshot, ExternalSubagentSourceProvider, ExternalSubagentToolRequest, ExternalSubagentToolSelector, SecretText, }; -use bitfun_services_core::markdown::FrontMatterMarkdown; +use bitfun_services_core::{jsonc::strip_jsonc, markdown::FrontMatterMarkdown}; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -1082,49 +1082,6 @@ fn normalize_path_lexically(path: &Path) -> PathBuf { normalized } -fn strip_jsonc(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let chars = input.chars().collect::>(); - let mut index = 0; - let mut in_string = false; - let mut escaped = false; - while index < chars.len() { - let current = chars[index]; - if in_string { - output.push(current); - if escaped { - escaped = false; - } else if current == '\\' { - escaped = true; - } else if current == '"' { - in_string = false; - } - index += 1; - continue; - } - if current == '"' { - in_string = true; - output.push(current); - index += 1; - } else if current == '/' && chars.get(index + 1) == Some(&'/') { - index += 2; - while index < chars.len() && chars[index] != '\n' { - index += 1; - } - } else if current == '/' && chars.get(index + 1) == Some(&'*') { - index += 2; - while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { - index += 1; - } - index = (index + 2).min(chars.len()); - } else { - output.push(current); - index += 1; - } - } - output -} - fn digest(parts: impl IntoIterator>) -> String { let mut hasher = Sha256::new(); for part in parts { diff --git a/src/crates/adapters/opencode-adapter/src/command_source.rs b/src/crates/adapters/opencode-adapter/src/command_source.rs index a9905cd5c2..55ba312310 100644 --- a/src/crates/adapters/opencode-adapter/src/command_source.rs +++ b/src/crates/adapters/opencode-adapter/src/command_source.rs @@ -10,6 +10,7 @@ use bitfun_product_domains::external_sources::{ PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, PromptCommandSourceProvider, SourceKey, SourceQualifiedCommandId, }; +pub(crate) use bitfun_services_core::jsonc::strip_jsonc; use bitfun_services_core::markdown::FrontMatterMarkdown; use bitfun_static_hook_support::{ collect_bounded_regular_files, read_bounded_text, BoundedDirectoryWalkError, @@ -964,97 +965,6 @@ fn environment_truthy(key: &str) -> bool { .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1")) } -pub(crate) fn strip_jsonc(input: &str) -> String { - let mut without_comments = String::with_capacity(input.len()); - let chars = input.chars().collect::>(); - let mut index = 0; - let mut in_string = false; - let mut escaped = false; - while index < chars.len() { - let current = chars[index]; - if in_string { - without_comments.push(current); - if escaped { - escaped = false; - } else if current == '\\' { - escaped = true; - } else if current == '"' { - in_string = false; - } - index += 1; - continue; - } - if current == '"' { - in_string = true; - without_comments.push(current); - index += 1; - continue; - } - if current == '/' && chars.get(index + 1) == Some(&'/') { - index += 2; - while index < chars.len() && chars[index] != '\n' { - index += 1; - } - without_comments.push('\n'); - index += usize::from(index < chars.len()); - continue; - } - if current == '/' && chars.get(index + 1) == Some(&'*') { - index += 2; - while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { - if chars[index] == '\n' { - without_comments.push('\n'); - } - index += 1; - } - index = (index + 2).min(chars.len()); - continue; - } - without_comments.push(current); - index += 1; - } - - let chars = without_comments.chars().collect::>(); - let mut output = String::with_capacity(chars.len()); - let mut index = 0; - let mut in_string = false; - let mut escaped = false; - while index < chars.len() { - let current = chars[index]; - if in_string { - output.push(current); - if escaped { - escaped = false; - } else if current == '\\' { - escaped = true; - } else if current == '"' { - in_string = false; - } - index += 1; - continue; - } - if current == '"' { - in_string = true; - output.push(current); - index += 1; - continue; - } - if current == ',' { - let mut lookahead = index + 1; - while lookahead < chars.len() && chars[lookahead].is_whitespace() { - lookahead += 1; - } - if matches!(chars.get(lookahead), Some('}') | Some(']')) { - index += 1; - continue; - } - } - output.push(current); - index += 1; - } - output -} - fn expand_template(template: &str, arguments: &str) -> String { let args = argument_regex() .find_iter(arguments) diff --git a/src/crates/adapters/opencode-adapter/src/mcp_source.rs b/src/crates/adapters/opencode-adapter/src/mcp_source.rs index 347cb9ce10..58ae481544 100644 --- a/src/crates/adapters/opencode-adapter/src/mcp_source.rs +++ b/src/crates/adapters/opencode-adapter/src/mcp_source.rs @@ -12,6 +12,7 @@ use bitfun_product_domains::external_sources::{ PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, }; +use bitfun_services_core::jsonc::strip_jsonc; use bitfun_static_hook_support::{read_bounded_text, BoundedTextRead}; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; @@ -1247,68 +1248,6 @@ fn deduplicate_layers_keep_last(layers: Vec) -> Vec { layers } -fn strip_jsonc(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let chars = input.chars().collect::>(); - let mut index = 0; - let mut in_string = false; - let mut escaped = false; - while index < chars.len() { - let current = chars[index]; - if in_string { - output.push(current); - if escaped { - escaped = false; - } else if current == '\\' { - escaped = true; - } else if current == '"' { - in_string = false; - } - index += 1; - continue; - } - if current == '"' { - in_string = true; - output.push(current); - index += 1; - continue; - } - if current == '/' && chars.get(index + 1) == Some(&'/') { - index += 2; - while index < chars.len() && chars[index] != '\n' { - index += 1; - } - output.push('\n'); - index += usize::from(index < chars.len()); - continue; - } - if current == '/' && chars.get(index + 1) == Some(&'*') { - index += 2; - while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { - if chars[index] == '\n' { - output.push('\n'); - } - index += 1; - } - index = (index + 2).min(chars.len()); - continue; - } - if current == ',' { - let mut lookahead = index + 1; - while lookahead < chars.len() && chars[lookahead].is_whitespace() { - lookahead += 1; - } - if matches!(chars.get(lookahead), Some('}') | Some(']')) { - index += 1; - continue; - } - } - output.push(current); - index += 1; - } - output -} - fn environment_truthy(key: &str) -> bool { std::env::var(key) .ok() diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 4a260d5794..0505f19cf5 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -1113,6 +1113,10 @@ impl ExecutionEngine { "User context cache miss: session_id={}, scope_key={}", session_id, user_context_identity.scope_key ); + let cache_generation = self + .session_manager + .user_context_cache_generation(session_id) + .await; let user_context_policy = current_agent.user_context_policy(); let (built_user_context, cacheable) = Self::build_user_context_for_cache_miss( execution_context.workspace.as_ref(), @@ -1123,13 +1127,21 @@ impl ExecutionEngine { .await; if cacheable { if let Some(ref user_context) = built_user_context { - self.session_manager - .remember_user_context( + let cached = self + .session_manager + .remember_user_context_if_generation( session_id, + cache_generation, user_context_identity.clone(), user_context.clone(), ) .await; + if !cached { + debug!( + "Skipped stale user context cache write after invalidation: session_id={}, scope_key={}", + session_id, user_context_identity.scope_key + ); + } } } else { debug!( @@ -4416,7 +4428,7 @@ mod tests { use crate::service::config::types::AIModelConfig; use crate::service::remote_ssh::workspace_state::workspace_session_identity; use crate::util::types::ToolDefinition; - use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem}; + use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem, WorkspacePathKind}; use serde_json::json; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -4484,6 +4496,22 @@ mod tests { Ok(false) } + async fn path_kind_no_follow( + &self, + path: &str, + ) -> anyhow::Result> { + self.record(); + if path.ends_with("AGENTS.override.md") + && self.fail_next_probe.swap(false, Ordering::SeqCst) + { + anyhow::bail!("temporary workspace connection failure") + } + Ok( + (path.ends_with("AGENTS.md") && !path.ends_with("AGENTS.override.md")) + .then_some(WorkspacePathKind::File), + ) + } + async fn read_dir(&self, _path: &str) -> anyhow::Result> { Ok(Vec::new()) } diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 7497d589dd..b828673deb 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -224,6 +224,7 @@ pub struct SessionManager { /// Sub-components context_store: Arc, prompt_cache_store: Arc, + prompt_cache_operation_locks: KeyedAsyncLock, token_anchor_store: Arc, turn_skill_agent_snapshot_store: Arc, skill_agent_baseline_override_snapshot_store: Arc>, @@ -1523,6 +1524,10 @@ impl SessionManager { if self.prompt_cache_store.has_session(session_id) { return; } + let _operation_guard = self.prompt_cache_operation_locks.lock(session_id).await; + if self.prompt_cache_store.has_session(session_id) { + return; + } let cache = if self.should_persist_session_id(session_id) { match self.effective_session_storage_path(session_id).await { @@ -1608,6 +1613,7 @@ impl SessionManager { ); return; }; + let _operation_guard = self.prompt_cache_operation_locks.lock(session_id).await; let cache = self .prompt_cache_store @@ -1784,6 +1790,7 @@ impl SessionManager { session_write_locks: Arc::new(DashMap::new()), context_store, prompt_cache_store: Arc::new(SessionPromptCacheStore::new()), + prompt_cache_operation_locks: KeyedAsyncLock::default(), token_anchor_store: Arc::new(TokenAnchorStore::new()), turn_skill_agent_snapshot_store: Arc::new(TurnSkillAgentSnapshotStore::new()), skill_agent_baseline_override_snapshot_store: Arc::new(DashMap::new()), @@ -1992,6 +1999,7 @@ impl SessionManager { let session_write_locks = self.session_write_locks.clone(); let context_store = self.context_store.clone(); let prompt_cache_store = self.prompt_cache_store.clone(); + let prompt_cache_operation_locks = self.prompt_cache_operation_locks.clone(); let token_anchor_store = self.token_anchor_store.clone(); let turn_skill_agent_snapshot_store = self.turn_skill_agent_snapshot_store.clone(); let skill_agent_baseline_override_snapshot_store = @@ -2024,6 +2032,7 @@ impl SessionManager { session_write_locks, context_store, prompt_cache_store, + prompt_cache_operation_locks, token_anchor_store, turn_skill_agent_snapshot_store, skill_agent_baseline_override_snapshot_store, @@ -2363,6 +2372,31 @@ impl SessionManager { .await; } + pub async fn user_context_cache_generation(&self, session_id: &str) -> u64 { + self.ensure_prompt_cache_loaded(session_id).await; + self.prompt_cache_store.user_context_generation(session_id) + } + + pub async fn remember_user_context_if_generation( + &self, + session_id: &str, + generation: u64, + identity: UserContextCacheIdentity, + user_context: String, + ) -> bool { + self.ensure_prompt_cache_loaded(session_id).await; + let stored = self.prompt_cache_store.set_user_context_if_generation( + session_id, + generation, + CachedUserContext::new(identity, user_context), + ); + if stored { + self.persist_prompt_cache_best_effort(session_id, "user_context_cached") + .await; + } + stored + } + pub async fn clone_prompt_cache( &self, source_session_id: &str, @@ -11645,6 +11679,88 @@ mod tests { ); } + #[tokio::test] + async fn prompt_cache_invalidation_waits_for_an_inflight_lazy_restore() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = Arc::new(test_manager(persistence_manager.clone())); + let session = manager + .create_session( + "Prompt cache".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should be created"); + let identity = UserContextCacheIdentity::new( + "workspace_context|workspace_instructions|project_layout", + ); + + manager + .remember_user_context( + &session.session_id, + identity.clone(), + "persisted user context".to_string(), + ) + .await; + manager + .prompt_cache_store + .delete_session(&session.session_id); + + let operation_guard = manager + .prompt_cache_operation_locks + .lock(&session.session_id) + .await; + let lookup_manager = manager.clone(); + let lookup_session_id = session.session_id.clone(); + let lookup_identity = identity.clone(); + let lookup = tokio::spawn(async move { + lookup_manager + .cached_user_context(&lookup_session_id, &lookup_identity) + .await + }); + tokio::task::yield_now().await; + let invalidate_manager = manager.clone(); + let invalidate_session_id = session.session_id.clone(); + let invalidate = tokio::spawn(async move { + invalidate_manager + .invalidate_prompt_cache( + &invalidate_session_id, + PromptCacheScope::UserContext, + "test", + ) + .await; + }); + tokio::task::yield_now().await; + drop(operation_guard); + + assert_eq!( + lookup.await.expect("lookup task should complete"), + Some("persisted user context".to_string()) + ); + invalidate.await.expect("invalidation task should complete"); + assert_eq!( + persistence_manager + .load_prompt_cache(workspace.path(), &session.session_id) + .await + .expect("prompt cache load should succeed"), + None + ); + manager + .prompt_cache_store + .delete_session(&session.session_id); + assert_eq!( + manager + .cached_user_context(&session.session_id, &identity) + .await, + None + ); + } + #[tokio::test] async fn clone_prompt_cache_copies_runtime_and_persisted_entries() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index b03ab1cc11..c338255df8 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -19,10 +19,11 @@ use bitfun_agent_runtime::sdk::{ }; use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ - ClockPort, LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, - LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, - PortResult, RuntimeServiceCapability, RuntimeServicePort, SessionStoragePathRequest, - SessionStorePort, SessionViewRestoreTiming, + AgentContextReloadRequest, ClockPort, LocalWorkspaceSnapshotPort, + LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, + LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, PortResult, + RuntimeServiceCapability, RuntimeServicePort, SessionStoragePathRequest, SessionStorePort, + SessionViewRestoreTiming, }; use bitfun_runtime_services::RuntimeServices; use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; @@ -35,7 +36,8 @@ use crate::agentic::events::EventQueue; use crate::agentic::keyed_lock::KeyedAsyncLockGuard; use crate::agentic::persistence::session_branch::SessionBranchRequest; use crate::agentic::persistence::{PersistenceManager, SessionMetadataPage}; -use crate::agentic::session::CoreSessionStorePort; +use crate::agentic::session::{CoreSessionStorePort, PromptCacheScope}; +use crate::agentic::tools::implementations::skills::SkillRegistry; use crate::service::session::{DialogTurnData, SessionMetadata}; use crate::service::session_usage::{generate_session_usage_report, SessionUsageReport}; use crate::service::snapshot::{ @@ -541,6 +543,44 @@ impl CoreAgentRuntimeCompatibility { ) } + /// Refresh user-visible declarative context without adding a second + /// lifecycle owner. Skill discovery and Session prompt caching keep their + /// existing owners; this method only coordinates one product request. + pub async fn reload_session_context( + &self, + request: AgentContextReloadRequest, + ) -> BitFunResult<()> { + let session_id = request.session_id.trim(); + validate_persisted_session_id(session_id)?; + + if self + .coordinator + .get_session_manager() + .get_session(session_id) + .is_none() + { + return Err(BitFunError::NotFound(format!( + "Session '{session_id}' is not loaded" + ))); + } + + if request.target.includes_skills() { + SkillRegistry::global().refresh().await; + } + if request.target.includes_instructions() { + self.coordinator + .get_session_manager() + .invalidate_prompt_cache( + session_id, + PromptCacheScope::UserContext, + "user_requested_instruction_reload", + ) + .await; + } + + Ok(()) + } + pub async fn restore_session_from_storage_path( &self, storage_path: &Path, @@ -1168,7 +1208,8 @@ mod tests { use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime}; use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ - LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotTurnRequest, + AgentContextReloadRequest, AgentContextReloadTarget, LocalWorkspaceSnapshotSessionRequest, + LocalWorkspaceSnapshotTurnRequest, }; use bitfun_runtime_services::RuntimeServices; use uuid::Uuid; @@ -1190,6 +1231,7 @@ mod tests { use crate::agentic::session::{ compression::{CompressionConfig, ContextCompressor}, PromptCachePolicy, SessionContextStore, SessionManager, SessionManagerConfig, + UserContextCacheIdentity, }; use crate::agentic::tools::registry::ToolRegistry; use crate::agentic::tools::{ToolPipeline, ToolStateManager}; @@ -1394,9 +1436,103 @@ mod tests { let _ = CoreAgentRuntimeCompatibility::list_persisted_sessions; let _ = CoreAgentRuntimeCompatibility::load_persisted_session_turns; let _ = CoreAgentRuntimeCompatibility::loaded_session_snapshot; + let _ = CoreAgentRuntimeCompatibility::reload_session_context; let _ = CoreAgentRuntimeCompatibility::unload_persisted_session; } + #[tokio::test] + async fn context_reload_invalidates_loaded_instructions_and_rejects_missing_sessions() { + let workspace = TestWorkspace::new(); + let persistence = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence, + SessionManagerConfig { + max_active_sessions: 4, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let execution_engine = Arc::new(ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + )); + let coordinator = Arc::new(ConversationCoordinator::new( + session_manager.clone(), + execution_engine, + tool_pipeline, + event_queue, + Arc::new(EventRouter::new()), + Arc::new( + crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts( + workspace.path().join("runtime-ownership"), + "bitfun".to_string(), + "test", + ), + ), + )); + let scheduler = DialogScheduler::new(coordinator.clone(), session_manager.clone()); + let compatibility = CoreAgentRuntimeCompatibility::build(coordinator, scheduler); + let session_id = "context-reload-session"; + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Reload test".to_string(), + "agentic".to_string(), + crate::agentic::core::SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("loaded session"); + let identity = UserContextCacheIdentity::new("workspace_instructions"); + session_manager + .remember_user_context(session_id, identity.clone(), "cached context".to_string()) + .await; + + compatibility + .reload_session_context(AgentContextReloadRequest { + session_id: session_id.to_string(), + target: AgentContextReloadTarget::Instructions, + }) + .await + .expect("reload loaded session"); + assert_eq!( + session_manager + .cached_user_context(session_id, &identity) + .await, + None + ); + + let missing_id = "missing-context-reload-session"; + let error = compatibility + .reload_session_context(AgentContextReloadRequest { + session_id: missing_id.to_string(), + target: AgentContextReloadTarget::Skills, + }) + .await + .expect_err("missing session must be rejected before refreshing skills"); + assert!(error.to_string().contains(missing_id), "{error}"); + } + #[test] fn persisted_session_compatibility_rejects_path_like_ids() { let error = validate_persisted_session_id("../../other-project/session") diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 73c0398a93..4cb110bd6d 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -274,17 +274,56 @@ pub struct WorkspaceDirEntry { pub is_symlink: bool, } +/// File type for one exact path without following its final symbolic link. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspacePathKind { + File, + Directory, + Symlink, + Other, +} + /// Unified file system operations that work for both local and remote workspaces. #[async_trait::async_trait] pub trait WorkspaceFileSystem: Send + Sync { async fn read_file(&self, path: &str) -> anyhow::Result>; async fn read_file_text(&self, path: &str) -> anyhow::Result; + /// Read UTF-8 text up to `max_bytes`. Production filesystem providers + /// should enforce the bound before or while transferring the file. + async fn read_file_text_bounded( + &self, + path: &str, + max_bytes: usize, + ) -> anyhow::Result> { + let content = self.read_file_text(path).await?; + Ok((content.len() <= max_bytes).then_some(content)) + } async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()>; async fn exists(&self, path: &str) -> anyhow::Result; async fn is_file(&self, path: &str) -> anyhow::Result; async fn is_dir(&self, path: &str) -> anyhow::Result; + /// Inspect one exact path without following its final symbolic link. + /// Providers that cannot guarantee no-follow semantics must return an + /// error instead of falling back to link-following metadata. + async fn path_kind_no_follow(&self, _path: &str) -> anyhow::Result> { + Err(anyhow::anyhow!( + "exact no-follow path metadata is not supported by this workspace filesystem" + )) + } /// List immediate children (non-recursive). Symlinks may be included; callers often skip them. async fn read_dir(&self, path: &str) -> anyhow::Result>; + /// List at most `max_entries` immediate children. Production providers + /// should stop local iteration or remote directory-batch requests once the + /// bound is met. + async fn read_dir_bounded( + &self, + path: &str, + max_entries: usize, + ) -> anyhow::Result> { + let mut entries = self.read_dir(path).await?; + entries.truncate(max_entries); + Ok(entries) + } } /// Unified shell execution options for local and remote workspaces. @@ -1134,6 +1173,31 @@ pub struct AgentSessionModeUpdateRequest { pub mode_id: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentContextReloadTarget { + All, + Skills, + Instructions, +} + +impl AgentContextReloadTarget { + pub const fn includes_skills(self) -> bool { + matches!(self, Self::All | Self::Skills) + } + + pub const fn includes_instructions(self) -> bool { + matches!(self, Self::All | Self::Instructions) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentContextReloadRequest { + pub session_id: String, + pub target: AgentContextReloadTarget, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkRequest { @@ -2281,6 +2345,53 @@ mod tests { use super::*; use std::sync::Mutex; + #[test] + fn context_reload_contract_is_closed_and_target_specific() { + let cases = [ + (AgentContextReloadTarget::All, "all", true, true), + (AgentContextReloadTarget::Skills, "skills", true, false), + ( + AgentContextReloadTarget::Instructions, + "instructions", + false, + true, + ), + ]; + + for (target, serialized_target, skills, instructions) in cases { + assert_eq!(target.includes_skills(), skills); + assert_eq!(target.includes_instructions(), instructions); + let request = AgentContextReloadRequest { + session_id: "session-1".to_string(), + target, + }; + assert_eq!( + serde_json::to_value(&request).expect("serialize request"), + serde_json::json!({ + "sessionId": "session-1", + "target": serialized_target, + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::json!({ + "sessionId": "session-1", + "target": serialized_target, + })) + .expect("deserialize request"), + request + ); + } + + assert!( + serde_json::from_value::(serde_json::json!({ + "sessionId": "session-1", + "target": "all", + "unexpected": true, + })) + .is_err() + ); + } + #[derive(Default)] struct ArchiveOnlySessionProvider { archived_requests: Mutex>, diff --git a/src/crates/execution/agent-runtime/src/prompt_cache.rs b/src/crates/execution/agent-runtime/src/prompt_cache.rs index 1fde4b3a4a..249fbd55bf 100644 --- a/src/crates/execution/agent-runtime/src/prompt_cache.rs +++ b/src/crates/execution/agent-runtime/src/prompt_cache.rs @@ -231,6 +231,7 @@ impl PromptCacheScope { pub struct SessionPromptCacheStore { session_caches: Arc>, + user_context_generations: Arc>, } pub enum PromptCacheLookup { @@ -249,10 +250,14 @@ impl SessionPromptCacheStore { pub fn new() -> Self { Self { session_caches: Arc::new(DashMap::new()), + user_context_generations: Arc::new(DashMap::new()), } } pub fn create_session(&self, session_id: &str) { + self.user_context_generations + .entry(session_id.to_string()) + .or_insert(0); self.session_caches .entry(session_id.to_string()) .or_default(); @@ -263,6 +268,9 @@ impl SessionPromptCacheStore { } pub fn replace_cache(&self, session_id: &str, cache: SessionPromptCache) { + self.user_context_generations + .entry(session_id.to_string()) + .or_insert(0); self.session_caches.insert(session_id.to_string(), cache); } @@ -354,7 +362,41 @@ impl SessionPromptCacheStore { } } + pub fn user_context_generation(&self, session_id: &str) -> u64 { + *self + .user_context_generations + .entry(session_id.to_string()) + .or_insert(0) + } + + pub fn set_user_context_if_generation( + &self, + session_id: &str, + expected_generation: u64, + entry: CachedUserContext, + ) -> bool { + let generation = self + .user_context_generations + .entry(session_id.to_string()) + .or_insert(0); + if *generation != expected_generation { + return false; + } + self.set_user_context(session_id, entry); + true + } + pub fn invalidate(&self, session_id: &str, scope: PromptCacheScope) -> bool { + let _user_context_generation = if scope.clears_user_context() { + let mut generation = self + .user_context_generations + .entry(session_id.to_string()) + .or_insert(0); + *generation = generation.saturating_add(1); + Some(generation) + } else { + None + }; let Some(mut cache) = self.session_caches.get_mut(session_id) else { return false; }; @@ -370,6 +412,7 @@ impl SessionPromptCacheStore { } pub fn delete_session(&self, session_id: &str) { + self.user_context_generations.remove(session_id); self.session_caches.remove(session_id); } } @@ -486,4 +529,23 @@ mod tests { assert!(cache.system_prompt.is_none()); assert!(cache.user_context.is_none()); } + + #[test] + fn invalidation_rejects_a_stale_user_context_write_even_when_cache_is_empty() { + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + let generation = store.user_context_generation("session-1"); + + assert!(!store.invalidate("session-1", PromptCacheScope::UserContext)); + assert!(!store.set_user_context_if_generation( + "session-1", + generation, + CachedUserContext::new(UserContextCacheIdentity::new("workspace_context"), "stale"), + )); + assert!(store + .get_cache("session-1") + .expect("session cache") + .user_context + .is_none()); + } } diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 9ca92a45e5..433e174356 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -29,6 +29,7 @@ log = { workspace = true } fs2 = { workspace = true } notify = { workspace = true, optional = true } ignore = { workspace = true } +globset = { workspace = true } sha2 = { workspace = true } flate2 = { workspace = true, optional = true } tar = { workspace = true, optional = true } diff --git a/src/crates/services/services-core/src/jsonc.rs b/src/crates/services/services-core/src/jsonc.rs new file mode 100644 index 0000000000..e99d903d58 --- /dev/null +++ b/src/crates/services/services-core/src/jsonc.rs @@ -0,0 +1,103 @@ +/// Remove JSONC comments and trailing commas while preserving string contents. +/// +/// This is intentionally a normalization helper rather than a configuration +/// loader. Callers still own schema validation and error reporting. +pub fn strip_jsonc(input: &str) -> String { + let mut without_comments = String::with_capacity(input.len()); + let chars = input.chars().collect::>(); + let mut index = 0; + let mut in_string = false; + let mut escaped = false; + + while index < chars.len() { + let current = chars[index]; + if in_string { + without_comments.push(current); + if escaped { + escaped = false; + } else if current == '\\' { + escaped = true; + } else if current == '"' { + in_string = false; + } + index += 1; + continue; + } + + if current == '"' { + in_string = true; + without_comments.push(current); + index += 1; + continue; + } + if current == '/' && chars.get(index + 1) == Some(&'/') { + index += 2; + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + if index < chars.len() { + without_comments.push('\n'); + index += 1; + } + continue; + } + if current == '/' && chars.get(index + 1) == Some(&'*') { + index += 2; + while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { + if chars[index] == '\n' { + without_comments.push('\n'); + } + index += 1; + } + index = (index + 2).min(chars.len()); + continue; + } + + without_comments.push(current); + index += 1; + } + + let chars = without_comments.chars().collect::>(); + let mut output = String::with_capacity(chars.len()); + let mut index = 0; + let mut in_string = false; + let mut escaped = false; + + while index < chars.len() { + let current = chars[index]; + if in_string { + output.push(current); + if escaped { + escaped = false; + } else if current == '\\' { + escaped = true; + } else if current == '"' { + in_string = false; + } + index += 1; + continue; + } + + if current == '"' { + in_string = true; + output.push(current); + index += 1; + continue; + } + if current == ',' { + let mut lookahead = index + 1; + while lookahead < chars.len() && chars[lookahead].is_whitespace() { + lookahead += 1; + } + if matches!(chars.get(lookahead), Some('}') | Some(']')) { + index += 1; + continue; + } + } + + output.push(current); + index += 1; + } + + output +} diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 0acb78bac1..2f7abd224b 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod dispatch_workspace; mod file_lock; pub mod filesystem; pub mod json_store; +pub mod jsonc; #[cfg(feature = "workspace-runtime")] pub mod local_runtime_ports; #[cfg(feature = "lsp")] diff --git a/src/crates/services/services-core/src/workspace.rs b/src/crates/services/services-core/src/workspace.rs index 7e847ef2a0..73489dd6b4 100644 --- a/src/crates/services/services-core/src/workspace.rs +++ b/src/crates/services/services-core/src/workspace.rs @@ -7,10 +7,11 @@ use async_trait::async_trait; use bitfun_runtime_ports::{ WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, - WorkspaceServices, WorkspaceShell, + WorkspacePathKind, WorkspaceServices, WorkspaceShell, }; use std::path::Path; use std::sync::Arc; +use tokio::io::AsyncReadExt; /// Local filesystem implementation of [`WorkspaceFileSystem`]. pub struct LocalWorkspaceFs; @@ -25,6 +26,27 @@ impl WorkspaceFileSystem for LocalWorkspaceFs { Ok(tokio::fs::read_to_string(path).await?) } + async fn read_file_text_bounded( + &self, + path: &str, + max_bytes: usize, + ) -> anyhow::Result> { + let metadata = tokio::fs::metadata(path).await?; + if metadata.len() > max_bytes as u64 { + return Ok(None); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + tokio::fs::File::open(path) + .await? + .take(max_bytes as u64 + 1) + .read_to_end(&mut bytes) + .await?; + if bytes.len() > max_bytes { + return Ok(None); + } + Ok(Some(String::from_utf8(bytes)?)) + } + async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()> { if let Some(parent) = Path::new(path).parent() { tokio::fs::create_dir_all(parent).await?; @@ -50,10 +72,41 @@ impl WorkspaceFileSystem for LocalWorkspaceFs { } } + async fn path_kind_no_follow(&self, path: &str) -> anyhow::Result> { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let file_type = metadata.file_type(); + Ok(Some(if file_type.is_symlink() { + WorkspacePathKind::Symlink + } else if file_type.is_dir() { + WorkspacePathKind::Directory + } else if file_type.is_file() { + WorkspacePathKind::File + } else { + WorkspacePathKind::Other + })) + } + async fn read_dir(&self, path: &str) -> anyhow::Result> { + self.read_dir_bounded(path, usize::MAX).await + } + + async fn read_dir_bounded( + &self, + path: &str, + max_entries: usize, + ) -> anyhow::Result> { let mut entries = Vec::new(); + let mut scanned_entries = 0usize; let mut read_dir = tokio::fs::read_dir(path).await?; - while let Ok(Some(entry)) = read_dir.next_entry().await { + while scanned_entries < max_entries { + let Ok(Some(entry)) = read_dir.next_entry().await else { + break; + }; + scanned_entries += 1; let path = entry.path(); let metadata = tokio::fs::symlink_metadata(&path).await?; if metadata.file_type().is_symlink() { diff --git a/src/crates/services/services-core/src/workspace_instructions.rs b/src/crates/services/services-core/src/workspace_instructions.rs index 44771b32fd..291aa6b05d 100644 --- a/src/crates/services/services-core/src/workspace_instructions.rs +++ b/src/crates/services/services-core/src/workspace_instructions.rs @@ -1,11 +1,37 @@ -use std::path::Path; +use globset::GlobBuilder; +use serde_json::Value; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use tokio::fs; +use tokio::io::AsyncReadExt; -pub const WORKSPACE_INSTRUCTION_FILE_NAMES: [&str; 3] = - ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"]; +use crate::jsonc::strip_jsonc; -const WORKSPACE_INSTRUCTION_FILE_GROUPS: [&[&str]; 2] = - [&["AGENTS.override.md", "AGENTS.md"], &["CLAUDE.md"]]; +pub const WORKSPACE_INSTRUCTION_FILE_NAMES: [&str; 5] = [ + "AGENTS.override.md", + "AGENTS.md", + "CLAUDE.md", + ".claude/CLAUDE.md", + "CLAUDE.local.md", +]; + +const AGENTS_INSTRUCTION_FILE_GROUP: &[&str] = &["AGENTS.override.md", "AGENTS.md"]; +const CLAUDE_INSTRUCTION_FILE_GROUP: &[&str] = &["CLAUDE.md", ".claude/CLAUDE.md"]; +const CLAUDE_LOCAL_INSTRUCTION_FILE: &str = "CLAUDE.local.md"; +const CLAUDE_RULES_DIRECTORY: &str = ".claude/rules"; +const OPENCODE_PROJECT_CONFIG_FILES: &[&str] = &[ + "opencode.json", + "opencode.jsonc", + ".opencode/opencode.json", + ".opencode/opencode.jsonc", +]; +const MAX_CLAUDE_IMPORT_DEPTH: usize = 5; +const MAX_INSTRUCTION_FILES: usize = 256; +const MAX_INSTRUCTION_FILE_BYTES: usize = 1024 * 1024; +const MAX_TOTAL_INSTRUCTION_BYTES: usize = 2 * 1024 * 1024; +const MAX_SCANNED_ENTRIES: usize = 4096; +const RECURSIVE_SCAN_IGNORED_DIRECTORIES: &[&str] = + &[".git", ".hg", ".svn", "node_modules", "target"]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceInstructionFile { @@ -13,37 +39,497 @@ pub struct WorkspaceInstructionFile { pub content: String, } -pub async fn read_workspace_instruction_files( - workspace_root: &Path, -) -> Result, String> { - let mut files = Vec::new(); +#[derive(Debug, Clone)] +struct InstructionDirEntry { + relative_path: String, + is_dir: bool, + is_symlink: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstructionEntryKind { + File, + Directory, + Symlink, + Other, +} + +enum InstructionIo<'a> { + Local(&'a Path), + #[cfg(feature = "workspace-runtime")] + Port { + fs: &'a dyn bitfun_runtime_ports::WorkspaceFileSystem, + root: &'a str, + }, +} + +impl InstructionIo<'_> { + async fn is_file(&self, relative_path: &str) -> Result { + match self { + Self::Local(root) => Ok(spawn_local_entry_kind(root, relative_path) + .await + .map_err(|error| format!("Failed to inspect local instruction path: {error}"))? + .is_some_and(|kind| kind == InstructionEntryKind::File)), + #[cfg(feature = "workspace-runtime")] + Self::Port { fs, root } => { + Ok( + port_entry_kind(*fs, (*root).to_string(), relative_path.to_string()) + .await? + .is_some_and(|kind| kind == InstructionEntryKind::File), + ) + } + } + } - for candidates in WORKSPACE_INSTRUCTION_FILE_GROUPS { - for file_name in candidates { - let path = workspace_root.join(file_name); - if !path.is_file() { + async fn is_dir(&self, relative_path: &str) -> Result { + match self { + Self::Local(root) => Ok(spawn_local_entry_kind(root, relative_path) + .await + .map_err(|error| format!("Failed to inspect local instruction path: {error}"))? + .is_some_and(|kind| kind == InstructionEntryKind::Directory)), + #[cfg(feature = "workspace-runtime")] + Self::Port { fs, root } => { + if relative_path.is_empty() { + let path = join_workspace_path(root, relative_path); + return fs.is_dir(&path).await.map_err(|error| { + format!("Failed to inspect workspace instruction directory {path}: {error}") + }); + } + let path = join_workspace_path(root, relative_path); + port_entry_kind(*fs, (*root).to_string(), relative_path.to_string()) + .await + .map(|entry| entry.is_some_and(|kind| kind == InstructionEntryKind::Directory)) + .map_err(|error| { + format!("Failed to inspect workspace instruction directory {path}: {error}") + }) + } + } + } + + async fn read_text(&self, relative_path: &str) -> Result, String> { + match self { + Self::Local(root) => { + let path = spawn_resolved_local_file(root, relative_path) + .await + .map_err(|error| { + format!("Failed to validate local instruction path: {error}") + })? + .ok_or_else(|| { + format!( + "Rejected workspace instruction path outside the workspace: {relative_path}" + ) + })?; + let metadata = fs::metadata(&path).await.map_err(|error| { + format!( + "Failed to inspect instruction file {}: {}", + path.display(), + error + ) + })?; + if metadata.len() > MAX_INSTRUCTION_FILE_BYTES as u64 { + return Ok(None); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + fs::File::open(&path) + .await + .map_err(|error| { + format!( + "Failed to open workspace instruction file {}: {}", + path.display(), + error + ) + })? + .take(MAX_INSTRUCTION_FILE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .await + .map_err(|error| { + format!( + "Failed to read workspace instruction file {}: {}", + path.display(), + error + ) + })?; + if bytes.len() > MAX_INSTRUCTION_FILE_BYTES { + return Ok(None); + } + String::from_utf8(bytes).map(Some).map_err(|error| { + format!( + "Workspace instruction file is not valid UTF-8 {}: {}", + path.display(), + error + ) + }) + } + #[cfg(feature = "workspace-runtime")] + Self::Port { fs, root } => { + let path = join_workspace_path(root, relative_path); + fs.read_file_text_bounded(&path, MAX_INSTRUCTION_FILE_BYTES) + .await + .map_err(|error| { + format!("Failed to read workspace instruction file {path}: {error}") + }) + } + } + } + + async fn read_dir( + &self, + relative_path: &str, + max_entries: usize, + ) -> Result, String> { + match self { + Self::Local(root) => { + let path = local_path(root, relative_path); + let mut entries = fs::read_dir(&path).await.map_err(|error| { + format!( + "Failed to read workspace instruction directory {}: {}", + path.display(), + error + ) + })?; + let mut output = Vec::new(); + while output.len() < max_entries { + let Some(entry) = entries.next_entry().await.map_err(|error| { + format!( + "Failed to read workspace instruction directory {}: {}", + path.display(), + error + ) + })? + else { + break; + }; + let file_type = entry.file_type().await.map_err(|error| { + format!( + "Failed to inspect workspace instruction entry {}: {}", + entry.path().display(), + error + ) + })?; + let name = entry.file_name().to_string_lossy().to_string(); + output.push(InstructionDirEntry { + relative_path: join_relative_path(relative_path, &name), + is_dir: file_type.is_dir(), + is_symlink: file_type.is_symlink(), + }); + } + Ok(output) + } + #[cfg(feature = "workspace-runtime")] + Self::Port { fs, root } => { + let path = join_workspace_path(root, relative_path); + let entries = fs + .read_dir_bounded(&path, max_entries) + .await + .map_err(|error| { + format!("Failed to read workspace instruction directory {path}: {error}") + })?; + Ok(entries + .into_iter() + .filter_map(|entry| { + port_entry_relative_path(root, relative_path, &entry.path).map( + |relative_path| InstructionDirEntry { + relative_path, + is_dir: entry.is_dir, + is_symlink: entry.is_symlink, + }, + ) + }) + .collect()) + } + } + } +} + +struct WorkspaceInstructionResolver<'a> { + io: InstructionIo<'a>, + files: Vec, + seen: HashSet, + read_file_count: usize, + total_instruction_bytes: usize, + scanned_entries: usize, + scan_limit_logged: bool, + file_limit_logged: bool, + byte_limit_logged: bool, +} + +impl<'a> WorkspaceInstructionResolver<'a> { + fn new(io: InstructionIo<'a>) -> Self { + Self { + io, + files: Vec::new(), + seen: HashSet::new(), + read_file_count: 0, + total_instruction_bytes: 0, + scanned_entries: 0, + scan_limit_logged: false, + file_limit_logged: false, + byte_limit_logged: false, + } + } + + async fn resolve(mut self) -> Result, String> { + if let Some(path) = self.first_existing(AGENTS_INSTRUCTION_FILE_GROUP).await? { + self.append_source_tree(path, false).await?; + } + if let Some(path) = self.first_existing(CLAUDE_INSTRUCTION_FILE_GROUP).await? { + self.append_source_tree(path, true).await?; + } + if self.io.is_file(CLAUDE_LOCAL_INSTRUCTION_FILE).await? { + self.append_source_tree(CLAUDE_LOCAL_INSTRUCTION_FILE.to_string(), true) + .await?; + } + + let rules = self.collect_files(CLAUDE_RULES_DIRECTORY).await?; + for rule in rules + .into_iter() + .filter(|path| path.to_ascii_lowercase().ends_with(".md")) + { + if self.seen.contains(&rule) { continue; } + let Some(content) = self.read_instruction_text(&rule).await? else { + continue; + }; + if claude_rule_has_paths_frontmatter(&content) { + continue; + } + self.append_source_tree_with_content(rule, true, Some(content)) + .await?; + } - let content = fs::read_to_string(&path).await.map_err(|e| { - format!( - "Failed to read workspace instruction file {}: {}", - path.display(), - e - ) - })?; + for config_path in OPENCODE_PROJECT_CONFIG_FILES { + self.append_opencode_config_instructions(config_path) + .await?; + } + + Ok(self.files) + } + + async fn first_existing(&self, candidates: &[&str]) -> Result, String> { + for candidate in candidates { + if self.io.is_file(candidate).await? { + return Ok(Some((*candidate).to_string())); + } + } + Ok(None) + } + + async fn read_instruction_text( + &mut self, + relative_path: &str, + ) -> Result, String> { + if self.read_file_count >= MAX_INSTRUCTION_FILES { + if !self.file_limit_logged { + log::warn!("Workspace instruction file limit reached; ignoring additional files"); + self.file_limit_logged = true; + } + return Ok(None); + } + if self.total_instruction_bytes >= MAX_TOTAL_INSTRUCTION_BYTES { + if !self.byte_limit_logged { + log::warn!("Workspace instruction byte limit reached; ignoring additional files"); + self.byte_limit_logged = true; + } + return Ok(None); + } + self.read_file_count += 1; + if !self.io.is_file(relative_path).await? { + return Ok(None); + } + let Some(content) = self.io.read_text(relative_path).await? else { + if !self.byte_limit_logged { + log::warn!( + "Ignoring a workspace instruction file larger than the per-file byte limit" + ); + self.byte_limit_logged = true; + } + return Ok(None); + }; + if self.total_instruction_bytes.saturating_add(content.len()) > MAX_TOTAL_INSTRUCTION_BYTES + { + self.total_instruction_bytes = MAX_TOTAL_INSTRUCTION_BYTES; + if !self.byte_limit_logged { + log::warn!("Workspace instruction byte limit reached; ignoring additional files"); + self.byte_limit_logged = true; + } + return Ok(None); + } + self.total_instruction_bytes += content.len(); + Ok(Some(content)) + } + + async fn append_source_tree( + &mut self, + relative_path: String, + follow_claude_imports: bool, + ) -> Result<(), String> { + self.append_source_tree_with_content(relative_path, follow_claude_imports, None) + .await + } + + async fn append_source_tree_with_content( + &mut self, + relative_path: String, + follow_claude_imports: bool, + initial_content: Option, + ) -> Result<(), String> { + let mut pending = vec![(relative_path, 0usize, initial_content)]; + while let Some((path, depth, prefetched_content)) = pending.pop() { + if self.seen.contains(&path) { + continue; + } + self.seen.insert(path.clone()); + let content = match prefetched_content { + Some(content) => Some(content), + None => self.read_instruction_text(&path).await?, + }; + let Some(content) = content else { continue }; + let imports = if follow_claude_imports && depth < MAX_CLAUDE_IMPORT_DEPTH { + claude_import_paths(&path, &content) + } else { + Vec::new() + }; if !content.trim().is_empty() { - files.push(WorkspaceInstructionFile { - name: (*file_name).to_string(), + self.files.push(WorkspaceInstructionFile { + name: path.clone(), content, }); } - break; + + pending.extend( + imports + .into_iter() + .rev() + .map(|import| (import, depth + 1, None)), + ); + } + Ok(()) + } + + async fn append_opencode_config_instructions( + &mut self, + config_path: &str, + ) -> Result<(), String> { + if !self.io.is_file(config_path).await? { + return Ok(()); + } + + let Some(content) = self.io.read_text(config_path).await? else { + log::warn!( + "Ignoring oversized project OpenCode instruction config {}", + config_path + ); + return Ok(()); + }; + let value = match serde_json::from_str::(&strip_jsonc(&content)) { + Ok(value) => value, + Err(error) => { + log::warn!( + "Ignoring invalid project OpenCode instruction config {}: {}", + config_path, + error + ); + return Ok(()); + } + }; + let Some(instructions) = value.get("instructions").and_then(Value::as_array) else { + return Ok(()); + }; + + for raw in instructions.iter().filter_map(Value::as_str) { + if unsupported_instruction_reference(raw) { + continue; + } + if has_glob_meta(raw) { + for path in self.expand_glob(raw).await? { + self.append_source_tree(path, false).await?; + } + } else if let Some(path) = normalize_relative_path(raw) { + self.append_source_tree(path, false).await?; + } + } + Ok(()) + } + + async fn expand_glob(&mut self, raw_pattern: &str) -> Result, String> { + let Some(pattern) = normalize_glob_pattern(raw_pattern) else { + return Ok(Vec::new()); + }; + let glob = match GlobBuilder::new(&pattern) + .literal_separator(true) + .backslash_escape(false) + .build() + { + Ok(glob) => glob.compile_matcher(), + Err(error) => { + log::warn!( + "Ignoring invalid project OpenCode instruction glob {}: {}", + raw_pattern, + error + ); + return Ok(Vec::new()); + } + }; + let prefix = glob_static_prefix(&pattern); + let mut matches = self + .collect_files(&prefix) + .await? + .into_iter() + .filter(|path| glob.is_match(path)) + .collect::>(); + matches.sort(); + matches.dedup(); + Ok(matches) + } + + async fn collect_files(&mut self, relative_root: &str) -> Result, String> { + if !self.io.is_dir(relative_root).await? { + return Ok(Vec::new()); + } + + let mut files = Vec::new(); + let mut pending = vec![relative_root.to_string()]; + while let Some(directory) = pending.pop() { + let remaining = MAX_SCANNED_ENTRIES.saturating_sub(self.scanned_entries); + if remaining == 0 { + if !self.scan_limit_logged { + log::warn!( + "Workspace instruction scan limit reached; ignoring additional entries" + ); + self.scan_limit_logged = true; + } + break; + } + let mut entries = self.io.read_dir(&directory, remaining).await?; + self.scanned_entries += entries.len(); + entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + for entry in entries.into_iter().rev() { + if entry.is_symlink { + continue; + } + if entry.is_dir { + if recursive_scan_ignores(&entry.relative_path) { + continue; + } + pending.push(entry.relative_path); + } else { + files.push(entry.relative_path); + } + } } + files.sort(); + Ok(files) } +} - Ok(files) +pub async fn read_workspace_instruction_files( + workspace_root: &Path, +) -> Result, String> { + WorkspaceInstructionResolver::new(InstructionIo::Local(workspace_root)) + .resolve() + .await } #[cfg(feature = "workspace-runtime")] @@ -51,41 +537,310 @@ pub async fn read_workspace_instruction_files_with_fs( fs: &dyn bitfun_runtime_ports::WorkspaceFileSystem, workspace_root: &str, ) -> Result, String> { - let mut files = Vec::new(); - - for candidates in WORKSPACE_INSTRUCTION_FILE_GROUPS { - for file_name in candidates { - let path = join_workspace_path(workspace_root, file_name); - let is_file = fs.is_file(&path).await.map_err(|error| { - format!("Failed to inspect workspace instruction file {path}: {error}") - })?; - if !is_file { - continue; - } + WorkspaceInstructionResolver::new(InstructionIo::Port { + fs, + root: workspace_root, + }) + .resolve() + .await +} - let content = fs.read_file_text(&path).await.map_err(|error| { - format!("Failed to read workspace instruction file {path}: {error}") - })?; - if !content.trim().is_empty() { - files.push(WorkspaceInstructionFile { - name: (*file_name).to_string(), - content, - }); - } - break; +fn local_path(root: &Path, relative_path: &str) -> PathBuf { + relative_path + .split('/') + .filter(|component| !component.is_empty()) + .fold(root.to_path_buf(), |path, component| path.join(component)) +} + +fn spawn_local_entry_kind( + root: &Path, + relative_path: &str, +) -> tokio::task::JoinHandle> { + let root = root.to_path_buf(); + let relative_path = relative_path.to_string(); + tokio::task::spawn_blocking(move || local_entry_kind(&root, &relative_path)) +} + +fn spawn_resolved_local_file( + root: &Path, + relative_path: &str, +) -> tokio::task::JoinHandle> { + let root = root.to_path_buf(); + let relative_path = relative_path.to_string(); + tokio::task::spawn_blocking(move || resolved_local_file(&root, &relative_path)) +} + +fn resolved_local_file(root: &Path, relative_path: &str) -> Option { + let root = std::fs::canonicalize(root).ok()?; + let path = local_path(&root, relative_path); + if !std::fs::symlink_metadata(&path).ok()?.file_type().is_file() { + return None; + } + let path = std::fs::canonicalize(path).ok()?; + path.starts_with(&root).then_some(path) +} + +fn local_entry_kind(root: &Path, relative_path: &str) -> Option { + let mut path = root.to_path_buf(); + let mut components = relative_path + .split('/') + .filter(|component| !component.is_empty()) + .peekable(); + if components.peek().is_none() { + let file_type = std::fs::symlink_metadata(root).ok()?.file_type(); + return Some(instruction_entry_kind(file_type)); + } + + while let Some(component) = components.next() { + path.push(component); + let file_type = std::fs::symlink_metadata(&path).ok()?.file_type(); + if components.peek().is_none() { + return Some(instruction_entry_kind(file_type)); + } + if !file_type.is_dir() || file_type.is_symlink() { + return None; } } + None +} - Ok(files) +fn instruction_entry_kind(file_type: std::fs::FileType) -> InstructionEntryKind { + if file_type.is_symlink() { + InstructionEntryKind::Symlink + } else if file_type.is_dir() { + InstructionEntryKind::Directory + } else if file_type.is_file() { + InstructionEntryKind::File + } else { + InstructionEntryKind::Other + } +} + +fn join_relative_path(parent: &str, name: &str) -> String { + if parent.is_empty() { + name.to_string() + } else { + format!("{parent}/{name}") + } } #[cfg(feature = "workspace-runtime")] -fn join_workspace_path(workspace_root: &str, file_name: &str) -> String { +fn join_workspace_path(workspace_root: &str, relative_path: &str) -> String { let root = workspace_root.trim_end_matches(['/', '\\']); + if relative_path.is_empty() { + return root.to_string(); + } let separator = if root.contains('\\') && !root.contains('/') { '\\' } else { '/' }; - format!("{root}{separator}{file_name}") + let relative_path = if separator == '\\' { + relative_path.replace('/', "\\") + } else { + relative_path.to_string() + }; + format!("{root}{separator}{relative_path}") +} + +#[cfg(feature = "workspace-runtime")] +fn port_entry_relative_path(root: &str, parent: &str, entry_path: &str) -> Option { + let normalized_root = root.replace('\\', "/").trim_end_matches('/').to_string(); + let normalized_entry = entry_path.replace('\\', "/"); + let root_prefix = format!("{normalized_root}/"); + if let Some(relative) = normalized_entry.strip_prefix(&root_prefix) { + return normalize_relative_path(relative); + } + let name = normalized_entry.rsplit('/').next()?; + normalize_relative_path(&join_relative_path(parent, name)) +} + +#[cfg(feature = "workspace-runtime")] +async fn port_entry_kind( + fs: &dyn bitfun_runtime_ports::WorkspaceFileSystem, + root: String, + relative_path: String, +) -> Result, String> { + use bitfun_runtime_ports::WorkspacePathKind; + + let mut current = String::new(); + let components: Vec = relative_path + .split('/') + .filter(|component| !component.is_empty()) + .map(str::to_string) + .collect(); + for (index, component) in components.iter().enumerate() { + current = join_relative_path(¤t, component); + let path = join_workspace_path(&root, ¤t); + let Some(kind) = fs.path_kind_no_follow(&path).await.map_err(|error| { + format!("Failed to inspect workspace instruction entry {relative_path}: {error}") + })? + else { + return Ok(None); + }; + + if index + 1 == components.len() { + return Ok(Some(match kind { + WorkspacePathKind::File => InstructionEntryKind::File, + WorkspacePathKind::Directory => InstructionEntryKind::Directory, + WorkspacePathKind::Symlink => InstructionEntryKind::Symlink, + WorkspacePathKind::Other => InstructionEntryKind::Other, + })); + } + if kind != WorkspacePathKind::Directory { + return Ok(None); + } + } + Ok(None) +} + +fn unsupported_instruction_reference(value: &str) -> bool { + let value = value.trim(); + value.is_empty() + || value.starts_with("http://") + || value.starts_with("https://") + || value.starts_with('~') + || value.starts_with('/') + || value.starts_with('\\') + || has_windows_drive_component(value) +} + +fn has_windows_drive_component(value: &str) -> bool { + value.replace('\\', "/").split('/').any(|component| { + let bytes = component.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' + }) +} + +fn normalize_relative_path(value: &str) -> Option { + if unsupported_instruction_reference(value) { + return None; + } + let mut components = Vec::new(); + for component in value.trim().replace('\\', "/").split('/') { + match component { + "" | "." => {} + ".." => { + components.pop()?; + } + _ => components.push(component.to_string()), + } + } + (!components.is_empty()).then(|| components.join("/")) +} + +fn normalize_glob_pattern(value: &str) -> Option { + if unsupported_instruction_reference(value) { + return None; + } + let normalized = value.trim().replace('\\', "/"); + let components = normalized + .split('/') + .filter(|component| !component.is_empty() && *component != ".") + .collect::>(); + if components.is_empty() || components.contains(&"..") { + return None; + } + Some(components.join("/")) +} + +fn has_glob_meta(value: &str) -> bool { + value.contains(['*', '?', '[', '{']) +} + +fn glob_static_prefix(pattern: &str) -> String { + pattern + .split('/') + .take_while(|component| !has_glob_meta(component)) + .collect::>() + .join("/") +} + +fn recursive_scan_ignores(relative_path: &str) -> bool { + relative_path.rsplit('/').next().is_some_and(|name| { + RECURSIVE_SCAN_IGNORED_DIRECTORIES + .iter() + .any(|ignored| name.eq_ignore_ascii_case(ignored)) + }) +} + +fn claude_rule_has_paths_frontmatter(content: &str) -> bool { + let mut lines = content.lines(); + if lines.next().map(str::trim) != Some("---") { + return false; + } + for line in lines { + let trimmed = line.trim(); + if trimmed == "---" { + return false; + } + if trimmed + .split_once(':') + .is_some_and(|(key, _)| key.trim() == "paths") + { + return true; + } + } + false +} + +fn claude_import_paths(source_path: &str, content: &str) -> Vec { + let source_parent = source_path.rsplit_once('/').map(|(parent, _)| parent); + let mut imports = Vec::new(); + let mut seen = HashSet::new(); + for token in content.split_whitespace() { + let token = token.trim_start_matches(['(', '[', '{', '"', '\'']); + let Some(raw_path) = token.strip_prefix('@') else { + continue; + }; + let raw_path = + raw_path.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}', '"', '\'']); + if unsupported_instruction_reference(raw_path) { + continue; + } + let candidate = source_parent + .map(|parent| format!("{parent}/{raw_path}")) + .unwrap_or_else(|| raw_path.to_string()); + if let Some(path) = normalize_relative_path(&candidate) { + if seen.insert(path.clone()) { + imports.push(path); + } + } + } + imports +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_windows_drive_prefix_is_not_a_workspace_relative_path() { + assert!(normalize_relative_path("guard/C:/.ssh/id_rsa").is_none()); + assert!(normalize_relative_path("guard/c:\\secret.md").is_none()); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_special_instruction_entry_is_ignored_without_opening_it() { + use std::os::unix::net::UnixListener; + + let temp = tempfile::tempdir().unwrap(); + let socket_path = temp.path().join("AGENTS.md"); + let _listener = UnixListener::bind(&socket_path).unwrap(); + + assert_eq!( + local_entry_kind(temp.path(), "AGENTS.md"), + Some(InstructionEntryKind::Other) + ); + assert!(resolved_local_file(temp.path(), "AGENTS.md").is_none()); + let files = tokio::time::timeout( + std::time::Duration::from_secs(1), + read_workspace_instruction_files(temp.path()), + ) + .await + .expect("special files must not block instruction discovery") + .unwrap(); + assert!(files.is_empty()); + } } diff --git a/src/crates/services/services-core/tests/declarative_workspace_instruction_contracts.rs b/src/crates/services/services-core/tests/declarative_workspace_instruction_contracts.rs new file mode 100644 index 0000000000..9ec4a3ef42 --- /dev/null +++ b/src/crates/services/services-core/tests/declarative_workspace_instruction_contracts.rs @@ -0,0 +1,230 @@ +use bitfun_services_core::workspace_instructions::read_workspace_instruction_files; +use std::fs; + +fn instruction_names( + files: &[bitfun_services_core::workspace_instructions::WorkspaceInstructionFile], +) -> Vec<&str> { + files.iter().map(|file| file.name.as_str()).collect() +} + +#[tokio::test] +async fn claude_project_files_and_unconditional_rules_have_deterministic_order() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join(".claude/rules/nested")).expect("rules dir"); + fs::write(temp.path().join("AGENTS.md"), "bitfun rules\n").expect("agents"); + fs::write(temp.path().join("CLAUDE.md"), "root claude\n").expect("root claude"); + fs::write(temp.path().join(".claude/CLAUDE.md"), "fallback claude\n").expect("fallback claude"); + fs::write(temp.path().join("CLAUDE.local.md"), "local claude\n").expect("local claude"); + fs::write(temp.path().join(".claude/rules/z-last.md"), "last rule\n").expect("last rule"); + fs::write( + temp.path().join(".claude/rules/nested/a-first.md"), + "first rule\n", + ) + .expect("first rule"); + fs::write( + temp.path().join(".claude/rules/path-scoped.md"), + "---\npaths:\n - src/**/*.rs\n---\nconditional rule\n", + ) + .expect("path scoped rule"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instructions"); + + assert_eq!( + instruction_names(&files), + vec![ + "AGENTS.md", + "CLAUDE.md", + "CLAUDE.local.md", + ".claude/rules/nested/a-first.md", + ".claude/rules/z-last.md", + ] + ); + assert_eq!(files[1].content, "root claude\n"); + assert!(!files.iter().any(|file| file.name == ".claude/CLAUDE.md")); + assert!(!files.iter().any(|file| file.name.contains("path-scoped"))); +} + +#[tokio::test] +async fn claude_internal_imports_are_depth_first_deduplicated_and_workspace_contained() { + let temp = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::create_dir_all(temp.path().join("docs")).expect("docs dir"); + fs::write(outside.path().join("outside.md"), "must not load\n").expect("outside"); + fs::write( + temp.path().join("CLAUDE.md"), + "Project rules.\n@docs/base.md\n@../outside.md\n@~/private.md\n", + ) + .expect("claude"); + fs::write( + temp.path().join("docs/base.md"), + "Base rules.\n@nested.md\n@nested.md\n", + ) + .expect("base"); + fs::write( + temp.path().join("docs/nested.md"), + "Nested rules.\n@../CLAUDE.md\n", + ) + .expect("nested"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instructions"); + + assert_eq!( + instruction_names(&files), + vec!["CLAUDE.md", "docs/base.md", "docs/nested.md"] + ); + assert_eq!( + files + .iter() + .filter(|file| file.name == "docs/nested.md") + .count(), + 1 + ); +} + +#[tokio::test] +async fn opencode_project_instructions_support_local_files_and_globs_only() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("docs/nested")).expect("docs dir"); + fs::create_dir_all(temp.path().join("more/nested")).expect("more dir"); + fs::create_dir_all(temp.path().join(".opencode")).expect("opencode dir"); + fs::write(temp.path().join("docs/a.md"), "a\n").expect("a"); + fs::write(temp.path().join("docs/b.md"), "b\n").expect("b"); + fs::write(temp.path().join("docs/nested/c.md"), "c\n").expect("c"); + fs::write(temp.path().join("exact.txt"), "exact\n").expect("exact"); + fs::write(temp.path().join("more/nested/rule.md"), "nested rule\n").expect("nested rule"); + fs::write( + temp.path().join("opencode.jsonc"), + r#"{ + // Project-local declarative context only. + "instructions": [ + "docs/*.md", + "exact.txt", + "https://example.invalid/rules.md", + "../outside.md", + "docs/a.md", + ], + }"#, + ) + .expect("root config"); + fs::write( + temp.path().join(".opencode/opencode.json"), + r#"{"instructions":["more/**/*.md","exact.txt"]}"#, + ) + .expect("directory config"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instructions"); + + assert_eq!( + instruction_names(&files), + vec!["docs/a.md", "docs/b.md", "exact.txt", "more/nested/rule.md",] + ); + assert!(!files.iter().any(|file| file.name.contains("outside"))); + assert!(!files.iter().any(|file| file.name.starts_with("http"))); +} + +#[tokio::test] +async fn broad_opencode_globs_skip_vcs_dependency_and_build_directories() { + let temp = tempfile::tempdir().expect("tempdir"); + for directory in ["docs", ".git", "node_modules/pkg", "target/debug"] { + fs::create_dir_all(temp.path().join(directory)).expect("instruction directory"); + } + fs::write(temp.path().join("docs/visible.md"), "visible\n").expect("visible"); + fs::write(temp.path().join(".git/private.md"), "private\n").expect("git file"); + fs::write( + temp.path().join("node_modules/pkg/dependency.md"), + "dependency\n", + ) + .expect("dependency file"); + fs::write(temp.path().join("target/debug/output.md"), "output\n").expect("build file"); + fs::write( + temp.path().join("opencode.json"), + r#"{"instructions":["**/*.md"]}"#, + ) + .expect("config"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instructions"); + + assert_eq!(instruction_names(&files), vec!["docs/visible.md"]); +} + +#[tokio::test] +async fn declarative_instruction_expansion_has_a_file_budget() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("docs")).expect("docs"); + for index in 0..260 { + fs::write( + temp.path().join(format!("docs/{index:03}.md")), + format!("rule {index}\n"), + ) + .expect("instruction file"); + } + fs::write( + temp.path().join("opencode.json"), + r#"{"instructions":["docs/*.md"]}"#, + ) + .expect("config"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instructions"); + + assert_eq!(files.len(), 256); +} + +#[tokio::test] +async fn path_scoped_claude_rules_consume_the_shared_read_budget() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join(".claude/rules")).expect("rules"); + for index in 0..260 { + fs::write( + temp.path().join(format!(".claude/rules/{index:03}.md")), + "---\npaths:\n - src/**/*.rs\n---\nconditional\n", + ) + .expect("path-scoped rule"); + } + fs::write( + temp.path().join("exact.md"), + "must stay outside the budget\n", + ) + .expect("exact"); + fs::write( + temp.path().join("opencode.json"), + r#"{"instructions":["exact.md"]}"#, + ) + .expect("config"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instructions"); + + assert!(files.is_empty()); +} + +#[tokio::test] +async fn oversized_declarative_instruction_files_are_ignored() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write( + temp.path().join("oversized.md"), + vec![b'x'; 1024 * 1024 + 1], + ) + .expect("oversized file"); + fs::write( + temp.path().join("opencode.json"), + r#"{"instructions":["oversized.md"]}"#, + ) + .expect("config"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instructions"); + + assert!(!files.iter().any(|file| file.name == "oversized.md")); +} diff --git a/src/crates/services/services-core/tests/jsonc_contracts.rs b/src/crates/services/services-core/tests/jsonc_contracts.rs new file mode 100644 index 0000000000..4187a0818a --- /dev/null +++ b/src/crates/services/services-core/tests/jsonc_contracts.rs @@ -0,0 +1,22 @@ +use bitfun_services_core::jsonc::strip_jsonc; + +#[test] +fn jsonc_normalization_preserves_string_tokens_and_removes_trailing_commas() { + let normalized = strip_jsonc( + r#"{ + // line comment + "url": "https://example.invalid/a//b", + "marker": "/* keep */", + "items": ["a", "b",], + /* block + comment */ + "nested": {"enabled": true,}, + }"#, + ); + let value: serde_json::Value = serde_json::from_str(&normalized).expect("normalized json"); + + assert_eq!(value["url"], "https://example.invalid/a//b"); + assert_eq!(value["marker"], "/* keep */"); + assert_eq!(value["items"], serde_json::json!(["a", "b"])); + assert_eq!(value["nested"]["enabled"], true); +} diff --git a/src/crates/services/services-core/tests/workspace_instruction_contracts.rs b/src/crates/services/services-core/tests/workspace_instruction_contracts.rs index 3a0d313665..0d722fca7a 100644 --- a/src/crates/services/services-core/tests/workspace_instruction_contracts.rs +++ b/src/crates/services/services-core/tests/workspace_instruction_contracts.rs @@ -1,7 +1,9 @@ #![cfg(feature = "workspace-runtime")] use bitfun_services_core::workspace::LocalWorkspaceFs; -use bitfun_services_core::workspace_instructions::read_workspace_instruction_files_with_fs; +use bitfun_services_core::workspace_instructions::{ + read_workspace_instruction_files, read_workspace_instruction_files_with_fs, +}; use std::fs; #[tokio::test] @@ -29,3 +31,56 @@ async fn port_backed_instructions_honor_agents_override_and_keep_claude_context( assert_eq!(files.len(), 1); assert_eq!(files[0].name, "CLAUDE.md"); } + +#[tokio::test] +async fn port_backed_and_local_instruction_resolution_have_identical_order_and_content() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join(".claude/rules")).expect("rules dir"); + fs::create_dir_all(temp.path().join("docs")).expect("docs dir"); + fs::write(temp.path().join("AGENTS.md"), "agents\n").expect("agents"); + fs::write(temp.path().join("CLAUDE.md"), "claude\n@docs/imported.md\n").expect("claude"); + fs::write(temp.path().join("docs/imported.md"), "imported\n").expect("imported"); + fs::write(temp.path().join(".claude/rules/base.md"), "base rule\n").expect("rule"); + fs::write( + temp.path().join("opencode.json"), + r#"{"instructions":["docs/*.md"]}"#, + ) + .expect("opencode config"); + let root = temp.path().to_string_lossy(); + + let local = read_workspace_instruction_files(temp.path()) + .await + .expect("local instructions"); + let port = read_workspace_instruction_files_with_fs(&LocalWorkspaceFs, &root) + .await + .expect("port instructions"); + + assert_eq!(port, local); +} + +#[cfg(unix)] +#[tokio::test] +async fn exact_instruction_paths_do_not_follow_symlinked_parent_directories() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::write(outside.path().join("private.md"), "outside\n").expect("outside file"); + symlink(outside.path(), temp.path().join("linked")).expect("directory symlink"); + fs::write( + temp.path().join("opencode.json"), + r#"{"instructions":["linked/private.md"]}"#, + ) + .expect("opencode config"); + let root = temp.path().to_string_lossy(); + + let local = read_workspace_instruction_files(temp.path()) + .await + .expect("local instructions"); + let port = read_workspace_instruction_files_with_fs(&LocalWorkspaceFs, &root) + .await + .expect("port instructions"); + + assert!(local.is_empty()); + assert!(port.is_empty()); +} diff --git a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs index 1a1f291d88..be42334fe4 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs @@ -718,6 +718,23 @@ impl RemoteFileService { Err(unsupported()) } + pub async fn read_dir_bounded( + &self, + _connection_id: &str, + _path: &str, + _max_entries: usize, + ) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn symlink_stat( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result> { + Err(unsupported()) + } + pub async fn build_tree( &self, _connection_id: &str, diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index 23d9e5b8c3..42ad8c90c9 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -15,8 +15,10 @@ use russh::client::{DisconnectReason, Handle, Handler, Msg}; use russh::Sig; use russh_keys::key::{KeyPair, PublicKey}; use russh_keys::PublicKeyBase64; +use russh_sftp::client::error::Error as SftpError; use russh_sftp::client::fs::ReadDir; -use russh_sftp::client::SftpSession; +use russh_sftp::client::{RawSftpSession, SftpSession}; +use russh_sftp::protocol::{File as SftpFile, StatusCode as SftpStatusCode}; #[cfg(feature = "ssh_config")] use ssh_config::SSHConfig; use std::collections::HashMap; @@ -30,6 +32,54 @@ use tokio::time::{Duration, Instant}; const SSH_COMMAND_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(100); const SSH_COMMAND_INTERRUPT_DRAIN_GRACE: Duration = Duration::from_millis(500); +const CONTAINER_ENTRY_METADATA_SCRIPT: &str = "\ +name=${item##*/}; \ +if [ -L \"$item\" ]; then kind=l; \ +elif [ -d \"$item\" ]; then kind=d; \ +elif [ -f \"$item\" ]; then kind=f; \ +else kind=o; fi; \ +if [ \"$kind\" = d ]; then size=; \ +else size=$(stat -c %s \"$item\" 2>/dev/null || stat -f %z \"$item\" 2>/dev/null || true); fi; \ +mtime=$(stat -c %Y \"$item\" 2>/dev/null || stat -f %m \"$item\" 2>/dev/null || true); \ +mode=$(stat -c %a \"$item\" 2>/dev/null || stat -f %Lp \"$item\" 2>/dev/null || true); \ +printf '%s\\0%s\\0%s\\0%s\\0%s\\0%s\\0' \"$name\" \"$item\" \"$kind\" \"$size\" \"$mtime\" \"$mode\";"; + +fn sftp_error_may_be_stale_transport(error: &SftpError) -> bool { + match error { + SftpError::Status(status) => matches!( + status.status_code, + SftpStatusCode::NoConnection | SftpStatusCode::ConnectionLost + ), + SftpError::IO(_) | SftpError::Timeout | SftpError::UnexpectedBehavior(_) => true, + SftpError::Limited(_) | SftpError::UnexpectedPacket => false, + } +} + +fn container_read_dir_script(path: &str, max_entries: Option) -> String { + let quoted = crate::remote_ssh::shell::quote_arg(path); + let entry_loop = crate::remote_ssh::shell::quote_arg(&format!( + "for item do [ -e \"$item\" ] || [ -L \"$item\" ] || continue; {CONTAINER_ENTRY_METADATA_SCRIPT} done" + )); + let (head_probe, producer) = match max_entries { + Some(max_entries) => ( + "command -v head >/dev/null 2>&1 && head -z -n 0 /dev/null 2>&1 || { echo 'NUL-delimited bounded directory reads require head -z' >&2; exit 78; }; ", + format!( + "find \"$dir\" -mindepth 1 -maxdepth 1 -print0 | head -z -n {max_entries}" + ), + ), + None => ( + "", + "find \"$dir\" -mindepth 1 -maxdepth 1 -print0".to_string(), + ), + }; + format!( + "dir={quoted}; \ + [ -d \"$dir\" ] && [ ! -L \"$dir\" ] || {{ echo 'Container directory is missing or is a symbolic link' >&2; exit 44; }}; \ + command -v find >/dev/null 2>&1 && command -v xargs >/dev/null 2>&1 || {{ echo 'Container directory reads require find and xargs' >&2; exit 78; }}; \ + {head_probe}\ + {producer} | xargs -0 -n 64 sh -c {entry_loop} sh" + ) +} fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str { if s.len() <= max_bytes { @@ -202,7 +252,8 @@ struct ActiveConnection { /// Runtime target after resolving `containerAccess: auto`. effective_config: SSHConnectionConfig, server_info: Option, - sftp_session: Arc>>>, + sftp_session: Arc, + bounded_sftp_session: Arc, #[allow(dead_code)] server_key: Option, /// Liveness flag; flipped to false from `SSHHandler::disconnected`. @@ -211,6 +262,77 @@ struct ActiveConnection { alive: Arc, } +struct SftpCache { + session: tokio::sync::RwLock>>, + init_lock: tokio::sync::Mutex<()>, +} + +impl SftpCache { + fn new() -> Self { + Self { + session: tokio::sync::RwLock::new(None), + init_lock: tokio::sync::Mutex::new(()), + } + } +} + +#[derive(Clone)] +struct SftpSessionLease { + session: Arc, + cache: Arc, +} + +struct BoundedSftpChannel { + session: Arc, + read_lock: tokio::sync::Mutex<()>, +} + +struct BoundedSftpCache { + channel: tokio::sync::RwLock>>, + init_lock: tokio::sync::Mutex<()>, +} + +impl BoundedSftpCache { + fn new() -> Self { + Self { + channel: tokio::sync::RwLock::new(None), + init_lock: tokio::sync::Mutex::new(()), + } + } +} + +#[derive(Clone)] +struct BoundedSftpSession { + channel: Arc, + cache: Arc, +} + +struct BoundedSftpReadGuard { + session: Arc, + armed: bool, +} + +impl BoundedSftpReadGuard { + fn new(session: Arc) -> Self { + Self { + session, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for BoundedSftpReadGuard { + fn drop(&mut self) { + if self.armed { + let _ = self.session.close_session(); + } + } +} + struct EstablishedSession { handle: Option>, jump_handles: Vec>, @@ -1393,7 +1515,7 @@ fn split_container_entry_fields<'a>( .next() .filter(|value| !value.is_empty()) .map(|value| value.trim().to_string()); - if name.is_empty() || path.is_empty() || !matches!(kind.as_str(), "d" | "f" | "l") { + if name.is_empty() || path.is_empty() || !matches!(kind.as_str(), "d" | "f" | "l" | "o") { anyhow::bail!("Container directory listing returned a malformed entry"); } Ok((name, path, kind, size, modified, permissions)) @@ -2490,7 +2612,8 @@ impl SSHConnectionManager { config, effective_config: established.effective_config, server_info: server_info.clone(), - sftp_session: Arc::new(tokio::sync::RwLock::new(None)), + sftp_session: Arc::new(SftpCache::new()), + bounded_sftp_session: Arc::new(BoundedSftpCache::new()), server_key: None, alive: established.alive, }, @@ -3327,9 +3450,8 @@ impl SSHConnectionManager { // A signal death is still a resolved status. Without this the // result fell through to the unknown `-1` below. if exit_status.is_none() { - exit_status = crate::remote_ssh::transport::ssh_exit_code_for_signal( - &signal_name, - ); + exit_status = + crate::remote_ssh::transport::ssh_exit_code_for_signal(&signal_name); } log::debug!( "Remote exec exit signal received: signal={:?}, stdout_len={}, stderr_len={}, duration_ms={}, command_preview={}", @@ -3775,8 +3897,8 @@ impl SSHConnectionManager { if let Some(si) = server_info.as_ref() { conn.server_info = Some(si.clone()); } - let mut sftp_guard = conn.sftp_session.write().await; - *sftp_guard = None; + conn.sftp_session = Arc::new(SftpCache::new()); + conn.bounded_sftp_session = Arc::new(BoundedSftpCache::new()); (stale_handle, stale_jump_handles) } else { let replaced = guard.insert( @@ -3787,7 +3909,8 @@ impl SSHConnectionManager { config, effective_config: established.effective_config, server_info, - sftp_session: Arc::new(tokio::sync::RwLock::new(None)), + sftp_session: Arc::new(SftpCache::new()), + bounded_sftp_session: Arc::new(BoundedSftpCache::new()), server_key: None, alive: established.alive, }, @@ -4348,23 +4471,33 @@ impl SSHConnectionManager { connection_id: &str, path: &str, ) -> anyhow::Result> { + self.container_read_dir_with_limit(connection_id, path, None) + .await + } + + pub async fn container_read_dir_bounded( + &self, + connection_id: &str, + path: &str, + max_entries: usize, + ) -> anyhow::Result> { + self.container_read_dir_with_limit(connection_id, path, Some(max_entries)) + .await + } + + async fn container_read_dir_with_limit( + &self, + connection_id: &str, + path: &str, + max_entries: Option, + ) -> anyhow::Result> { + if max_entries == Some(0) { + return Ok(Vec::new()); + } let path = self.resolve_sftp_path(connection_id, path).await?; - let quoted = crate::remote_ssh::shell::quote_arg(&path); - let script = format!( - "dir={quoted}; \ - for item in \"$dir\"/.[!.]* \"$dir\"/..?* \"$dir\"/*; do \ - [ -e \"$item\" ] || [ -L \"$item\" ] || continue; \ - name=${{item##*/}}; \ - if [ -d \"$item\" ]; then kind=d; size=; \ - elif [ -L \"$item\" ]; then kind=l; size=$(wc -c < \"$item\" 2>/dev/null || true); \ - else kind=f; size=$(wc -c < \"$item\" 2>/dev/null || true); fi; \ - mtime=$(stat -c %Y \"$item\" 2>/dev/null || stat -f %m \"$item\" 2>/dev/null || true); \ - mode=$(stat -c %a \"$item\" 2>/dev/null || stat -f %Lp \"$item\" 2>/dev/null || true); \ - printf '%s\\0%s\\0%s\\0%s\\0%s\\0%s\\0' \"$name\" \"$item\" \"$kind\" \"$size\" \"$mtime\" \"$mode\"; \ - done" - ); + let script = container_read_dir_script(&path, max_entries); let (stdout, stderr, status) = self.execute_workspace_bytes(connection_id, &script).await?; - if status != 0 { + if status != 0 || !stderr.is_empty() { anyhow::bail!( "Failed to list container directory '{}': {}", path, @@ -4449,13 +4582,8 @@ impl SSHConnectionManager { let quoted = crate::remote_ssh::shell::quote_arg(&path); let script = format!( "item={quoted}; [ -e \"$item\" ] || [ -L \"$item\" ] || exit 44; \ - name=${{item##*/}}; \ - if [ -d \"$item\" ]; then kind=d; size=; \ - elif [ -L \"$item\" ]; then kind=l; size=$(wc -c < \"$item\" 2>/dev/null || true); \ - else kind=f; size=$(wc -c < \"$item\" 2>/dev/null || true); fi; \ - mtime=$(stat -c %Y \"$item\" 2>/dev/null || stat -f %m \"$item\" 2>/dev/null || true); \ - mode=$(stat -c %a \"$item\" 2>/dev/null || stat -f %Lp \"$item\" 2>/dev/null || true); \ - printf '%s\\0%s\\0%s\\0%s\\0%s\\0%s\\0' \"$name\" \"$item\" \"$kind\" \"$size\" \"$mtime\" \"$mode\"" + {metadata_script}", + metadata_script = CONTAINER_ENTRY_METADATA_SCRIPT, ); let (stdout, stderr, status) = self.execute_workspace_bytes(connection_id, &script).await?; if status == 44 { @@ -4545,35 +4673,50 @@ impl SSHConnectionManager { /// browsing the remote folder picker) is recovered transparently instead /// of cascading into a stale cached SFTP handle that fails forever. pub async fn get_sftp(&self, connection_id: &str) -> anyhow::Result> { - self.ensure_alive_or_reconnect(connection_id).await?; + Ok(self.get_sftp_lease(connection_id).await?.session) + } - // First check if we have an existing SFTP session - { - let guard = self.connections.read().await; - if let Some(conn) = guard.get(connection_id) { - let sftp_guard = conn.sftp_session.read().await; - if let Some(ref sftp) = *sftp_guard { - return Ok(sftp.clone()); - } - } - } + async fn get_sftp_lease(&self, connection_id: &str) -> anyhow::Result { + self.ensure_alive_or_reconnect(connection_id).await?; - // Get handle (clone the Arc) - let handle: Arc> = { + // Capture the transport and cache from the same connection generation. + // A reconnect may replace both while channel setup awaits; publishing + // only into this captured cache prevents an old host's session from + // becoming visible through the new generation. + let (handle, cache): (Arc>, Arc) = { let guard = self.connections.read().await; - let conn = guard + let connection = guard .get(connection_id) .ok_or_else(|| anyhow!("Connection {} not found", connection_id))?; - if conn.effective_config.uses_docker_exec() { + if connection.effective_config.uses_docker_exec() { anyhow::bail!( "SFTP is unavailable for Docker container workspaces; use workspace file operations" ); } - conn.handle + let handle = connection + .handle .clone() - .ok_or_else(|| anyhow!("SSH handle is unavailable for {}", connection_id))? + .ok_or_else(|| anyhow!("SSH handle is unavailable for {}", connection_id))?; + (handle, connection.sftp_session.clone()) }; + if let Some(session) = cache.session.read().await.as_ref().cloned() { + return Ok(SftpSessionLease { + session, + cache: cache.clone(), + }); + } + + // Serialize initialization within one generation so concurrent callers + // cannot exhaust the server's channel/session limit. + let _init_guard = cache.init_lock.lock().await; + if let Some(session) = cache.session.read().await.as_ref().cloned() { + return Ok(SftpSessionLease { + session, + cache: cache.clone(), + }); + } + // Open a channel and request SFTP subsystem let channel = handle .channel_open_session() @@ -4589,17 +4732,81 @@ impl SSHConnectionManager { .map_err(|e| anyhow!("Failed to create SFTP session: {}", e))?; let sftp = Arc::new(sftp); + *cache.session.write().await = Some(sftp.clone()); - // Store the SFTP session - { - let mut guard = self.connections.write().await; - if let Some(conn) = guard.get_mut(connection_id) { - let mut sftp_guard = conn.sftp_session.write().await; - *sftp_guard = Some(sftp.clone()); + Ok(SftpSessionLease { + session: sftp, + cache: cache.clone(), + }) + } + + /// Get or create the raw SFTP session used by bounded directory reads. + /// + /// The high-level russh-sftp `read_dir` API buffers until EOF. Keeping a + /// separate raw session lets callers stop issuing `readdir` requests as + /// soon as their entry budget is satisfied. + async fn get_bounded_sftp(&self, connection_id: &str) -> anyhow::Result { + self.ensure_alive_or_reconnect(connection_id).await?; + + let (handle, cache): (Arc>, Arc) = { + let guard = self.connections.read().await; + let connection = guard + .get(connection_id) + .ok_or_else(|| anyhow!("Connection {} not found", connection_id))?; + if connection.effective_config.uses_docker_exec() { + anyhow::bail!( + "SFTP is unavailable for Docker container workspaces; use workspace file operations" + ); } + let handle = connection + .handle + .clone() + .ok_or_else(|| anyhow!("SSH handle is unavailable for {}", connection_id))?; + (handle, connection.bounded_sftp_session.clone()) + }; + + let cached_channel = cache.channel.read().await.as_ref().cloned(); + if let Some(channel) = cached_channel { + return Ok(BoundedSftpSession { + channel, + cache: cache.clone(), + }); + } + + let _init_guard = cache.init_lock.lock().await; + let cached_channel = cache.channel.read().await.as_ref().cloned(); + if let Some(channel) = cached_channel { + return Ok(BoundedSftpSession { + channel, + cache: cache.clone(), + }); } - Ok(sftp) + let channel = handle + .channel_open_session() + .await + .map_err(|error| anyhow!("Failed to open channel for SFTP: {}", error))?; + channel + .request_subsystem(true, "sftp") + .await + .map_err(|error| anyhow!("Failed to request SFTP subsystem: {}", error))?; + let session = RawSftpSession::new(channel.into_stream()); + session + .init() + .await + .map_err(|error| anyhow!("Failed to create bounded SFTP session: {}", error))?; + let channel = Arc::new(BoundedSftpChannel { + session: Arc::new(session), + read_lock: tokio::sync::Mutex::new(()), + }); + + let mut cached = cache.channel.write().await; + *cached = Some(channel.clone()); + drop(cached); + Ok(BoundedSftpSession { + channel, + cache: cache.clone(), + }) } /// Read a file via SFTP @@ -4825,46 +5032,207 @@ impl SSHConnectionManager { /// network blip does not permanently break the remote folder picker. pub async fn sftp_read_dir(&self, connection_id: &str, path: &str) -> anyhow::Result { let resolved = self.resolve_sftp_path(connection_id, path).await?; - let sftp = self.get_sftp(connection_id).await?; - match sftp.read_dir(&resolved).await { + let lease = self.get_sftp_lease(connection_id).await?; + match lease.session.read_dir(&resolved).await { Ok(entries) => Ok(entries), Err(first_err) => { - log::warn!( - "SFTP read_dir '{}' failed (will retry once after refreshing session): {}", - resolved, - first_err - ); - self.invalidate_sftp_session(connection_id).await; - // Force the alive flag to false so ensure_alive_or_reconnect rebuilds - // the underlying SSH transport too — the previous failure may indicate - // the channel was torn down even though the keepalive callback has not - // fired yet. - self.mark_dead(connection_id).await; - let sftp = self.get_sftp(connection_id).await?; - sftp.read_dir(&resolved) + let generation_is_current = + self.sftp_generation_is_current(connection_id, &lease).await; + if generation_is_current && !sftp_error_may_be_stale_transport(&first_err) { + return Err(anyhow!( + "Failed to read directory '{}': {}", + resolved, + first_err + )); + } + if generation_is_current { + log::warn!( + "SFTP read_dir '{}' failed (will retry once after refreshing session): {}", + resolved, + first_err + ); + self.invalidate_sftp_generation(connection_id, &lease).await; + // Force the alive flag to false so ensure_alive_or_reconnect rebuilds + // the underlying SSH transport too — the previous failure may indicate + // the channel was torn down even though the keepalive callback has not + // fired yet. + } + let lease = self.get_sftp_lease(connection_id).await?; + lease + .session + .read_dir(&resolved) .await .map_err(|e| anyhow!("Failed to read directory '{}': {}", resolved, e)) } } } - /// Drop the cached SFTP session for a connection so the next call opens a - /// fresh channel. Safe to call when no session is cached. - async fn invalidate_sftp_session(&self, connection_id: &str) { + /// Read at most `max_entries` directory entries without asking the SFTP + /// server for the remainder of the directory. + pub async fn sftp_read_dir_bounded( + &self, + connection_id: &str, + path: &str, + max_entries: usize, + ) -> anyhow::Result> { + if max_entries == 0 { + return Ok(Vec::new()); + } + let resolved = self.resolve_sftp_path(connection_id, path).await?; + let session = self.get_bounded_sftp(connection_id).await?; + match Self::read_bounded_sftp_entries(&session, &resolved, max_entries).await { + Ok(entries) => Ok(entries), + Err(first_error) => { + let generation_is_current = self + .bounded_sftp_generation_is_current(connection_id, &session) + .await; + if generation_is_current && !sftp_error_may_be_stale_transport(&first_error) { + return Err(anyhow!( + "Failed to read directory '{}': {}", + resolved, + first_error + )); + } + if generation_is_current { + log::warn!( + "Bounded SFTP read_dir '{}' failed (will retry once after refreshing session): {}", + resolved, + first_error + ); + self.invalidate_bounded_sftp_generation(connection_id, &session) + .await; + } + let session = self.get_bounded_sftp(connection_id).await?; + Self::read_bounded_sftp_entries(&session, &resolved, max_entries) + .await + .map_err(|error| anyhow!("Failed to read directory '{}': {}", resolved, error)) + } + } + } + + async fn read_bounded_sftp_entries( + bounded: &BoundedSftpSession, + path: &str, + max_entries: usize, + ) -> Result, SftpError> { + let _read_lock = bounded.channel.read_lock.lock().await; + let session = bounded.channel.session.as_ref(); + let mut read_guard = BoundedSftpReadGuard::new(bounded.channel.session.clone()); + let handle = match session.opendir(path.to_string()).await { + Ok(handle) => handle.handle, + Err(error) => { + read_guard.disarm(); + return Err(error); + } + }; + let result = async { + let mut entries = Vec::new(); + while entries.len() < max_entries { + match session.readdir(handle.as_str()).await { + Ok(batch) => { + if batch.files.is_empty() { + break; + } + for entry in batch.files { + if entry.filename == "." || entry.filename == ".." { + continue; + } + entries.push(entry); + if entries.len() == max_entries { + break; + } + } + } + Err(SftpError::Status(status)) if status.status_code == SftpStatusCode::Eof => { + break; + } + Err(error) => return Err(error), + } + } + Ok(entries) + } + .await; + let close = session.close(handle).await; + match result { + Err(error) if close.is_ok() => { + read_guard.disarm(); + Err(error) + } + Err(error) => Err(error), + Ok(entries) => { + close?; + read_guard.disarm(); + Ok(entries) + } + } + } + + async fn bounded_sftp_generation_is_current( + &self, + connection_id: &str, + session: &BoundedSftpSession, + ) -> bool { + self.connections + .read() + .await + .get(connection_id) + .is_some_and(|connection| Arc::ptr_eq(&connection.bounded_sftp_session, &session.cache)) + } + + async fn invalidate_bounded_sftp_generation( + &self, + connection_id: &str, + failed: &BoundedSftpSession, + ) { let guard = self.connections.read().await; - if let Some(conn) = guard.get(connection_id) { - let mut sftp_guard = conn.sftp_session.write().await; - *sftp_guard = None; + let Some(connection) = guard.get(connection_id) else { + return; + }; + if !Arc::ptr_eq(&connection.bounded_sftp_session, &failed.cache) { + return; + } + + let mut cached = failed.cache.channel.write().await; + if !cached + .as_ref() + .is_some_and(|channel| Arc::ptr_eq(channel, &failed.channel)) + { + return; } + *cached = None; + connection.alive.store(false, Ordering::SeqCst); + } + + async fn sftp_generation_is_current( + &self, + connection_id: &str, + lease: &SftpSessionLease, + ) -> bool { + self.connections + .read() + .await + .get(connection_id) + .is_some_and(|connection| Arc::ptr_eq(&connection.sftp_session, &lease.cache)) } - /// Force the liveness flag to false. Triggers a transparent reconnect on - /// the next call to [`Self::ensure_alive_or_reconnect`]. - async fn mark_dead(&self, connection_id: &str) { + async fn invalidate_sftp_generation(&self, connection_id: &str, failed: &SftpSessionLease) { let guard = self.connections.read().await; - if let Some(conn) = guard.get(connection_id) { - conn.alive.store(false, Ordering::SeqCst); + let Some(connection) = guard.get(connection_id) else { + return; + }; + if !Arc::ptr_eq(&connection.sftp_session, &failed.cache) { + return; } + + let mut cached = failed.cache.session.write().await; + if !cached + .as_ref() + .is_some_and(|session| Arc::ptr_eq(session, &failed.session)) + { + return; + } + *cached = None; + connection.alive.store(false, Ordering::SeqCst); } /// Create directory via SFTP @@ -4967,6 +5335,21 @@ impl SSHConnectionManager { .map_err(|e| anyhow!("Failed to stat '{}': {}", path, e)) } + /// Get metadata for the exact SFTP path without following its final symlink. + pub async fn sftp_lstat( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result { + let path = self.resolve_sftp_path(connection_id, path).await?; + let sftp = self.get_sftp(connection_id).await?; + sftp.as_ref() + .symlink_metadata(&path) + .await + .map_err(anyhow::Error::new) + .with_context(|| format!("Failed to inspect '{}'", path)) + } + // ============================================================================ // PTY (Interactive Terminal) Operations // ============================================================================ @@ -5418,7 +5801,8 @@ mod tests { config: config.clone(), effective_config: config.clone(), server_info: Some(server_info.clone()), - sftp_session: Arc::new(tokio::sync::RwLock::new(None)), + sftp_session: Arc::new(SftpCache::new()), + bounded_sftp_session: Arc::new(BoundedSftpCache::new()), server_key: None, alive: alive.clone(), }, @@ -5623,6 +6007,65 @@ mod tests { assert_eq!(entry.permissions.as_deref(), Some("755")); } + #[test] + fn container_metadata_does_not_follow_symlinks_or_read_file_bodies() { + let symlink_check = CONTAINER_ENTRY_METADATA_SCRIPT.find("[ -L").unwrap(); + let directory_check = CONTAINER_ENTRY_METADATA_SCRIPT.find("[ -d").unwrap(); + + assert!(symlink_check < directory_check); + assert!(CONTAINER_ENTRY_METADATA_SCRIPT.contains("[ -f")); + assert!(CONTAINER_ENTRY_METADATA_SCRIPT.contains("else kind=o")); + assert!(!CONTAINER_ENTRY_METADATA_SCRIPT.contains("wc -c")); + assert!(CONTAINER_ENTRY_METADATA_SCRIPT.contains("stat -c %s")); + assert!(CONTAINER_ENTRY_METADATA_SCRIPT.contains("stat -f %z")); + } + + #[test] + fn bounded_container_directory_script_stops_the_nul_producer() { + let bounded = container_read_dir_script("/workspace", Some(4096)); + let unbounded = container_read_dir_script("/workspace", None); + + assert!(bounded.contains("find \"$dir\" -mindepth 1 -maxdepth 1 -print0")); + assert!(bounded.contains("head -z -n 4096")); + assert!(bounded.contains("xargs -0 -n 64")); + assert!(!bounded.contains("\"$dir\"/*")); + assert!(!unbounded.contains("head -z -n 4096")); + } + + #[test] + fn container_special_files_are_not_reported_as_regular_files() { + let entry = parse_container_file_output("pipe\0/workspace/pipe\0o\00\01720000000\0644\0") + .unwrap() + .unwrap(); + + assert!(!entry.is_file); + assert!(!entry.is_dir); + assert!(!entry.is_symlink); + } + + #[test] + fn only_transport_sftp_errors_trigger_a_reconnect() { + let status = |status_code| { + SftpError::Status(russh_sftp::protocol::Status { + id: 1, + status_code, + error_message: String::new(), + language_tag: String::new(), + }) + }; + + assert!(sftp_error_may_be_stale_transport(&status( + SftpStatusCode::ConnectionLost + ))); + assert!(sftp_error_may_be_stale_transport(&SftpError::Timeout)); + assert!(!sftp_error_may_be_stale_transport(&status( + SftpStatusCode::NoSuchFile + ))); + assert!(!sftp_error_may_be_stale_transport(&status( + SftpStatusCode::PermissionDenied + ))); + } + #[tokio::test] #[ignore = "requires BITFUN_TEST_DOCKER_CONTAINER to name a running container"] async fn local_docker_workspace_round_trip() { diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs index d8b91e4c8e..7416befa96 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs @@ -162,58 +162,59 @@ impl RemoteFileService { &self, connection_id: &str, path: &str, + ) -> anyhow::Result> { + self.read_dir_with_limit(connection_id, path, None).await + } + + pub async fn read_dir_bounded( + &self, + connection_id: &str, + path: &str, + max_entries: usize, + ) -> anyhow::Result> { + self.read_dir_with_limit(connection_id, path, Some(max_entries)) + .await + } + + async fn read_dir_with_limit( + &self, + connection_id: &str, + path: &str, + max_entries: Option, ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; if manager.is_container_workspace(connection_id).await { - return manager.container_read_dir(connection_id, path).await; + return match max_entries { + Some(max_entries) => { + manager + .container_read_dir_bounded(connection_id, path, max_entries) + .await + } + None => manager.container_read_dir(connection_id, path).await, + }; } let path_resolved = manager.resolve_sftp_path(connection_id, path).await?; - let mut entries = manager.sftp_read_dir(connection_id, path).await?; - - let mut result = Vec::new(); - - for entry in entries.by_ref() { - let name = entry.file_name(); - - // Skip . and .. - if name == "." || name == ".." { - continue; - } - - let full_path = if path_resolved.ends_with('/') { - format!("{}{}", path_resolved, name) - } else { - format!("{}/{}", path_resolved, name) - }; - - let metadata = entry.metadata(); - let is_dir = entry.file_type().is_dir(); - let is_symlink = entry.file_type().is_symlink(); - let is_file = entry.file_type().is_file(); - - // FileAttributes mtime is Unix timestamp in seconds; convert to milliseconds - // for JavaScript Date compatibility. - // Use size for any non-directory (regular files, symlinks, etc.). SFTP `is_file()` - // is false for symlinks and some file types, which previously hid size incorrectly. - let size = if is_dir { None } else { metadata.size }; - let modified = metadata.mtime.map(|t| (t as u64) * 1000); - - // Get permissions string - let permissions = Some(format_permissions(metadata.permissions)); - - result.push(RemoteDirEntry { - name, - path: full_path, - is_dir, - is_file, - is_symlink, - size, - modified, - permissions, - }); + match max_entries { + Some(max_entries) => Ok(manager + .sftp_read_dir_bounded(connection_id, path, max_entries) + .await? + .into_iter() + .map(|entry| { + remote_dir_entry_from_metadata(&path_resolved, entry.filename, entry.attrs) + }) + .collect()), + None => Ok(manager + .sftp_read_dir(connection_id, path) + .await? + .map(|entry| { + remote_dir_entry_from_metadata( + &path_resolved, + entry.file_name(), + entry.metadata(), + ) + }) + .collect()), } - - Ok(result) } /// Build a tree of remote directory structure (full walk; used by file explorer). @@ -447,31 +448,78 @@ impl RemoteFileService { } match manager.sftp_stat(connection_id, path).await { - Ok(attrs) => { - let name = remote_posix_basename(path); - - let is_dir = attrs.is_dir(); - let is_symlink = attrs.is_symlink(); - // File is neither dir nor symlink - let is_file = !is_dir && !is_symlink; - let size = if is_dir { None } else { attrs.size }; - let modified = attrs.mtime.map(|t| (t as u64) * 1000); - let permissions = Some(format_permissions(attrs.permissions)); - - Ok(Some(RemoteFileEntry { - name, - path: path.to_string(), - is_dir, - is_file, - is_symlink, - size, - modified, - permissions, - })) - } + Ok(attrs) => Ok(Some(remote_file_entry_from_metadata(path, attrs))), Err(_) => Ok(None), } } + + /// Get metadata for one exact path without following its final symlink. + pub async fn symlink_stat( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result> { + let manager = self.get_manager(connection_id).await?; + if manager.is_container_workspace(connection_id).await { + return manager.container_stat(connection_id, path).await; + } + match manager.sftp_lstat(connection_id, path).await { + Ok(attrs) => Ok(Some(remote_file_entry_from_metadata(path, attrs))), + Err(error) if is_sftp_not_found(&error) => Ok(None), + Err(error) => Err(error), + } + } +} + +fn is_sftp_not_found(error: &anyhow::Error) -> bool { + matches!( + error.downcast_ref::(), + Some(russh_sftp::client::error::Error::Status(status)) + if status.status_code == russh_sftp::protocol::StatusCode::NoSuchFile + ) +} + +fn remote_dir_entry_from_metadata( + parent: &str, + name: String, + metadata: russh_sftp::client::fs::Metadata, +) -> RemoteDirEntry { + let path = if parent.ends_with('/') { + format!("{parent}{name}") + } else { + format!("{parent}/{name}") + }; + let file_type = metadata.file_type(); + let is_dir = file_type.is_dir(); + RemoteDirEntry { + name, + path, + is_dir, + is_file: file_type.is_file(), + is_symlink: file_type.is_symlink(), + size: if is_dir { None } else { metadata.size }, + modified: metadata.mtime.map(|time| (time as u64) * 1000), + permissions: Some(format_permissions(metadata.permissions)), + } +} + +fn remote_file_entry_from_metadata( + path: &str, + attrs: russh_sftp::client::fs::Metadata, +) -> RemoteFileEntry { + let file_type = attrs.file_type(); + let is_dir = file_type.is_dir(); + let is_symlink = file_type.is_symlink(); + RemoteFileEntry { + name: remote_posix_basename(path), + path: path.to_string(), + is_dir, + is_file: file_type.is_file(), + is_symlink, + size: if is_dir { None } else { attrs.size }, + modified: attrs.mtime.map(|time| (time as u64) * 1000), + permissions: Some(format_permissions(attrs.permissions)), + } } /// Format file permissions as string (e.g., "rwxr-xr-x") @@ -513,7 +561,9 @@ fn format_permissions(mode: Option) -> String { #[cfg(test)] mod tests { - use super::remote_posix_basename; + use super::{is_sftp_not_found, remote_file_entry_from_metadata, remote_posix_basename}; + use russh_sftp::client::error::Error as SftpError; + use russh_sftp::protocol::{Status, StatusCode}; #[test] fn remote_basename_never_uses_host_path_separators() { @@ -524,4 +574,33 @@ mod tests { assert_eq!(remote_posix_basename("/workspace/目录/"), "目录"); assert_eq!(remote_posix_basename("/"), "/"); } + + #[test] + fn only_no_such_file_is_mapped_to_missing_metadata() { + let error = |status_code| { + anyhow::Error::new(SftpError::Status(Status { + id: 1, + status_code, + error_message: String::new(), + language_tag: String::new(), + })) + .context("Failed to inspect remote path") + }; + + assert!(is_sftp_not_found(&error(StatusCode::NoSuchFile))); + assert!(!is_sftp_not_found(&error(StatusCode::PermissionDenied))); + assert!(!is_sftp_not_found(&error(StatusCode::ConnectionLost))); + } + + #[test] + fn sftp_special_files_are_not_reported_as_regular_files() { + let mut attrs = russh_sftp::protocol::FileAttributes::default(); + attrs.permissions = Some(0o010644); + + let entry = remote_file_entry_from_metadata("/workspace/pipe", attrs); + + assert!(!entry.is_file); + assert!(!entry.is_dir); + assert!(!entry.is_symlink); + } } diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs index d12a29c4f0..451d055602 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use bitfun_runtime_ports::{ WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, - WorkspaceServices, WorkspaceShell, + WorkspacePathKind, WorkspaceServices, WorkspaceShell, }; use std::sync::Arc; @@ -40,6 +40,41 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { Ok(String::from_utf8_lossy(&bytes).to_string()) } + async fn read_file_text_bounded( + &self, + path: &str, + max_bytes: usize, + ) -> anyhow::Result> { + let Some(entry) = self + .file_service + .symlink_stat(&self.connection_id, path) + .await? + else { + return Ok(None); + }; + if !entry.is_file || entry.size.is_some_and(|size| size > max_bytes as u64) { + return Ok(None); + } + let mut exceeded = false; + let bytes = match self + .file_service + .read_file_with_progress(&self.connection_id, path, &mut |bytes_read, total_size| { + let over_limit = bytes_read > max_bytes as u64 || total_size > max_bytes as u64; + exceeded |= over_limit; + !over_limit + }) + .await + { + Ok(bytes) => bytes, + Err(_) if exceeded => return Ok(None), + Err(error) => return Err(error), + }; + if bytes.len() > max_bytes { + return Ok(None); + } + Ok(Some(String::from_utf8_lossy(&bytes).to_string())) + } + async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()> { if let Some((parent, _)) = path .rsplit_once('/') @@ -66,6 +101,24 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { self.file_service.is_dir(&self.connection_id, path).await } + async fn path_kind_no_follow(&self, path: &str) -> anyhow::Result> { + Ok(self + .file_service + .symlink_stat(&self.connection_id, path) + .await? + .map(|entry| { + if entry.is_symlink { + WorkspacePathKind::Symlink + } else if entry.is_dir { + WorkspacePathKind::Directory + } else if entry.is_file { + WorkspacePathKind::File + } else { + WorkspacePathKind::Other + } + })) + } + async fn read_dir(&self, path: &str) -> anyhow::Result> { let entries = self .file_service @@ -81,6 +134,26 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { }) .collect()) } + + async fn read_dir_bounded( + &self, + path: &str, + max_entries: usize, + ) -> anyhow::Result> { + let entries = self + .file_service + .read_dir_bounded(&self.connection_id, path, max_entries) + .await?; + Ok(entries + .into_iter() + .map(|entry| WorkspaceDirEntry { + name: entry.name, + path: entry.path, + is_dir: entry.is_dir, + is_symlink: entry.is_symlink, + }) + .collect()) + } } /// SSH-backed shell implementation of [`WorkspaceShell`]. diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 87350fe7d3..d3ec99ddcd 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -46,6 +46,7 @@ import { resolveSlashActionInputValue, type SlashActionId, } from '../utils/slashActionSelection'; +import { parseReloadCommand, supportsLocalReloadContext } from '../utils/reloadCommand'; import { notificationService } from '@/shared/notification-system'; import { useI18n } from '@/infrastructure/i18n'; import { inputReducer, initialInputState, type InputAction } from '../reducers/inputReducer'; @@ -520,6 +521,12 @@ export const ChatInput: React.FC = ({ ); const { commands: acpAgentCommands } = useAcpSlashCommands(acpSessionForInput); const isAcpInputSession = Boolean(acpSessionForInput); + const reloadContextSupported = supportsLocalReloadContext({ + desktopRuntime: isTauriRuntime(), + acpSession: isAcpInputSession, + dispatchTransport: usesDispatchTransport, + }); + const canReloadContext = reloadContextSupported && Boolean(effectiveTargetSessionId); const { entries: acpPlanEntries } = useAcpPlan(acpSessionForInput?.sessionId ?? null); const threadGoalController = useThreadGoalController(effectiveTargetSession, { isBtwSession, @@ -2587,12 +2594,12 @@ export const ChatInput: React.FC = ({ command: '/usage', label: t('chatInput.usageAction'), }, - ...(canUseSkillsForTarget + ...(canReloadContext ? [{ kind: 'action' as const, - id: 'reload-skills' as const, - command: '/reload-skills', - label: t('chatInput.reloadSkillsAction'), + id: 'reload' as const, + command: '/reload', + label: t('chatInput.reloadAction'), }] : []), ...(!derivedState?.isProcessing @@ -2623,7 +2630,7 @@ export const ChatInput: React.FC = ({ const cmd = i.command.slice(1).toLowerCase(); return cmd.includes(q) || i.label.toLowerCase().includes(q); }); - }, [canLaunchReview, canUseSkillsForTarget, derivedState?.isProcessing, isAcpInputSession, isBtwSession, isSubagentInputTarget, slashCommandState.query, t]); + }, [canLaunchReview, canReloadContext, derivedState?.isProcessing, isAcpInputSession, isBtwSession, isSubagentInputTarget, slashCommandState.query, t]); const getFilteredMcpPromptCommands = useCallback((): SlashMcpPromptItem[] => { if (isAcpInputSession) { @@ -3215,10 +3222,15 @@ export const ChatInput: React.FC = ({ threadGoalController, ]); - const submitReloadSkillsFromInput = useCallback(async () => { + const submitReloadFromInput = useCallback(async () => { const message = inputState.value.trim(); - if (!/^\/reload-skills\s*$/i.test(message)) { - notificationService.warning(t('chatInput.reloadSkillsUsage')); + const parsed = parseReloadCommand(message); + if (!parsed || parsed.kind === 'invalid') { + notificationService.warning(t('chatInput.reloadUsage')); + return; + } + if (!effectiveTargetSessionId) { + notificationService.error(t('chatInput.reloadNoSession')); return; } @@ -3227,34 +3239,36 @@ export const ChatInput: React.FC = ({ setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); try { - // Re-fetch skill configs with forceRefresh=true. The Tauri command - // (skill_api.rs::get_skill_configs) calls SkillRegistry::global().refresh() - // before serializing the result, so this single call both refreshes - // the registry cache and returns the new view. Pass workspacePath so - // workspace-level skills (`.bitfun/skills/`, `.cursor/skills/`, etc.) - // are included in the count — without it, the registry falls back - // to user + built-in slots only and the toast would undercount. - const skills = await configAPI.getSkillConfigs({ - forceRefresh: true, - workspacePath: sessionBoundWorkspacePath || undefined, + await agentAPI.reloadSessionContext({ + sessionId: effectiveTargetSessionId, + target: parsed.target, }); + const successMessage = parsed.target === 'all' + ? t('chatInput.reloadAllDone') + : parsed.target === 'skills' + ? t('chatInput.reloadSkillsDone') + : t('chatInput.reloadInstructionsDone'); notificationService.success( - t('chatInput.reloadSkillsDone', { count: skills.length }), + successMessage, { duration: 3000 } ); } catch (error) { - log.error('Failed to trigger /reload-skills', { error }); + log.error('Failed to reload session context', { + error, + sessionId: effectiveTargetSessionId, + target: parsed.target, + }); dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: message }); notificationService.error( error instanceof Error ? error.message : t('error.unknown'), { - title: t('chatInput.reloadSkillsFailed'), + title: t('chatInput.reloadFailed'), duration: 5000, } ); } - }, [dispatchInput, inputState.value, sessionBoundWorkspacePath, setQueuedInput, t]); + }, [dispatchInput, effectiveTargetSessionId, inputState.value, setQueuedInput, t]); const submitReviewFromInput = useCallback(async () => { if (!canLaunchReview) { @@ -3807,6 +3821,9 @@ export const ChatInput: React.FC = ({ !isAcpInputSession && !usesDispatchTransport && messageOverride === undefined; + const parsedReload = messageOverride === undefined + ? parseReloadCommand(message) + : null; if (localSlashCommandsEnabled && await submitExternalPromptCommandFromInput( message, @@ -3847,8 +3864,12 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && /^\/reload-skills\s*$/i.test(message)) { - await submitReloadSkillsFromInput(); + if (parsedReload && !reloadContextSupported) { + notificationService.warning(t('chatInput.reloadDesktopOnly')); + return; + } + if (parsedReload?.kind === 'reload') { + await submitReloadFromInput(); return; } @@ -3878,8 +3899,8 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/reload-skills')) { - notificationService.warning(t('chatInput.reloadSkillsUsage')); + if (localSlashCommandsEnabled && parsedReload?.kind === 'invalid') { + notificationService.warning(t('chatInput.reloadUsage')); return; } @@ -4019,7 +4040,8 @@ export const ChatInput: React.FC = ({ submitInitFromInput, submitReviewFromInput, submitMcpPromptFromInput, - submitReloadSkillsFromInput, + submitReloadFromInput, + reloadContextSupported, confirmPromptCacheGuardIfNeeded, t, resolveTypedMcpPromptCommand, diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts index 2680427709..9e4d70255f 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts @@ -353,7 +353,7 @@ describe('isChatInputActionVisibleForTarget', () => { }); it('keeps other slash actions visible for subagent targets', () => { - for (const actionId of ['usage', 'compact', 'reload-skills']) { + for (const actionId of ['usage', 'compact', 'reload']) { expect( isChatInputActionVisibleForTarget({ actionId, diff --git a/src/web-ui/src/flow_chat/utils/reloadCommand.test.ts b/src/web-ui/src/flow_chat/utils/reloadCommand.test.ts new file mode 100644 index 0000000000..9b612dc91a --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/reloadCommand.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { parseReloadCommand, supportsLocalReloadContext } from './reloadCommand'; + +describe('parseReloadCommand', () => { + it.each([ + ['/reload', 'all'], + [' /RELOAD ', 'all'], + ['/reload skills', 'skills'], + ['/reload Instructions', 'instructions'], + ['/reload-skills', 'skills'], + ] as const)('parses %s as %s', (input, target) => { + expect(parseReloadCommand(input)).toEqual({ kind: 'reload', target }); + }); + + it('keeps unrelated input outside the local command path', () => { + expect(parseReloadCommand('/reloader')).toBeNull(); + expect(parseReloadCommand('please /reload')).toBeNull(); + }); + + it.each([ + '/reload mcp', + '/reload skills instructions', + '/reload\nskills\ninstructions', + '/reload-skills instructions', + ])('rejects unsupported form %s', input => { + expect(parseReloadCommand(input)).toEqual({ kind: 'invalid' }); + }); +}); + +describe('supportsLocalReloadContext', () => { + it('allows only local Desktop sessions', () => { + expect(supportsLocalReloadContext({ + desktopRuntime: true, + acpSession: false, + dispatchTransport: false, + })).toBe(true); + expect(supportsLocalReloadContext({ + desktopRuntime: false, + acpSession: false, + dispatchTransport: false, + })).toBe(false); + expect(supportsLocalReloadContext({ + desktopRuntime: true, + acpSession: true, + dispatchTransport: false, + })).toBe(false); + expect(supportsLocalReloadContext({ + desktopRuntime: true, + acpSession: false, + dispatchTransport: true, + })).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/reloadCommand.ts b/src/web-ui/src/flow_chat/utils/reloadCommand.ts new file mode 100644 index 0000000000..319021e79c --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/reloadCommand.ts @@ -0,0 +1,35 @@ +export type ReloadTarget = 'all' | 'skills' | 'instructions'; + +export type ParsedReloadCommand = + | { kind: 'reload'; target: ReloadTarget } + | { kind: 'invalid' }; + +export function supportsLocalReloadContext(options: { + desktopRuntime: boolean; + acpSession: boolean; + dispatchTransport: boolean; +}): boolean { + return options.desktopRuntime && !options.acpSession && !options.dispatchTransport; +} + +export function parseReloadCommand(input: string): ParsedReloadCommand | null { + const command = input.trim(); + if (/^\/reload-skills$/i.test(command)) { + return { kind: 'reload', target: 'skills' }; + } + if (/^\/reload-skills(?:\s|$)/i.test(command)) { + return { kind: 'invalid' }; + } + if (!/^\/reload(?:\s|$)/i.test(command)) { + return null; + } + + const target = command.slice('/reload'.length).trim().toLowerCase(); + if (!target) { + return { kind: 'reload', target: 'all' }; + } + if (target === 'skills' || target === 'instructions') { + return { kind: 'reload', target }; + } + return { kind: 'invalid' }; +} diff --git a/src/web-ui/src/flow_chat/utils/slashActionSelection.test.ts b/src/web-ui/src/flow_chat/utils/slashActionSelection.test.ts index 5f26b354b4..6648dc1c40 100644 --- a/src/web-ui/src/flow_chat/utils/slashActionSelection.test.ts +++ b/src/web-ui/src/flow_chat/utils/slashActionSelection.test.ts @@ -18,7 +18,7 @@ describe('resolveSlashActionInputValue', () => { ['compact', '/compact'], ['usage', '/usage'], ['init', '/init'], - ['reload-skills', '/reload-skills'], + ['reload', '/reload'], ] as const)('fills the %s action', (actionId, expected) => { expect(resolveSlashActionInputValue(actionId, '/', false)).toBe(expected); }); diff --git a/src/web-ui/src/flow_chat/utils/slashActionSelection.ts b/src/web-ui/src/flow_chat/utils/slashActionSelection.ts index ebe6065580..dc04394813 100644 --- a/src/web-ui/src/flow_chat/utils/slashActionSelection.ts +++ b/src/web-ui/src/flow_chat/utils/slashActionSelection.ts @@ -5,7 +5,7 @@ export type SlashActionId = | 'review' | 'goal' | 'usage' - | 'reload-skills' + | 'reload' | 'compact' | 'init'; @@ -54,7 +54,7 @@ export function resolveSlashActionInputValue( return '/usage'; case 'init': return '/init'; - case 'reload-skills': - return '/reload-skills'; + case 'reload': + return '/reload'; } } diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts index f4531f7496..a99ff8b615 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts @@ -27,6 +27,21 @@ describe('AgentAPI', () => { }); }); + it('reloads one closed session context target through a structured request', async () => { + invokeMock.mockResolvedValueOnce(undefined); + + await expect(agentAPI.reloadSessionContext({ + sessionId: 'session-1', + target: 'instructions', + })).resolves.toBeUndefined(); + expect(invokeMock).toHaveBeenCalledWith('reload_session_context', { + request: { + sessionId: 'session-1', + target: 'instructions', + }, + }); + }); + it('returns whether session cancellation was accepted for an active turn', async () => { invokeMock.mockResolvedValueOnce({ cancelled: true, diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 2b5598238e..9b6b2559a8 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -266,6 +266,13 @@ export interface UpdateSessionModelRequest { includeInternal?: boolean; } +export type AgentContextReloadTarget = 'all' | 'skills' | 'instructions'; + +export interface AgentContextReloadRequest { + sessionId: string; + target: AgentContextReloadTarget; +} + export interface UpdateSessionTitleRequest { sessionId: string; title: string; @@ -884,6 +891,16 @@ export class AgentAPI { } } + async reloadSessionContext( + request: AgentContextReloadRequest, + ): Promise { + try { + await api.invoke('reload_session_context', { request }); + } catch (error) { + throw createTauriCommandError('reload_session_context', error, request); + } + } + async updateSessionTitle(request: UpdateSessionTitleRequest): Promise { try { return await api.invoke('update_session_title', { request }); diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 6e385b5b60..13119781bf 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -756,10 +756,14 @@ "deepreviewBusy": "A review is already running for this session. Stop or finish it before starting Review: Strict.", "deepreviewNestedDisabled": "Review: Strict can only be started from the main session.", "deepreviewThreadTitle": "$t(shared:features.deepReview)", - "reloadSkillsAction": "Reload skills", - "reloadSkillsUsage": "Use /reload-skills without extra arguments.", - "reloadSkillsDone": "Reloaded skills ({{count}} available)", - "reloadSkillsFailed": "Failed to reload skills", + "reloadAction": "Reload", + "reloadUsage": "Use /reload, or reload one target with /reload skills or /reload instructions.", + "reloadNoSession": "There is no active session to reload.", + "reloadDesktopOnly": "Reload is available for local Desktop sessions and in the CLI.", + "reloadAllDone": "Reloaded skills. Instructions will be reread for the next message.", + "reloadSkillsDone": "Reloaded skills.", + "reloadInstructionsDone": "Instructions will be reread for the next message.", + "reloadFailed": "Reload failed", "currentMode": "Current mode: {{mode}}", "noMatchingMode": "No matching mode", "noMatchingCommand": "No matching command", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index af94ab9f62..3f0092d2dd 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -750,10 +750,14 @@ "deepreviewBusy": "当前会话已有审核正在进行,请停止或等待完成后再开始严格审核。", "deepreviewNestedDisabled": "严格审核只能从主会话启动。", "deepreviewThreadTitle": "严格审核", - "reloadSkillsAction": "重新加载技能", - "reloadSkillsUsage": "使用 /reload-skills 时不要带额外参数。", - "reloadSkillsDone": "技能已重新加载(当前可用 {{count}} 个)", - "reloadSkillsFailed": "技能重新加载失败", + "reloadAction": "重新加载", + "reloadUsage": "使用 /reload,或通过 /reload skills 和 /reload instructions 只重新加载一类内容。", + "reloadNoSession": "当前没有可重新加载的会话。", + "reloadDesktopOnly": "重新加载仅适用于桌面端本地会话和 CLI。", + "reloadAllDone": "技能已重新加载;指令将在下一条消息时重新读取。", + "reloadSkillsDone": "技能已重新加载。", + "reloadInstructionsDone": "指令将在下一条消息时重新读取。", + "reloadFailed": "重新加载失败", "currentMode": "当前模式: {{mode}}", "noMatchingMode": "没有匹配的模式", "noMatchingCommand": "没有匹配的命令", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 8275585599..2cebcb0a88 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -750,10 +750,14 @@ "deepreviewBusy": "目前會話已有審核正在進行,請停止或等待完成後再開始嚴格審核。", "deepreviewNestedDisabled": "嚴格審核只能從主會話啟動。", "deepreviewThreadTitle": "嚴格審核", - "reloadSkillsAction": "重新載入技能", - "reloadSkillsUsage": "使用 /reload-skills 時不要帶額外參數。", - "reloadSkillsDone": "技能已重新載入(目前可用 {{count}} 個)", - "reloadSkillsFailed": "技能重新載入失敗", + "reloadAction": "重新載入", + "reloadUsage": "使用 /reload,或透過 /reload skills 和 /reload instructions 只重新載入一類內容。", + "reloadNoSession": "目前沒有可重新載入的工作階段。", + "reloadDesktopOnly": "重新載入僅適用於桌面端本機工作階段和 CLI。", + "reloadAllDone": "技能已重新載入;指令將在下一則訊息時重新讀取。", + "reloadSkillsDone": "技能已重新載入。", + "reloadInstructionsDone": "指令將在下一則訊息時重新讀取。", + "reloadFailed": "重新載入失敗", "currentMode": "目前模式: {{mode}}", "noMatchingMode": "沒有匹配的模式", "noMatchingCommand": "沒有匹配的命令",