diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 2a0447bdf..0f0723cfa 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -6,6 +6,11 @@ 5524a35241ba67ba061aa1a279fd943213601a2f 7a90db6d94bf70e4e200cb39c0122bdb1fc7fa66 +# P2-05 纯搬迁与 include 规范化(2026-09-27 / 28) +c33325c2047f7dee74e02a53cb1b1c30e6bd9b62 +3d4ab59cfbd57bd6bfbd4141a13c6a16e5f8d500 +124f5e1c942ddc5399b9f7ed56aab75ed55876af + # P2-02 / P2-03 / P2-04 已合入本分支的纯搬迁与 include 改写 4009835e29da6c901a0c4ea1ef13a6e99479bbf5 bd8ac3ebd916eafbce1040cea39ff9d966bb3493 diff --git a/AGENT.md b/AGENT.md index d68d7c501..6c7495338 100644 --- a/AGENT.md +++ b/AGENT.md @@ -157,7 +157,7 @@ On Linux desktop builds, install the WebKitGTK development package before config `src/lsp/` is a process-local LSP client pool. Queries and post-edit diagnostics must use the session cwd from `ToolContext`, not the daemon process cwd, and must `weakly_canonical` paths before workspace-boundary or slot-key compares. Servers are detected from PATH / the project; ACECode never downloads language servers. -Permission modes live in `src/permissions.hpp`: `Default` (prompt writes/exec), `AcceptEdits` (auto-allow file writes, still prompt shell), `Yolo` (auto-allow tools), and `Plan` (explore / write only the active plan file). Memory writes stay path-locked even under broader modes. Non-loopback `--dangerous` is rejected. +Permission modes live in `src/permissions/permissions.hpp`: `Default` (prompt writes/exec), `AcceptEdits` (auto-allow file writes, still prompt shell), `Yolo` (auto-allow tools), and `Plan` (explore / write only the active plan file). Memory writes stay path-locked even under broader modes. Non-loopback `--dangerous` is rejected. Other shared subsystems that are easy to miss: `src/gitinfo/` (prompt + REST git snapshots), `src/hooks/` (Codex-compatible lifecycle hooks), `src/pty/` (console dock, formerly the `web/pty/` subdirectory; `/api/pty` and `/ws/pty` are loopback-only), `src/loop/` (scheduled loop tasks), `src/experts/`, and `src/remote_control/`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3cd3f8c02..cd7ac7b74 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -125,7 +125,7 @@ flowchart TB | [src/agent_loop.cpp](src/agent_loop.cpp) and [src/agent_loop.hpp](src/agent_loop.hpp) | Multi-turn agent state machine, streaming callbacks, tool-call loop, cancellation, max-iteration handling, and completion semantics. | | [src/provider/](src/provider) | `LlmProvider` implementations, provider factory/swap logic, Copilot auth integration, OpenAI-compatible streaming, model profiles, and context-window resolution. | | [src/tool/](src/tool) | Tool registry, built-in tools, tool result metadata, summaries, MCP bridge, skills tools, memory tools, and optional web-search tool. | -| [src/permissions.hpp](src/permissions.hpp) | Permission modes and glob-style tool/path allow rules. | +| [src/permissions/permissions.hpp](src/permissions/permissions.hpp) | Permission modes and glob-style tool/path allow rules. | | [src/session/](src/session) | Session JSONL persistence, metadata sidecars, replay, resume restore, rewind checkpoints, daemon session registry, and event dispatch. | | [src/commands/](src/commands) | Slash command registry and built-in command implementations. | | [src/config/](src/config) | Config load/save/validation, saved model profiles, and default schema behavior. | @@ -198,7 +198,7 @@ Context-window resolution uses saved profile data, bundled models.dev metadata, `ToolExecutor` owns the authoritative tool registry. Built-ins include shell execution, file read/write/edit, grep, glob, task completion, structured user questions, skills, memory, optional web search, and MCP-provided tools. -Permission behavior is centralized in [src/permissions.hpp](src/permissions.hpp): +Permission behavior is centralized in [src/permissions/permissions.hpp](src/permissions/permissions.hpp): - `Default`: auto-allow read-only tools, prompt for writes and exec. - `AcceptEdits`: auto-allow file writes/edits, still prompt for shell commands. diff --git a/CLAUDE.md b/CLAUDE.md index 9dae53c55..b58c2f9c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,11 +10,13 @@ The Superpowers plugin is disabled for this repository. Do not invoke or follow ## Current Runtime Surfaces +P2-05 状态存储边界:`utils/state_file` 仅管理通用 JSON 读写、跨进程文件锁与原子更新;模型探测缓存属于 `provider/model_probe_cache`,搜索地区缓存属于 `tool/web_search/region_cache`,桌面活跃 / 首页工作区记录属于 `desktop/workspace_state`,TUI 命令用量属于 `tui/slash_command_usage`。各模块复用同步事务,保留 `state.json` 字段与原有失败返回语义。 + ACECode ships one main executable with terminal TUI and daemon subcommands, plus an optional desktop shell target. - TUI mode starts from [main.cpp](main.cpp), configures provider/tools/commands, runs the FTXUI loop, and posts worker callbacks back into the UI event queue. - Daemon mode starts from [src/daemon/cli.cpp](src/daemon/cli.cpp), converges on `worker.cpp::run_worker`, writes runtime files, then serves [src/web/](src/web) routes and WebSocket events. -- Headless print mode (`acecode -p ""`, openspec add-headless-print-mode) starts from `main.cpp::dispatch_non_tui_command` → [src/headless/headless_runner.cpp](src/headless/headless_runner.cpp) — worker.cpp bootstrap 的精简复刻(无 run/ 文件、无心跳、无 Crow),会话走 SessionRegistry 正常落盘可 resume。进程级标记 `headless::active()`(src/headless/headless_mode) 有两个消费点:AgentLoop 权限门(默认自动拒绝并给模型解释文案,`--yolo` 全放行且不弹权限确认)与 AskUserQuestion(自动应答,优先于 goal unattended 分支);spawn_subagent 子会话同进程自动继承。stdout 只出最终 assistant 文本;退出码 0/1/64/130。Windows 下 -p 分支经 `CommandLineToArgvW` 重建 UTF-8 argv。**连续对话**(每轮新进程、磁盘续接,对齐 claude -p 家族一):`-c/--continue` 接当前 cwd 最近普通会话(跳过 subagent 子会话);`--resume ` 接指定会话 —— print 模式下 id 必填(裸 `--resume` 会把位置参数 prompt 吞成 id,与 TUI 语义刻意不同),显式给出的 `--model`/`--permission-mode` 覆盖会话保存值(`SessionRegistry::resume` 透传 + `make_entry_locked` 显式优先,web resume 不传这两个字段不受影响);`--session-id ` 自定新会话 id(`SessionOptions::preset_session_id`,字符集 `[A-Za-z0-9-_]{1,64}` 防路径穿越,撞已有 id 报错提示改用 --resume);`--output-format json` 成败都输出单个 `{"type":"result","session_id",...}` 对象供脚本链式调用。帮助:顶层 `acecode --help`(main.cpp `print_top_level_help`)+ `acecode -p --help`(`headless_options.cpp::print_mode_help`)。 +- Headless print mode (`acecode -p ""`, openspec add-headless-print-mode) starts from `main.cpp::dispatch_non_tui_command` → [src/headless/headless_runner.cpp](src/headless/headless_runner.cpp) — worker.cpp bootstrap 的精简复刻(无 run/ 文件、无心跳、无 Crow),会话走 SessionRegistry 正常落盘可 resume。进程级标记 `headless::active()`(src/permissions/interaction_mode.hpp) 有两个消费点:AgentLoop 权限门(默认自动拒绝并给模型解释文案,`--yolo` 全放行且不弹权限确认)与 AskUserQuestion(自动应答,优先于 goal unattended 分支);spawn_subagent 子会话同进程自动继承。stdout 只出最终 assistant 文本;退出码 0/1/64/130。Windows 下 -p 分支经 `CommandLineToArgvW` 重建 UTF-8 argv。**连续对话**(每轮新进程、磁盘续接,对齐 claude -p 家族一):`-c/--continue` 接当前 cwd 最近普通会话(跳过 subagent 子会话);`--resume ` 接指定会话 —— print 模式下 id 必填(裸 `--resume` 会把位置参数 prompt 吞成 id,与 TUI 语义刻意不同),显式给出的 `--model`/`--permission-mode` 覆盖会话保存值(`SessionRegistry::resume` 透传 + `make_entry_locked` 显式优先,web resume 不传这两个字段不受影响);`--session-id ` 自定新会话 id(`SessionOptions::preset_session_id`,字符集 `[A-Za-z0-9-_]{1,64}` 防路径穿越,撞已有 id 报错提示改用 --resume);`--output-format json` 成败都输出单个 `{"type":"result","session_id",...}` 对象供脚本链式调用。帮助:顶层 `acecode --help`(main.cpp `print_top_level_help`)+ `acecode -p --help`(`headless_options.cpp::print_mode_help`)。 - Headless capability policy (`openspec add-headless-tool-selection`, `add-headless-capability-discovery`):`-p` 新会话未传模型时走默认模型(resume 未传时沿用会话模型)、未传权限时固定 `default`、未传 `--max-turns` 时强制无限(不继承全局 cap)。工具面默认保留当前配置下实际注册的系统工具,Skill/MCP 均为空;`--disable-tools` 精确移除系统工具,`--enable-skills` 写入 runtime-only `SkillsConfig::allowed` 并只在非空时注册 `skills_list/skill_view`,`--enable-mcp` 先过滤本地 `mcp_servers` 副本再启动 runtime。三个参数都支持重复/逗号/等号形式,未知或全局 disabled 名称在 provider 前 exit 64。`--list-tools` 复用默认 headless 工具注册链并列出真正可被 `--disable-tools` 接受的 Builtin 名,`--list-skills` 列当前 cwd 真正可被 `--enable-skills` 接受的 Skill 名与描述,`--list-mcp` 列配置中未 disabled 的 MCP server 名;三者可组合且无需 prompt,发现分支在 stdin、startup hooks、model/session/LSP/MCP runtime 之前退出,不执行工具,与任何执行参数混用时报 usage error。SessionRegistry per-entry skill registry、skill tools、slash expansion 与 spawn_subagent 都复用本次过滤后的 config/executor;allowlist 不落盘。 - Web UI code lives in [web/src/](web/src), builds with React 18, Vite, Tailwind v4, `markdown-it`, `highlight.js`, and `diff2html`, then gets embedded from `web/dist/` by CMake. - Desktop mode is opt-in through `-DACECODE_BUILD_DESKTOP=ON`; [src/desktop/](src/desktop) manages workspace registry, daemon pool, webview host, tray, notifications, and bridge calls. @@ -271,7 +273,7 @@ worktree 落在**主仓根**的 `.acecode/worktrees/`,分支 `worktree- LOOP 策略 > 继承的 `inherited_write_root_`)非空即有写边界,Yolo 不再免除,只有 dangerous 整体放行;文件工具报 `Write boundary blocked`,bash 走 `agent_loop_shell_guard.hpp` 的可证明写目标守卫(Windows 上两侧先 weakly_canonical,junction 形态不同不误拦)。`spawn_subagent` 让子会话共享父会话的 worktree(`SessionOptions::inherited_worktree`,meta `worktree_session.inherited=true`,`entry->cwd` 取 `original_cwd` 与父会话同 project dir,AgentLoop cwd 切进 worktree)、继承 LOOP 策略与 `ToolContext::write_root`;`EnterWorktree` / `ExitWorktree` 对继承者拒绝;`wait_subagent` 用 `AgentLoop::last_turn_failed()` 把夭折的子会话报成 ChildFailed 而不是 completed;指令加载器在 linked worktree 根(`.git` 指针含 `/.git/worktrees/`)止步。事后兜底:spawn 在父会话处于 worktree 时记主 checkout 的 `git status --porcelain` 快照,wait 结束比对并把 worktree 之外新出现的路径附进结果(`metadata.workspace_touched`);LOOP 调度器对建了 worktree 的 run 做同样的事,结果进 `loop_runs.workspace_touched`(schema v3),Web 循环页显示警告。**守卫不是沙箱**:`cd 主仓 && node fix.js` 这类动态写入只能靠事后检测发现;进程级沙箱落地后 bash 守卫可退役,但文件工具那道边界要留 —— 它们在 daemon 进程内执行,沙箱管不到(Codex 的 apply_patch 绕过 `--add-dir` 就是同一个洞)。回归测试见 [docs/subagents.md](docs/subagents.md) 的测试地图。 +**写边界与子代理继承(fix-subagent-write-boundary)。** 起因是一次线上排障:同事的 LOOP 在 worktree 里派了 5 个子代理,IM 模块那个把改动写进了主 checkout。根因有三条,都是"隔离只罩住一个会话对象、不随会话树传递":(1) `spawn_subagent` 只把 `ctx.cwd`(worktree 路径)当普通 cwd 交给子会话,`loop_execution` / WorktreeSessionInfo 都不传,子会话的系统提示说自己 "Session worktree: inactive";(2) `is_cwd_validation_exempt` 让 Yolo 会话跳过全部路径校验,只有 LOOP 主会话被补回一道 `PathValidator(cwd_)` 边界,子会话继承了 Yolo 却不是 LOOP 会话,两头都不沾;(3) 项目指令加载器从 worktree 一路向上走到 HOME,把主 checkout 的 AGENTS.md 再加载一遍并带上绝对路径 Source 头,等于把主仓路径喂给模型。现在:`AgentLoop::write_root()`(会话 worktree > LOOP 策略 > 继承的 `inherited_write_root_`)非空即有写边界,Yolo 不再免除,只有 dangerous 整体放行;文件工具报 `Write boundary blocked`,bash 走 `permissions/shell_write_guard.hpp` 的可证明写目标守卫(Windows 上两侧先 weakly_canonical,junction 形态不同不误拦)。`spawn_subagent` 让子会话共享父会话的 worktree(`SessionOptions::inherited_worktree`,meta `worktree_session.inherited=true`,`entry->cwd` 取 `original_cwd` 与父会话同 project dir,AgentLoop cwd 切进 worktree)、继承 LOOP 策略与 `ToolContext::write_root`;`EnterWorktree` / `ExitWorktree` 对继承者拒绝;`wait_subagent` 用 `AgentLoop::last_turn_failed()` 把夭折的子会话报成 ChildFailed 而不是 completed;指令加载器在 linked worktree 根(`.git` 指针含 `/.git/worktrees/`)止步。事后兜底:spawn 在父会话处于 worktree 时记主 checkout 的 `git status --porcelain` 快照,wait 结束比对并把 worktree 之外新出现的路径附进结果(`metadata.workspace_touched`);LOOP 调度器对建了 worktree 的 run 做同样的事,结果进 `loop_runs.workspace_touched`(schema v3),Web 循环页显示警告。**守卫不是沙箱**:`cd 主仓 && node fix.js` 这类动态写入只能靠事后检测发现;进程级沙箱落地后 bash 守卫可退役,但文件工具那道边界要留 —— 它们在 daemon 进程内执行,沙箱管不到(Codex 的 apply_patch 绕过 `--add-dir` 就是同一个洞)。回归测试见 [docs/subagents.md](docs/subagents.md) 的测试地图。 ### 多文件夹项目(「编辑项目」,仿 Codex) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5b1d06eb9..c76f8d221 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -182,7 +182,7 @@ set(ACECODE_TUI_DIRS ${CMAKE_SOURCE_DIR}/src/markdown) set(ACECODE_TUI_TESTABLE_SUBSETS commands/ resume/ path_reference/ markdown/ - drag_scroll.cpp text_input_ops.cpp skill_commands.cpp) + drag_scroll.cpp text_input_ops.cpp skill_commands.cpp slash_command_usage.cpp) set(ACECODE_TUI_SOURCES) foreach(_tui_dir IN LISTS ACECODE_TUI_DIRS) if(NOT IS_DIRECTORY "${_tui_dir}") @@ -222,7 +222,7 @@ set(ACECODE_NATIVE_BRIDGE_SUPPORT_SOURCES ${CMAKE_SOURCE_DIR}/src/config/model_provider_registry.cpp ${CMAKE_SOURCE_DIR}/src/config/request_headers.cpp ${CMAKE_SOURCE_DIR}/src/config/saved_models.cpp - ${CMAKE_SOURCE_DIR}/src/provider/builtin_model_catalog.cpp + ${CMAKE_SOURCE_DIR}/src/config/builtin_model_catalog.cpp ${CMAKE_SOURCE_DIR}/src/platform/process/os_process_posix.cpp ${CMAKE_SOURCE_DIR}/src/platform/process/os_process_windows.cpp ${CMAKE_SOURCE_DIR}/src/ipc/runtime_files.cpp @@ -245,12 +245,13 @@ set(ACECODE_NATIVE_BRIDGE_SUPPORT_SOURCES ${CMAKE_SOURCE_DIR}/src/platform/clipboard.cpp ${CMAKE_SOURCE_DIR}/src/utils/cwd_hash.cpp ${CMAKE_SOURCE_DIR}/src/utils/encoding.cpp - ${CMAKE_SOURCE_DIR}/src/utils/model_capabilities.cpp + ${CMAKE_SOURCE_DIR}/src/config/model_capabilities.cpp ${CMAKE_SOURCE_DIR}/src/utils/paths.cpp ${CMAKE_SOURCE_DIR}/src/utils/state_file.cpp ) set(ACECODE_DESKTOP_SUPPORT_SOURCES + ${CMAKE_SOURCE_DIR}/src/desktop/workspace_state.cpp ${CMAKE_SOURCE_DIR}/src/desktop/application_icon.cpp ${CMAKE_SOURCE_DIR}/src/desktop/linux_webview_scale_policy.cpp ${CMAKE_SOURCE_DIR}/src/desktop/daemon_pool.cpp diff --git a/docs/ai_sugesstion.md b/docs/ai_sugesstion.md index baed95321..84f69d286 100644 --- a/docs/ai_sugesstion.md +++ b/docs/ai_sugesstion.md @@ -59,7 +59,7 @@ ACECode 已经有不错的 agent 底座:C++17 共享 agent core、TUI/daemon/w 适配代码方向: - `src/tool/`:新增 `code_mode` 工具和 broker。 -- `src/permissions.hpp`:增加 brokered call 的权限上下文。 +- `src/permissions/permissions.hpp`:增加 brokered call 的权限上下文。 - `src/session/`:记录脚本、子调用、审批和最终结果,便于 replay。 候选 OpenSpec:`sandboxed-code-mode-tools` @@ -126,7 +126,7 @@ AI coding agent 的安全边界不应只停留在“读工具自动、写工具 适配代码方向: -- `src/permissions.hpp`:风险模型和 grant scope。 +- `src/permissions/permissions.hpp`:风险模型和 grant scope。 - `src/tool/`:工具定义附带风险 metadata。 - `web/src/components/PermissionModal.jsx`、TUI confirm:展示参数 diff 和风险解释。 diff --git a/docs/help-source/group3.py b/docs/help-source/group3.py index ca649c2cf..f1c0fc758 100644 --- a/docs/help-source/group3.py +++ b/docs/help-source/group3.py @@ -23,7 +23,7 @@ figure("MD-02", "ACEModel 的模型选择", "展示 ACEModel Provider 和三个内置 Model ID,以及保存后的预设。API Key 必须隐藏,避免截入账号信息。")), section("verify", "验证能力与连接状态", '''

检查已保存模型的视觉、工具等能力标识,然后用真实的小任务验证所需功能。

认证失败时检查密钥和实际地址;模型不存在时核对 Model ID;有回答但不能完成工具任务时检查工具能力与权限。排查步骤见模型连接与认证问题。

''') -], ["src/provider/builtin_model_catalog.cpp", "src/config/saved_models.cpp", "web/src/components/model-settings/ModelProfileDialog.jsx", "web/src/components/model-settings/ProviderCatalogPicker.jsx"]), +], ["src/config/builtin_model_catalog.cpp", "src/config/saved_models.cpp", "web/src/components/model-settings/ModelProfileDialog.jsx", "web/src/components/model-settings/ProviderCatalogPicker.jsx"]), "providers": page("按实际接口协议选择 Provider,再填写服务给出的连接信息。不同服务商的认证方式和可选参数分别配置。", [ section("openai", "OpenAI 兼容接口", diff --git a/docs/help-source/group4.py b/docs/help-source/group4.py index 3f2ddd2ac..686347ae0 100644 --- a/docs/help-source/group4.py +++ b/docs/help-source/group4.py @@ -42,7 +42,7 @@ '''

停止任务会阻止后续工作,但已经完成的写入和命令副作用不会自动回滚。拒绝一个工具后,可以说明原因并要求智能体采用更小范围的方案。

''', note("危险启动模式", "--yolo / --dangerous 启动参数会绕过权限和路径安全检查,适合明确受控的本地环境,不应作为日常排错的通用办法。危险模式不能开启远程 Web 访问。", True), '''

只想减少文件编辑的确认时,先考虑自动接收编辑。需要了解如何检查已经发生的修改,参见回退与恢复。

''') -], ["web/src/lib/permissionMode.js", "web/src/components/SettingsPage.jsx", "src/permissions.hpp", "src/headless/headless_options.cpp", "docs/user-manual.md"]), +], ["web/src/lib/permissionMode.js", "web/src/components/SettingsPage.jsx", "src/permissions/permissions.hpp", "src/headless/headless_options.cpp", "docs/user-manual.md"]), "network": page("分别配置访问模型服务的出站代理,以及让其他设备连接 ACECode 的远程 Web 入口。", [ section("outbound", "模型请求使用的代理", diff --git a/docs/help-source/sources.json b/docs/help-source/sources.json index 07a78a629..92c0b1acc 100644 --- a/docs/help-source/sources.json +++ b/docs/help-source/sources.json @@ -142,7 +142,7 @@ "article": "ACEModel", "file": "acemodel.html", "sources": [ - "src/provider/builtin_model_catalog.cpp", + "src/config/builtin_model_catalog.cpp", "src/config/saved_models.cpp", "web/src/components/model-settings/ModelProfileDialog.jsx", "web/src/components/model-settings/ProviderCatalogPicker.jsx" @@ -197,7 +197,7 @@ "sources": [ "web/src/lib/permissionMode.js", "web/src/components/SettingsPage.jsx", - "src/permissions.hpp", + "src/permissions/permissions.hpp", "src/headless/headless_options.cpp", "docs/user-manual.md" ] diff --git a/docs/subagents.md b/docs/subagents.md index 22220bbc6..1a9c3faa7 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -44,7 +44,7 @@ 的系统提示写明 worktree 归父会话所有;`EnterWorktree` / `ExitWorktree` 对继承者一律拒绝。 - **写边界**:`AgentLoop::write_root()` 非空(会话 worktree > LOOP 策略 > 继承的根)时,Yolo 不再免除路径校验 —— 写工具指向边界之外报 `Write boundary blocked`,bash 里可证明的写 - 目标同样被拦(`agent_loop_shell_guard.hpp`);file_read 与只读工具仍可读任何位置; + 目标同样被拦(`permissions/shell_write_guard.hpp`);file_read 与只读工具仍可读任何位置; dangerous 模式整体放行。曾经这条边界只挂在 LOOP 主会话上,子会话继承了 Yolo 却不继承 LOOP 身份,于是"起了 worktree 但子代理在主 checkout 里改东西"。 - **主 checkout 监视**(事后兜底):父会话在 worktree 里时,spawn 记下主 checkout 的 diff --git "a/docs/\347\253\236\345\223\201\345\257\271\347\205\247_opencode_2026-09.md" "b/docs/\347\253\236\345\223\201\345\257\271\347\205\247_opencode_2026-09.md" index 10a9db810..083be66a5 100644 --- "a/docs/\347\253\236\345\223\201\345\257\271\347\205\247_opencode_2026-09.md" +++ "b/docs/\347\253\236\345\223\201\345\257\271\347\205\247_opencode_2026-09.md" @@ -241,5 +241,5 @@ E1 OpenAPI ──→ E2 ACP / E4 VS Code / SDK ## 7. 核实方法 - opencode:读 `AGENTS.md`、`CONTEXT.md`、`packages/web/src/content/docs/*.mdx`(36 页)建立功能清单,再逐项进 `packages/opencode/src`、`packages/core/src`、`packages/plugin/src`、`packages/tui/src`、`packages/app/src`、`packages/llm/src` 核对实现存在且非 stub。 -- ACECode:对 `src/tool/builtin_tool_registry.hpp`、`src/commands/*.cpp` 的注册点、`src/permissions.hpp`、`src/config/config.hpp`、`src/prompt/system_prompt.cpp`、`docs/hooks.md` 做 grep 级核实;"无"的结论均来自 grep 零命中 + 目录结构确认。 +- ACECode:对 `src/tool/builtin_tool_registry.hpp`、`src/commands/*.cpp` 的注册点、`src/permissions/permissions.hpp`、`src/config/config.hpp`、`src/prompt/system_prompt.cpp`、`docs/hooks.md` 做 grep 级核实;"无"的结论均来自 grep 零命中 + 目录结构确认。 - 未核实、只依据文档的项已在表中用"docs"或"需核对"标出。 diff --git a/openspec/changes/refactor20260927-restructure-src-layers/branch-inventory.md b/openspec/changes/refactor20260927-restructure-src-layers/branch-inventory.md index 164461c3e..ea5a572f0 100644 --- a/openspec/changes/refactor20260927-restructure-src-layers/branch-inventory.md +++ b/openspec/changes/refactor20260927-restructure-src-layers/branch-inventory.md @@ -458,4 +458,9 @@ Base: `master` (`e15df9eadfaf05a170fd8b83fb26939452e2d3e9`). Read-only snapshot. - 5028f277e315af16bc9ccd612038834693acdab3 ``` +## P2-05 迁移提示(2026-09-28) +本记录面向前述九个遗留 ref,随阶段性提交推送,没有向外部人员发送消息。 +P2-05 不保留旧路径转发头。合并本阶段主线前,先在各自的迁移分支上运行 `normalize_includes.py --scope src` 与 `--scope tests`,再按既有方案演练 rebase / patch。不要改写或删除原 ref。 + +本阶段除映射表中的文件搬迁外,还拆出了 `config/vocab/permission_mode.hpp`、`provider/models_dev_catalog_cache`、`provider/model_probe_cache`、`tool/{safe_text_write,text_file_errors}`、`tool/web_search/region_cache`、`desktop/workspace_state` 与 `tui/slash_command_usage`。涉及 `state_file`、编码报错或 models.dev 全局缓存的调用方需显式包含对应模块的新头,不能仅依靠旧的间接 include。原状态字段和模型工具名映射保持。 diff --git a/openspec/changes/refactor20260927-restructure-src-layers/design.md b/openspec/changes/refactor20260927-restructure-src-layers/design.md index a1628828a..acf210652 100644 --- a/openspec/changes/refactor20260927-restructure-src-layers/design.md +++ b/openspec/changes/refactor20260927-restructure-src-layers/design.md @@ -171,7 +171,7 @@ cmake/version.hpp.in 生成的头名 generated/version.hpp 不变 具体文件由 split-agent-loop 落地后回填 `layers.tsv`。 10. **R12 行数棘轮**:新文件不超过 1000 行;存量超限文件写入 `scripts/layers/size_baseline.txt`,行数只能下降。`src/apps/web/**` 标注「用户约束豁免」,`external/stb` 标注「第三方」,都不计入基线。 -11. **R13 例外到期**:例外必须在 `layers.tsv` 的 exceptions 段登记 from、to、owner、到期日,到期未清理 lint 就失败。P2 的转发头一律登记在这里,冻结当天到期。 +11. **R13 例外到期**:例外必须在 `layers.tsv` 的 exceptions 段登记 from、to、owner、到期日,到期未清理 lint 就失败。P2 不保留转发头(D24);本段只登记其它确有需要的例外。 12. **R14 tests**:tests 视为 rank 100,可以 include 任何层;目录必须镜像模块名;测试 helper 只能放 `tests/test_support/`。 13. **R15 所有权棘轮**:由 `check_ownership.py` 统计,src/apps/web 豁免。统计五类指标:裸 `new/delete`、`.detach()`、`std::thread` 成员或局部变量、存进长寿对象或跨线程的回调里的 `[&]`/`[this]`、`set_*(T*)` 延迟注入。数量只能下降。目标值见 adopt-ownership-conventions。 @@ -212,7 +212,7 @@ cmake/version.hpp.in 生成的头名 generated/version.hpp 不变 4. **P3 冻结窗口**(半天):约 44 个模块目录 `git mv` 到 6 个分组下(M1,全部 R100,改动 0 行),再机械修正 CMake 与文档(M2)。 5. **P6 先搬后拆**:两个巨型文件在搬迁时保持 R100,其它分支对它们的改动能跟过去;先拆会破坏重命名检测。拆出来的文件直接落在最终目录。 -**转发头只在 P2 使用,冻结当天全部删除。** 不批量保留,原因有三:转发头等于把旧目录重建出来,会被 GLOB 收进去;它救不了被搬走文件内部的 `../`;需要迁移的分支不超过 9 个,自助迁移的成本远低于维护转发头。 +**P2 起不保留转发头(D24,用户 2026-09-28 再次确认)。** 原因有三:转发头等于把旧目录重建出来,会被 GLOB 收进去;它救不了被搬走文件内部的 `../`;需要迁移的分支不超过 9 个,自助迁移的成本远低于维护转发头。 ### CMake 护栏与搬迁 @@ -252,7 +252,7 @@ cmake/version.hpp.in 生成的头名 generated/version.hpp 不变 - **[冻结窗口期间 master 被推进]** → 不 rebase 已生成的提交,丢弃后在新 master 上重跑脚本(脚本是确定性的)。 - **[重名文件合并到同一目录]** → `validate_map.py` 报告重名:`runtime.hpp/.cpp` 3 份、`main.cpp` 2 份、`pointer_overlay.cpp` 2 份(`tool/agent_browser/pointer_overlay.cpp` 冻结前改名为 `browser_pointer_overlay.cpp`)。 - **[多根 include 的遮蔽]** → R8 唯一解析 + R9 子目录不与模块或分组同名,两条都由 lint 硬检查。 -- **[P2 期间树先变差再变好]**:约 2 周内会临时多出 llm、permissions、platform、ipc、workspace、pty、session_host、agent 等顶层目录和转发头 → 接受;每个 PR 都要求 lint 违规数单调下降,P2-08 完成时为 0。 +- **[P2 期间树先变差再变好]**:约 2 周内会临时多出 llm、permissions、platform、ipc、workspace、pty、session_host、agent 等顶层目录 → 接受(D24 禁止转发头);每个 PR 都要求 lint 违规数单调下降,P2-08 完成时为 0。 - **[git log / blame 断链]** → 机械提交写进 `.git-blame-ignore-revs`;文档说明用 `git log --follow` 与 `git blame -C -C -M`。 - **[历史被改写导致分支失去共同祖先]** → 搬迁前后禁止 `strip-ai-attribution` 或 filter-repo 这类改写历史的操作。 @@ -509,5 +509,6 @@ P0-01 → P0-03 → P0-04 → P1-01 → P2-02 → P2-05 → P2-06 → P2-07 → | D22 | 本期范围 | 一期 = P0–P4 + P6A/B + P7-O(含 D6–D9);其余放二期 | 已定(按推荐;D6–D9 由用户确认纳入一期) | 全部 | | D24 | P2 转发头 | **P2 不在旧路径留转发头,也不登记 exception**。原因:映射表里「旧路径 → 最终路径」的精确行既描述已搬走的真文件,又会命中留在旧路径的转发头,`validate_map --strict` 与 `migrate_branch --check` 都报 destination collision,P3 M1 也会把两者搬到同一处;而 include 改写本树由 `normalize_includes` 按映射表完成,遗留分支由 `migrate_branch --apply-map` 完成,转发头没有独立价值。§7.2「P2」行的「转发头已登记」改为「旧路径不留文件,`validate_map --strict` 通过」;R13 的 exceptions 段仍保留给其它类型的例外 | **已定(2026-09-27,P2-02 执行时登记)** | P2 全部 PR、P3-02 | | D23 | P0 授权增删的编译单元与 G0 的对照规则 | 原始 G0 固定在 `3ddb7d43`(P0-07 采集,存 `baseline/g0/original/`)。P0 之后的目标快照与原始 G0 逐元组比较时只允许两类差异:移除的元组其 source 必须是 `src_layout_map.tsv` 的 `delete` 行(P0-08)或其生成对象;新增的元组必须是 P2-01 新增的原语文件(`src/utils/abandonable_call.{hpp,cpp}`、`abort_signal.hpp`、`joining_thread.hpp`、`lifetime_token.hpp`、`scope_exit.hpp`,File API 会把显式登记的头文件也列进 target 源清单)或 `acecode_unit_tests` 下新增的 `tests/` 源文件;target 集合不得增减。判定工具 `scripts/refactor/compare_snapshots.py`。P0 验收完成时的快照另存为 `baseline/g0/post-p0/`,P1 起的逐元组比较以它为对照,原始 G0 只用于追溯 | **已定(2026-09-27,P0 验收时登记)** | P0-07、P0-08、P1、P3 | +| D25 | state_file 专用状态的归属 | 五组业务逻辑全部迁回各自模块,底层只保留通用文件读写、锁和原子更新;不按是否存在向上 include 缩减任务,不延期到 P4 / 二期 | **已定(用户 2026-09-28 确认)** | P2-05 | 「按推荐执行」的决策可以在对应任务开工前推翻;推翻后需同步修改本表和受影响任务的描述。 diff --git a/openspec/changes/refactor20260927-restructure-src-layers/layout-map.md b/openspec/changes/refactor20260927-restructure-src-layers/layout-map.md index e2acc834e..84fcd03a6 100644 --- a/openspec/changes/refactor20260927-restructure-src-layers/layout-map.md +++ b/openspec/changes/refactor20260927-restructure-src-layers/layout-map.md @@ -17,7 +17,7 @@ | `src/agent_loop.{hpp,cpp}` | `src/engine/agent/agent_loop.{hpp,cpp}` | P2-08 先 R100 移到 `src/agent/`;P3 挂到 engine/ | 之后由 split-agent-loop 拆分 | | `src/agent_loop_doom_guard.{hpp,cpp}` | `src/engine/agent/guards/doom_guard.{hpp,cpp}` | P2-08 | | | `src/agent_loop_shell_guard.hpp` | `src/domain/permissions/shell_write_guard.hpp` | P2-05 | 与 `sandbox/command_classifier.cpp:919` 分词器的合并放二期 | -| `src/permissions.hpp` | `src/domain/permissions/permissions.hpp` + `src/base/config/vocab/permission_mode.hpp` | P2-05 | 28-54 行的枚举与 304-350 行的模式名规范化拆到 vocab;`config.cpp:3`、`settings_mutations.cpp:2`、`exec_decision.hpp:12` 改用 vocab;tests 中 38 处裸 include 改为 `permissions/permissions.hpp` | +| `src/permissions.hpp` | `src/domain/permissions/permissions.hpp` + `src/base/config/vocab/permission_mode.hpp` | P2-05 | 28-54 行的枚举与 304-350 行的模式名规范化拆到 vocab(自由函数 `permission_mode_name` / `parse_permission_mode_name` / `canonical_permission_mode_name`,`PermissionManager` 的三个静态入口改为转发);`config.cpp:3`、`settings_mutations.cpp:2`、`exec_decision.hpp:12` 改用 vocab;tests 中 38 处裸 include 改为 `permissions/permissions.hpp` | | `src/tui_state.hpp` | `src/apps/tui/tui_state.hpp` | P2-08 | tests 中 7 处裸 include 同步改 | | `src/main.cpp` | `src/apps/cli/main.cpp` | P2-08 先 R100 移到 `src/cli/main.cpp`;P3 挂到 apps/ | 之后由 split-tui-main 拆分 | | `src/version.hpp.in` | `cmake/version.hpp.in` | P2-08 | 生成名 `generated/version.hpp` 不变;`upgrade/version` 同时改名为 `utils/semver`,消除同名遮蔽 | @@ -28,14 +28,14 @@ |---|---|---|---| | `src/utils/` | `src/base/utils/` | P3 | 例外见下面几行 | | `src/utils/{file_operations,tool_errors,tool_args_parser}.hpp` | `src/adapters/tool/` | P2-05 | 切断 utils→tool(`file_operations.hpp:4-5`、`tool_errors.hpp:3`) | -| `src/utils/text_file_buffer.{hpp,cpp}` | `base/utils/text_file_buffer.*`(只留纯编解码)+ `adapters/tool/safe_text_write.*` | P2-05 | `safe_write_text_file`、MCP 配置拦截(cpp:847-852)、带工具名的文案(631/684/707/779)上移;`web/routes/routes_files.cpp:325` 只改调用点(D21) | +| `src/utils/text_file_buffer.{hpp,cpp}` | `base/utils/text_file_buffer.*`(只留纯编解码)+ `adapters/tool/{safe_text_write,text_file_errors}.*` | P2-05 | `safe_write_text_file`、MCP 配置拦截与带工具名的四处报错文案上移;底层返回结构化编码错误,工具层统一补充原有报错提示并使用当前工具名映射。Web 路由只调整 include 与调用点(D21),保留原有报错行为;通用原始字节 IO 供安全写复用 | | `src/utils/token_tracker.{hpp,cpp}` | `src/domain/session/` | P2-05 | | -| `src/utils/{models_dev_catalog.*,model_capabilities.cpp}` | `src/base/config/` + `src/adapters/provider/models_dev_catalog_cache.*` | P2-05 | cpp 212-277、343 行依赖 registry 的部分上移,断开 config→provider→utils→provider→config 的环 | +| `src/utils/{models_dev_catalog.*,model_capabilities.cpp}` | `src/base/config/` + `src/adapters/provider/models_dev_catalog_cache.*` | P2-05 | cpp 212-277、343 行依赖 registry 的部分上移,断开 config→provider→utils→provider→config 的环。执行中发现头里声明的 `provider_catalog_snapshot` 从未定义也无人调用,随拆分删除 | | `src/utils/path_validator.hpp` | `src/domain/permissions/path_validator.hpp` | P2-05 | | | `src/utils/{clipboard,open_url,power_inhibitor}.*` | `src/base/platform/` | P2-03 | | | `src/utils/{terminal_capability.*,terminal_theme_detect*,terminal_title.*,terminal_input.hpp}` | `src/base/platform/terminal/` | P2-03 | `terminal_title` 的 `sanitize_title` 在 P2-07 拆到 `domain/session/session_title_text` | | `src/utils/{drag_scroll,text_input_ops}.*` | `src/apps/tui/` | P2-08 | 登记进 `ACECODE_TUI_TESTABLE_SUBSETS` | -| `src/utils/state_file.*` | `src/base/utils/`(只留通用部分) | P2-05 | 5 组专用函数搬回各自使用方,切断 `state_file.hpp:3 → config/saved_models` | +| `src/utils/state_file.*` | `src/base/utils/`(只留通用部分) | P2-05 | 五组专用函数全部归位(用户 2026-09-28 确认):模型探测缓存 → `provider/model_probe_cache`;搜索地区缓存 → `tool/web_search/region_cache`;两个桌面 workspace hash → `desktop/workspace_state`;TUI 斜杠命令用量 → `tui/slash_command_usage`。底层只提供通用读写、锁与同步原子更新;沿用现有 JSON 字段、容错和写失败语义,不延期 | | (新增)`joining_thread`、`lifetime_token`、`scope_exit`、`abandonable_call`、`abort_signal` | `src/base/utils/` | P2-01(属 adopt-ownership-conventions) | 必须在 P3 之前合入 | | `src/tool/{diff_utils,word_diff,diff_view_truncate}.*` | `src/base/utils/` | P2-02 | | | `src/skills/frontmatter.*` | `src/base/utils/` | P2-06 | memory→skills 边随之消失 | diff --git a/openspec/changes/refactor20260927-restructure-src-layers/tasks.md b/openspec/changes/refactor20260927-restructure-src-layers/tasks.md index 32a1c27a2..94a6c7ad8 100644 --- a/openspec/changes/refactor20260927-restructure-src-layers/tasks.md +++ b/openspec/changes/refactor20260927-restructure-src-layers/tasks.md @@ -162,7 +162,7 @@ - 构建通过,lint 违规数下降; - Windows 上 ConPTY 与 winpty 控制台都能打开(Web 控制台停靠区冒烟); - desktop 构建通过,且不链接 acecode_testable。 -- [ ] 3.3 【P2-04】【子】【并】跨进程协议与工作区。〔认领: Claude-p2-04 2026-09-27〕〔接手: Codex-root 2026-09-28,用户确认原 Claude 任务及子任务已停止〕 +- [x] 3.3 【P2-04】【子】【并】跨进程协议与工作区。〔认领: Claude-p2-04 2026-09-27〕〔接手: Codex-root 2026-09-28,用户确认原 Claude 任务及子任务已停止〕〔验收: Codex-root 2026-09-28,PR #80 已合入;四平台、完整本机与 Desktop 复核见 verification/P2-04-ipc-and-workspace.md §10,Windows 时序失败按记录保留〕 - runtime_files、guid、open_request、daemon_protocol、agent_browser_runtime → `src/ipc/`; - workspace_registry、`web/handlers/files_handler` → `src/workspace/`; - `config.cpp:485-501` 的 `get_*_dir` → `utils/paths`。 diff --git a/openspec/changes/refactor20260927-restructure-src-layers/verification/P2-05-config-and-utils.md b/openspec/changes/refactor20260927-restructure-src-layers/verification/P2-05-config-and-utils.md new file mode 100644 index 000000000..ed9c13a60 --- /dev/null +++ b/openspec/changes/refactor20260927-restructure-src-layers/verification/P2-05-config-and-utils.md @@ -0,0 +1,115 @@ +# P2-05 config 与 utils 拆分验证记录 + +2026-09-28,分支 `refactor20260927/P2-05`,工作区 `N:/Users/shao/acecode-p2-05`。 +用户确认原 Claude 任务及子任务已停止后,由 Codex 接手。主线 `0c0e37cb` 已通过 `f2ef7eaf` 合入;接手前的源码修改保留并继续审查。 + +## 范围与实现 + +- 权限模式枚举和模式名转换移到 `config/vocab/permission_mode.hpp`;配置不再依赖完整的 `PermissionManager`。真正使用该类的 `sandbox/exec_permission.hpp` 显式包含它。 +- models.dev 的纯构造和格式化移到 config,全局 registry 缓存移到 `provider/models_dev_catalog_cache`。移除原来只有声明、无定义和调用的 `provider_catalog_snapshot`。 +- `state_file` 的五组业务逻辑全部迁回所属模块:模型探测缓存到 provider,搜索地区缓存到 tool,活跃 / 首页工作区到 desktop,斜杠命令用量到 tui。底层只管理状态文件、同步事务和锁。JSON 字段、容错、暂停写入和失败返回语义保持。 +- 安全写、MCP 配置拦截移到 `tool/safe_text_write`;四处带工具名的报错提示移到 `tool/text_file_errors`。编解码层返回结构化错误,工具层使用当前模型工具名映射生成原有文案;Web 只改 include 和调用点。 +- 已有纯搬迁覆盖 file_operations / tool_errors / tool_args_parser、token_tracker、path_validator / shell guard / interaction_mode、upgrade HTTP / semver、theme_id / pointer_appearance 等 P2-05 项目。旧路径不保留转发头。 +- 状态与安全写测试随职责拆分,保留原 suite / case 名;增加两项工具别名与禁止有损写入的回归。原并发测试扩展为五组模块混合写入与暂停写入的检查。 + +## 范围偏差的处理 + +接手时的未提交修改只抽出了模型探测缓存,理由是其余四组没有向上 include,并将其延期到 P4 / 二期。这未完整落实原任务的职责划分。按用户再次确认,五组全部在 P2-05 完成(D25)。 + +原修改还把工具名作为参数传给底层,让带工具名的文案继续留在 utils,并使 Web / resume 使用默认 `file_read`。本次将文案真正上移;Web 和模型工具沿用原来的动态名称,纯内部的恢复读取不展示错误文案。 + +## 本地构建与测试 + +使用独立的全新构建目录 `build-p2-codex`,只读复用 canonical master 的 vcpkg 依赖。先在本工作区完成 `pnpm install --frozen-lockfile` / `pnpm build`,再使用 VS 2022 x64 开发环境配置 CMake: + +```text +cmake -S . -B build-p2-codex -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DACECODE_BUILD_DESKTOP=ON -DVCPKG_MANIFEST_INSTALL=OFF -DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake -DVCPKG_INSTALLED_DIR=N:/Users/shao/acecode/build/vcpkg_installed -DVCPKG_TARGET_TRIPLET=x64-windows-static -DVCPKG_MANIFEST_FEATURES=tests +cmake --build build-p2-codex --target acecode acecode-desktop acecode_unit_tests computer_use_native_smoke computer_use_broker_smoke agent_browser_host_smoke agent_browser_pointer_demo acecode_upgrade_restart_smoke --parallel 10 +``` + +| 检查 | 结果 | +|---|---| +| CLI / Desktop / GoogleTest / 五个 EXCLUDE_FROM_ALL 冒烟目标构建 | 通过;冒烟程序仅构建,此处不声称已做交互验证 | +| 定向 GoogleTest | 151 项通过,0 失败;覆盖状态、搜索地区、编码 / 文案、权限与 MCP | +| 完整 GoogleTest 与 G0 用例清单对照 | 通过:列出 5117 / 执行 5116 / 跳过 9 / 失败 0,退出码 0;相对同机 P2-02 后的 master 只增加本次两项回归,原用例和 SKIP 用例集合均未增删 | +| 前端 `pnpm test` / `pnpm build` | 通过 | +| `python -m unittest discover -s scripts/refactor/tests -p 'test_*.py'` | 74 项通过 | +| `normalize_includes.py --scope src --check` / `--scope tests --check` | 通过 | +| `validate_map.py --strict` / `check_file_size.py --strict` / `check_ownership.py --strict` | 通过,未提高基线 | +| `check_layers.py --enforce-parent-includes` | 通过已启用的阻断项;全量过渡报告 88 项,config / utils 的反向依赖已消失;其余属于后续 P2 | +| `openspec validate refactor20260927-restructure-src-layers --strict` | 通过 | +| `git diff --check` | 通过 | +| 帮助站点生成 | 从 group3 / group4 源文件重新生成 49 篇,生成值与当前 sources.json 一致 | + +GoogleTest 的 HOME / USERPROFILE / APPDATA / LOCALAPPDATA / TEMP / TMP 都指向仓库外的独立目录,未使用用户的运行时配置。首轮目录在用户 TEMP 下嵌套过深,触发 Windows seed 复制路径过长以及窄屏路径文本截断;首轮记录保留为 `p2-05-gtest-long-path.json`。改用 `N:/ac-p205-0928/` 后,同一二进制的 10 项失败用例全部通过(2.797 秒),无代码修补或断言放宽。完整复跑使用单独的短路径 home/tmp,结果 5116 项执行、9 项跳过、0 失败,退出码 0;日志为 `p2-05-gtest-run.log`,清单为 `p2-05-gtest.json`。 + +## CMake 目标与编译归属 + +通过 File API 采集实际配置。与 `N:/Users/shao/acecode-p2-shared/master-windows-targets.json` 比较时,两侧都用 `translate_for_comparison(reverse=True)` 归回旧路径;仅将同一个 vcpkg 安装目录的绝对 / 源根相对前缀统一。没有忽略 target、定义、选项或源文件差异。 + +- 目标数 59 → 59,目标依赖没有变化;Desktop 不链接 `acecode_testable`。 +- 编译元组 3572 → 3622。既有元组删除数为 0;搬迁文件的归属、语言、定义和选项一致。 +- 增加 50 个元组,与逐项白名单完全一致:7 个拆出实现文件、8 个声明头、5 个拆分测试文件、6 个 testable 实现分别被 5 个原消费目标引用的 30 个对象元组。 +- 6 个通用可测试实现进入 `acecode_testable`;`desktop/workspace_state.cpp` 进入原有的 `acecode_desktop_support`;TUI 用量实现已登记 `ACECODE_TUI_TESTABLE_SUBSETS`。 +- 新增编译元组均沿用对应目标既有的编译属性;未出现新的编译选项或宏。 + +## 迁移检查与未完成验收 + +`migrate_branch.py --check` 已执行。它检查整个最终目录方案,当前返回 1:仍报告尚未进行的 P3 分组搬迁 / 文档迁移,以及后续 P2 的分层问题。其 include 规范化、映射碰撞、所有权、seed 完整性子检查均为 0 项。独立的过渡布局检查如上,不能把整体返回 1 记为已通过。 + +按设计中的文档安排,seed 中权限头路径与其版本 / 哈希将在 P3 M2b 一起更新;不单独修改 seed 的生成契约。P2-05 的九个遗留 ref 迁移提示登记在 branch-inventory.md。 + +源码提交 `8b182f184e57d139e703ceb794ffd3d1f2eaf558` 已触发 [四平台 CI](https://github.com/tmoonlight/acecode/actions/runs/36334375820),启用完整测试及 Deepin 构建。四平台构建现均通过,测试结果见下表;本任务仍不提前勾选验收。完整构建日志、定向 XML、完整清单、目标对照与各检查 JSON 保存在仓库外 `N:/Users/shao/AppData/Local/Temp/codex-refactor20260928/`。 + +## 四平台原始交付复核与集成版本 + +源码 `8b182f18` 的四平台结果: + +| 平台 | target / 编译元组 | 列出 / 执行 / SKIP / 失败 | +|---|---|---| +| Windows x64 | 59 / 3622 | 5117 / 5110 / 10 / 5 | +| Linux x64 | 50 / 3536 | 5037 / 5028 / 16 / 0 | +| macOS arm64 | 57 / 3681 | 5042 / 5035 / 14 / 11 | +| Deepin x64 | 13 / 1393 | 不启用测试 | + +目标和既有编译元组无删除。相对 post-p0,三个测试平台各增加 93 个元组(前序 P2-02 的 43 + 本任务的 50);Deepin 增加 40(前序 19 + 本任务 21)。新增实现、头、拆分测试和生成对象均逐项登记,编译属性沿用所属目标。 + +三个测试平台均只增加本任务的两项回归用例,没有删除用例或增加 SKIP。macOS 的 10 项原基线失败与 P1 / P2-02 已登记的 SSE 时序抖动仍保留。Windows 的 4 项原基线失败之外,多一项 `AgentLoopTurnSteering.InterruptStartsStructuredTurnBeforeOrdinaryQueue`:等待第二次 provider 调用的 250ms 断言失败。该用例正文未改动(本任务只改 permissions 头路径),在同一源码的本机全量及 Linux / macOS CI 中通过;本机原二进制追加 20 次运行全部通过(`p2-05-steering-repeat.log`)。保留该次 CI 失败,继续用集成版本核对,不删除断言或扩充跳过条件。 + +Deepin 的 provenance 中 `source_revision` 为空,原因与 P2-03 / P2-04 相同:容器 Git 报 dubious ownership。已从 build job 108662299627 的 checkout 日志核对完整的 `8b182f184e57d139e703ceb794ffd3d1f2eaf558`,与 source_ref 相同;没有用空字段证明来源。 + +集成提交 `4a3366e68b48cfd9dd1b9d2653925d9ef01f0e0c` 接入 P2-04 `aa6872f9`(含已合入 master 的 P2-03),六处冲突只合并两边的模块路径与 include 责任,五组状态逻辑和工具错误适配保持。补齐 P2-02 / P2-03 / P2-04 六个纯搬迁、纯 include 提交在 `.git-blame-ignore-revs` 的登记。 + +集成后 src / tests include、映射、分层阻断项、行数、所有权、OpenSpec strict、前端完整测试均通过。全新本机构建 `build/p2-05-integrated-4a3366e6` 已完成 CLI / Desktop / GoogleTest / 五个冒烟目标共 1058 步,退出码 0。File API 为 59 target / 3623 元组,相对原始交付只多前序 P2-03 的别名头,无目标变更或既有元组删除、编译属性变化;等价报告为仓库外 `p2-05-integrated-target-equivalence.json`。 + +集成版本完整本机测试已在独立短路径 `N:/a05i/` 完成:5117 项列出、5116 项执行、9 项跳过、0 失败、退出码 0。之前 CI 的 `AgentLoopTurnSteering.InterruptStartsStructuredTurnBeforeOrdinaryQueue` 在此次完整运行中通过。分层违规由 P2-04 集成版本的 152 项降至 87 项,既有阻断规则均通过。 + +### 桌面状态的真实重启验证 + +通过官方 `scripts/dev_environment.py desktop --yes` 两次启动本工作区的集成 Desktop,HOME / USERPROFILE / APPDATA / LOCALAPPDATA / TEMP / TMP / WebView2 profile 均位于仓库外 `N:/ac-p205-desktop/`。启动器在文档提交 `0b52a7dd` 下只更新构建版本元数据,C++ / CMake 与已验证的 `4a3366e6` 完全一致。 + +- 首次在隔离 daemon 注册 `workspace-one`,通过原生桥激活工作区、保存首页选择,在实际 UI 中预览 `p2-05-smoke.txt`。因从 API 注册工作区,刷新页面取得最新列表;正常跳过首次引导后点击,未绕过 UI 遮挡。 +- 正常退出后核对 `state.json` 同时保留 `last_active_workspace_hash` 与 `last_home_workspace_hash`。再次启动,原生桥读取到同一活跃工作区与首页选择,文件预览仍可用。 +- 两次 Desktop / daemon 均正常退出,用户原 Desktop 进程保持运行,全程无屏幕截图。仓库外证据为 `p2-05-integrated-desktop-smoke.json`。 + +[四平台 CI 36336950956](https://github.com/tmoonlight/acecode/actions/runs/36336950956) 的 Linux 已完成(5028 项执行、16 项跳过、0 失败);macOS 保留原有 10 项失败及已登记的 SSE 时序抖动,Deepin 配置与构建完成,三个平台的目标清单均已对照通过。Windows 构建与完整测试均已完成。 + +P2-04 已由 PR #80 合入 master `4db4d6e0a5b474b43dd4376a7b072c3169c4b3ae`,本分支随后同步该合并点,并在 tasks.md 勾选 P2-04。唯一冲突为 blame-ignore 里相同的前序登记;保留双方记录。相对已验证的 `4a3366e6`,src / tests / CMake / web / scripts / workflow 均无差异,同步只增加验收文档和任务完成标记。 + + +### 集成版本最终四平台验收 + +[CI 36336950956](https://github.com/tmoonlight/acecode/actions/runs/36336950956) 固定源码为 `4a3366e68b48cfd9dd1b9d2653925d9ef01f0e0c`,全平台构建完成: + +| 平台 | target / 编译元组 | 列出 / 执行 / SKIP / 失败 | +|---|---|---| +| Windows x64 | 59 / 3623 | 5117 / 5110 / 10 / 3 | +| Linux x64 | 50 / 3537 | 5037 / 5028 / 16 / 0 | +| macOS arm64 | 57 / 3682 | 5042 / 5035 / 14 / 11 | +| Deepin x64 | 13 / 1394 | 不配置测试 | + +四平台目标集合、既有编译元组和编译属性保持。相对 post-p0 的正常平台新增 94 个元组(P2-02 的 43 + P2-03 的 1 + P2-05 的 50),Deepin 新增 41 个(19 + 1 + 21),全部由明确的拆出物或其生成对象解释。三个测试平台仅新增两项工具错误适配回归,无用例删除或 SKIP 变化。 + +Windows 的三个失败均为既有基线项(HookRunner 的环境覆盖用例及两个 Python 脚本测试);此前的抢占 250ms 时序用例本次通过,MCP 基线失败本次也未出现。Linux 完整测试 0 失败。macOS 保留 10 项基线失败及 P1 / P2-02 已登记的 SSE 时序抖动,没有新增未解释的失败。不将构建采集工作流的 success 描述成断言全绿。 + +Deepin 的 provenance 空源码字段已由 build job 108669512748 的 checkout 日志独立核对为完整 `4a3366e6` SHA。最新 master 的集成只涉及验收文档和任务标记,已用 `git diff --quiet 4a3366e6 -- src tests CMakeLists.txt cmake web scripts .github` 确认代码与验证版本一致。 diff --git a/scripts/refactor/src_layout_map.tsv b/scripts/refactor/src_layout_map.tsv index 02823f52a..1e77c04f6 100644 --- a/scripts/refactor/src_layout_map.tsv +++ b/scripts/refactor/src_layout_map.tsv @@ -272,6 +272,7 @@ tests/utils/terminal_theme_detect_test.cpp tests/platform/terminal_theme_detect_ tests/utils/terminal_title_test.cpp tests/platform/terminal_title_test.cpp P2-03 move Mirror the corresponding relocated source tests/utils/text_input_ops_test.cpp tests/tui/text_input_ops_test.cpp P2-08 move Mirror the corresponding relocated source tests/utils/token_tracker_test.cpp tests/session/token_tracker_test.cpp P2-05 move Mirror the corresponding relocated source +tests/utils/state_json_web_search_test.cpp tests/tool/state_json_web_search_test.cpp P2-05 move Mirror the extracted search region cache tests/web/files_handler_test.cpp tests/workspace/files_handler_test.cpp P2-04 move Mirror the corresponding relocated source tests/web/message_payload_test.cpp tests/agent/event_payload/message_payload_test.cpp P2-08 move Mirror the corresponding relocated source tests/web/skill_command_expander_test.cpp tests/skills/skill_command_expander_test.cpp P2-06 move Mirror the corresponding relocated source @@ -296,6 +297,12 @@ tests/computer_use/helpers/native_control_checks.hpp tests/test_support/computer tests/computer_use/helpers/native_smoke_backend.hpp tests/test_support/computer_use/native_smoke_backend.hpp P1-01 move - tests/computer_use/helpers/ole_drag_fixture.hpp tests/test_support/computer_use/ole_drag_fixture.hpp P1-01 move - src/utils/text_file_buffer.cpp src/adapters/tool/safe_text_write.cpp P2-05 extract Extract policy-dependent writes; pure codec remains +src/utils/text_file_buffer.cpp src/adapters/tool/text_file_errors.cpp P2-05 extract Extract model-facing recovery guidance; codec returns structured errors +src/permissions.hpp src/base/config/vocab/permission_mode.hpp P2-05 extract Extract permission mode enum and name conversion +src/utils/state_file.cpp src/adapters/provider/model_probe_cache.cpp P2-05 extract Extract model probe schema and cache policy +src/utils/state_file.cpp src/adapters/tool/web_search/region_cache.cpp P2-05 extract Extract search region state +src/utils/state_file.cpp src/apps/desktop/workspace_state.cpp P2-05 extract Extract active and home workspace state +src/utils/state_file.cpp src/apps/tui/slash_command_usage.cpp P2-05 extract Extract TUI command usage state src/utils/models_dev_catalog.cpp src/adapters/provider/models_dev_catalog_cache.cpp P2-05 extract Extract registry-dependent cache src/config/config.cpp src/base/utils/paths.cpp P2-04 extract Extract get_acecode_dir/get_run_dir/get_logs_dir src/tool/tool_executor.hpp src/domain/llm/tool_result.hpp P2-02 extract Extract ToolResult and ToolSummary diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp index 9aa68a12d..71b749e93 100644 --- a/src/agent_loop.cpp +++ b/src/agent_loop.cpp @@ -1,6 +1,6 @@ #include "agent_loop.hpp" #include "agent_loop_doom_guard.hpp" -#include "agent_loop_shell_guard.hpp" +#include "permissions/shell_write_guard.hpp" #include "computer_use/runtime.hpp" #include "workspace/workspace_registry.hpp" #include "sandbox/exec_permission.hpp" @@ -11,8 +11,8 @@ #include "utils/encoding.hpp" #include "utils/logger.hpp" #include "utils/stream_processing.hpp" -#include "utils/text_file_buffer.hpp" -#include "utils/tool_errors.hpp" +#include "tool/text_file_errors.hpp" +#include "tool/tool_errors.hpp" #include "utils/uuid.hpp" #include "commands/compact.hpp" #include "commands/compact_prompt.hpp" @@ -44,7 +44,7 @@ #include "hooks/hook_config.hpp" #include "hooks/hook_manager.hpp" #include "hooks/hook_payload.hpp" -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "pa/pa_context_budget.hpp" #include "pa/pa_overflow_rescue.hpp" #include "pa/pa_quirks.hpp" @@ -5602,7 +5602,7 @@ bool AgentLoop::execute_tool_calls( for (const auto& [failed_path, when] : recent_safe_edit_failures_) { (void)when; if (!command_mentions_path(ctx_command, failed_path)) continue; - auto check = read_text_file_buffer(failed_path); + auto check = with_text_file_tool_errors(read_text_file_buffer(failed_path, false)); if (!check.success) { tool_result.success = false; if (!tool_result.output.empty() && tool_result.output.back() != '\n') { diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp index 585dcb9b5..f60f5d847 100644 --- a/src/agent_loop.hpp +++ b/src/agent_loop.hpp @@ -2,9 +2,9 @@ #include "llm/llm_provider.hpp" #include "tool/tool_executor.hpp" -#include "permissions.hpp" -#include "utils/path_validator.hpp" -#include "utils/token_tracker.hpp" +#include "permissions/permissions.hpp" +#include "permissions/path_validator.hpp" +#include "session/token_tracker.hpp" #include "session/session_manager.hpp" #include "session/event_dispatcher.hpp" #include "session/side_chat.hpp" diff --git a/src/commands/command_registry.hpp b/src/commands/command_registry.hpp index 098fb583f..92b23ad2a 100644 --- a/src/commands/command_registry.hpp +++ b/src/commands/command_registry.hpp @@ -4,7 +4,7 @@ #include "agent_loop.hpp" #include "llm/llm_provider.hpp" #include "config/config.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include "session/session_manager.hpp" #include "provider/session_model_binding.hpp" diff --git a/src/commands/configure.cpp b/src/commands/configure.cpp index 73ec6b262..4cf2a1f3a 100644 --- a/src/commands/configure.cpp +++ b/src/commands/configure.cpp @@ -7,9 +7,10 @@ #include "provider/auth/github_auth.hpp" #include "network/proxy_resolver.hpp" #include "provider/anthropic_provider.hpp" -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "utils/logger.hpp" -#include "utils/models_dev_catalog.hpp" +#include "config/models_dev_catalog.hpp" +#include "provider/models_dev_catalog_cache.hpp" #include "platform/terminal/terminal_input.hpp" #include diff --git a/src/commands/configure_catalog.cpp b/src/commands/configure_catalog.cpp index 73b605a0f..7804a94b1 100644 --- a/src/commands/configure_catalog.cpp +++ b/src/commands/configure_catalog.cpp @@ -1,7 +1,8 @@ #include "configure_catalog.hpp" +#include "provider/models_dev_catalog_cache.hpp" #include "configure_picker.hpp" -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "utils/encoding.hpp" #include "platform/terminal/terminal_input.hpp" diff --git a/src/commands/configure_catalog.hpp b/src/commands/configure_catalog.hpp index 72c4da62f..d54db14b7 100644 --- a/src/commands/configure_catalog.hpp +++ b/src/commands/configure_catalog.hpp @@ -1,7 +1,7 @@ #pragma once #include "config/config.hpp" -#include "utils/models_dev_catalog.hpp" +#include "config/models_dev_catalog.hpp" #include #include diff --git a/src/commands/goal_command.cpp b/src/commands/goal_command.cpp index 5fe2f5256..7d49c9092 100644 --- a/src/commands/goal_command.cpp +++ b/src/commands/goal_command.cpp @@ -1,7 +1,7 @@ #include "goal_command.hpp" #include "session/thread_goal_store.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include #include diff --git a/src/commands/models_command.cpp b/src/commands/models_command.cpp index 996672fe5..b5ca181bd 100644 --- a/src/commands/models_command.cpp +++ b/src/commands/models_command.cpp @@ -1,7 +1,7 @@ #include "models_command.hpp" #include "provider/models_dev_registry.hpp" -#include "utils/models_dev_catalog.hpp" +#include "config/models_dev_catalog.hpp" #include #include diff --git a/src/commands/resume_state_sync.hpp b/src/commands/resume_state_sync.hpp index 645f637c5..477c33d3d 100644 --- a/src/commands/resume_state_sync.hpp +++ b/src/commands/resume_state_sync.hpp @@ -4,7 +4,7 @@ #include "session/session_client.hpp" #include "session/session_storage.hpp" #include "tui_state.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include diff --git a/src/computer_use/macos_pointer.mm b/src/computer_use/macos_pointer.mm index 8d88630f9..6829062e4 100644 --- a/src/computer_use/macos_pointer.mm +++ b/src/computer_use/macos_pointer.mm @@ -1,5 +1,5 @@ #include "macos_native.hpp" -#include "pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" @interface ACEComputerPointerPanel : NSPanel @end diff --git a/src/computer_use/native_macos.mm b/src/computer_use/native_macos.mm index a5e73a8bd..547a6d877 100644 --- a/src/computer_use/native_macos.mm +++ b/src/computer_use/native_macos.mm @@ -1,6 +1,6 @@ #include "native_backend.hpp" #include "macos_native.hpp" -#include "pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" #include diff --git a/src/computer_use/native_windows.cpp b/src/computer_use/native_windows.cpp index 341408365..08ac0bfb1 100644 --- a/src/computer_use/native_windows.cpp +++ b/src/computer_use/native_windows.cpp @@ -7,7 +7,7 @@ #include "surface_policy.hpp" #include "pointer_overlay.hpp" #include "pointer_capture.hpp" -#include "pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" #include "utils/base64.hpp" #include diff --git a/src/computer_use/pointer_overlay.cpp b/src/computer_use/pointer_overlay.cpp index a3b7c1cb8..ea5567933 100644 --- a/src/computer_use/pointer_overlay.cpp +++ b/src/computer_use/pointer_overlay.cpp @@ -1,5 +1,5 @@ #include "pointer_overlay.hpp" -#include "pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" #ifdef _WIN32 #include diff --git a/src/computer_use/runtime.cpp b/src/computer_use/runtime.cpp index 3b73e78b8..d6b610032 100644 --- a/src/computer_use/runtime.cpp +++ b/src/computer_use/runtime.cpp @@ -2,7 +2,7 @@ #define _WIN32_WINNT 0x0A00 #endif #include "runtime.hpp" -#include "pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" #include "helper_process_posix.hpp" #include diff --git a/src/provider/builtin_model_catalog.cpp b/src/config/builtin_model_catalog.cpp similarity index 100% rename from src/provider/builtin_model_catalog.cpp rename to src/config/builtin_model_catalog.cpp diff --git a/src/provider/builtin_model_catalog.hpp b/src/config/builtin_model_catalog.hpp similarity index 95% rename from src/provider/builtin_model_catalog.hpp rename to src/config/builtin_model_catalog.hpp index 9474447ad..4b976b404 100644 --- a/src/provider/builtin_model_catalog.hpp +++ b/src/config/builtin_model_catalog.hpp @@ -1,6 +1,6 @@ #pragma once -#include "utils/models_dev_catalog.hpp" +#include "config/models_dev_catalog.hpp" #include diff --git a/src/config/config.cpp b/src/config/config.cpp index 435b987d8..b19f827e6 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -1,12 +1,12 @@ #include "config.hpp" -#include "permissions.hpp" +#include "config/vocab/permission_mode.hpp" #include "config_recovery.hpp" #include "config_mutation.hpp" #include "mcp_config.hpp" #include "model_provider_registry.hpp" #include "request_headers.hpp" -#include "themes/theme_id.hpp" +#include "config/vocab/theme_id.hpp" #include "utils/constants.hpp" #include "utils/atomic_file.hpp" #include "utils/logger.hpp" @@ -98,10 +98,10 @@ bool is_one_of(const std::string& value, std::initializer_list allo } std::string normalize_permission_mode_name(std::string value) { - // 别名(accept-edits / acceptEdits → auto)集中在 PermissionManager 维护; + // 别名(accept-edits / acceptEdits → auto)集中在 config/vocab/permission_mode.hpp 维护; // 老配置里的 accept-edits 读进来即归一成 auto,下次保存写 auto。 - if (auto parsed = PermissionManager::parse_mode_name(value)) { - return PermissionManager::mode_name(*parsed); + if (auto parsed = parse_permission_mode_name(value)) { + return permission_mode_name(*parsed); } if (!value.empty()) { LOG_WARN("[config] default_permission_mode='" + value + diff --git a/src/config/config.hpp b/src/config/config.hpp index a0c16153b..b2742e324 100644 --- a/src/config/config.hpp +++ b/src/config/config.hpp @@ -2,7 +2,7 @@ #include "desktop_close_behavior.hpp" #include "saved_models.hpp" -#include "computer_use/pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" #include "utils/constants.hpp" #include "utils/paths.hpp" diff --git a/src/utils/model_capabilities.cpp b/src/config/model_capabilities.cpp similarity index 100% rename from src/utils/model_capabilities.cpp rename to src/config/model_capabilities.cpp diff --git a/src/utils/models_dev_catalog.cpp b/src/config/models_dev_catalog.cpp similarity index 85% rename from src/utils/models_dev_catalog.cpp rename to src/config/models_dev_catalog.cpp index 7fc459ac8..efa4978c4 100644 --- a/src/utils/models_dev_catalog.cpp +++ b/src/config/models_dev_catalog.cpp @@ -1,11 +1,8 @@ #include "models_dev_catalog.hpp" -#include "provider/models_dev_registry.hpp" - #include #include #include -#include #include namespace acecode { @@ -209,39 +206,6 @@ ProviderEntry parse_provider(const std::string& key, const nlohmann::json& pj) { return p; } -struct CatalogCache { - std::shared_ptr source; - std::vector providers; - unsigned long long version = 0; -}; - -std::mutex& cache_mutex() { - static std::mutex m; - return m; -} - -CatalogCache& cache_storage() { - static CatalogCache c; - return c; -} - -const std::vector& refresh_cache_if_stale() { - std::lock_guard lk(cache_mutex()); - auto current = current_registry(); - auto& cache = cache_storage(); - if (cache.source.get() == current.get() && cache.source) { - return cache.providers; - } - cache.source = current; - cache.providers = current ? build_catalog(*current) : std::vector{}; - std::sort(cache.providers.begin(), cache.providers.end(), - [](const ProviderEntry& a, const ProviderEntry& b) { - return a.name < b.name; - }); - ++cache.version; - return cache.providers; -} - } // namespace std::vector build_catalog(const nlohmann::json& registry) { @@ -255,27 +219,6 @@ std::vector build_catalog(const nlohmann::json& registry) { return out; } -const std::vector& all_providers() { - return refresh_cache_if_stale(); -} - -std::vector openai_compat_providers() { - const auto& providers = all_providers(); - std::vector out; - out.reserve(providers.size()); - for (const auto& p : providers) { - if (p.openai_compatible) out.push_back(&p); - } - return out; -} - -const ProviderEntry* find_provider(const std::string& id) { - for (const auto& p : all_providers()) { - if (p.id == id) return &p; - } - return nullptr; -} - const ModelEntry* find_model(const ProviderEntry& provider, const std::string& model_id) { for (const auto& m : provider.models) { if (m.id == model_id) return &m; @@ -340,10 +283,4 @@ std::string format_capabilities(const ModelEntry& model) { return oss.str(); } -unsigned long long catalog_version() { - refresh_cache_if_stale(); - std::lock_guard lk(cache_mutex()); - return cache_storage().version; -} - } // namespace acecode diff --git a/src/utils/models_dev_catalog.hpp b/src/config/models_dev_catalog.hpp similarity index 69% rename from src/utils/models_dev_catalog.hpp rename to src/config/models_dev_catalog.hpp index 4829c8a9c..3117110aa 100644 --- a/src/utils/models_dev_catalog.hpp +++ b/src/config/models_dev_catalog.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -43,20 +42,10 @@ struct ProviderEntry { // blob other than the global registry. std::vector build_catalog(const nlohmann::json& registry); -// Stable cached value view for callers that retain providers across a registry -// refresh. The returned shared_ptr owns the exact parsed generation. -std::shared_ptr> provider_catalog_snapshot( - const std::shared_ptr& registry); +// 全局 registry 的缓存视图(all_providers / find_provider / catalog_version …)在 +// provider/models_dev_catalog_cache.hpp(P2-05):它依赖 provider 的 models_dev_registry, +// 这里只留纯函数。 -// Cached view over the global registry (current_registry()). The cache is keyed -// off the shared_ptr identity, so calling this after refresh_registry_*() picks -// up the new data automatically. -const std::vector& all_providers(); - -// Same as all_providers(), filtered to providers whose base_url is set. -std::vector openai_compat_providers(); - -const ProviderEntry* find_provider(const std::string& id); const ModelEntry* find_model(const ProviderEntry& provider, const std::string& model_id); // Display helpers (safe to call with empty entries). @@ -67,8 +56,4 @@ std::string format_cost(const std::optional& input, std::vector model_capability_tags(const ModelEntry& model); std::string format_capabilities(const ModelEntry& model); -// Version of the catalog cache. Increments whenever the cache is rebuilt — used -// by tests and by long-lived UI components that want to invalidate state. -unsigned long long catalog_version(); - } // namespace acecode diff --git a/src/config/saved_models.cpp b/src/config/saved_models.cpp index 7ba4eac9e..5c5a0f195 100644 --- a/src/config/saved_models.cpp +++ b/src/config/saved_models.cpp @@ -4,7 +4,7 @@ #include "model_provider_registry.hpp" #include "request_headers.hpp" -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "utils/sha256.hpp" #include diff --git a/src/config/settings_mutations.cpp b/src/config/settings_mutations.cpp index 3ee15a78b..57b403de8 100644 --- a/src/config/settings_mutations.cpp +++ b/src/config/settings_mutations.cpp @@ -1,5 +1,5 @@ #include "settings_mutations.hpp" -#include "permissions.hpp" +#include "config/vocab/permission_mode.hpp" #include "model_provider_registry.hpp" #include "saved_models_revision.hpp" @@ -137,12 +137,12 @@ SettingsMutationResult set_default_permission_mode( const SettingsMutationOptions& options) { return run_mutation( [mode](AppConfig& cfg, std::string& error) { - const auto parsed = PermissionManager::parse_mode_name(mode); + const auto parsed = parse_permission_mode_name(mode); if (!parsed) { error = "unsupported default permission mode"; return false; } - const std::string canonical = PermissionManager::mode_name(*parsed); + const std::string canonical = permission_mode_name(*parsed); if (cfg.default_permission_mode == canonical) return false; cfg.default_permission_mode = canonical; return true; diff --git a/src/config/vocab/permission_mode.hpp b/src/config/vocab/permission_mode.hpp new file mode 100644 index 000000000..547f9eca2 --- /dev/null +++ b/src/config/vocab/permission_mode.hpp @@ -0,0 +1,59 @@ +#pragma once + +// 权限模式的词汇表(P2-05 自 permissions.hpp 拆出):config 只需要解析 / 序列化模式名, +// 不该依赖整个 PermissionManager。config/vocab 只能 include 标准库(R6)。 + +#include +#include + +namespace acecode { + +// Permission mode for the current session +enum class PermissionMode { + Default, // Prompt for write/exec tools, auto-allow read-only + // Auto(openspec add-auto-mode-sandbox,复刻 Codex 的 Auto 预设,取代原 + // accept-edits):文件编辑自动放行;bash 走 src/sandbox 的决策表 —— + // 已知安全命令与沙盒内的未知命令直接跑,危险命令 / 越权申请 / 无沙盒时 + // 的未知命令才确认。老名字 accept-edits / acceptEdits 仍可解析。 + Auto, + Yolo, // Auto-allow all tool permissions without prompting (no sandbox) + Plan // Explore and write only the active plan file before approval +}; + +inline const char* permission_mode_name(PermissionMode m) { + switch (m) { + case PermissionMode::Default: return "default"; + case PermissionMode::Auto: return "auto"; + case PermissionMode::Yolo: return "yolo"; + case PermissionMode::Plan: return "plan"; + } + return "unknown"; +} + +// 模式名解析的唯一入口:别名(accept-edits / acceptEdits = auto)只在这里 +// 维护。返回 nullopt = 未知名字,调用方自己决定报错还是回退。 +inline std::optional parse_permission_mode_name(std::string name) { + // 去两端空白。 + while (!name.empty() && (name.back() == ' ' || name.back() == '\t' || + name.back() == '\r' || name.back() == '\n')) { + name.pop_back(); + } + std::size_t start = 0; + while (start < name.size() && (name[start] == ' ' || name[start] == '\t')) ++start; + name = name.substr(start); + if (name == "default") return PermissionMode::Default; + if (name == "auto" || name == "accept-edits" || name == "acceptEdits") { + return PermissionMode::Auto; + } + if (name == "yolo") return PermissionMode::Yolo; + if (name == "plan") return PermissionMode::Plan; + return std::nullopt; +} + +// 解析后再序列化:把别名归一成线上名("accept-edits" → "auto"),未知名原样返回。 +inline std::string canonical_permission_mode_name(const std::string& name) { + if (auto mode = parse_permission_mode_name(name)) return permission_mode_name(*mode); + return name; +} + +} // namespace acecode diff --git a/src/computer_use/pointer_appearance.hpp b/src/config/vocab/pointer_appearance.hpp similarity index 100% rename from src/computer_use/pointer_appearance.hpp rename to src/config/vocab/pointer_appearance.hpp diff --git a/src/themes/theme_id.hpp b/src/config/vocab/theme_id.hpp similarity index 100% rename from src/themes/theme_id.hpp rename to src/config/vocab/theme_id.hpp diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index ea78eb73a..76e645fff 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -54,7 +54,7 @@ #include "utils/cwd_hash.hpp" #include "utils/encoding.hpp" #include "utils/logger.hpp" -#include "utils/state_file.hpp" +#include "workspace_state.hpp" #include "utils/utf8_path.hpp" #include "utils/uuid.hpp" #include "version.hpp" diff --git a/src/desktop/workspace_state.cpp b/src/desktop/workspace_state.cpp new file mode 100644 index 000000000..705081425 --- /dev/null +++ b/src/desktop/workspace_state.cpp @@ -0,0 +1,37 @@ +#include "workspace_state.hpp" + +#include "utils/state_file.hpp" + +#include + +namespace acecode { + +std::string read_last_active_workspace_hash() { + auto j = read_state_json(); + if (!j.contains("last_active_workspace_hash")) return ""; + if (!j["last_active_workspace_hash"].is_string()) return ""; + return j["last_active_workspace_hash"].get(); +} + +void write_last_active_workspace_hash(const std::string& hash) { + (void)update_state_json([hash](nlohmann::json& state) { + state["last_active_workspace_hash"] = hash; + return true; + }); +} + +std::string read_last_home_workspace_hash() { + auto j = read_state_json(); + if (!j.contains("last_home_workspace_hash")) return ""; + if (!j["last_home_workspace_hash"].is_string()) return ""; + return j["last_home_workspace_hash"].get(); +} + +void write_last_home_workspace_hash(const std::string& hash) { + (void)update_state_json([hash](nlohmann::json& state) { + state["last_home_workspace_hash"] = hash; + return true; + }); +} + +} // namespace acecode diff --git a/src/desktop/workspace_state.hpp b/src/desktop/workspace_state.hpp new file mode 100644 index 000000000..eae5fc903 --- /dev/null +++ b/src/desktop/workspace_state.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace acecode { + +// desktop multi-workspace: 上次活跃 workspace 的 cwd_hash。 +// 读: 文件不存在 / 字段缺失 / 类型不符 → 空字符串。永不抛异常。 +// 写: 原子写,保留其他 key;失败只 LOG_WARN 不阻断。 +std::string read_last_active_workspace_hash(); +void write_last_active_workspace_hash(const std::string& hash); + +// desktop home page: 首页新建会话选择器上次选中的 workspace cwd_hash。 +// 空字符串是有效值,表示"不使用工作区"。 +std::string read_last_home_workspace_hash(); +void write_last_home_workspace_hash(const std::string& hash); + +} // namespace acecode diff --git a/src/headless/headless_options.cpp b/src/headless/headless_options.cpp index 310d4e45a..a080181a7 100644 --- a/src/headless/headless_options.cpp +++ b/src/headless/headless_options.cpp @@ -1,5 +1,5 @@ #include "headless_options.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include #include diff --git a/src/headless/headless_runner.cpp b/src/headless/headless_runner.cpp index 2dd661d22..59bac2889 100644 --- a/src/headless/headless_runner.cpp +++ b/src/headless/headless_runner.cpp @@ -5,7 +5,7 @@ #include "headless_capability_catalog.hpp" #include "headless_final_text.hpp" #include "headless_jsonl.hpp" -#include "headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "headless_name_selection.hpp" #include "config/config.hpp" @@ -15,7 +15,7 @@ #include "hooks/hook_manager.hpp" #include "lsp/lsp_service.hpp" #include "network/proxy_resolver.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/local_session_client.hpp" #include "session/session_registry.hpp" #include "session/thread_service.hpp" diff --git a/src/main.cpp b/src/main.cpp index 6938c8c0b..eacf8e65f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -101,7 +101,7 @@ #include "tool/web_search/web_search_tool.hpp" #include "utils/logger.hpp" #include "utils/paths.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "agent_loop.hpp" #include "tui/tui_ask_channel.hpp" #include "session/thread_service.hpp" @@ -121,7 +121,7 @@ #include "commands/compact.hpp" #include "commands/resume_state_sync.hpp" #include "platform/native_ui/notifications.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include "markdown/markdown_formatter.hpp" #include "session/session_manager.hpp" #include "session/session_auto_title.hpp" @@ -148,6 +148,7 @@ #include "platform/terminal/terminal_capability.hpp" #include "platform/open_url.hpp" #include "utils/state_file.hpp" +#include "tui/slash_command_usage.hpp" #include "tui/slash_dropdown.hpp" #include "tui/path_reference_dropdown.hpp" #include "tui/path_reference_input.hpp" diff --git a/src/upgrade/http.cpp b/src/network/http.cpp similarity index 100% rename from src/upgrade/http.cpp rename to src/network/http.cpp diff --git a/src/upgrade/http.hpp b/src/network/http.hpp similarity index 100% rename from src/upgrade/http.hpp rename to src/network/http.hpp diff --git a/src/headless/headless_mode.cpp b/src/permissions/interaction_mode.cpp similarity index 90% rename from src/headless/headless_mode.cpp rename to src/permissions/interaction_mode.cpp index a552fe558..231a3adc2 100644 --- a/src/headless/headless_mode.cpp +++ b/src/permissions/interaction_mode.cpp @@ -1,4 +1,4 @@ -#include "headless_mode.hpp" +#include "interaction_mode.hpp" #include diff --git a/src/headless/headless_mode.hpp b/src/permissions/interaction_mode.hpp similarity index 100% rename from src/headless/headless_mode.hpp rename to src/permissions/interaction_mode.hpp diff --git a/src/utils/path_validator.hpp b/src/permissions/path_validator.hpp similarity index 99% rename from src/utils/path_validator.hpp rename to src/permissions/path_validator.hpp index 39f3abdb3..1d7169e15 100644 --- a/src/utils/path_validator.hpp +++ b/src/permissions/path_validator.hpp @@ -1,6 +1,6 @@ #pragma once -#include "utf8_path.hpp" +#include "utils/utf8_path.hpp" #include #include diff --git a/src/permissions.hpp b/src/permissions/permissions.hpp similarity index 88% rename from src/permissions.hpp rename to src/permissions/permissions.hpp index 5d75624a9..a6bda7471 100644 --- a/src/permissions.hpp +++ b/src/permissions/permissions.hpp @@ -1,6 +1,9 @@ #pragma once +#include "config/vocab/permission_mode.hpp" + #include +#include #include #include #include @@ -24,18 +27,6 @@ struct PermissionRule { int priority = 0; // higher = evaluated first }; -// Permission mode for the current session -enum class PermissionMode { - Default, // Prompt for write/exec tools, auto-allow read-only - // Auto(openspec add-auto-mode-sandbox,复刻 Codex 的 Auto 预设,取代原 - // accept-edits):文件编辑自动放行;bash 走 src/sandbox 的决策表 —— - // 已知安全命令与沙盒内的未知命令直接跑,危险命令 / 越权申请 / 无沙盒时 - // 的未知命令才确认。老名字 accept-edits / acceptEdits 仍可解析。 - Auto, - Yolo, // Auto-allow all tool permissions without prompting (no sandbox) - Plan // Explore and write only the active plan file before approval -}; - // Result of a permission check enum class PermissionResult { Allow, // Execute the tool @@ -301,40 +292,14 @@ class PermissionManager { return next; } - static const char* mode_name(PermissionMode m) { - switch (m) { - case PermissionMode::Default: return "default"; - case PermissionMode::Auto: return "auto"; - case PermissionMode::Yolo: return "yolo"; - case PermissionMode::Plan: return "plan"; - } - return "unknown"; - } - - // 模式名解析的唯一入口:别名(accept-edits / acceptEdits = auto)只在这里 - // 维护。返回 nullopt = 未知名字,调用方自己决定报错还是回退。 + // 模式名与解析已下沉到 config/vocab/permission_mode.hpp(P2-05):config 只需要解析 / 序列化 + // 模式名,不该依赖整个 PermissionManager;这里保留静态入口让既有调用方不变。 + static const char* mode_name(PermissionMode m) { return permission_mode_name(m); } static std::optional parse_mode_name(std::string name) { - // 去两端空白。 - while (!name.empty() && (name.back() == ' ' || name.back() == '\t' || - name.back() == '\r' || name.back() == '\n')) { - name.pop_back(); - } - std::size_t start = 0; - while (start < name.size() && (name[start] == ' ' || name[start] == '\t')) ++start; - name = name.substr(start); - if (name == "default") return PermissionMode::Default; - if (name == "auto" || name == "accept-edits" || name == "acceptEdits") { - return PermissionMode::Auto; - } - if (name == "yolo") return PermissionMode::Yolo; - if (name == "plan") return PermissionMode::Plan; - return std::nullopt; + return parse_permission_mode_name(std::move(name)); } - - // 解析后再序列化:把别名归一成线上名("accept-edits" → "auto"),未知名原样返回。 static std::string canonical_mode_name(const std::string& name) { - if (auto mode = parse_mode_name(name)) return mode_name(*mode); - return name; + return canonical_permission_mode_name(name); } static const char* mode_description(PermissionMode m) { diff --git a/src/agent_loop_shell_guard.hpp b/src/permissions/shell_write_guard.hpp similarity index 99% rename from src/agent_loop_shell_guard.hpp rename to src/permissions/shell_write_guard.hpp index b3adc937d..aa11ea32d 100644 --- a/src/agent_loop_shell_guard.hpp +++ b/src/permissions/shell_write_guard.hpp @@ -11,7 +11,7 @@ #include #include -#include "utils/path_validator.hpp" +#include "permissions/path_validator.hpp" #include "utils/utf8_path.hpp" #include diff --git a/src/provider/model_context_resolver.cpp b/src/provider/model_context_resolver.cpp index a8de5786d..24d55c7d1 100644 --- a/src/provider/model_context_resolver.cpp +++ b/src/provider/model_context_resolver.cpp @@ -2,7 +2,7 @@ #include "network/proxy_resolver.hpp" #include "utils/logger.hpp" -#include "builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "provider/codex/codex_model_catalog.hpp" #include "model_context_metadata.hpp" #include "models_dev_registry.hpp" diff --git a/src/provider/model_probe_cache.cpp b/src/provider/model_probe_cache.cpp new file mode 100644 index 000000000..5f4590b29 --- /dev/null +++ b/src/provider/model_probe_cache.cpp @@ -0,0 +1,176 @@ +#include "model_probe_cache.hpp" + +#include "utils/state_file.hpp" + +#include + +#include +#include +#include + +namespace acecode { + +namespace { + +constexpr const char* kModelProbeCacheKey = "model_probe_cache"; +constexpr std::size_t kMaxModelProbeCacheEntries = 128; +constexpr std::size_t kMaxCachedProbeModels = 2000; +constexpr std::size_t kMaxCachedModelIdBytes = 512; + +bool valid_sha256_fingerprint(const std::string& fingerprint) { + if (fingerprint.size() != 64) return false; + for (unsigned char c : fingerprint) { + if (!std::isxdigit(c)) return false; + } + return true; +} + +std::optional parse_model_probe_cache_entry( + const nlohmann::json& value) { + try { + if (!value.is_object()) return std::nullopt; + auto version = value.find("version"); + if (version == value.end() || !version->is_number_integer() || + version->get() != 1) { + return std::nullopt; + } + auto models = value.find("models"); + if (models == value.end() || !models->is_array() || + models->size() > kMaxCachedProbeModels) { + return std::nullopt; + } + + ModelProbeCacheEntry entry; + std::set seen; + for (const auto& model : *models) { + if (!model.is_string()) return std::nullopt; + const std::string id = model.get(); + if (id.empty() || id.size() > kMaxCachedModelIdBytes) { + return std::nullopt; + } + if (seen.insert(id).second) entry.models.push_back(id); + } + + auto contexts = value.find("model_context_windows"); + if (contexts != value.end()) { + if (!contexts->is_object()) return std::nullopt; + for (auto it = contexts->begin(); it != contexts->end(); ++it) { + if (!seen.count(it.key()) || !it.value().is_number_integer()) { + continue; + } + const auto context = it.value().get(); + if (context > 0 && context <= (std::numeric_limits::max)()) { + entry.context_windows[it.key()] = static_cast(context); + } + } + } + + const auto reasonings = value.find("model_reasoning"); + for (const auto& id : entry.models) { + entry.reasoning[id] = std::nullopt; + if (reasonings == value.end() || !reasonings->is_object()) continue; + const auto declaration = reasonings->find(id); + if (declaration == reasonings->end() || !declaration->is_object()) continue; + std::string error; + auto parsed = parse_model_reasoning_options(*declaration, error); + if (parsed.has_value() && parsed->supported && + !parsed->supported_efforts.empty()) { + entry.reasoning[id] = std::move(parsed); + } + } + + auto probed_at = value.find("probed_at_ms"); + if (probed_at != value.end() && probed_at->is_number_integer()) { + entry.probed_at_ms = probed_at->get(); + } + return entry; + } catch (const std::exception&) { + return std::nullopt; + } +} + +nlohmann::json model_probe_cache_entry_to_json( + const ModelProbeCacheEntry& source) { + nlohmann::json value = { + {"version", 1}, + {"models", nlohmann::json::array()}, + {"model_context_windows", nlohmann::json::object()}, + {"model_reasoning", nlohmann::json::object()}, + {"probed_at_ms", source.probed_at_ms}, + }; + std::set seen; + for (const auto& id : source.models) { + if (id.empty() || id.size() > kMaxCachedModelIdBytes || + !seen.insert(id).second || + value["models"].size() >= kMaxCachedProbeModels) { + continue; + } + value["models"].push_back(id); + value["model_reasoning"][id] = nullptr; + const auto declaration = source.reasoning.find(id); + if (declaration != source.reasoning.end() && declaration->second.has_value()) { + const auto encoded = model_reasoning_options_to_json(*declaration->second); + std::string error; + const auto parsed = parse_model_reasoning_options(encoded, error); + if (parsed.has_value() && parsed->supported && + !parsed->supported_efforts.empty()) { + value["model_reasoning"][id] = encoded; + } + } + } + for (const auto& [id, context] : source.context_windows) { + if (context > 0 && seen.count(id)) { + value["model_context_windows"][id] = context; + } + } + return value; +} + +} // namespace + +std::optional read_model_probe_cache( + const std::string& connection_fingerprint) { + if (!valid_sha256_fingerprint(connection_fingerprint)) return std::nullopt; + const auto state = read_state_json(); + auto cache = state.find(kModelProbeCacheKey); + if (cache == state.end() || !cache->is_object()) return std::nullopt; + auto entry = cache->find(connection_fingerprint); + if (entry == cache->end()) return std::nullopt; + return parse_model_probe_cache_entry(*entry); +} + +bool write_model_probe_cache( + const std::string& connection_fingerprint, + const ModelProbeCacheEntry& entry) { + if (!valid_sha256_fingerprint(connection_fingerprint)) return false; + return update_state_json([connection_fingerprint, entry](nlohmann::json& state) { + if (!state.contains(kModelProbeCacheKey) || + !state[kModelProbeCacheKey].is_object()) { + state[kModelProbeCacheKey] = nlohmann::json::object(); + } + auto& cache = state[kModelProbeCacheKey]; + cache[connection_fingerprint] = model_probe_cache_entry_to_json(entry); + + while (cache.size() > kMaxModelProbeCacheEntries) { + auto oldest = cache.begin(); + std::int64_t oldest_time = (std::numeric_limits::max)(); + for (auto it = cache.begin(); it != cache.end(); ++it) { + std::int64_t timestamp = 0; + if (it.value().is_object()) { + auto field = it.value().find("probed_at_ms"); + if (field != it.value().end() && field->is_number_integer()) { + timestamp = field->get(); + } + } + if (timestamp < oldest_time) { + oldest = it; + oldest_time = timestamp; + } + } + cache.erase(oldest); + } + return true; + }); +} + +} // namespace acecode diff --git a/src/provider/model_probe_cache.hpp b/src/provider/model_probe_cache.hpp new file mode 100644 index 000000000..2ef76ba8c --- /dev/null +++ b/src/provider/model_probe_cache.hpp @@ -0,0 +1,39 @@ +#pragma once + +// provider 模型探测结果的 state.json 缓存(P2-05 自 utils/state_file 拆出):它的条目类型依赖 +// config/saved_models 的 ModelReasoningOptions,留在 utils 会让 utils 向上依赖 config;读写经 +// state_file 的通用入口(read_state_json / update_state_json),锁与原子写的顺序不变。 + +#include "config/saved_models.hpp" + +#include +#include +#include +#include +#include + +namespace acecode { + +// Successful Provider model probes are cached by an opaque SHA-256 connection +// fingerprint. The state file stores only the fingerprint and probe output; +// provider URLs, API keys, and request headers never reach this helper. +struct ModelProbeCacheEntry { + std::vector models; + std::map context_windows; + std::int64_t probed_at_ms = 0; + std::map> reasoning; +}; + +// Missing/malformed entries return nullopt. The fingerprint must be exactly a +// 64-character hexadecimal SHA-256 digest. +std::optional read_model_probe_cache( + const std::string& connection_fingerprint); + +// Atomically upserts one cache entry while preserving unrelated state and +// other connection fingerprints. Returns false for an invalid fingerprint or +// durable write failure. +bool write_model_probe_cache( + const std::string& connection_fingerprint, + const ModelProbeCacheEntry& entry); + +} // namespace acecode diff --git a/src/provider/models_dev_catalog_cache.cpp b/src/provider/models_dev_catalog_cache.cpp new file mode 100644 index 000000000..42c961ffa --- /dev/null +++ b/src/provider/models_dev_catalog_cache.cpp @@ -0,0 +1,75 @@ +#include "models_dev_catalog_cache.hpp" + +#include "models_dev_registry.hpp" + +#include +#include +#include + +namespace acecode { + +namespace { + +struct CatalogCache { + std::shared_ptr source; + std::vector providers; + unsigned long long version = 0; +}; + +std::mutex& cache_mutex() { + static std::mutex m; + return m; +} + +CatalogCache& cache_storage() { + static CatalogCache c; + return c; +} + +const std::vector& refresh_cache_if_stale() { + std::lock_guard lk(cache_mutex()); + auto current = current_registry(); + auto& cache = cache_storage(); + if (cache.source.get() == current.get() && cache.source) { + return cache.providers; + } + cache.source = current; + cache.providers = current ? build_catalog(*current) : std::vector{}; + std::sort(cache.providers.begin(), cache.providers.end(), + [](const ProviderEntry& a, const ProviderEntry& b) { + return a.name < b.name; + }); + ++cache.version; + return cache.providers; +} + +} // namespace + +const std::vector& all_providers() { + return refresh_cache_if_stale(); +} + +std::vector openai_compat_providers() { + const auto& providers = all_providers(); + std::vector out; + out.reserve(providers.size()); + for (const auto& p : providers) { + if (p.openai_compatible) out.push_back(&p); + } + return out; +} + +const ProviderEntry* find_provider(const std::string& id) { + for (const auto& p : all_providers()) { + if (p.id == id) return &p; + } + return nullptr; +} + +unsigned long long catalog_version() { + refresh_cache_if_stale(); + std::lock_guard lk(cache_mutex()); + return cache_storage().version; +} + +} // namespace acecode diff --git a/src/provider/models_dev_catalog_cache.hpp b/src/provider/models_dev_catalog_cache.hpp new file mode 100644 index 000000000..310bd4fd8 --- /dev/null +++ b/src/provider/models_dev_catalog_cache.hpp @@ -0,0 +1,28 @@ +#pragma once + +// models.dev 目录对全局 registry 的缓存视图(P2-05 自 utils/models_dev_catalog 拆出): +// 缓存按 current_registry() 的 shared_ptr 身份失效,依赖 provider 的 models_dev_registry, +// 所以留在 adapters 层;纯目录构造与格式化在 config/models_dev_catalog.hpp。 + +#include "config/models_dev_catalog.hpp" + +#include +#include + +namespace acecode { + +// Cached view over the global registry (current_registry()). The cache is keyed +// off the shared_ptr identity, so calling this after refresh_registry_*() picks +// up the new data automatically. +const std::vector& all_providers(); + +// Same as all_providers(), filtered to providers whose base_url is set. +std::vector openai_compat_providers(); + +const ProviderEntry* find_provider(const std::string& id); + +// Version of the catalog cache. Increments whenever the cache is rebuilt — used +// by tests and by long-lived UI components that want to invalidate state. +unsigned long long catalog_version(); + +} // namespace acecode diff --git a/src/sandbox/command_classifier.cpp b/src/sandbox/command_classifier.cpp index 979d0da5c..14165bf17 100644 --- a/src/sandbox/command_classifier.cpp +++ b/src/sandbox/command_classifier.cpp @@ -1,6 +1,6 @@ #include "command_classifier.hpp" -#include "utils/path_validator.hpp" +#include "permissions/path_validator.hpp" #include #include diff --git a/src/sandbox/exec_decision.hpp b/src/sandbox/exec_decision.hpp index d57c8a215..7bb9241ed 100644 --- a/src/sandbox/exec_decision.hpp +++ b/src/sandbox/exec_decision.hpp @@ -9,7 +9,7 @@ #include "command_classifier.hpp" #include "exec_rules.hpp" -#include "permissions.hpp" +#include "config/vocab/permission_mode.hpp" #include "sandbox_types.hpp" #include diff --git a/src/sandbox/exec_permission.hpp b/src/sandbox/exec_permission.hpp index 05d7b75b6..5bafb82c2 100644 --- a/src/sandbox/exec_permission.hpp +++ b/src/sandbox/exec_permission.hpp @@ -1,6 +1,7 @@ #pragma once #include "exec_decision.hpp" +#include "permissions/permissions.hpp" #include "sandbox_policy.hpp" #include diff --git a/src/sandbox/sandbox_runtime.cpp b/src/sandbox/sandbox_runtime.cpp index 091bbfbb0..36426147b 100644 --- a/src/sandbox/sandbox_runtime.cpp +++ b/src/sandbox/sandbox_runtime.cpp @@ -1,5 +1,7 @@ #include "sandbox_runtime.hpp" +#include "config/vocab/permission_mode.hpp" + #include "utils/logger.hpp" #include "utils/utf8_path.hpp" @@ -247,7 +249,7 @@ std::string SandboxRuntime::status_text(PermissionMode mode, const std::string& else if (!cfg.enabled) oss << " (config.sandbox.enabled=false)"; else if (!p.available && !p.reason.empty()) oss << " (" << p.reason << ")"; oss << "\n"; - oss << "Permission mode : " << PermissionManager::mode_name(mode) << "\n"; + oss << "Permission mode : " << permission_mode_name(mode) << "\n"; const SandboxMode sm = mode_sandbox(mode, usable); oss << "Auto-run policy : " << sandbox_mode_name(sm) << "\n"; const SandboxPolicy policy = policy_for(sm == SandboxMode::FullAccess ? SandboxMode::ReadOnly : sm, write_root); diff --git a/src/session/permission_prompter.hpp b/src/session/permission_prompter.hpp index 8fa0b6a92..a30d99415 100644 --- a/src/session/permission_prompter.hpp +++ b/src/session/permission_prompter.hpp @@ -15,7 +15,7 @@ // (兼容现有 main.cpp 路径);daemon 模式由 SessionRegistry 在创建 AgentLoop // 后调 set_permission_prompter() 注入 AsyncPrompter。 -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "event_dispatcher.hpp" #include "session_client.hpp" diff --git a/src/session/session_manager.cpp b/src/session/session_manager.cpp index f7dcf52e0..88f69918d 100644 --- a/src/session/session_manager.cpp +++ b/src/session/session_manager.cpp @@ -1,5 +1,5 @@ #include "session_manager.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session_serializer.hpp" #include "fork_attachment_context.hpp" #include "session_rewind.hpp" diff --git a/src/session/session_registry.hpp b/src/session/session_registry.hpp index aad7f9a1f..ae2a953fb 100644 --- a/src/session/session_registry.hpp +++ b/src/session/session_registry.hpp @@ -17,7 +17,7 @@ #include "agent_loop.hpp" #include "config/config.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "provider/session_model_binding.hpp" #include "config/saved_models.hpp" diff --git a/src/session/session_storage.cpp b/src/session/session_storage.cpp index 2f820766f..0086a8ed4 100644 --- a/src/session/session_storage.cpp +++ b/src/session/session_storage.cpp @@ -1,5 +1,5 @@ #include "session_storage.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session_serializer.hpp" #include "session_title_generator.hpp" #include "task_suggestion_store.hpp" diff --git a/src/utils/token_tracker.cpp b/src/session/token_tracker.cpp similarity index 100% rename from src/utils/token_tracker.cpp rename to src/session/token_tracker.cpp diff --git a/src/utils/token_tracker.hpp b/src/session/token_tracker.hpp similarity index 100% rename from src/utils/token_tracker.hpp rename to src/session/token_tracker.hpp diff --git a/src/themes/theme_store.cpp b/src/themes/theme_store.cpp index 9b5b55331..91b8dd76c 100644 --- a/src/themes/theme_store.cpp +++ b/src/themes/theme_store.cpp @@ -2,7 +2,7 @@ #include "utils/base64.hpp" #include "theme_package.hpp" -#include "upgrade/version.hpp" +#include "utils/semver.hpp" #include "image/image_processor.hpp" #include "utils/atomic_file.hpp" #include "utils/sha256.hpp" diff --git a/src/themes/theme_store.hpp b/src/themes/theme_store.hpp index 54ccb71ba..4bf906d9d 100644 --- a/src/themes/theme_store.hpp +++ b/src/themes/theme_store.hpp @@ -1,7 +1,7 @@ #pragma once -#include "upgrade/http.hpp" -#include "theme_id.hpp" +#include "network/http.hpp" +#include "config/vocab/theme_id.hpp" #include #include diff --git a/src/tool/apply_patch_tool.cpp b/src/tool/apply_patch_tool.cpp index 90a5eb415..393c9fc1f 100644 --- a/src/tool/apply_patch_tool.cpp +++ b/src/tool/apply_patch_tool.cpp @@ -6,10 +6,11 @@ #include "lsp/lsp_diagnostics.hpp" #include "mtime_tracker.hpp" #include "llm/tool_icons.hpp" -#include "utils/file_operations.hpp" +#include "tool/file_operations.hpp" #include "utils/logger.hpp" -#include "utils/text_file_buffer.hpp" -#include "utils/tool_errors.hpp" +#include "tool/text_file_errors.hpp" +#include "tool/safe_text_write.hpp" +#include "tool/tool_errors.hpp" #include "utils/utf8_path.hpp" #include @@ -180,7 +181,7 @@ ToolResult execute_apply_patch(const std::string& arguments_json, const ToolCont if (error) *error = size_check.output; return std::nullopt; } - auto read = read_text_file_buffer(path); + auto read = with_text_file_tool_errors(read_text_file_buffer(path, false)); if (!read.success) { if (for_delete) { entry.decodable = false; diff --git a/src/tool/ask_user_question_tool.cpp b/src/tool/ask_user_question_tool.cpp index fb63fa8c1..06d347f75 100644 --- a/src/tool/ask_user_question_tool.cpp +++ b/src/tool/ask_user_question_tool.cpp @@ -2,7 +2,7 @@ #include "ask_user_question_types.hpp" -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "session/session_manager.hpp" #include "utils/logger.hpp" diff --git a/src/tool/bash_tool.cpp b/src/tool/bash_tool.cpp index aed930846..883500248 100644 --- a/src/tool/bash_tool.cpp +++ b/src/tool/bash_tool.cpp @@ -5,7 +5,7 @@ #include "utils/logger.hpp" #include "utils/encoding.hpp" #include "utils/stream_processing.hpp" -#include "utils/tool_errors.hpp" +#include "tool/tool_errors.hpp" #include "utils/utf8_path.hpp" #include "sandbox/exec_permission.hpp" #include "sandbox/sandbox_backend.hpp" diff --git a/src/tool/file_edit_tool.cpp b/src/tool/file_edit_tool.cpp index 3636ed484..c3b6d73a0 100644 --- a/src/tool/file_edit_tool.cpp +++ b/src/tool/file_edit_tool.cpp @@ -5,10 +5,11 @@ #include "llm/tool_protocol_names.hpp" #include "lsp/lsp_diagnostics.hpp" #include "utils/logger.hpp" -#include "utils/tool_args_parser.hpp" -#include "utils/tool_errors.hpp" -#include "utils/file_operations.hpp" -#include "utils/text_file_buffer.hpp" +#include "tool/tool_args_parser.hpp" +#include "tool/tool_errors.hpp" +#include "tool/file_operations.hpp" +#include "tool/text_file_errors.hpp" +#include "tool/safe_text_write.hpp" #include "utils/utf8_path.hpp" #include #include @@ -448,7 +449,7 @@ static ToolResult execute_file_edit(const std::string& arguments_json, const Too TextFileBuffer buffer; if (file_exists) { - auto read_result = read_text_file_buffer(file_path); + auto read_result = with_text_file_tool_errors(read_text_file_buffer(file_path, false)); if (!read_result.success) { auto metadata = MtimeTracker::instance().read_metadata(file_path); if (metadata && metadata->lossy) { diff --git a/src/utils/file_operations.hpp b/src/tool/file_operations.hpp similarity index 99% rename from src/utils/file_operations.hpp rename to src/tool/file_operations.hpp index a557362f2..dd20d3923 100644 --- a/src/utils/file_operations.hpp +++ b/src/tool/file_operations.hpp @@ -3,7 +3,7 @@ #include "tool_errors.hpp" #include "tool/tool_executor.hpp" #include "tool/mtime_tracker.hpp" -#include "utf8_path.hpp" +#include "utils/utf8_path.hpp" #include #include #include diff --git a/src/tool/file_read_tool.cpp b/src/tool/file_read_tool.cpp index d44e60c60..d70791221 100644 --- a/src/tool/file_read_tool.cpp +++ b/src/tool/file_read_tool.cpp @@ -4,10 +4,10 @@ #include "llm/tool_icons.hpp" #include "llm/tool_protocol_names.hpp" #include "utils/encoding.hpp" -#include "utils/file_operations.hpp" +#include "tool/file_operations.hpp" #include "utils/logger.hpp" -#include "utils/text_file_buffer.hpp" -#include "utils/tool_errors.hpp" +#include "tool/text_file_errors.hpp" +#include "tool/tool_errors.hpp" #include "utils/utf8_path.hpp" #include @@ -683,7 +683,7 @@ TextBufferResult read_file_probe( if (static_cast(probe.size()) < file_size) { trim_trailing_partial_utf8(probe); } - return decode_text_file_bytes(probe, path, true); + return with_text_file_tool_errors(decode_text_file_bytes(probe, path, true)); } std::string large_legacy_materialization_error( @@ -760,7 +760,7 @@ ToolResult execute_file_read( TextBufferResult probe_result = file_size <= FileOperations::MAX_EDIT_FILE_SIZE - ? read_text_file_buffer(request.file_path, true) + ? with_text_file_tool_errors(read_text_file_buffer(request.file_path, true)) : read_file_probe(request.file_path, file_size); if (!probe_result.success) { return ToolResult{probe_result.error, false}; @@ -892,7 +892,7 @@ ToolResult execute_file_read( }; } full_result = - read_text_file_buffer(request.file_path, true); + with_text_file_tool_errors(read_text_file_buffer(request.file_path, true)); if (!full_result.success) { return ToolResult{full_result.error, false}; } @@ -911,7 +911,7 @@ ToolResult execute_file_read( false }; } - full_result = read_text_file_buffer(request.file_path, true); + full_result = with_text_file_tool_errors(read_text_file_buffer(request.file_path, true)); if (!full_result.success) { return ToolResult{full_result.error, false}; } diff --git a/src/tool/file_write_tool.cpp b/src/tool/file_write_tool.cpp index 8057fab36..8c52e39a0 100644 --- a/src/tool/file_write_tool.cpp +++ b/src/tool/file_write_tool.cpp @@ -4,9 +4,10 @@ #include "llm/tool_icons.hpp" #include "lsp/lsp_diagnostics.hpp" #include "utils/logger.hpp" -#include "utils/tool_args_parser.hpp" -#include "utils/tool_errors.hpp" -#include "utils/text_file_buffer.hpp" +#include "tool/tool_args_parser.hpp" +#include "tool/tool_errors.hpp" +#include "tool/text_file_errors.hpp" +#include "tool/safe_text_write.hpp" #include "utils/utf8_path.hpp" #include #include @@ -43,7 +44,7 @@ static ToolResult execute_file_write(const std::string& arguments_json, const To std::string old_content; TextFileMetadata write_metadata = default_new_file_text_metadata(); if (file_exists) { - auto read_result = read_text_file_buffer(file_path); + auto read_result = with_text_file_tool_errors(read_text_file_buffer(file_path, false)); if (!read_result.success) { auto metadata = MtimeTracker::instance().read_metadata(file_path); if (metadata && metadata->lossy) { diff --git a/src/tool/image_generate/image_generate_tool.cpp b/src/tool/image_generate/image_generate_tool.cpp index 8e2edd0f2..8b0240dc6 100644 --- a/src/tool/image_generate/image_generate_tool.cpp +++ b/src/tool/image_generate/image_generate_tool.cpp @@ -3,13 +3,13 @@ #include "image_generation_client.hpp" #include "image_generation_policy.hpp" #include "llm/tool_icons.hpp" -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "session/output_attachments.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "utils/encoding.hpp" #include "utils/logger.hpp" -#include "utils/tool_errors.hpp" +#include "tool/tool_errors.hpp" #include "utils/utf8_path.hpp" #include diff --git a/src/tool/safe_text_write.cpp b/src/tool/safe_text_write.cpp new file mode 100644 index 000000000..c1a91f6f8 --- /dev/null +++ b/src/tool/safe_text_write.cpp @@ -0,0 +1,102 @@ +#include "safe_text_write.hpp" + +#include "config/mcp_config.hpp" +#include "text_file_errors.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include + +namespace acecode { + +TextSafeWriteResult safe_write_text_file( + const std::string& path, + const std::string& lf_text, + const TextFileMetadata& metadata, + const std::function& before_write) { + try { + if (const auto servers = validate_mcp_file_edit(path, lf_text)) { + if (before_write) before_write(path); + // Configuration JSON is persisted as UTF-8, independently of an + // external editor's previous encoding. Snapshot and active-file + // publication share the same transaction as the settings APIs. + write_validated_config_file(path, normalize_text_to_lf(lf_text), *servers); + return {true, {}, false, false}; + } + } catch (const std::exception& error) { + return {false, error.what(), false, false}; + } + auto encoded = with_text_file_tool_errors(encode_text_for_write(lf_text, metadata)); + if (!encoded.success) { + return {false, encoded.error, false, false}; + } + + std::string pre_write_bytes; + std::string error; + const bool existed = std::filesystem::exists(path_from_utf8(path)); + if (existed && !read_file_bytes(path, pre_write_bytes, error)) { + return {false, error, false, false}; + } + + if (before_write) { + before_write(path); + } + + if (!write_file_bytes(path, encoded.bytes, error)) { + return {false, error, false, false}; + } + + std::string written_bytes; + if (!read_file_bytes(path, written_bytes, error)) { + if (existed && !write_file_bytes(path, pre_write_bytes, error)) { + return {false, + "[Error] Post-write verification could not read " + path + + " and rollback failed: " + error, + false, + true}; + } + return {false, + "[Error] Post-write verification could not read " + path + + "; edit was rolled back.", + true, + false}; + } + + auto round_trip = with_text_file_tool_errors( + decode_text_file_bytes_with_metadata(written_bytes, metadata, path)); + if (round_trip.success && round_trip.buffer.text == normalize_text_to_lf(lf_text)) { + return {true, {}, false, false}; + } + + std::string rollback_error; + if (existed) { + if (!write_file_bytes(path, pre_write_bytes, rollback_error)) { + return {false, + "[Error] Post-write round-trip verification failed for " + path + + " and rollback failed: " + rollback_error, + false, + true}; + } + } else { + std::error_code ec; + std::filesystem::remove(path_from_utf8(path), ec); + if (ec) { + return {false, + "[Error] Post-write round-trip verification failed for " + path + + " and rollback failed: " + ec.message(), + false, + true}; + } + } + + std::string reason = round_trip.success + ? "[Error] Post-write round-trip verification changed the intended text" + : round_trip.error; + return {false, + reason + "; edit was rolled back for " + path + ".", + true, + false}; +} + +} // namespace acecode diff --git a/src/tool/safe_text_write.hpp b/src/tool/safe_text_write.hpp new file mode 100644 index 000000000..34637f911 --- /dev/null +++ b/src/tool/safe_text_write.hpp @@ -0,0 +1,27 @@ +#pragma once + +// 文本文件的安全写(P2-05 自 utils/text_file_buffer 拆出):写前 MCP 配置拦截、按原编码回写、 +// 写后往返校验与回滚。它依赖 config/mcp_config 与模型侧工具名,所以属于 tool 层;纯编解码留在 +// utils/text_file_buffer。 + +#include "utils/text_file_buffer.hpp" + +#include +#include + +namespace acecode { + +struct TextSafeWriteResult { + bool success = false; + std::string error; + bool rolled_back = false; + bool rollback_failed = false; +}; + +TextSafeWriteResult safe_write_text_file( + const std::string& path, + const std::string& lf_text, + const TextFileMetadata& metadata, + const std::function& before_write = {}); + +} // namespace acecode diff --git a/src/tool/show_image_tool.cpp b/src/tool/show_image_tool.cpp index a5ee9dd20..ed38328e4 100644 --- a/src/tool/show_image_tool.cpp +++ b/src/tool/show_image_tool.cpp @@ -1,8 +1,8 @@ #include "show_image_tool.hpp" #include "llm/tool_icons.hpp" -#include "utils/tool_args_parser.hpp" -#include "utils/tool_errors.hpp" +#include "tool/tool_args_parser.hpp" +#include "tool/tool_errors.hpp" #include "utils/utf8_path.hpp" #include diff --git a/src/tool/task_suggestion_tools.cpp b/src/tool/task_suggestion_tools.cpp index 49d7c0999..ebbf0de46 100644 --- a/src/tool/task_suggestion_tools.cpp +++ b/src/tool/task_suggestion_tools.cpp @@ -2,7 +2,7 @@ #include "session/session_manager.hpp" #include "session/task_suggestion_service.hpp" -#include "utils/tool_args_parser.hpp" +#include "tool/tool_args_parser.hpp" namespace acecode { namespace { diff --git a/src/tool/text_file_errors.cpp b/src/tool/text_file_errors.cpp new file mode 100644 index 000000000..856d6b932 --- /dev/null +++ b/src/tool/text_file_errors.cpp @@ -0,0 +1,47 @@ +#include "text_file_errors.hpp" + +#include "llm/tool_protocol_names.hpp" + +namespace acecode { +namespace { + +std::string format_text_file_error(TextFileError kind, const std::string& fallback) { + if (kind == TextFileError::None) return fallback; + const auto read_tool_name = model_tool_name_for_native("file_read"); + switch (kind) { + case TextFileError::AmbiguousUtf8Bom: + return "[Error] UTF-8 BOM file can be read with " + read_tool_name + + " using lossy decoding, but its bytes are too ambiguous to edit safely."; + case TextFileError::DamagedUtf8: + return "[Error] File can be read with " + read_tool_name + + " using lossy UTF-8 decoding, but its encoding is too ambiguous to edit safely."; + case TextFileError::UnknownEncoding: + return "[Error] File can be read with " + read_tool_name + + " using lossy decoding, but its encoding is too ambiguous to edit safely."; + case TextFileError::LossyWrite: + return "[Error] Target file cannot be safely written as text because it was decoded lossily. Use " + + read_tool_name + " for inspection and convert the file to a confirmed encoding before editing."; + case TextFileError::None: + return fallback; + } + return fallback; +} + +} // namespace + +TextBufferResult with_text_file_tool_errors(TextBufferResult result) { + if (!result.success && result.error_kind != TextFileError::None) { + result.error = format_text_file_error(result.error_kind, result.error); + result.buffer.metadata.error = result.error; + } + return result; +} + +TextEncodeResult with_text_file_tool_errors(TextEncodeResult result) { + if (!result.success) { + result.error = format_text_file_error(result.error_kind, result.error); + } + return result; +} + +} // namespace acecode diff --git a/src/tool/text_file_errors.hpp b/src/tool/text_file_errors.hpp new file mode 100644 index 000000000..20a0304e1 --- /dev/null +++ b/src/tool/text_file_errors.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "utils/text_file_buffer.hpp" + +namespace acecode { + +// Add model-facing recovery instructions to codec errors. The error kind and +// all file data remain unchanged; decode metadata mirrors the displayed error. +TextBufferResult with_text_file_tool_errors(TextBufferResult result); +TextEncodeResult with_text_file_tool_errors(TextEncodeResult result); + +} // namespace acecode diff --git a/src/tool/theme_create_tool.cpp b/src/tool/theme_create_tool.cpp index 51882858d..689006c48 100644 --- a/src/tool/theme_create_tool.cpp +++ b/src/tool/theme_create_tool.cpp @@ -1,6 +1,6 @@ #include "theme_create_tool.hpp" -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "session/session_manager.hpp" #include "themes/theme_drafts.hpp" #include "utils/paths.hpp" diff --git a/src/tool/thread_tools.cpp b/src/tool/thread_tools.cpp index 7acc05997..3f59afafb 100644 --- a/src/tool/thread_tools.cpp +++ b/src/tool/thread_tools.cpp @@ -2,7 +2,7 @@ #include "session/session_manager.hpp" #include "session/thread_service.hpp" -#include "utils/tool_args_parser.hpp" +#include "tool/tool_args_parser.hpp" #include #include diff --git a/src/utils/tool_args_parser.hpp b/src/tool/tool_args_parser.hpp similarity index 100% rename from src/utils/tool_args_parser.hpp rename to src/tool/tool_args_parser.hpp diff --git a/src/utils/tool_errors.hpp b/src/tool/tool_errors.hpp similarity index 100% rename from src/utils/tool_errors.hpp rename to src/tool/tool_errors.hpp diff --git a/src/tool/tool_executor.cpp b/src/tool/tool_executor.cpp index d610846fb..7d41962bf 100644 --- a/src/tool/tool_executor.cpp +++ b/src/tool/tool_executor.cpp @@ -4,7 +4,7 @@ #include "session/output_attachments.hpp" #include "utils/logger.hpp" #include "utils/encoding.hpp" -#include "utils/tool_errors.hpp" +#include "tool/tool_errors.hpp" #include "utils/utf8_path.hpp" #include #include diff --git a/src/tool/web_search/region_cache.cpp b/src/tool/web_search/region_cache.cpp new file mode 100644 index 000000000..d24c1c84c --- /dev/null +++ b/src/tool/web_search/region_cache.cpp @@ -0,0 +1,51 @@ +#include "region_cache.hpp" + +#include "utils/logger.hpp" +#include "utils/state_file.hpp" + +#include +#include + +namespace acecode { + +std::optional read_web_search_region_cache() { + auto j = read_state_json(); + if (!j.contains("web_search") || !j["web_search"].is_object()) return std::nullopt; + const auto& wsj = j["web_search"]; + if (!wsj.contains("region_detected") || !wsj["region_detected"].is_string()) { + return std::nullopt; + } + std::string region = wsj["region_detected"].get(); + if (region != "global" && region != "cn") return std::nullopt; + WebSearchRegionCache c; + c.region = std::move(region); + if (wsj.contains("region_detected_at_ms") && + wsj["region_detected_at_ms"].is_number_integer()) { + c.detected_at_ms = wsj["region_detected_at_ms"].get(); + } + return c; +} + +void write_web_search_region_cache(const WebSearchRegionCache& cache) { + if (cache.region != "global" && cache.region != "cn") { + LOG_WARN("[state_file] refusing to write web_search region '" + + cache.region + "' (must be global or cn)"); + return; + } + nlohmann::json wsj = nlohmann::json::object(); + wsj["region_detected"] = cache.region; + wsj["region_detected_at_ms"] = cache.detected_at_ms; + (void)update_state_json([wsj = std::move(wsj)](nlohmann::json& state) { + state["web_search"] = wsj; + return true; + }); +} + +void clear_web_search_region_cache() { + (void)update_state_json([](nlohmann::json& state) { + // 字段不存在时不重写文件,保留原来的无操作语义。 + return state.erase("web_search") != 0; + }); +} + +} // namespace acecode diff --git a/src/tool/web_search/region_cache.hpp b/src/tool/web_search/region_cache.hpp new file mode 100644 index 000000000..2c7816f3a --- /dev/null +++ b/src/tool/web_search/region_cache.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace acecode { + +// 联网搜索 region 缓存(参见 add-web-search-tool 的 RegionDetector)。 +// state.json 中的存储格式: +// { "web_search": { "region_detected": "global"|"cn", +// "region_detected_at_ms": } } +struct WebSearchRegionCache { + std::string region; // "global" / "cn" + long long detected_at_ms = 0; +}; + +// 读取缓存的 region。文件不存在 / 字段缺失 / region 不是 global|cn → nullopt。 +// detected_at_ms 即使为 0 / 缺失也接受(只表示来源未知,region 仍可信)。 +std::optional read_web_search_region_cache(); + +// 写缓存的 region。原子写,保留其他 key。region 不在 {global, cn} 时静默不写。 +void write_web_search_region_cache(const WebSearchRegionCache& cache); + +// 删除 web_search 缓存(/websearch refresh 用)。其它 key 保留。 +void clear_web_search_region_cache(); + +} // namespace acecode diff --git a/src/tool/web_search/region_detector.cpp b/src/tool/web_search/region_detector.cpp index cfe54e5e3..670f32167 100644 --- a/src/tool/web_search/region_detector.cpp +++ b/src/tool/web_search/region_detector.cpp @@ -2,7 +2,7 @@ #include "network/proxy_resolver.hpp" #include "utils/logger.hpp" -#include "utils/state_file.hpp" +#include "region_cache.hpp" #include diff --git a/src/tool/workspace_tools.cpp b/src/tool/workspace_tools.cpp index a0ebaead9..04ab99147 100644 --- a/src/tool/workspace_tools.cpp +++ b/src/tool/workspace_tools.cpp @@ -1,7 +1,7 @@ #include "workspace_tools.hpp" #include "workspace/workspace_registry.hpp" -#include "utils/tool_args_parser.hpp" +#include "tool/tool_args_parser.hpp" #include "utils/utf8_path.hpp" #include diff --git a/src/tui/confirm_question.hpp b/src/tui/confirm_question.hpp index 4d64a64ba..7f404f381 100644 --- a/src/tui/confirm_question.hpp +++ b/src/tui/confirm_question.hpp @@ -11,7 +11,7 @@ // 2) 把字符串拼接和 fallback 逻辑(unknown tool / 坏 JSON / 缺字段) // 集中到一处,避免 main.cpp 的 render 路径里堆字符串。 -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include #include diff --git a/src/tui/mode_picker.hpp b/src/tui/mode_picker.hpp index b87138e34..9b5e75e21 100644 --- a/src/tui/mode_picker.hpp +++ b/src/tui/mode_picker.hpp @@ -1,6 +1,6 @@ #pragma once -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include #include diff --git a/src/tui/settings/settings_center.cpp b/src/tui/settings/settings_center.cpp index 200757c73..6c498d457 100644 --- a/src/tui/settings/settings_center.cpp +++ b/src/tui/settings/settings_center.cpp @@ -6,7 +6,7 @@ #include "settings_center.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "config/request_headers.hpp" #include "config/settings_mutations.hpp" #include "workspace/workspace_registry.hpp" diff --git a/src/tui/slash_command_usage.cpp b/src/tui/slash_command_usage.cpp new file mode 100644 index 000000000..4998bdbb7 --- /dev/null +++ b/src/tui/slash_command_usage.cpp @@ -0,0 +1,79 @@ +#include "slash_command_usage.hpp" + +#include "utils/state_file.hpp" + +#include +#include +#include +#include + +namespace acecode { +namespace { + +constexpr const char* kTuiSlashCommandUsageKey = "tui_slash_command_usage"; +bool valid_slash_command_name(const std::string& name) { + if (name.empty()) return false; + for (char c : name) { + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') return false; + } + return true; +} + +std::optional parse_positive_count( + const nlohmann::json& value) { + if (value.is_number_unsigned()) { + const auto count = value.get(); + if (count > 0) return count; + return std::nullopt; + } + if (value.is_number_integer()) { + const auto count = value.get(); + if (count > 0) return static_cast(count); + } + return std::nullopt; +} + +std::map parse_tui_slash_command_usage( + const nlohmann::json& state) { + std::map counts; + if (!state.contains(kTuiSlashCommandUsageKey) || + !state[kTuiSlashCommandUsageKey].is_object()) { + return counts; + } + + for (auto it = state[kTuiSlashCommandUsageKey].begin(); + it != state[kTuiSlashCommandUsageKey].end(); ++it) { + if (!valid_slash_command_name(it.key())) continue; + auto count = parse_positive_count(it.value()); + if (count) counts.emplace(it.key(), *count); + } + return counts; +} + +} // namespace + +std::map read_tui_slash_command_usage() { + return parse_tui_slash_command_usage(read_state_json()); +} + +SlashCommandUsageWriteResult record_tui_slash_command_use( + const std::string& command_name) { + if (!valid_slash_command_name(command_name)) return {}; + + SlashCommandUsageWriteResult result; + // 同步事务:拿不到锁时不计算次数;写盘失败仍返回本次算出的内存次数。 + result.persisted = update_state_json([&result, command_name](nlohmann::json& state) { + auto counts = parse_tui_slash_command_usage(state); + auto& count = counts[command_name]; + if (count < (std::numeric_limits::max)()) ++count; + result.count = count; + + nlohmann::json usage = nlohmann::json::object(); + for (const auto& [name, value] : counts) usage[name] = value; + state[kTuiSlashCommandUsageKey] = std::move(usage); + return true; + }); + return result; +} + +} // namespace acecode diff --git a/src/tui/slash_command_usage.hpp b/src/tui/slash_command_usage.hpp new file mode 100644 index 000000000..7c30c1b34 --- /dev/null +++ b/src/tui/slash_command_usage.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace acecode { + +// TUI slash-command adaptive ordering. The persisted shape is: +// { "tui_slash_command_usage": { "help": 3, "model": 7 } } +// Reads are tolerant: missing/malformed containers and invalid individual +// entries are ignored. Only aggregate command names/counts are stored. +std::map read_tui_slash_command_usage(); + +struct SlashCommandUsageWriteResult { + // Updated count for the current process even when persistence failed. + std::uint64_t count = 0; + bool persisted = false; +}; + +// Atomically increment one command's durable count while preserving unrelated +// state.json keys. Counts saturate at uint64_t max rather than overflowing. +SlashCommandUsageWriteResult record_tui_slash_command_use( + const std::string& command_name); + +} // namespace acecode diff --git a/src/tui/tui_helpers.cpp b/src/tui/tui_helpers.cpp index 4babb96d3..b1a1cde96 100644 --- a/src/tui/tui_helpers.cpp +++ b/src/tui/tui_helpers.cpp @@ -28,7 +28,7 @@ #include "tui/todo_checklist_view.hpp" #include "tui/unclipped_reflect.hpp" #include "tui/vertical_scroll.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include "utils/text_input_ops.hpp" #include "tool/mcp_manager.hpp" #include "lsp/lsp_service.hpp" diff --git a/src/tui_state.hpp b/src/tui_state.hpp index 2b3a9f4ad..6c41db827 100644 --- a/src/tui_state.hpp +++ b/src/tui_state.hpp @@ -1,6 +1,6 @@ #pragma once -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "path_reference/path_reference.hpp" #include "skills/skill_usage_store.hpp" diff --git a/src/upgrade/executable_version.cpp b/src/upgrade/executable_version.cpp index 994df899f..423e64c5f 100644 --- a/src/upgrade/executable_version.cpp +++ b/src/upgrade/executable_version.cpp @@ -1,5 +1,5 @@ #include "executable_version.hpp" -#include "version.hpp" +#include "utils/semver.hpp" #include #include diff --git a/src/upgrade/manifest.hpp b/src/upgrade/manifest.hpp index e024e8cf8..b032ddf49 100644 --- a/src/upgrade/manifest.hpp +++ b/src/upgrade/manifest.hpp @@ -1,6 +1,6 @@ #pragma once -#include "version.hpp" +#include "utils/semver.hpp" #include #include diff --git a/src/upgrade/upgrade.cpp b/src/upgrade/upgrade.cpp index b9b9c3cc6..ca58e735e 100644 --- a/src/upgrade/upgrade.cpp +++ b/src/upgrade/upgrade.cpp @@ -5,7 +5,7 @@ #include "platform/terminal/console.hpp" #include "diagnostics.hpp" #include "executable_version.hpp" -#include "http.hpp" +#include "network/http.hpp" #include "macos_bundle.hpp" #include "manifest.hpp" #include "package.hpp" diff --git a/src/upgrade/version.cpp b/src/utils/semver.cpp similarity index 99% rename from src/upgrade/version.cpp rename to src/utils/semver.cpp index 672a945ab..c2a608c83 100644 --- a/src/upgrade/version.cpp +++ b/src/utils/semver.cpp @@ -1,4 +1,4 @@ -#include "version.hpp" +#include "semver.hpp" #include #include diff --git a/src/upgrade/version.hpp b/src/utils/semver.hpp similarity index 100% rename from src/upgrade/version.hpp rename to src/utils/semver.hpp diff --git a/src/utils/state_file.cpp b/src/utils/state_file.cpp index 6cbabb31e..3530b9f5b 100644 --- a/src/utils/state_file.cpp +++ b/src/utils/state_file.cpp @@ -8,12 +8,9 @@ #include #include -#include #include #include -#include #include -#include #include #include @@ -156,160 +153,6 @@ class StateFileWriteLock { #endif }; -constexpr const char* kTuiSlashCommandUsageKey = "tui_slash_command_usage"; -constexpr const char* kModelProbeCacheKey = "model_probe_cache"; -constexpr std::size_t kMaxModelProbeCacheEntries = 128; -constexpr std::size_t kMaxCachedProbeModels = 2000; -constexpr std::size_t kMaxCachedModelIdBytes = 512; - -bool valid_sha256_fingerprint(const std::string& fingerprint) { - if (fingerprint.size() != 64) return false; - for (unsigned char c : fingerprint) { - if (!std::isxdigit(c)) return false; - } - return true; -} - -std::optional parse_model_probe_cache_entry( - const nlohmann::json& value) { - try { - if (!value.is_object()) return std::nullopt; - auto version = value.find("version"); - if (version == value.end() || !version->is_number_integer() || - version->get() != 1) { - return std::nullopt; - } - auto models = value.find("models"); - if (models == value.end() || !models->is_array() || - models->size() > kMaxCachedProbeModels) { - return std::nullopt; - } - - ModelProbeCacheEntry entry; - std::set seen; - for (const auto& model : *models) { - if (!model.is_string()) return std::nullopt; - const std::string id = model.get(); - if (id.empty() || id.size() > kMaxCachedModelIdBytes) { - return std::nullopt; - } - if (seen.insert(id).second) entry.models.push_back(id); - } - - auto contexts = value.find("model_context_windows"); - if (contexts != value.end()) { - if (!contexts->is_object()) return std::nullopt; - for (auto it = contexts->begin(); it != contexts->end(); ++it) { - if (!seen.count(it.key()) || !it.value().is_number_integer()) { - continue; - } - const auto context = it.value().get(); - if (context > 0 && context <= (std::numeric_limits::max)()) { - entry.context_windows[it.key()] = static_cast(context); - } - } - } - - const auto reasonings = value.find("model_reasoning"); - for (const auto& id : entry.models) { - entry.reasoning[id] = std::nullopt; - if (reasonings == value.end() || !reasonings->is_object()) continue; - const auto declaration = reasonings->find(id); - if (declaration == reasonings->end() || !declaration->is_object()) continue; - std::string error; - auto parsed = parse_model_reasoning_options(*declaration, error); - if (parsed.has_value() && parsed->supported && - !parsed->supported_efforts.empty()) { - entry.reasoning[id] = std::move(parsed); - } - } - - auto probed_at = value.find("probed_at_ms"); - if (probed_at != value.end() && probed_at->is_number_integer()) { - entry.probed_at_ms = probed_at->get(); - } - return entry; - } catch (const std::exception&) { - return std::nullopt; - } -} - -nlohmann::json model_probe_cache_entry_to_json( - const ModelProbeCacheEntry& source) { - nlohmann::json value = { - {"version", 1}, - {"models", nlohmann::json::array()}, - {"model_context_windows", nlohmann::json::object()}, - {"model_reasoning", nlohmann::json::object()}, - {"probed_at_ms", source.probed_at_ms}, - }; - std::set seen; - for (const auto& id : source.models) { - if (id.empty() || id.size() > kMaxCachedModelIdBytes || - !seen.insert(id).second || - value["models"].size() >= kMaxCachedProbeModels) { - continue; - } - value["models"].push_back(id); - value["model_reasoning"][id] = nullptr; - const auto declaration = source.reasoning.find(id); - if (declaration != source.reasoning.end() && declaration->second.has_value()) { - const auto encoded = model_reasoning_options_to_json(*declaration->second); - std::string error; - const auto parsed = parse_model_reasoning_options(encoded, error); - if (parsed.has_value() && parsed->supported && - !parsed->supported_efforts.empty()) { - value["model_reasoning"][id] = encoded; - } - } - } - for (const auto& [id, context] : source.context_windows) { - if (context > 0 && seen.count(id)) { - value["model_context_windows"][id] = context; - } - } - return value; -} - -bool valid_slash_command_name(const std::string& name) { - if (name.empty()) return false; - for (char c : name) { - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') return false; - } - return true; -} - -std::optional parse_positive_count( - const nlohmann::json& value) { - if (value.is_number_unsigned()) { - const auto count = value.get(); - if (count > 0) return count; - return std::nullopt; - } - if (value.is_number_integer()) { - const auto count = value.get(); - if (count > 0) return static_cast(count); - } - return std::nullopt; -} - -std::map parse_tui_slash_command_usage( - const nlohmann::json& state) { - std::map counts; - if (!state.contains(kTuiSlashCommandUsageKey) || - !state[kTuiSlashCommandUsageKey].is_object()) { - return counts; - } - - for (auto it = state[kTuiSlashCommandUsageKey].begin(); - it != state[kTuiSlashCommandUsageKey].end(); ++it) { - if (!valid_slash_command_name(it.key())) continue; - auto count = parse_positive_count(it.value()); - if (count) counts.emplace(it.key(), *count); - } - return counts; -} - // 加载现有 state.json:解析失败 / 文件不存在 → 返回空 object。 // is_corrupted 在文件存在但解析失败时设为 true,让上层知道写回时要覆盖。 nlohmann::json load_state_or_empty(bool* is_corrupted = nullptr) { @@ -425,114 +268,12 @@ void set_state_file_path_for_test(const std::string& path) { test_path_override() = path; } -namespace { - -// 写入任意 JSON value,保留其它 key,原子写。供 web_search 缓存等结构化写入复用。 -bool write_state_value(const std::string& key, const nlohmann::json& value) { - const std::string p = state_file_path(); - StateFileWriteLock file_lock(p); - if (!file_lock.acquired()) { - LOG_WARN("[state_file] " + file_lock.error()); - return false; - } - bool corrupted = false; - auto j = load_state_or_empty(&corrupted); - if (corrupted) { - LOG_WARN("[state_file] state.json corrupted, rewriting"); - } - j[key] = value; - - std::error_code ec; - fs::create_directories(path_from_utf8(p).parent_path(), ec); - - if (!atomic_write_file(p, j.dump(2))) { - LOG_WARN("[state_file] failed to write " + p); - return false; - } - return true; -} - -bool erase_state_key(const std::string& key) { - const std::string p = state_file_path(); - StateFileWriteLock file_lock(p); - if (!file_lock.acquired()) { - LOG_WARN("[state_file] " + file_lock.error()); - return false; - } - bool corrupted = false; - auto j = load_state_or_empty(&corrupted); - if (corrupted) { - LOG_WARN("[state_file] state.json corrupted, rewriting"); - } - if (!j.contains(key)) return true; // 没有可删的就别动文件,避免无谓 I/O - j.erase(key); - - std::error_code ec; - fs::create_directories(path_from_utf8(p).parent_path(), ec); - - if (!atomic_write_file(p, j.dump(2))) { - LOG_WARN("[state_file] failed to write " + p); - return false; - } - return true; -} - -} // namespace - -std::optional read_web_search_region_cache() { +nlohmann::json read_state_json() { std::lock_guard lock(state_file_mutex()); - auto j = load_state_or_empty(); - if (!j.contains("web_search") || !j["web_search"].is_object()) return std::nullopt; - const auto& wsj = j["web_search"]; - if (!wsj.contains("region_detected") || !wsj["region_detected"].is_string()) { - return std::nullopt; - } - std::string region = wsj["region_detected"].get(); - if (region != "global" && region != "cn") return std::nullopt; - WebSearchRegionCache c; - c.region = std::move(region); - if (wsj.contains("region_detected_at_ms") && - wsj["region_detected_at_ms"].is_number_integer()) { - c.detected_at_ms = wsj["region_detected_at_ms"].get(); - } - return c; -} - -void write_web_search_region_cache(const WebSearchRegionCache& cache) { - if (cache.region != "global" && cache.region != "cn") { - LOG_WARN("[state_file] refusing to write web_search region '" + - cache.region + "' (must be global or cn)"); - return; - } - std::lock_guard lock(state_file_mutex()); - nlohmann::json wsj = nlohmann::json::object(); - wsj["region_detected"] = cache.region; - wsj["region_detected_at_ms"] = cache.detected_at_ms; - (void)write_state_value("web_search", wsj); -} - -void clear_web_search_region_cache() { - std::lock_guard lock(state_file_mutex()); - (void)erase_state_key("web_search"); + return load_state_or_empty(); } -std::optional read_model_probe_cache( - const std::string& connection_fingerprint) { - if (!valid_sha256_fingerprint(connection_fingerprint)) return std::nullopt; - std::lock_guard lock(state_file_mutex()); - const auto state = load_state_or_empty(); - auto cache = state.find(kModelProbeCacheKey); - if (cache == state.end() || !cache->is_object()) return std::nullopt; - auto entry = cache->find(connection_fingerprint); - if (entry == cache->end()) return std::nullopt; - return parse_model_probe_cache_entry(*entry); -} - -bool write_model_probe_cache( - const std::string& connection_fingerprint, - const ModelProbeCacheEntry& entry) { - if (!valid_sha256_fingerprint(connection_fingerprint)) return false; - +bool update_state_json(const std::function& mutate) { std::lock_guard lock(state_file_mutex()); const std::string path = state_file_path(); StateFileWriteLock file_lock(path); @@ -545,31 +286,7 @@ bool write_model_probe_cache( if (corrupted) { LOG_WARN("[state_file] state.json corrupted, rewriting"); } - if (!state.contains(kModelProbeCacheKey) || - !state[kModelProbeCacheKey].is_object()) { - state[kModelProbeCacheKey] = nlohmann::json::object(); - } - auto& cache = state[kModelProbeCacheKey]; - cache[connection_fingerprint] = model_probe_cache_entry_to_json(entry); - - while (cache.size() > kMaxModelProbeCacheEntries) { - auto oldest = cache.begin(); - std::int64_t oldest_time = (std::numeric_limits::max)(); - for (auto it = cache.begin(); it != cache.end(); ++it) { - std::int64_t timestamp = 0; - if (it.value().is_object()) { - auto field = it.value().find("probed_at_ms"); - if (field != it.value().end() && field->is_number_integer()) { - timestamp = field->get(); - } - } - if (timestamp < oldest_time) { - oldest = it; - oldest_time = timestamp; - } - } - cache.erase(oldest); - } + if (!mutate(state)) return true; std::error_code ec; fs::create_directories(path_from_utf8(path).parent_path(), ec); @@ -580,67 +297,4 @@ bool write_model_probe_cache( return true; } -std::string read_last_active_workspace_hash() { - std::lock_guard lock(state_file_mutex()); - auto j = load_state_or_empty(); - if (!j.contains("last_active_workspace_hash")) return ""; - if (!j["last_active_workspace_hash"].is_string()) return ""; - return j["last_active_workspace_hash"].get(); -} - -void write_last_active_workspace_hash(const std::string& hash) { - std::lock_guard lock(state_file_mutex()); - (void)write_state_value("last_active_workspace_hash", hash); -} - -std::string read_last_home_workspace_hash() { - std::lock_guard lock(state_file_mutex()); - auto j = load_state_or_empty(); - if (!j.contains("last_home_workspace_hash")) return ""; - if (!j["last_home_workspace_hash"].is_string()) return ""; - return j["last_home_workspace_hash"].get(); -} - -void write_last_home_workspace_hash(const std::string& hash) { - std::lock_guard lock(state_file_mutex()); - (void)write_state_value("last_home_workspace_hash", hash); -} - -std::map read_tui_slash_command_usage() { - std::lock_guard lock(state_file_mutex()); - return parse_tui_slash_command_usage(load_state_or_empty()); -} - -SlashCommandUsageWriteResult record_tui_slash_command_use( - const std::string& command_name) { - if (!valid_slash_command_name(command_name)) return {}; - - std::lock_guard lock(state_file_mutex()); - const std::string path = state_file_path(); - StateFileWriteLock file_lock(path); - if (!file_lock.acquired()) { - LOG_WARN("[state_file] " + file_lock.error()); - return {}; - } - bool corrupted = false; - auto state = load_state_or_empty(&corrupted); - if (corrupted) { - LOG_WARN("[state_file] state.json corrupted, rewriting"); - } - - auto counts = parse_tui_slash_command_usage(state); - auto& count = counts[command_name]; - if (count < (std::numeric_limits::max)()) ++count; - - nlohmann::json usage = nlohmann::json::object(); - for (const auto& [name, value] : counts) usage[name] = value; - state[kTuiSlashCommandUsageKey] = std::move(usage); - - const bool persisted = atomic_write_file(path, state.dump(2)); - if (!persisted) { - LOG_WARN("[state_file] failed to write " + path); - } - return {count, persisted}; -} - } // namespace acecode diff --git a/src/utils/state_file.hpp b/src/utils/state_file.hpp index 4ced1e9cf..36b4dfb49 100644 --- a/src/utils/state_file.hpp +++ b/src/utils/state_file.hpp @@ -1,22 +1,18 @@ #pragma once -#include "config/saved_models.hpp" - -#include - // 跨会话进程状态(once-only 提示标记等),持久化到 ~/.acecode/state.json。 // 跟 config.json 区分:config.json 是用户编辑面,state.json 是 ACECode 自己 // 写的运行时状态(同一个数据目录下,通过 paths::resolve_data_dir(RunMode) // 得到)。 // // 设计取舍见 openspec/changes/add-legacy-terminal-fallback/design.md -// Decision 4。对外只暴露按用途划分的 typed helper,避免调用方直接耦合 -// state.json 的整体 schema。 +// Decision 4。各模块的专用 helper 管理自己的字段与校验规则;本层只提供 +// 通用读写、跨进程互斥和原子更新,业务调用方不直接操作 state.json。 -#include -#include +#include #include -#include + +#include namespace acecode { @@ -54,73 +50,12 @@ void set_state_file_path_for_test(const std::string& path); // 用法:write_state_flag("legacy_terminal_hint_shown", true); void write_state_flag(const std::string& key, bool value); -// 联网搜索 region 缓存(参见 add-web-search-tool 的 RegionDetector)。 -// state.json 中的存储格式: -// { "web_search": { "region_detected": "global"|"cn", -// "region_detected_at_ms": } } -struct WebSearchRegionCache { - std::string region; // "global" / "cn" - long long detected_at_ms = 0; -}; - -// 读取缓存的 region。文件不存在 / 字段缺失 / region 不是 global|cn → nullopt。 -// detected_at_ms 即使为 0 / 缺失也接受(只表示来源未知,region 仍可信)。 -std::optional read_web_search_region_cache(); - -// 写缓存的 region。原子写,保留其他 key。region 不在 {global, cn} 时静默不写。 -void write_web_search_region_cache(const WebSearchRegionCache& cache); - -// 删除 web_search 缓存(/websearch refresh 用)。其它 key 保留。 -void clear_web_search_region_cache(); - -// Successful Provider model probes are cached by an opaque SHA-256 connection -// fingerprint. The state file stores only the fingerprint and probe output; -// provider URLs, API keys, and request headers never reach this helper. -struct ModelProbeCacheEntry { - std::vector models; - std::map context_windows; - std::int64_t probed_at_ms = 0; - std::map> reasoning; -}; - -// Missing/malformed entries return nullopt. The fingerprint must be exactly a -// 64-character hexadecimal SHA-256 digest. -std::optional read_model_probe_cache( - const std::string& connection_fingerprint); - -// Atomically upserts one cache entry while preserving unrelated state and -// other connection fingerprints. Returns false for an invalid fingerprint or -// durable write failure. -bool write_model_probe_cache( - const std::string& connection_fingerprint, - const ModelProbeCacheEntry& entry); - -// desktop multi-workspace: 上次活跃 workspace 的 cwd_hash。 -// 读: 文件不存在 / 字段缺失 / 类型不符 → 空字符串。永不抛异常。 -// 写: 原子写,保留其他 key;失败只 LOG_WARN 不阻断。 -std::string read_last_active_workspace_hash(); -void write_last_active_workspace_hash(const std::string& hash); - -// desktop home page: 首页新建会话选择器上次选中的 workspace cwd_hash。 -// 空字符串是有效值,表示"不使用工作区"。 -std::string read_last_home_workspace_hash(); -void write_last_home_workspace_hash(const std::string& hash); - -// TUI slash-command adaptive ordering. The persisted shape is: -// { "tui_slash_command_usage": { "help": 3, "model": 7 } } -// Reads are tolerant: missing/malformed containers and invalid individual -// entries are ignored. Only aggregate command names/counts are stored. -std::map read_tui_slash_command_usage(); - -struct SlashCommandUsageWriteResult { - // Updated count for the current process even when persistence failed. - std::uint64_t count = 0; - bool persisted = false; -}; - -// Atomically increment one command's durable count while preserving unrelated -// state.json keys. Counts saturate at uint64_t max rather than overflowing. -SlashCommandUsageWriteResult record_tui_slash_command_use( - const std::string& command_name); +// 各模块共用的状态存储入口: +// read_state_json:进程内锁下读取整份 state.json,缺失 / 损坏视为空对象。 +nlohmann::json read_state_json(); +// update_state_json:进程内锁 + 跨进程文件锁下「读-改-写」;mutate 在本次调用内同步执行, +// 不保存回调,回调中不得重入 state_file API。mutate 返回 false 表示放弃写入 +// (仍视为成功)。损坏的 state.json 在此整体重写并记 LOG_WARN;拿不到锁或写失败返回 false。 +bool update_state_json(const std::function& mutate); } // namespace acecode diff --git a/src/utils/text_file_buffer.cpp b/src/utils/text_file_buffer.cpp index 162f4a24a..0493807be 100644 --- a/src/utils/text_file_buffer.cpp +++ b/src/utils/text_file_buffer.cpp @@ -1,9 +1,6 @@ #include "text_file_buffer.hpp" -#include "config/mcp_config.hpp" #include "sha256.hpp" -#include "tool_errors.hpp" -#include "llm/tool_protocol_names.hpp" #include "utf8_path.hpp" #include @@ -57,41 +54,6 @@ bool starts_with_bom(const std::string& bytes, return len == 2 || static_cast(bytes[2]) == c; } -bool read_raw_file(const std::string& path, std::string& out, std::string& error) { - std::ifstream ifs(path_from_utf8(path), std::ios::binary); - if (!ifs.is_open()) { - error = ToolErrors::cannot_open_file(path); - return false; - } - out.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); - return true; -} - -bool write_raw_file(const std::string& path, const std::string& bytes, std::string& error) { - auto fs_path = path_from_utf8(path); - auto parent = fs_path.parent_path(); - if (!parent.empty() && !std::filesystem::exists(parent)) { - try { - std::filesystem::create_directories(parent); - } catch (const std::filesystem::filesystem_error& e) { - error = "[Error] Cannot create parent directory: " + std::string(e.what()); - return false; - } - } - - std::ofstream ofs(fs_path, std::ios::binary | std::ios::trunc); - if (!ofs.is_open()) { - error = ToolErrors::cannot_write_file(path); - return false; - } - ofs.write(bytes.data(), static_cast(bytes.size())); - if (!ofs.good()) { - error = ToolErrors::cannot_write_file(path); - return false; - } - return true; -} - void append_utf8(std::string& out, unsigned int cp) { if (cp <= 0x7F) { out.push_back(static_cast(cp)); @@ -507,7 +469,8 @@ TextBufferResult fail_decode(const std::string& path, const std::string& raw, TextEncoding encoding, const std::string& error, - bool binary = false) { + bool binary = false, + TextFileError error_kind = TextFileError::None) { TextFileMetadata metadata; metadata.encoding = encoding; metadata.binary = binary; @@ -518,7 +481,7 @@ TextBufferResult fail_decode(const std::string& path, buffer.path = path; buffer.raw_bytes = raw; buffer.metadata = metadata; - return {false, std::move(buffer), error}; + return {false, std::move(buffer), error, error_kind}; } LineEndingStyle write_style_for(LineEndingStyle style) { @@ -528,6 +491,41 @@ LineEndingStyle write_style_for(LineEndingStyle style) { } // namespace +bool read_file_bytes(const std::string& path, std::string& out, std::string& error) { + std::ifstream ifs(path_from_utf8(path), std::ios::binary); + if (!ifs.is_open()) { + error = "[Error] Cannot open file: " + path; + return false; + } + out.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + return true; +} + +bool write_file_bytes(const std::string& path, const std::string& bytes, std::string& error) { + auto fs_path = path_from_utf8(path); + auto parent = fs_path.parent_path(); + if (!parent.empty() && !std::filesystem::exists(parent)) { + try { + std::filesystem::create_directories(parent); + } catch (const std::filesystem::filesystem_error& e) { + error = "[Error] Cannot create parent directory: " + std::string(e.what()); + return false; + } + } + + std::ofstream ofs(fs_path, std::ios::binary | std::ios::trunc); + if (!ofs.is_open()) { + error = "[Error] Cannot write to file: " + path; + return false; + } + ofs.write(bytes.data(), static_cast(bytes.size())); + if (!ofs.good()) { + error = "[Error] Cannot write to file: " + path; + return false; + } + return true; +} + std::string text_encoding_label(TextEncoding encoding) { switch (encoding) { case TextEncoding::Utf8: return "utf-8"; @@ -628,7 +626,8 @@ TextBufferResult decode_text_file_bytes(const std::string& bytes, lossy.replacement_count); } return fail_decode(path, bytes, TextEncoding::Unsupported, - "[Error] UTF-8 BOM file can be read with " + model_tool_name_for_native("file_read") + " using lossy decoding, but its bytes are too ambiguous to edit safely."); + "[Error] UTF-8 BOM file bytes are too ambiguous to edit safely.", + false, TextFileError::AmbiguousUtf8Bom); } return make_decoded_result(path, bytes, body, TextEncoding::Utf8Bom, true); } @@ -681,7 +680,8 @@ TextBufferResult decode_text_file_bytes(const std::string& bytes, lossy.replacement_count); } return fail_decode(path, bytes, TextEncoding::Unsupported, - "[Error] File can be read with " + model_tool_name_for_native("file_read") + " using lossy UTF-8 decoding, but its encoding is too ambiguous to edit safely."); + "[Error] Damaged UTF-8 is too ambiguous to edit safely.", + false, TextFileError::DamagedUtf8); } #ifdef _WIN32 @@ -704,7 +704,8 @@ TextBufferResult decode_text_file_bytes(const std::string& bytes, } return fail_decode(path, bytes, TextEncoding::Unsupported, - "[Error] File can be read with " + model_tool_name_for_native("file_read") + " using lossy decoding, but its encoding is too ambiguous to edit safely."); + "[Error] File encoding is too ambiguous to edit safely.", + false, TextFileError::UnknownEncoding); } TextBufferResult decode_text_file_bytes_with_metadata(const std::string& bytes, @@ -766,7 +767,7 @@ TextBufferResult decode_text_file_bytes_with_metadata(const std::string& bytes, TextBufferResult read_text_file_buffer(const std::string& path, bool allow_lossy) { std::string raw; std::string error; - if (!read_raw_file(path, raw, error)) { + if (!read_file_bytes(path, raw, error)) { return fail_decode(path, raw, TextEncoding::Unsupported, error); } return decode_text_file_bytes(raw, path, allow_lossy); @@ -776,7 +777,8 @@ TextEncodeResult encode_text_for_write(const std::string& lf_text, const TextFileMetadata& metadata) { if (metadata.lossy) { return {false, {}, - "[Error] Target file cannot be safely written as text because it was decoded lossily. Use " + model_tool_name_for_native("file_read") + " for inspection and convert the file to a confirmed encoding before editing."}; + "[Error] Target file cannot be safely written as text because it was decoded lossily.", + TextFileError::LossyWrite}; } if (metadata.binary || metadata.unsupported || metadata.encoding == TextEncoding::Binary || @@ -838,94 +840,6 @@ TextFileMetadata default_new_file_text_metadata() { return metadata; } -TextSafeWriteResult safe_write_text_file( - const std::string& path, - const std::string& lf_text, - const TextFileMetadata& metadata, - const std::function& before_write) { - try { - if (const auto servers = validate_mcp_file_edit(path, lf_text)) { - if (before_write) before_write(path); - // Configuration JSON is persisted as UTF-8, independently of an - // external editor's previous encoding. Snapshot and active-file - // publication share the same transaction as the settings APIs. - write_validated_config_file(path, normalize_text_to_lf(lf_text), *servers); - return {true, {}, false, false}; - } - } catch (const std::exception& error) { - return {false, error.what(), false, false}; - } - auto encoded = encode_text_for_write(lf_text, metadata); - if (!encoded.success) { - return {false, encoded.error, false, false}; - } - - std::string pre_write_bytes; - std::string error; - const bool existed = std::filesystem::exists(path_from_utf8(path)); - if (existed && !read_raw_file(path, pre_write_bytes, error)) { - return {false, error, false, false}; - } - - if (before_write) { - before_write(path); - } - - if (!write_raw_file(path, encoded.bytes, error)) { - return {false, error, false, false}; - } - - std::string written_bytes; - if (!read_raw_file(path, written_bytes, error)) { - if (existed && !write_raw_file(path, pre_write_bytes, error)) { - return {false, - "[Error] Post-write verification could not read " + path + - " and rollback failed: " + error, - false, - true}; - } - return {false, - "[Error] Post-write verification could not read " + path + - "; edit was rolled back.", - true, - false}; - } - - auto round_trip = decode_text_file_bytes_with_metadata(written_bytes, metadata, path); - if (round_trip.success && round_trip.buffer.text == normalize_text_to_lf(lf_text)) { - return {true, {}, false, false}; - } - - std::string rollback_error; - if (existed) { - if (!write_raw_file(path, pre_write_bytes, rollback_error)) { - return {false, - "[Error] Post-write round-trip verification failed for " + path + - " and rollback failed: " + rollback_error, - false, - true}; - } - } else { - std::error_code ec; - std::filesystem::remove(path_from_utf8(path), ec); - if (ec) { - return {false, - "[Error] Post-write round-trip verification failed for " + path + - " and rollback failed: " + ec.message(), - false, - true}; - } - } - - std::string reason = round_trip.success - ? "[Error] Post-write round-trip verification changed the intended text" - : round_trip.error; - return {false, - reason + "; edit was rolled back for " + path + ".", - true, - false}; -} - std::vector split_lf_lines_preserve_empty(const std::string& lf_text) { std::vector lines; size_t start = 0; diff --git a/src/utils/text_file_buffer.hpp b/src/utils/text_file_buffer.hpp index f09f60bc8..96eb9403c 100644 --- a/src/utils/text_file_buffer.hpp +++ b/src/utils/text_file_buffer.hpp @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include @@ -44,23 +43,27 @@ struct TextFileBuffer { TextFileMetadata metadata; }; +// Encoding failures that callers can supplement with recovery instructions. +enum class TextFileError { + None, + AmbiguousUtf8Bom, + DamagedUtf8, + UnknownEncoding, + LossyWrite +}; + struct TextBufferResult { bool success = false; TextFileBuffer buffer; std::string error; + TextFileError error_kind = TextFileError::None; }; struct TextEncodeResult { bool success = false; std::string bytes; std::string error; -}; - -struct TextSafeWriteResult { - bool success = false; - std::string error; - bool rolled_back = false; - bool rollback_failed = false; + TextFileError error_kind = TextFileError::None; }; std::string text_encoding_label(TextEncoding encoding); @@ -83,11 +86,9 @@ TextEncodeResult encode_text_for_write(const std::string& lf_text, const TextFileMetadata& metadata); TextFileMetadata default_new_file_text_metadata(); -TextSafeWriteResult safe_write_text_file( - const std::string& path, - const std::string& lf_text, - const TextFileMetadata& metadata, - const std::function& before_write = {}); +// Raw byte IO; failures report the affected path in error. +bool read_file_bytes(const std::string& path, std::string& out, std::string& error); +bool write_file_bytes(const std::string& path, const std::string& bytes, std::string& error); std::vector split_lf_lines_preserve_empty(const std::string& lf_text); std::string line_range_content(const std::string& lf_text, diff --git a/src/web/handlers/model_catalog_handler.cpp b/src/web/handlers/model_catalog_handler.cpp index c103ff722..fa5bf8250 100644 --- a/src/web/handlers/model_catalog_handler.cpp +++ b/src/web/handlers/model_catalog_handler.cpp @@ -1,7 +1,7 @@ #include "model_catalog_handler.hpp" #include "config/saved_models.hpp" -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include #include diff --git a/src/web/handlers/model_catalog_handler.hpp b/src/web/handlers/model_catalog_handler.hpp index 4099aa32b..6ccc7c362 100644 --- a/src/web/handlers/model_catalog_handler.hpp +++ b/src/web/handlers/model_catalog_handler.hpp @@ -1,7 +1,7 @@ #pragma once #include "provider/models_dev_registry.hpp" -#include "utils/models_dev_catalog.hpp" +#include "config/models_dev_catalog.hpp" #include #include diff --git a/src/web/handlers/models_handler.cpp b/src/web/handlers/models_handler.cpp index 244b35ee6..7c231faa5 100644 --- a/src/web/handlers/models_handler.cpp +++ b/src/web/handlers/models_handler.cpp @@ -2,7 +2,7 @@ #include "config/model_provider_registry.hpp" #include "config/request_headers.hpp" -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "provider/model_context_metadata.hpp" #include "utils/sha256.hpp" #include "network/proxy_resolver.hpp" diff --git a/src/web/handlers/permission_mode_handler.hpp b/src/web/handlers/permission_mode_handler.hpp index 91270f669..8b9761b98 100644 --- a/src/web/handlers/permission_mode_handler.hpp +++ b/src/web/handlers/permission_mode_handler.hpp @@ -4,7 +4,7 @@ // active session's PermissionManager mode so the Web UI permission selector is // backed by daemon state instead of local-only UI state. -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include diff --git a/src/web/routes/routes_files.cpp b/src/web/routes/routes_files.cpp index 4fac5336e..ae88f4e44 100644 --- a/src/web/routes/routes_files.cpp +++ b/src/web/routes/routes_files.cpp @@ -3,7 +3,8 @@ #include "skills/skill_init.hpp" #include "config/config.hpp" #include "tool/mtime_tracker.hpp" -#include "utils/text_file_buffer.hpp" +#include "tool/text_file_errors.hpp" +#include "tool/safe_text_write.hpp" namespace acecode::web { @@ -233,7 +234,7 @@ void WebServer::Impl::register_files() { const std::string abs_path = path_to_utf8(abs_file); auto guard = MtimeTracker::instance().acquire_write_guard(abs_path); - auto decoded = read_text_file_buffer(abs_path, false); + auto decoded = with_text_file_tool_errors(read_text_file_buffer(abs_path, false)); if (!decoded.success) { return editable_error_response(req, 415, "unsafe text", decoded.error); } @@ -298,7 +299,7 @@ void WebServer::Impl::register_files() { } const std::string abs_path = path_to_utf8(abs_file); auto guard = MtimeTracker::instance().acquire_write_guard(abs_path); - auto current = read_text_file_buffer(abs_path, false); + auto current = with_text_file_tool_errors(read_text_file_buffer(abs_path, false)); if (!current.success) { return editable_error_response(req, 415, "unsafe text", current.error); } @@ -315,7 +316,7 @@ void WebServer::Impl::register_files() { } const std::string normalized_text = normalize_text_to_lf(text); - auto encoded = encode_text_for_write(normalized_text, current.buffer.metadata); + auto encoded = with_text_file_tool_errors(encode_text_for_write(normalized_text, current.buffer.metadata)); if (!encoded.success) { return editable_error_response(req, 415, "unsafe text", encoded.error); } @@ -328,7 +329,7 @@ void WebServer::Impl::register_files() { return editable_error_response(req, 500, "write failed", written.error); } - auto updated = read_text_file_buffer(abs_path, false); + auto updated = with_text_file_tool_errors(read_text_file_buffer(abs_path, false)); if (!updated.success) { return editable_error_response( req, 500, "post-write read failed", updated.error); diff --git a/src/web/routes/routes_misc.cpp b/src/web/routes/routes_misc.cpp index ab11939ea..37040c79f 100644 --- a/src/web/routes/routes_misc.cpp +++ b/src/web/routes/routes_misc.cpp @@ -3,9 +3,10 @@ #include "config/config_recovery.hpp" #include "config/settings_mutations.hpp" #include "feedback/feedback_upload.hpp" -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "tool/mcp_manager.hpp" // /api/mcp/toggle 运行时 enable/disable #include "utils/state_file.hpp" +#include "provider/model_probe_cache.hpp" #include "upgrade/diagnostics.hpp" #include diff --git a/src/web/routes/routes_models.cpp b/src/web/routes/routes_models.cpp index b4b1b97fd..5d7a6cc99 100644 --- a/src/web/routes/routes_models.cpp +++ b/src/web/routes/routes_models.cpp @@ -4,7 +4,8 @@ #include "web/handlers/model_connection_test_handler.hpp" #include "config/settings_mutations.hpp" #include "provider/models_dev_registry.hpp" -#include "utils/models_dev_catalog.hpp" +#include "config/models_dev_catalog.hpp" +#include "provider/models_dev_catalog_cache.hpp" #include diff --git a/tests/README.md b/tests/README.md index 39840b2fe..da5781c88 100644 --- a/tests/README.md +++ b/tests/README.md @@ -18,7 +18,7 @@ Test files live at `tests//_test.cpp`: |--------------------------------------|------------------------------------------------| | `src/platform/terminal/terminal_title.cpp` | `tests/platform/terminal_title_test.cpp` | | `src/session/session_storage.cpp` | `tests/session/session_storage_test.cpp` | -| `src/permissions.hpp` | `tests/permissions_test.cpp` | +| `src/permissions/permissions.hpp` | `tests/permissions/permissions_test.cpp` | Adding a new test file needs no CMake edit — `tests/CMakeLists.txt` globs `*_test.cpp` (note: singular `_test`, not `_tests`). diff --git a/tests/agent_loop/agent_loop_apply_patch_test.cpp b/tests/agent_loop/agent_loop_apply_patch_test.cpp index 0a3c5414a..4487bcb2e 100644 --- a/tests/agent_loop/agent_loop_apply_patch_test.cpp +++ b/tests/agent_loop/agent_loop_apply_patch_test.cpp @@ -10,7 +10,7 @@ #include "agent_loop.hpp" #include "config/config.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "test_support/agent/stub_provider.hpp" #include "tool/builtin_tool_registry.hpp" #include "tool/tool_executor.hpp" diff --git a/tests/agent_loop/agent_loop_ask_user_question_parallel_test.cpp b/tests/agent_loop/agent_loop_ask_user_question_parallel_test.cpp index 72cf6204d..bcd9bfb7d 100644 --- a/tests/agent_loop/agent_loop_ask_user_question_parallel_test.cpp +++ b/tests/agent_loop/agent_loop_ask_user_question_parallel_test.cpp @@ -28,7 +28,7 @@ #include "tool/ask_user_question_tool.hpp" #include "tool/task_complete_tool.hpp" #include "tool/tool_executor.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "session/ask_user_question_prompter.hpp" #include "session/event_dispatcher.hpp" diff --git a/tests/agent_loop/agent_loop_compact_events_test.cpp b/tests/agent_loop/agent_loop_compact_events_test.cpp index 8a6c83516..6716dcae1 100644 --- a/tests/agent_loop/agent_loop_compact_events_test.cpp +++ b/tests/agent_loop/agent_loop_compact_events_test.cpp @@ -2,7 +2,7 @@ #include "agent_loop.hpp" #include "commands/compact_prompt.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "session/compact_checkpoint.hpp" #include "session/compact_notice.hpp" diff --git a/tests/agent_loop/agent_loop_computer_use_scheduling_test.cpp b/tests/agent_loop/agent_loop_computer_use_scheduling_test.cpp index 893aa668b..fc75163ed 100644 --- a/tests/agent_loop/agent_loop_computer_use_scheduling_test.cpp +++ b/tests/agent_loop/agent_loop_computer_use_scheduling_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "test_support/agent/stub_provider.hpp" #include "tool/computer_use_tool.hpp" #include "test_support/sandbox/test_support.hpp" diff --git a/tests/agent_loop/agent_loop_doom_guard_test.cpp b/tests/agent_loop/agent_loop_doom_guard_test.cpp index 7e724f133..36d8e4920 100644 --- a/tests/agent_loop/agent_loop_doom_guard_test.cpp +++ b/tests/agent_loop/agent_loop_doom_guard_test.cpp @@ -2,7 +2,7 @@ #include "agent_loop.hpp" #include "agent_loop_doom_guard.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "test_support/agent/stub_provider.hpp" #include "tool/tool_executor.hpp" diff --git a/tests/agent_loop/agent_loop_empty_response_integration_test.cpp b/tests/agent_loop/agent_loop_empty_response_integration_test.cpp index c88b880ba..9d14b4b45 100644 --- a/tests/agent_loop/agent_loop_empty_response_integration_test.cpp +++ b/tests/agent_loop/agent_loop_empty_response_integration_test.cpp @@ -14,7 +14,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "provider/openai_provider.hpp" #include "tool/tool_executor.hpp" diff --git a/tests/agent_loop/agent_loop_goal_test.cpp b/tests/agent_loop/agent_loop_goal_test.cpp index 8bb758e61..0871e22a9 100644 --- a/tests/agent_loop/agent_loop_goal_test.cpp +++ b/tests/agent_loop/agent_loop_goal_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "session/thread_goal_store.hpp" diff --git a/tests/agent_loop/agent_loop_metadata_injection_test.cpp b/tests/agent_loop/agent_loop_metadata_injection_test.cpp index 047a4ec07..08baceaf4 100644 --- a/tests/agent_loop/agent_loop_metadata_injection_test.cpp +++ b/tests/agent_loop/agent_loop_metadata_injection_test.cpp @@ -14,7 +14,7 @@ #include "tool/task_complete_tool.hpp" #include "tool/tool_executor.hpp" #include "utils/diff_utils.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "session/tool_metadata_codec.hpp" diff --git a/tests/agent_loop/agent_loop_pa_rescue_test.cpp b/tests/agent_loop/agent_loop_pa_rescue_test.cpp index fa0493385..49d926bee 100644 --- a/tests/agent_loop/agent_loop_pa_rescue_test.cpp +++ b/tests/agent_loop/agent_loop_pa_rescue_test.cpp @@ -7,7 +7,7 @@ #include "agent_loop.hpp" #include "pa/pa_context_budget.hpp" #include "pa/pa_overflow_rescue.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" diff --git a/tests/agent_loop/agent_loop_plan_mode_test.cpp b/tests/agent_loop/agent_loop_plan_mode_test.cpp index 56bef04e9..23ccebe5c 100644 --- a/tests/agent_loop/agent_loop_plan_mode_test.cpp +++ b/tests/agent_loop/agent_loop_plan_mode_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "test_support/agent/stub_provider.hpp" diff --git a/tests/agent_loop/agent_loop_question_interjection_test.cpp b/tests/agent_loop/agent_loop_question_interjection_test.cpp index e3dd8298d..1af476cf8 100644 --- a/tests/agent_loop/agent_loop_question_interjection_test.cpp +++ b/tests/agent_loop/agent_loop_question_interjection_test.cpp @@ -22,7 +22,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "session/ask_user_question_prompter.hpp" #include "session/event_dispatcher.hpp" diff --git a/tests/agent_loop/agent_loop_session_summary_event_test.cpp b/tests/agent_loop/agent_loop_session_summary_event_test.cpp index ea18875d4..f6ca19747 100644 --- a/tests/agent_loop/agent_loop_session_summary_event_test.cpp +++ b/tests/agent_loop/agent_loop_session_summary_event_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "test_support/agent/stub_provider.hpp" diff --git a/tests/agent_loop/agent_loop_termination_test.cpp b/tests/agent_loop/agent_loop_termination_test.cpp index db6a05ead..b6eec4256 100644 --- a/tests/agent_loop/agent_loop_termination_test.cpp +++ b/tests/agent_loop/agent_loop_termination_test.cpp @@ -26,7 +26,7 @@ #include "tool/task_complete_tool.hpp" #include "tool/tool_executor.hpp" #include "llm/tool_protocol_names.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "provider/retry_policy.hpp" #include "provider/text_tool_call_recovery.hpp" diff --git a/tests/agent_loop/agent_loop_todo_write_test.cpp b/tests/agent_loop/agent_loop_todo_write_test.cpp index d44b31f70..9180d0fe8 100644 --- a/tests/agent_loop/agent_loop_todo_write_test.cpp +++ b/tests/agent_loop/agent_loop_todo_write_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "test_support/agent/stub_provider.hpp" #include "tool/todo_write_tool.hpp" diff --git a/tests/agent_loop/agent_loop_tool_lifecycle_events_test.cpp b/tests/agent_loop/agent_loop_tool_lifecycle_events_test.cpp index 026659902..9b9f5d6ca 100644 --- a/tests/agent_loop/agent_loop_tool_lifecycle_events_test.cpp +++ b/tests/agent_loop/agent_loop_tool_lifecycle_events_test.cpp @@ -6,7 +6,7 @@ #include "agent_loop.hpp" #include "workspace/workspace_registry.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "session/event_dispatcher.hpp" #include "session/session_manager.hpp" diff --git a/tests/agent_loop/agent_loop_tool_preamble_test.cpp b/tests/agent_loop/agent_loop_tool_preamble_test.cpp index 0dd212e49..95f3156e5 100644 --- a/tests/agent_loop/agent_loop_tool_preamble_test.cpp +++ b/tests/agent_loop/agent_loop_tool_preamble_test.cpp @@ -16,7 +16,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "llm/llm_provider.hpp" #include "session/event_dispatcher.hpp" #include "session/session_manager.hpp" diff --git a/tests/agent_loop/agent_loop_tool_protocol_names_test.cpp b/tests/agent_loop/agent_loop_tool_protocol_names_test.cpp index 36b221586..23d47c1d5 100644 --- a/tests/agent_loop/agent_loop_tool_protocol_names_test.cpp +++ b/tests/agent_loop/agent_loop_tool_protocol_names_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "provider/dsml_tool_call_recovery.hpp" #include "provider/text_tool_call_recovery.hpp" #include "test_support/agent/stub_provider.hpp" diff --git a/tests/agent_loop/agent_loop_tool_result_storage_test.cpp b/tests/agent_loop/agent_loop_tool_result_storage_test.cpp index 914f25dd9..e7d33d37b 100644 --- a/tests/agent_loop/agent_loop_tool_result_storage_test.cpp +++ b/tests/agent_loop/agent_loop_tool_result_storage_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "session/tool_result_storage.hpp" diff --git a/tests/agent_loop/agent_loop_trajectory_test.cpp b/tests/agent_loop/agent_loop_trajectory_test.cpp index 971a68d13..4ea504452 100644 --- a/tests/agent_loop/agent_loop_trajectory_test.cpp +++ b/tests/agent_loop/agent_loop_trajectory_test.cpp @@ -1,5 +1,5 @@ #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "session/session_trajectory.hpp" diff --git a/tests/agent_loop/agent_loop_turn_steering_test.cpp b/tests/agent_loop/agent_loop_turn_steering_test.cpp index a55393a8e..2d5b246bf 100644 --- a/tests/agent_loop/agent_loop_turn_steering_test.cpp +++ b/tests/agent_loop/agent_loop_turn_steering_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "session/turn_timing.hpp" diff --git a/tests/agent_loop/agent_loop_worker_recovery_test.cpp b/tests/agent_loop/agent_loop_worker_recovery_test.cpp index 9e322a9ce..a7ae15517 100644 --- a/tests/agent_loop/agent_loop_worker_recovery_test.cpp +++ b/tests/agent_loop/agent_loop_worker_recovery_test.cpp @@ -1,7 +1,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "test_support/agent/stub_provider.hpp" #include "tool/tool_executor.hpp" diff --git a/tests/agent_loop/agent_loop_workspace_folders_test.cpp b/tests/agent_loop/agent_loop_workspace_folders_test.cpp index a3a710960..ce69bd02e 100644 --- a/tests/agent_loop/agent_loop_workspace_folders_test.cpp +++ b/tests/agent_loop/agent_loop_workspace_folders_test.cpp @@ -11,7 +11,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/event_dispatcher.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" diff --git a/tests/commands/builtin_commands_test.cpp b/tests/commands/builtin_commands_test.cpp index 9f3b67d56..5d0063089 100644 --- a/tests/commands/builtin_commands_test.cpp +++ b/tests/commands/builtin_commands_test.cpp @@ -7,7 +7,7 @@ #include "config/saved_models_revision.hpp" #include "hooks/hook_manager.hpp" #include "hooks/hook_runtime.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "provider/provider_factory.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" @@ -15,7 +15,7 @@ #include "tool/mcp_scope.hpp" #include "config/mcp_config.hpp" #include "tool/tool_executor.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include "utils/paths.hpp" #include "test_support/agent/stub_provider.hpp" diff --git a/tests/commands/configure_catalog_test.cpp b/tests/commands/configure_catalog_test.cpp index 55780ed10..ce1ab70dc 100644 --- a/tests/commands/configure_catalog_test.cpp +++ b/tests/commands/configure_catalog_test.cpp @@ -10,7 +10,7 @@ #include #include "commands/configure_catalog.hpp" -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include diff --git a/tests/commands/goal_command_test.cpp b/tests/commands/goal_command_test.cpp index 4f2f0cb98..e2926855f 100644 --- a/tests/commands/goal_command_test.cpp +++ b/tests/commands/goal_command_test.cpp @@ -4,12 +4,12 @@ #include "commands/command_registry.hpp" #include "commands/goal_command.hpp" #include "config/config.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "session/thread_goal_store.hpp" #include "tool/tool_executor.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include #include diff --git a/tests/commands/remote_control_command_test.cpp b/tests/commands/remote_control_command_test.cpp index 702507ee0..a0076341a 100644 --- a/tests/commands/remote_control_command_test.cpp +++ b/tests/commands/remote_control_command_test.cpp @@ -3,13 +3,13 @@ #include "agent_loop.hpp" #include "config/config.hpp" #include "commands/remote_control_command.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "remote_control/channel_plugin.hpp" #include "remote_control/remote_control_service.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" #include "tool/tool_executor.hpp" -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" #include #include diff --git a/tests/computer_use/helpers/native_smoke.cpp b/tests/computer_use/helpers/native_smoke.cpp index 81d86ab32..7d7b9f48d 100644 --- a/tests/computer_use/helpers/native_smoke.cpp +++ b/tests/computer_use/helpers/native_smoke.cpp @@ -2,7 +2,7 @@ // window and controls; never register this interactive executable with ctest. #include "computer_use/native_windows.hpp" #include "computer_use/element_target.hpp" -#include "computer_use/pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" #include "test_support/computer_use/native_control_checks.hpp" #include "test_support/computer_use/native_smoke_backend.hpp" #include "test_support/computer_use/ole_drag_fixture.hpp" diff --git a/tests/provider/builtin_model_catalog_test.cpp b/tests/config/builtin_model_catalog_test.cpp similarity index 98% rename from tests/provider/builtin_model_catalog_test.cpp rename to tests/config/builtin_model_catalog_test.cpp index afd861b9a..0928066d7 100644 --- a/tests/provider/builtin_model_catalog_test.cpp +++ b/tests/config/builtin_model_catalog_test.cpp @@ -1,6 +1,6 @@ #include -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "utils/constants.hpp" #include diff --git a/tests/utils/models_dev_catalog_test.cpp b/tests/config/models_dev_catalog_test.cpp similarity index 99% rename from tests/utils/models_dev_catalog_test.cpp rename to tests/config/models_dev_catalog_test.cpp index c2ab1cf36..6d9c106c7 100644 --- a/tests/utils/models_dev_catalog_test.cpp +++ b/tests/config/models_dev_catalog_test.cpp @@ -7,7 +7,7 @@ #include -#include "utils/models_dev_catalog.hpp" +#include "config/models_dev_catalog.hpp" #include diff --git a/tests/computer_use/pointer_appearance_test.cpp b/tests/config/pointer_appearance_test.cpp similarity index 97% rename from tests/computer_use/pointer_appearance_test.cpp rename to tests/config/pointer_appearance_test.cpp index 6454df41b..816512b28 100644 --- a/tests/computer_use/pointer_appearance_test.cpp +++ b/tests/config/pointer_appearance_test.cpp @@ -1,4 +1,4 @@ -#include "computer_use/pointer_appearance.hpp" +#include "config/vocab/pointer_appearance.hpp" #include "computer_use/runtime.hpp" #include diff --git a/tests/desktop/workspace_state_test.cpp b/tests/desktop/workspace_state_test.cpp new file mode 100644 index 000000000..53f63b8de --- /dev/null +++ b/tests/desktop/workspace_state_test.cpp @@ -0,0 +1,57 @@ +// 场景:迁移原状态持久化用例,保持文件格式、容错与失败返回语义。 +#include +#include "desktop/workspace_state.hpp" +#include "test_support/utils/state_file_fixture.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::test_support::StateFileTest; +using acecode::test_support::write_raw; + +// 场景:last_active_workspace_hash 序列化往返 — desktop 多 workspace 模型靠这条 +// 跨启动持久化"上次活跃 workspace"。 +TEST_F(StateFileTest, LastActiveWorkspaceHashRoundTrip) { + EXPECT_EQ(acecode::read_last_active_workspace_hash(), ""); // 初始空 + acecode::write_last_active_workspace_hash("abc1234567890def"); + EXPECT_EQ(acecode::read_last_active_workspace_hash(), "abc1234567890def"); + // 覆盖写 + acecode::write_last_active_workspace_hash("ffffffffffffffff"); + EXPECT_EQ(acecode::read_last_active_workspace_hash(), "ffffffffffffffff"); + // 共存其他 key 不互相覆盖 + acecode::write_state_flag("some_flag", true); + EXPECT_EQ(acecode::read_last_active_workspace_hash(), "ffffffffffffffff"); + EXPECT_TRUE(acecode::read_state_flag("some_flag")); +} + +// 场景:last_active_workspace_hash 字段类型不对(数字)→ read 返回空字符串而不是抛 +TEST_F(StateFileTest, LastActiveWrongTypeReadsEmpty) { + write_raw(path_, R"({"last_active_workspace_hash": 12345})"); + EXPECT_EQ(acecode::read_last_active_workspace_hash(), ""); +} + +// 场景:首页 workspace 选择器跨 desktop 启动保存上次选择。 +// 空字符串是有效选择,表示"不使用工作区"。 +TEST_F(StateFileTest, LastHomeWorkspaceHashRoundTripAllowsEmpty) { + EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); + acecode::write_last_home_workspace_hash("abc1234567890def"); + EXPECT_EQ(acecode::read_last_home_workspace_hash(), "abc1234567890def"); + + acecode::write_last_home_workspace_hash(""); + EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); + + acecode::write_state_flag("some_flag", true); + EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); + EXPECT_TRUE(acecode::read_state_flag("some_flag")); +} + +// 场景:last_home_workspace_hash 字段类型不对 → read 返回空字符串而不是抛。 +TEST_F(StateFileTest, LastHomeWorkspaceWrongTypeReadsEmpty) { + write_raw(path_, R"({"last_home_workspace_hash": 12345})"); + EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); +} diff --git a/tests/experts/expert_registry_test.cpp b/tests/experts/expert_registry_test.cpp index 17f5ca981..170bf107f 100644 --- a/tests/experts/expert_registry_test.cpp +++ b/tests/experts/expert_registry_test.cpp @@ -2,7 +2,7 @@ #include "experts/expert_registry.hpp" #include "test_support/agent/stub_provider.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "prompt/system_prompt.hpp" #include "session/session_registry.hpp" #include "session/session_storage.hpp" diff --git a/tests/gitinfo/web_worktree_intent_test.cpp b/tests/gitinfo/web_worktree_intent_test.cpp index 2b67908e9..b72c28bd8 100644 --- a/tests/gitinfo/web_worktree_intent_test.cpp +++ b/tests/gitinfo/web_worktree_intent_test.cpp @@ -11,7 +11,7 @@ #include -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "config/config.hpp" #include "session/session_registry.hpp" #include "tool/tool_executor.hpp" diff --git a/tests/headless/headless_ask_result_test.cpp b/tests/headless/headless_ask_result_test.cpp index 2f4132ed5..0a2437e81 100644 --- a/tests/headless/headless_ask_result_test.cpp +++ b/tests/headless/headless_ask_result_test.cpp @@ -1,6 +1,6 @@ #include -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "tool/ask_user_question_tool.hpp" #include "tool/tool_executor.hpp" diff --git a/tests/hooks/hook_agent_loop_test.cpp b/tests/hooks/hook_agent_loop_test.cpp index 9142191cb..9d8c3af55 100644 --- a/tests/hooks/hook_agent_loop_test.cpp +++ b/tests/hooks/hook_agent_loop_test.cpp @@ -1,11 +1,11 @@ #include #include "agent_loop.hpp" -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "hooks/hook_config.hpp" #include "hooks/hook_manager.hpp" #include "hooks/hook_runtime.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "tool/tool_executor.hpp" #include "test_support/agent/stub_provider.hpp" diff --git a/tests/llm/tool_protocol_names_test.cpp b/tests/llm/tool_protocol_names_test.cpp index 7a37aabf7..b3d7c5a48 100644 --- a/tests/llm/tool_protocol_names_test.cpp +++ b/tests/llm/tool_protocol_names_test.cpp @@ -4,7 +4,7 @@ #include "tool/builtin_tool_registry.hpp" #include "tool/tool_executor.hpp" #include "llm/tool_protocol_names.hpp" -#include "utils/tool_errors.hpp" +#include "tool/tool_errors.hpp" #include #include diff --git a/tests/loop/loop_scheduler_test.cpp b/tests/loop/loop_scheduler_test.cpp index 83a399349..2e5f18b1c 100644 --- a/tests/loop/loop_scheduler_test.cpp +++ b/tests/loop/loop_scheduler_test.cpp @@ -1,7 +1,7 @@ #include "loop/loop_scheduler.hpp" #include "config/config.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_registry.hpp" #include "tool/tool_executor.hpp" #include "utils/utf8_path.hpp" diff --git a/tests/upgrade/upgrade_http_test.cpp b/tests/network/upgrade_http_test.cpp similarity index 99% rename from tests/upgrade/upgrade_http_test.cpp rename to tests/network/upgrade_http_test.cpp index f95eaa58e..babce0d76 100644 --- a/tests/upgrade/upgrade_http_test.cpp +++ b/tests/network/upgrade_http_test.cpp @@ -1,7 +1,7 @@ #include "config/config.hpp" #include "upgrade/check.hpp" #include "upgrade/diagnostics.hpp" -#include "upgrade/http.hpp" +#include "network/http.hpp" #include "upgrade/manifest.hpp" #include "upgrade/upgrade.hpp" #include "utils/sha256.hpp" diff --git a/tests/permissions_test.cpp b/tests/permissions/permissions_test.cpp similarity index 99% rename from tests/permissions_test.cpp rename to tests/permissions/permissions_test.cpp index e06440c08..a7e5fb019 100644 --- a/tests/permissions_test.cpp +++ b/tests/permissions/permissions_test.cpp @@ -7,7 +7,7 @@ #include -#include "permissions.hpp" +#include "permissions/permissions.hpp" using acecode::PermissionManager; using acecode::PermissionMode; diff --git a/tests/agent_loop/shell_write_guard_test.cpp b/tests/permissions/shell_write_guard_test.cpp similarity index 99% rename from tests/agent_loop/shell_write_guard_test.cpp rename to tests/permissions/shell_write_guard_test.cpp index b6eb0a88d..94ef6ae4e 100644 --- a/tests/agent_loop/shell_write_guard_test.cpp +++ b/tests/permissions/shell_write_guard_test.cpp @@ -1,6 +1,6 @@ #include -#include "agent_loop_shell_guard.hpp" +#include "permissions/shell_write_guard.hpp" #include #include diff --git a/tests/provider/model_probe_cache_test.cpp b/tests/provider/model_probe_cache_test.cpp new file mode 100644 index 000000000..475b5434a --- /dev/null +++ b/tests/provider/model_probe_cache_test.cpp @@ -0,0 +1,121 @@ +// 场景:迁移原状态持久化用例,保持文件格式、容错与失败返回语义。 +#include +#include "provider/model_probe_cache.hpp" +#include "test_support/utils/state_file_fixture.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::test_support::StateFileTest; +using acecode::test_support::write_raw; + +TEST_F(StateFileTest, ModelProbeCacheRoundTripsAndPreservesOtherState) { + const std::string fingerprint(64, 'a'); + acecode::write_state_flag("some_flag", true); + + acecode::ModelProbeCacheEntry entry; + entry.models = {"starrylight", "moonlight", "starrylight"}; + entry.context_windows = { + {"starrylight", 200000}, + {"moonlight", 128000}, + {"not-in-models", 999}, + }; + entry.probed_at_ms = 123456789; + + ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); + auto cached = acecode::read_model_probe_cache(fingerprint); + ASSERT_TRUE(cached.has_value()); + EXPECT_EQ(cached->models, + (std::vector{"starrylight", "moonlight"})); + EXPECT_EQ(cached->context_windows, + (std::map{{"moonlight", 128000}, + {"starrylight", 200000}})); + EXPECT_EQ(cached->probed_at_ms, 123456789); + EXPECT_TRUE(acecode::read_state_flag("some_flag")); + + entry.models = {"aurora"}; + entry.context_windows = {{"aurora", 200000}}; + entry.probed_at_ms = 123456790; + ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); + cached = acecode::read_model_probe_cache(fingerprint); + ASSERT_TRUE(cached.has_value()); + EXPECT_EQ(cached->models, (std::vector{"aurora"})); + EXPECT_EQ(cached->context_windows.at("aurora"), 200000); + EXPECT_EQ(cached->probed_at_ms, 123456790); +} + +TEST_F(StateFileTest, ModelProbeCacheRejectsInvalidFingerprintAndMalformedEntry) { + acecode::ModelProbeCacheEntry entry; + entry.models = {"model-a"}; + EXPECT_FALSE(acecode::write_model_probe_cache("not-a-sha256", entry)); + EXPECT_FALSE(acecode::read_model_probe_cache("not-a-sha256").has_value()); + EXPECT_FALSE(fs::exists(path_)); + + const std::string fingerprint(64, 'b'); + nlohmann::json malformed = { + {"model_probe_cache", + {{fingerprint, + {{"version", 1}, {"models", "not-an-array"}}}}}, + }; + write_raw(path_, malformed.dump()); + EXPECT_FALSE(acecode::read_model_probe_cache(fingerprint).has_value()); +} + +TEST_F(StateFileTest, ModelProbeCachePreservesReasoningAndExplicitRemoval) { + const std::string fingerprint(64, 'd'); + acecode::ModelReasoningOptions reasoning; + reasoning.supported = true; + reasoning.default_enabled = true; + reasoning.supported_efforts = {"low", "high"}; + reasoning.default_effort = "high"; + acecode::ModelProbeCacheEntry entry; + entry.models = {"supported", "unknown"}; + entry.reasoning["supported"] = reasoning; + entry.reasoning["not-in-models"] = reasoning; + ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); + auto restored = acecode::read_model_probe_cache(fingerprint); + ASSERT_TRUE(restored.has_value()); + ASSERT_TRUE(restored->reasoning.at("supported").has_value()); + EXPECT_EQ(restored->reasoning.at("supported")->supported_efforts, + reasoning.supported_efforts); + EXPECT_EQ(restored->reasoning.at("supported")->default_effort, "high"); + EXPECT_FALSE(restored->reasoning.at("unknown").has_value()); + EXPECT_EQ(restored->reasoning.count("not-in-models"), 0u); + + entry.reasoning.clear(); + ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); + restored = acecode::read_model_probe_cache(fingerprint); + ASSERT_TRUE(restored.has_value()); + ASSERT_EQ(restored->reasoning.size(), 2u); + EXPECT_FALSE(restored->reasoning.at("supported").has_value()); + EXPECT_FALSE(restored->reasoning.at("unknown").has_value()); +} + +TEST_F(StateFileTest, LegacyOrInvalidProbeReasoningRestoresAsExplicitNull) { + const std::string fingerprint(64, 'e'); + nlohmann::json cache{ + {"version", 1}, {"models", {"model"}}, {"probed_at_ms", 123}}; + auto write_cache = [&] { + write_raw(path_, nlohmann::json{ + {"model_probe_cache", {{fingerprint, cache}}}}.dump()); + }; + write_cache(); + auto restored = acecode::read_model_probe_cache(fingerprint); + ASSERT_TRUE(restored.has_value()); + EXPECT_FALSE(restored->reasoning.at("model").has_value()); + + cache["model_reasoning"] = {{"model", { + {"supported", true}, {"mandatory", false}, {"default_enabled", true}, + {"supports_max_tokens", false}, {"supported_efforts", {"unknown"}}, + }}}; + write_cache(); + restored = acecode::read_model_probe_cache(fingerprint); + ASSERT_TRUE(restored.has_value()); + EXPECT_FALSE(restored->reasoning.at("model").has_value()); + EXPECT_EQ(restored->probed_at_ms, 123); +} diff --git a/tests/remote_control/session_channel_binder_test.cpp b/tests/remote_control/session_channel_binder_test.cpp index b84cb077f..83a071f34 100644 --- a/tests/remote_control/session_channel_binder_test.cpp +++ b/tests/remote_control/session_channel_binder_test.cpp @@ -277,7 +277,7 @@ TEST(KeepaliveDecider, HealthProbeDueAfterInterval) { #include "remote_control/remote_control_service.hpp" #include "config/config.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/local_session_client.hpp" #include "session/session_registry.hpp" #include "tool/ask_user_question_tool.hpp" diff --git a/tests/session/session_registry_test.cpp b/tests/session/session_registry_test.cpp index 80d59b2af..e7de041ce 100644 --- a/tests/session/session_registry_test.cpp +++ b/tests/session/session_registry_test.cpp @@ -12,7 +12,7 @@ #include -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "config/config.hpp" #include "config/saved_models.hpp" #include "config/saved_models_revision.hpp" diff --git a/tests/session/session_resume_restore_test.cpp b/tests/session/session_resume_restore_test.cpp index 57f323a95..a5dc921f3 100644 --- a/tests/session/session_resume_restore_test.cpp +++ b/tests/session/session_resume_restore_test.cpp @@ -5,7 +5,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/compact_checkpoint.hpp" #include "session/session_resume_restore.hpp" #include "tool/mtime_tracker.hpp" diff --git a/tests/utils/token_tracker_test.cpp b/tests/session/token_tracker_test.cpp similarity index 98% rename from tests/utils/token_tracker_test.cpp rename to tests/session/token_tracker_test.cpp index 6cdc1ab4d..37e0a01c6 100644 --- a/tests/utils/token_tracker_test.cpp +++ b/tests/session/token_tracker_test.cpp @@ -1,6 +1,6 @@ #include -#include "utils/token_tracker.hpp" +#include "session/token_tracker.hpp" TEST(TokenTracker, ContextPercentRoundsAndClamps) { EXPECT_EQ(acecode::TokenTracker::context_percent_for(0, 128000), 0); diff --git a/tests/skill_registry_test.cpp b/tests/skill_registry_test.cpp index 20b423f77..7d8abb522 100644 --- a/tests/skill_registry_test.cpp +++ b/tests/skill_registry_test.cpp @@ -2,7 +2,7 @@ #include "agent_loop.hpp" #include "commands/command_registry.hpp" #include "config/config.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "tool/tool_executor.hpp" #include "utils/encoding.hpp" #include "utils/utf8_path.hpp" diff --git a/tests/skills/skill_activation_test.cpp b/tests/skills/skill_activation_test.cpp index 519aaa8d7..3c5452559 100644 --- a/tests/skills/skill_activation_test.cpp +++ b/tests/skills/skill_activation_test.cpp @@ -8,7 +8,7 @@ #include #include "agent_loop.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "skills/skill_activation.hpp" #include "skills/skill_registry.hpp" #include "tool/tool_executor.hpp" diff --git a/tests/test_support/agent_loop/characterization_fixture.hpp b/tests/test_support/agent_loop/characterization_fixture.hpp index b8dd072b3..3f5c1d9c3 100644 --- a/tests/test_support/agent_loop/characterization_fixture.hpp +++ b/tests/test_support/agent_loop/characterization_fixture.hpp @@ -2,7 +2,7 @@ #include "agent_loop.hpp" #include "test_support/agent/stub_provider.hpp" -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "hooks/hook_manager.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" diff --git a/tests/test_support/utils/state_file_fixture.hpp b/tests/test_support/utils/state_file_fixture.hpp new file mode 100644 index 000000000..5007f6791 --- /dev/null +++ b/tests/test_support/utils/state_file_fixture.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include "utils/state_file.hpp" + +#include +#include +#include +#include + +namespace acecode::test_support { +namespace fs = std::filesystem; + +class StateFileTest : public ::testing::Test { +protected: + void SetUp() override { + // 在临时目录里给本测试一份独立 state.json 路径。 + auto tmp = fs::temp_directory_path() / + ("acecode_state_test_" + std::to_string(std::rand())); + fs::create_directories(tmp); + path_ = (tmp / "state.json").string(); + acecode::set_state_file_path_for_test(path_); + } + void TearDown() override { + acecode::set_state_file_writes_paused(false); + acecode::set_state_file_path_for_test(""); + std::error_code ec; + fs::remove_all(fs::path(path_).parent_path(), ec); + } + std::string path_; +}; + +inline void write_raw(const std::string& path, const std::string& contents) { + std::ofstream ofs(path, std::ios::binary | std::ios::trunc); + ofs << contents; +} + +} // namespace acecode::test_support diff --git a/tests/tool/safe_text_write_test.cpp b/tests/tool/safe_text_write_test.cpp new file mode 100644 index 000000000..e194ab68b --- /dev/null +++ b/tests/tool/safe_text_write_test.cpp @@ -0,0 +1,110 @@ +#include + +#include "llm/tool_protocol_names.hpp" +#include "tool/file_write_tool.hpp" +#include "tool/safe_text_write.hpp" +#include "tool/text_file_errors.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +fs::path temp_path(const std::string& suffix) { + static std::atomic seq{0}; + return fs::temp_directory_path() / + ("acecode_safe_text_write_" + std::to_string(++seq) + suffix); +} + +void write_bytes(const fs::path& path, const std::string& bytes) { + std::ofstream ofs(path, std::ios::binary); + ofs.write(bytes.data(), static_cast(bytes.size())); +} + +std::string read_bytes(const fs::path& path) { + std::ifstream ifs(path, std::ios::binary); + return std::string(std::istreambuf_iterator(ifs), + std::istreambuf_iterator()); +} + +} // namespace + +// 场景:目标编码不可写时,安全写必须拒绝并保留原文件。沿用搬迁前的用例名。 +TEST(TextFileBuffer, SafeWriteRollsBackOnVerificationMismatch) { + auto path = temp_path(".txt"); + write_bytes(path, "before\n"); + + auto metadata = acecode::default_new_file_text_metadata(); + metadata.encoding = acecode::TextEncoding::Unsupported; + + auto result = acecode::safe_write_text_file(path.string(), "after\n", metadata); + EXPECT_FALSE(result.success); + EXPECT_EQ(read_bytes(path), "before\n"); + + fs::remove(path); +} + +// 回归:文案迁出底层后,严格读取和写工具仍须使用当前工具名映射,且不能覆盖歧义编码文件。 +TEST(TextFileToolErrors, AmbiguousFilesKeepMappedGuidanceAndOriginalBytes) { + acecode::ScopedModelToolNameMappings names({{"file_read", "inspect_file"}}); + const std::string damaged = std::string(u8"中文\n") + static_cast(0xE4); + struct Input { + std::string bytes; + std::string expected_error; + }; + const Input inputs[] = { + {std::string("\xEF\xBB\xBF", 3) + damaged, + "[Error] UTF-8 BOM file can be read with inspect_file using lossy decoding, but its bytes are too ambiguous to edit safely."}, + {damaged, + "[Error] File can be read with inspect_file using lossy UTF-8 decoding, but its encoding is too ambiguous to edit safely."}, + {std::string(1, static_cast(0x81)), + "[Error] File can be read with inspect_file using lossy decoding, but its encoding is too ambiguous to edit safely."}, + }; + auto tool = acecode::create_file_write_tool(); + for (const auto& input : inputs) { + const auto path = temp_path(".txt"); + write_bytes(path, input.bytes); + const auto utf8_path = acecode::path_to_utf8(path); + const auto result = tool.execute( + nlohmann::json{{"file_path", utf8_path}, {"content", "replacement"}}.dump(), + acecode::ToolContext{}); + EXPECT_FALSE(result.success); + EXPECT_EQ(result.output, input.expected_error); + EXPECT_EQ(read_bytes(path), input.bytes); + + // 文件 API 也从严格读取结果取得文案;两处错误字段必须保持一致。 + const auto decoded = acecode::with_text_file_tool_errors( + acecode::read_text_file_buffer(utf8_path)); + EXPECT_EQ(decoded.error, input.expected_error); + EXPECT_EQ(decoded.buffer.metadata.error, input.expected_error); + fs::remove(path); + } +} + +// 场景:有损读取的元数据不能用于回写;提示保留工具别名,写前回调也不得触发。 +TEST(TextFileToolErrors, LossyWriteKeepsMappedGuidanceAndDoesNotRunWriteHook) { + acecode::ScopedModelToolNameMappings names({{"file_read", "inspect_file"}}); + const auto path = temp_path(".txt"); + const std::string original = std::string(u8"中文\n") + static_cast(0xE4); + write_bytes(path, original); + const auto utf8_path = acecode::path_to_utf8(path); + auto decoded = acecode::read_text_file_buffer(utf8_path, true); + ASSERT_TRUE(decoded.success) << decoded.error; + ASSERT_TRUE(decoded.buffer.metadata.lossy); + bool hook_called = false; + const auto result = acecode::safe_write_text_file( + utf8_path, "replacement", decoded.buffer.metadata, + [&hook_called](const std::string&) { hook_called = true; }); + EXPECT_FALSE(result.success); + EXPECT_FALSE(hook_called); + EXPECT_EQ(result.error, + "[Error] Target file cannot be safely written as text because it was decoded lossily. Use inspect_file for inspection and convert the file to a confirmed encoding before editing."); + EXPECT_EQ(read_bytes(path), original); + fs::remove(path); +} diff --git a/tests/tool/spawn_subagent_tool_test.cpp b/tests/tool/spawn_subagent_tool_test.cpp index e0a389d0a..af0f47f01 100644 --- a/tests/tool/spawn_subagent_tool_test.cpp +++ b/tests/tool/spawn_subagent_tool_test.cpp @@ -13,7 +13,7 @@ #include "config/config.hpp" #include "experts/expert_registry.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/local_session_client.hpp" #include "session/session_registry.hpp" #include "session/session_storage.hpp" diff --git a/tests/utils/state_json_web_search_test.cpp b/tests/tool/state_json_web_search_test.cpp similarity index 97% rename from tests/utils/state_json_web_search_test.cpp rename to tests/tool/state_json_web_search_test.cpp index a551f915b..0da848e9e 100644 --- a/tests/utils/state_json_web_search_test.cpp +++ b/tests/tool/state_json_web_search_test.cpp @@ -1,4 +1,4 @@ -// 覆盖 src/utils/state_file 中新增的 web_search region 缓存读写。 +// 覆盖 tool/web_search/region_cache 的地区缓存读写,通用 IO 仍由 utils/state_file 提供。 // 与 state_file_test 同模式,经 set_state_file_path_for_test 把读写路径切到 // 测试临时目录,避免污染真实 ~/.acecode/state.json。 // @@ -13,6 +13,7 @@ #include #include "utils/state_file.hpp" +#include "tool/web_search/region_cache.hpp" #include diff --git a/tests/tool/theme_create_tool_test.cpp b/tests/tool/theme_create_tool_test.cpp index 6eabde40f..cdff54885 100644 --- a/tests/tool/theme_create_tool_test.cpp +++ b/tests/tool/theme_create_tool_test.cpp @@ -1,7 +1,7 @@ #include #include "tool/theme_create_tool.hpp" #include "session/session_manager.hpp" -#include "headless/headless_mode.hpp" +#include "permissions/interaction_mode.hpp" #include "utils/utf8_path.hpp" #include "utils/uuid.hpp" #include "test_support/themes/theme_test_resources.hpp" diff --git a/tests/tool/tool_executor_name_resolution_test.cpp b/tests/tool/tool_executor_name_resolution_test.cpp index 79152ff45..f39869fb9 100644 --- a/tests/tool/tool_executor_name_resolution_test.cpp +++ b/tests/tool/tool_executor_name_resolution_test.cpp @@ -9,7 +9,7 @@ #include "tool/tool_executor.hpp" #include "llm/tool_protocol_names.hpp" -#include "utils/tool_errors.hpp" +#include "tool/tool_errors.hpp" #include #include diff --git a/tests/tool/web_search/backend_router_test.cpp b/tests/tool/web_search/backend_router_test.cpp index add75945d..826c3f3f8 100644 --- a/tests/tool/web_search/backend_router_test.cpp +++ b/tests/tool/web_search/backend_router_test.cpp @@ -13,6 +13,7 @@ #include "tool/web_search/backend_router.hpp" #include "config/config.hpp" +#include "tool/web_search/region_cache.hpp" #include "utils/state_file.hpp" #include diff --git a/tests/tool/web_search/region_detector_test.cpp b/tests/tool/web_search/region_detector_test.cpp index e9ea1f62c..539909b2c 100644 --- a/tests/tool/web_search/region_detector_test.cpp +++ b/tests/tool/web_search/region_detector_test.cpp @@ -17,6 +17,7 @@ #include #include "tool/web_search/region_detector.hpp" +#include "tool/web_search/region_cache.hpp" #include "utils/state_file.hpp" #include diff --git a/tests/tui/slash_command_usage_test.cpp b/tests/tui/slash_command_usage_test.cpp new file mode 100644 index 000000000..fb4075271 --- /dev/null +++ b/tests/tui/slash_command_usage_test.cpp @@ -0,0 +1,77 @@ +// 场景:迁移原状态持久化用例,保持文件格式、容错与失败返回语义。 +#include +#include "tui/slash_command_usage.hpp" +#include "test_support/utils/state_file_fixture.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::test_support::StateFileTest; +using acecode::test_support::write_raw; + +TEST_F(StateFileTest, SlashCommandUsageIncrementsAndPreservesOtherState) { + write_raw(path_, R"({"some_flag":true,"last_active_workspace_hash":"abc"})"); + + const auto first = acecode::record_tui_slash_command_use("model"); + const auto second = acecode::record_tui_slash_command_use("model"); + + EXPECT_TRUE(first.persisted); + EXPECT_EQ(first.count, 1u); + EXPECT_TRUE(second.persisted); + EXPECT_EQ(second.count, 2u); + const auto counts = acecode::read_tui_slash_command_usage(); + ASSERT_EQ(counts.size(), 1u); + EXPECT_EQ(counts.at("model"), 2u); + EXPECT_TRUE(acecode::read_state_flag("some_flag")); + EXPECT_EQ(acecode::read_state_json().at("last_active_workspace_hash").get(), "abc"); +} + +TEST_F(StateFileTest, SlashCommandUsageIgnoresInvalidEntries) { + write_raw(path_, + R"({"tui_slash_command_usage":{"good":4,"zero":0,"negative":-2,"fraction":1.5,"text":"7","bad name":9,"opsx/apply":3}})"); + + const auto counts = acecode::read_tui_slash_command_usage(); + + ASSERT_EQ(counts.size(), 2u); + EXPECT_EQ(counts.at("good"), 4u); + EXPECT_EQ(counts.at("opsx/apply"), 3u); + EXPECT_EQ(counts.count("zero"), 0u); + EXPECT_EQ(counts.count("negative"), 0u); + EXPECT_EQ(counts.count("fraction"), 0u); + EXPECT_EQ(counts.count("text"), 0u); + EXPECT_EQ(counts.count("bad name"), 0u); + + write_raw(path_, R"({"tui_slash_command_usage":[1,2,3]})"); + EXPECT_TRUE(acecode::read_tui_slash_command_usage().empty()); +} + +TEST_F(StateFileTest, SlashCommandUsageSaturatesAtUint64Max) { + const auto maximum = (std::numeric_limits::max)(); + nlohmann::json state = { + {"tui_slash_command_usage", {{"help", maximum}}}, + }; + write_raw(path_, state.dump()); + + const auto result = acecode::record_tui_slash_command_use("help"); + + EXPECT_TRUE(result.persisted); + EXPECT_EQ(result.count, maximum); + EXPECT_EQ(acecode::read_tui_slash_command_usage().at("help"), maximum); +} + +TEST_F(StateFileTest, SlashCommandUsageWriteFailureReturnsInMemoryCount) { + const fs::path directory_target = + fs::path(path_).parent_path() / "usage-state-directory"; + fs::create_directories(directory_target); + acecode::set_state_file_path_for_test(directory_target.string()); + + const auto result = acecode::record_tui_slash_command_use("help"); + + EXPECT_FALSE(result.persisted); + EXPECT_EQ(result.count, 1u); +} diff --git a/tests/tui/subagent_host_test.cpp b/tests/tui/subagent_host_test.cpp index 3dc67e3aa..17bbefc84 100644 --- a/tests/tui/subagent_host_test.cpp +++ b/tests/tui/subagent_host_test.cpp @@ -13,7 +13,7 @@ #include #include "config/config.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "session/session_storage.hpp" #include "tool/tool_executor.hpp" #include "tui/subagent_host.hpp" diff --git a/tests/utils/state_file_modules_test.cpp b/tests/utils/state_file_modules_test.cpp new file mode 100644 index 000000000..cf9dca3d3 --- /dev/null +++ b/tests/utils/state_file_modules_test.cpp @@ -0,0 +1,67 @@ +// 场景:五组业务状态迁出 utils 后仍共享同一写锁,并发写入不能覆盖其它模块的字段。 +// 保留原并发用例名称,扩展验证跨模块原子更新及数据目录迁移时的写入暂停。 +#include + +#include "desktop/workspace_state.hpp" +#include "provider/model_probe_cache.hpp" +#include "tool/web_search/region_cache.hpp" +#include "tui/slash_command_usage.hpp" +#include "test_support/utils/state_file_fixture.hpp" + +#include +#include +#include +#include + +using acecode::test_support::StateFileTest; + +TEST_F(StateFileTest, ConcurrentCheckedWritesPreserveEveryKey) { + constexpr int kWriterCount = 12; + std::vector writers; + writers.reserve(kWriterCount); + for (int i = 0; i < kWriterCount; ++i) { + writers.emplace_back([i]() { + EXPECT_TRUE(acecode::try_write_state_flag( + "concurrent_flag_" + std::to_string(i), true)); + acecode::write_last_active_workspace_hash("active-" + std::to_string(i)); + acecode::write_last_home_workspace_hash("home-" + std::to_string(i)); + acecode::write_web_search_region_cache({i % 2 ? "cn" : "global", i}); + const auto usage = acecode::record_tui_slash_command_use("help"); + EXPECT_TRUE(usage.persisted); + acecode::ModelProbeCacheEntry probe; + probe.models = {"model-" + std::to_string(i)}; + probe.probed_at_ms = i; + EXPECT_TRUE(acecode::write_model_probe_cache( + std::string(64, "0123456789abcdef"[i]), probe)); + }); + } + for (auto& writer : writers) writer.join(); + + for (int i = 0; i < kWriterCount; ++i) { + EXPECT_TRUE(acecode::read_state_flag("concurrent_flag_" + std::to_string(i))); + const auto probe = acecode::read_model_probe_cache( + std::string(64, "0123456789abcdef"[i])); + ASSERT_TRUE(probe.has_value()); + EXPECT_EQ(probe->models, std::vector{"model-" + std::to_string(i)}); + } + EXPECT_EQ(acecode::read_tui_slash_command_usage().at("help"), kWriterCount); + EXPECT_FALSE(acecode::read_last_active_workspace_hash().empty()); + EXPECT_FALSE(acecode::read_last_home_workspace_hash().empty()); + EXPECT_TRUE(acecode::read_web_search_region_cache().has_value()); + + // 场景:暂停写入后,各模块均应保留原内容;命令计数不能伪报已更新。 + const auto before = acecode::read_state_json(); + acecode::set_state_file_writes_paused(true); + EXPECT_FALSE(acecode::try_write_state_flag("paused_flag", true)); + acecode::write_last_active_workspace_hash("paused-active"); + acecode::write_last_home_workspace_hash("paused-home"); + acecode::write_web_search_region_cache({"cn", 999}); + acecode::clear_web_search_region_cache(); + const auto paused_usage = acecode::record_tui_slash_command_use("help"); + EXPECT_FALSE(paused_usage.persisted); + EXPECT_EQ(paused_usage.count, 0u); + acecode::ModelProbeCacheEntry paused_probe; + paused_probe.models = {"paused-model"}; + EXPECT_FALSE(acecode::write_model_probe_cache(std::string(64, 'a'), paused_probe)); + EXPECT_EQ(acecode::read_state_json(), before); +} diff --git a/tests/utils/state_file_test.cpp b/tests/utils/state_file_test.cpp index 11e7d7d58..59f1a9a02 100644 --- a/tests/utils/state_file_test.cpp +++ b/tests/utils/state_file_test.cpp @@ -12,6 +12,7 @@ #include #include "utils/state_file.hpp" +#include "test_support/utils/state_file_fixture.hpp" #include #include @@ -30,32 +31,11 @@ #include namespace fs = std::filesystem; +using acecode::test_support::StateFileTest; +using acecode::test_support::write_raw; namespace { -class StateFileTest : public ::testing::Test { -protected: - void SetUp() override { - // 在临时目录里给本测试一份独立 state.json 路径。 - auto tmp = fs::temp_directory_path() / - ("acecode_state_test_" + std::to_string(std::rand())); - fs::create_directories(tmp); - path_ = (tmp / "state.json").string(); - acecode::set_state_file_path_for_test(path_); - } - void TearDown() override { - acecode::set_state_file_path_for_test(""); - std::error_code ec; - fs::remove_all(fs::path(path_).parent_path(), ec); - } - std::string path_; -}; - -void write_raw(const std::string& path, const std::string& contents) { - std::ofstream ofs(path, std::ios::binary | std::ios::trunc); - ofs << contents; -} - std::string quote_arg(const std::string& value) { return "\"" + value + "\""; } @@ -134,23 +114,6 @@ TEST_F(StateFileTest, MultipleKeysCoexist) { EXPECT_TRUE(acecode::read_state_flag("another_flag")); } -// 场景:同进程内多个状态写入并发发生时,read-modify-write 必须串行,不能丢 key。 -TEST_F(StateFileTest, ConcurrentCheckedWritesPreserveEveryKey) { - constexpr int kWriterCount = 12; - std::vector writers; - writers.reserve(kWriterCount); - for (int i = 0; i < kWriterCount; ++i) { - writers.emplace_back([i]() { - EXPECT_TRUE(acecode::try_write_state_flag( - "concurrent_flag_" + std::to_string(i), true)); - }); - } - for (auto& writer : writers) writer.join(); - for (int i = 0; i < kWriterCount; ++i) { - EXPECT_TRUE(acecode::read_state_flag("concurrent_flag_" + std::to_string(i))); - } -} - TEST_F(StateFileTest, ClaimFlagIsGrantedExactlyOnceAcrossConcurrentCallers) { constexpr int kClaimerCount = 16; std::atomic claimed{0}; @@ -258,213 +221,3 @@ TEST_F(StateFileTest, EmptyFileTreatedAsEmptyState) { acecode::write_state_flag("legacy_terminal_hint_shown", true); EXPECT_TRUE(acecode::read_state_flag("legacy_terminal_hint_shown")); } - -// 场景:last_active_workspace_hash 序列化往返 — desktop 多 workspace 模型靠这条 -// 跨启动持久化"上次活跃 workspace"。 -TEST_F(StateFileTest, LastActiveWorkspaceHashRoundTrip) { - EXPECT_EQ(acecode::read_last_active_workspace_hash(), ""); // 初始空 - acecode::write_last_active_workspace_hash("abc1234567890def"); - EXPECT_EQ(acecode::read_last_active_workspace_hash(), "abc1234567890def"); - // 覆盖写 - acecode::write_last_active_workspace_hash("ffffffffffffffff"); - EXPECT_EQ(acecode::read_last_active_workspace_hash(), "ffffffffffffffff"); - // 共存其他 key 不互相覆盖 - acecode::write_state_flag("some_flag", true); - EXPECT_EQ(acecode::read_last_active_workspace_hash(), "ffffffffffffffff"); - EXPECT_TRUE(acecode::read_state_flag("some_flag")); -} - -// 场景:last_active_workspace_hash 字段类型不对(数字)→ read 返回空字符串而不是抛 -TEST_F(StateFileTest, LastActiveWrongTypeReadsEmpty) { - write_raw(path_, R"({"last_active_workspace_hash": 12345})"); - EXPECT_EQ(acecode::read_last_active_workspace_hash(), ""); -} - -// 场景:首页 workspace 选择器跨 desktop 启动保存上次选择。 -// 空字符串是有效选择,表示"不使用工作区"。 -TEST_F(StateFileTest, LastHomeWorkspaceHashRoundTripAllowsEmpty) { - EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); - acecode::write_last_home_workspace_hash("abc1234567890def"); - EXPECT_EQ(acecode::read_last_home_workspace_hash(), "abc1234567890def"); - - acecode::write_last_home_workspace_hash(""); - EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); - - acecode::write_state_flag("some_flag", true); - EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); - EXPECT_TRUE(acecode::read_state_flag("some_flag")); -} - -// 场景:last_home_workspace_hash 字段类型不对 → read 返回空字符串而不是抛。 -TEST_F(StateFileTest, LastHomeWorkspaceWrongTypeReadsEmpty) { - write_raw(path_, R"({"last_home_workspace_hash": 12345})"); - EXPECT_EQ(acecode::read_last_home_workspace_hash(), ""); -} - -TEST_F(StateFileTest, ModelProbeCacheRoundTripsAndPreservesOtherState) { - const std::string fingerprint(64, 'a'); - acecode::write_state_flag("some_flag", true); - - acecode::ModelProbeCacheEntry entry; - entry.models = {"starrylight", "moonlight", "starrylight"}; - entry.context_windows = { - {"starrylight", 200000}, - {"moonlight", 128000}, - {"not-in-models", 999}, - }; - entry.probed_at_ms = 123456789; - - ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); - auto cached = acecode::read_model_probe_cache(fingerprint); - ASSERT_TRUE(cached.has_value()); - EXPECT_EQ(cached->models, - (std::vector{"starrylight", "moonlight"})); - EXPECT_EQ(cached->context_windows, - (std::map{{"moonlight", 128000}, - {"starrylight", 200000}})); - EXPECT_EQ(cached->probed_at_ms, 123456789); - EXPECT_TRUE(acecode::read_state_flag("some_flag")); - - entry.models = {"aurora"}; - entry.context_windows = {{"aurora", 200000}}; - entry.probed_at_ms = 123456790; - ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); - cached = acecode::read_model_probe_cache(fingerprint); - ASSERT_TRUE(cached.has_value()); - EXPECT_EQ(cached->models, (std::vector{"aurora"})); - EXPECT_EQ(cached->context_windows.at("aurora"), 200000); - EXPECT_EQ(cached->probed_at_ms, 123456790); -} - -TEST_F(StateFileTest, ModelProbeCacheRejectsInvalidFingerprintAndMalformedEntry) { - acecode::ModelProbeCacheEntry entry; - entry.models = {"model-a"}; - EXPECT_FALSE(acecode::write_model_probe_cache("not-a-sha256", entry)); - EXPECT_FALSE(acecode::read_model_probe_cache("not-a-sha256").has_value()); - EXPECT_FALSE(fs::exists(path_)); - - const std::string fingerprint(64, 'b'); - nlohmann::json malformed = { - {"model_probe_cache", - {{fingerprint, - {{"version", 1}, {"models", "not-an-array"}}}}}, - }; - write_raw(path_, malformed.dump()); - EXPECT_FALSE(acecode::read_model_probe_cache(fingerprint).has_value()); -} - -TEST_F(StateFileTest, SlashCommandUsageIncrementsAndPreservesOtherState) { - write_raw(path_, R"({"some_flag":true,"last_active_workspace_hash":"abc"})"); - - const auto first = acecode::record_tui_slash_command_use("model"); - const auto second = acecode::record_tui_slash_command_use("model"); - - EXPECT_TRUE(first.persisted); - EXPECT_EQ(first.count, 1u); - EXPECT_TRUE(second.persisted); - EXPECT_EQ(second.count, 2u); - const auto counts = acecode::read_tui_slash_command_usage(); - ASSERT_EQ(counts.size(), 1u); - EXPECT_EQ(counts.at("model"), 2u); - EXPECT_TRUE(acecode::read_state_flag("some_flag")); - EXPECT_EQ(acecode::read_last_active_workspace_hash(), "abc"); -} - -TEST_F(StateFileTest, SlashCommandUsageIgnoresInvalidEntries) { - write_raw(path_, - R"({"tui_slash_command_usage":{"good":4,"zero":0,"negative":-2,"fraction":1.5,"text":"7","bad name":9,"opsx/apply":3}})"); - - const auto counts = acecode::read_tui_slash_command_usage(); - - ASSERT_EQ(counts.size(), 2u); - EXPECT_EQ(counts.at("good"), 4u); - EXPECT_EQ(counts.at("opsx/apply"), 3u); - EXPECT_EQ(counts.count("zero"), 0u); - EXPECT_EQ(counts.count("negative"), 0u); - EXPECT_EQ(counts.count("fraction"), 0u); - EXPECT_EQ(counts.count("text"), 0u); - EXPECT_EQ(counts.count("bad name"), 0u); - - write_raw(path_, R"({"tui_slash_command_usage":[1,2,3]})"); - EXPECT_TRUE(acecode::read_tui_slash_command_usage().empty()); -} - -TEST_F(StateFileTest, SlashCommandUsageSaturatesAtUint64Max) { - const auto maximum = (std::numeric_limits::max)(); - nlohmann::json state = { - {"tui_slash_command_usage", {{"help", maximum}}}, - }; - write_raw(path_, state.dump()); - - const auto result = acecode::record_tui_slash_command_use("help"); - - EXPECT_TRUE(result.persisted); - EXPECT_EQ(result.count, maximum); - EXPECT_EQ(acecode::read_tui_slash_command_usage().at("help"), maximum); -} - -TEST_F(StateFileTest, SlashCommandUsageWriteFailureReturnsInMemoryCount) { - const fs::path directory_target = - fs::path(path_).parent_path() / "usage-state-directory"; - fs::create_directories(directory_target); - acecode::set_state_file_path_for_test(directory_target.string()); - - const auto result = acecode::record_tui_slash_command_use("help"); - - EXPECT_FALSE(result.persisted); - EXPECT_EQ(result.count, 1u); -} - -TEST_F(StateFileTest, ModelProbeCachePreservesReasoningAndExplicitRemoval) { - const std::string fingerprint(64, 'd'); - acecode::ModelReasoningOptions reasoning; - reasoning.supported = true; - reasoning.default_enabled = true; - reasoning.supported_efforts = {"low", "high"}; - reasoning.default_effort = "high"; - acecode::ModelProbeCacheEntry entry; - entry.models = {"supported", "unknown"}; - entry.reasoning["supported"] = reasoning; - entry.reasoning["not-in-models"] = reasoning; - ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); - auto restored = acecode::read_model_probe_cache(fingerprint); - ASSERT_TRUE(restored.has_value()); - ASSERT_TRUE(restored->reasoning.at("supported").has_value()); - EXPECT_EQ(restored->reasoning.at("supported")->supported_efforts, - reasoning.supported_efforts); - EXPECT_EQ(restored->reasoning.at("supported")->default_effort, "high"); - EXPECT_FALSE(restored->reasoning.at("unknown").has_value()); - EXPECT_EQ(restored->reasoning.count("not-in-models"), 0u); - - entry.reasoning.clear(); - ASSERT_TRUE(acecode::write_model_probe_cache(fingerprint, entry)); - restored = acecode::read_model_probe_cache(fingerprint); - ASSERT_TRUE(restored.has_value()); - ASSERT_EQ(restored->reasoning.size(), 2u); - EXPECT_FALSE(restored->reasoning.at("supported").has_value()); - EXPECT_FALSE(restored->reasoning.at("unknown").has_value()); -} - -TEST_F(StateFileTest, LegacyOrInvalidProbeReasoningRestoresAsExplicitNull) { - const std::string fingerprint(64, 'e'); - nlohmann::json cache{ - {"version", 1}, {"models", {"model"}}, {"probed_at_ms", 123}}; - auto write_cache = [&] { - write_raw(path_, nlohmann::json{ - {"model_probe_cache", {{fingerprint, cache}}}}.dump()); - }; - write_cache(); - auto restored = acecode::read_model_probe_cache(fingerprint); - ASSERT_TRUE(restored.has_value()); - EXPECT_FALSE(restored->reasoning.at("model").has_value()); - - cache["model_reasoning"] = {{"model", { - {"supported", true}, {"mandatory", false}, {"default_enabled", true}, - {"supports_max_tokens", false}, {"supported_efforts", {"unknown"}}, - }}}; - write_cache(); - restored = acecode::read_model_probe_cache(fingerprint); - ASSERT_TRUE(restored.has_value()); - EXPECT_FALSE(restored->reasoning.at("model").has_value()); - EXPECT_EQ(restored->probed_at_ms, 123); -} diff --git a/tests/utils/text_file_buffer_test.cpp b/tests/utils/text_file_buffer_test.cpp index 16e76a2e4..1bbc63b45 100644 --- a/tests/utils/text_file_buffer_test.cpp +++ b/tests/utils/text_file_buffer_test.cpp @@ -22,12 +22,6 @@ void write_bytes(const fs::path& path, const std::string& bytes) { ofs.write(bytes.data(), static_cast(bytes.size())); } -std::string read_bytes(const fs::path& path) { - std::ifstream ifs(path, std::ios::binary); - return std::string(std::istreambuf_iterator(ifs), - std::istreambuf_iterator()); -} - std::string bytes(std::initializer_list values) { std::string out; for (unsigned char value : values) { @@ -97,7 +91,7 @@ TEST(TextFileBuffer, LossyUtf8ReadReplacesSingleBadByte) { auto strict = acecode::decode_text_file_bytes(raw); EXPECT_FALSE(strict.success); - EXPECT_NE(strict.error.find("file_read"), std::string::npos); + EXPECT_EQ(strict.error_kind, acecode::TextFileError::DamagedUtf8); auto decoded = acecode::decode_text_file_bytes(raw, "", true); ASSERT_TRUE(decoded.success) << decoded.error; @@ -142,20 +136,6 @@ TEST(TextFileBuffer, StrictReadTextFileBufferStillRefusesCorruptUtf8) { fs::remove(path); } -TEST(TextFileBuffer, SafeWriteRollsBackOnVerificationMismatch) { - auto path = temp_path(".txt"); - write_bytes(path, "before\n"); - - auto metadata = acecode::default_new_file_text_metadata(); - metadata.encoding = acecode::TextEncoding::Unsupported; - - auto result = acecode::safe_write_text_file(path.string(), "after\n", metadata); - EXPECT_FALSE(result.success); - EXPECT_EQ(read_bytes(path), "before\n"); - - fs::remove(path); -} - TEST(TextFileBuffer, AnsiEscapesDoNotMakeTextLookBinary) { std::string ansi; for (int i = 0; i < 200; ++i) { diff --git a/tests/web/model_catalog_handler_test.cpp b/tests/web/model_catalog_handler_test.cpp index 70cd775e8..fb3b0dec3 100644 --- a/tests/web/model_catalog_handler_test.cpp +++ b/tests/web/model_catalog_handler_test.cpp @@ -1,6 +1,6 @@ #include -#include "provider/builtin_model_catalog.hpp" +#include "config/builtin_model_catalog.hpp" #include "test_support/repo_root.hpp" #include "utils/constants.hpp" #include "web/handlers/model_catalog_handler.hpp" diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 2dc5ac70b..5f285ee51 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -30,7 +30,7 @@ #include "config/config_recovery.hpp" #include "config/saved_models.hpp" #include "config/saved_models_revision.hpp" -#include "permissions.hpp" +#include "permissions/permissions.hpp" #include "desktop/daemon_supervisor.hpp" #include "workspace/workspace_registry.hpp" #include "experts/expert_registry.hpp"