diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 00000000..4d1cf306 --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,11 @@ +# ACECode v1.0.0 + +- HTML / HTM 文件预览新增浏览器按钮,可直接在 ACECode 内置浏览器中打开本地页面;支持中文、空格等路径,保留未保存修改的保存/放弃/取消提示。 +- 设置新增可搜索的快捷键页面,支持新建会话、切换历史、展开面板、聚焦输入、调整思考深度等常用操作。 +- 消息通道使用本地平台图标,移除图标圆角背景,稳定卡片布局并调整管理与连接按钮顺序。 +- 优化长会话上下文与提示词缓存:保持已发送前缀稳定,持久保存上下文快照与更新,改进压缩后的任务清单和会话恢复。 +- 子代理未指定模型时继承父会话当前模型;兼容模型服务的缓存字段拒绝与压缩重试。 +- 更新入口移至左侧栏底部,保留更新进度与重启提示。 +- 虚拟办公室支持独立偏好与欢迎预览,修复欢迎页面和新手引导同时出现时的启动卡死。 + +升级安装完成后请重启 ACECode。Agent Browser 集成在 Desktop 中,无需独立浏览器宿主或扩展。 diff --git a/docs/subagents.md b/docs/subagents.md index 30ec7e31..8fb1e6dc 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -30,13 +30,15 @@ Multi-Agent V2,openspec `add-mesh-swarm-mode`)。 |---|---| | `prompt`(必填) | 子会话的首条用户消息。以 `/` 开头时按子会话 cwd 走 `try_expand_skill_command`(与 Web 输入框同一套 skill 命令展开),原文存 `display_text` | | `wait`(默认 true) | true:阻塞至子会话本轮结束,返回最终答复;false:点火即返,返回 `session_id`(流水线接力 / fan-out) | -| `model` | 可选 saved_models 名称;缺省用 daemon/TUI 默认模型 | +| `model` | 可选 saved_models 名称;缺省或空值继承父会话当前模型,显式值优先;无父模型状态时沿用配置默认值 | | `timeout_seconds` | 可选等待上限;0/缺省 = 无限(父会话 abort 仍可打断) | - `is_read_only = true`:spawn 本身自动放行不弹确认;子会话内部的危险操作由子会话自己的 PermissionManager 把关(权限模式继承父会话)。 - 子会话继承父会话的 cwd 与权限模式。父会话不在 registry(TUI 主会话)时,权限模式经 `SubagentToolDeps::fallback_permissions` 继承。 +- 模型继承读取 `ToolContext::session_manager` 的当前 saved model,适用于 daemon、TUI 和 + headless;父会话切换模型后,后续子会话使用新选择。专家成员绑定不改变模型选择优先级。 - **继承父会话的隔离**(fix-subagent-write-boundary):父会话在 worktree 里时,子会话共享 同一个 worktree —— `SessionOptions::inherited_worktree` 进子会话 meta (`worktree_session.inherited=true`,resume 照常恢复),AgentLoop cwd 切进 worktree,而 diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/.openspec.yaml b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/.openspec.yaml new file mode 100644 index 00000000..a26662f7 --- /dev/null +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/design.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/design.md new file mode 100644 index 00000000..172a3f45 --- /dev/null +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/design.md @@ -0,0 +1,27 @@ +## Context + +App owns navigation and panels; ChatView owns input, cancellation and reasoning changes. Existing Ctrl+K and Ctrl+backquote listeners and xterm forwarding are separate. Browser-default guards prevent Ctrl+- before bubble listeners. ChannelsSettings already maps enabled channels to disconnect, but status, management and pending requests add conditional rows. + +## Goals / Non-Goals + +Keep each action's existing handler and lifetime owner. Share shortcut definitions between dispatch, help and terminal forwarding. No rebinding editor, backend protocol change, packaging or release. + +## Decisions + +1. A pure shortcut catalog defines exact modifiers, platform labels and scope; a shared hook handles IME, repeats, consumed events and overlay checks. App owns global actions; ChatView owns conversation actions. Replace the existing global listeners to prevent duplicate dispatch. The zoom guard still prevents native zoom for navigation keys, and marks those events with a shared symbol so the application handler can consume them. This also suppresses zoom when a dialog blocks navigation. Let xterm forward app shortcuts without sending them to the shell. +2. Defaults: Ctrl+Alt+N new session, Ctrl+Alt+I focus input, Ctrl+Alt+B project sidebar, Ctrl+Alt+E right panel, Ctrl+, settings, Ctrl+/ shortcuts, Ctrl+Shift+. stop; Ctrl+- forward and Ctrl+Shift+- back. macOS uses Cmd/Option except the existing Ctrl+backquote console key. Reasoning accepts Ctrl/Cmd+Alt+Period/Comma with or without Shift, covering both physical punctuation keys and literal >/<. No wrapping; order comes from canonical reasoning efforts and only supported levels participate. Effective default depth determines the initial step. +3. Scoped actions do not act behind dialogs, menus, question/permission controls, file editors or a side chat. Reasoning uses the existing async mutation and in-flight guard, including during busy turns. Navigation retains unsaved-file confirmation. New session uses the existing current-workspace action and prevents repeat creation. +4. Settings section follows existing navigation/search/i18n patterns, with the archived section's sticky offsets and opaque surface. The searchable catalog includes existing Enter, Shift+Enter, history, Escape, save, find, search and console shortcuts with scope explanations. +5. Channel cards keep a single stable header and description; management sits before a constant-width connection control. Status is an accessible dot; pending count is attached to management. Full status, recovery and approvals move into ManageDialog. No conditional rows on cards. Narrow layouts use one column. +6. Bundle upstream SVG brand assets locally and document version/source/license; use actual vector shapes with theme-safe rendering, no runtime CDN dependency. + +## Risks / Trade-offs + +- Browser shortcuts and punctuation layout differences → exact modifier matching, physical-key fallbacks, IME/AltGraph guards, browser regression coverage. +- Hidden/multiple ChatViews → bind conversation shortcuts only to the visible active main composer; do not leak to side chats. +- Reduced status text → accessible status titles and discoverable management details preserve recovery and approval operations. +- Existing channel change said generic monochrome chat icons → this user's explicit brand-icon request supersedes that choice. + +## Migration Plan + +Ship with the regular Web build; Desktop requires a later embedded bundle rebuild. No stored-data migration. Reverting this change restores the old UI and listeners. diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/proposal.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/proposal.md new file mode 100644 index 00000000..520d11b4 --- /dev/null +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/proposal.md @@ -0,0 +1,25 @@ +## Why + +常用操作缺少统一可查找的快捷键入口;消息通道卡片随配置、连接和待批准请求增高,操作位置不稳定且平台不易识别。 + +## What Changes + +- 设置新增“快捷键”页签,按功能及按键搜索,搜索栏沿用已归档会话的吸顶布局。 +- 实现已确认的七项默认快捷键,以及 Ctrl+- 前进、Ctrl+Shift+- 后退、Ctrl+Alt+> / < 增减当前会话思维深度。 +- 保留现有快捷键,明确输入法、弹窗、编辑器及终端的事件优先级。 +- 消息通道卡片保持稳定高度,管理位于连接按钮左侧;启用后可取消连接。状态和请求详情集中到管理对话框。 +- 使用本地打包的现成平台 SVG 图标,记录来源。 + +## Capabilities + +### New Capabilities +- `keyboard-shortcuts`: 设置内快捷键目录和受上下文约束的常用操作快捷键。 +- `compact-message-channels`: 稳定尺寸的消息通道卡片、管理入口和品牌图标。 + +### Modified Capabilities + +无。 + +## Impact + +影响 Web/Desktop 共用的 App、ChatView、设置、通道卡片、快捷键工具、图标资源和本地化。复用现有导航、停止、模型控制和通道 API,不改变服务端协议。 diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/compact-message-channels/spec.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/compact-message-channels/spec.md new file mode 100644 index 00000000..8ace11c8 --- /dev/null +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/compact-message-channels/spec.md @@ -0,0 +1,26 @@ +## Purpose + +Provide recognizable message-channel cards whose actions remain in stable positions throughout setup, connection and approval changes while preserving access to operational details and recovery. + +## ADDED Requirements + +### Requirement: Stable channel cards +Cards SHALL show management immediately left of the connection action. An enabled channel SHALL offer 取消连接, including while connecting or retrying. Configuration, connection status and pending approvals SHALL NOT add rows or increase card height. + +#### Scenario: State transitions +- **WHEN** a channel becomes configured, connects, fails or receives pending approval requests +- **THEN** its card keeps the same height and button positions at the same viewport, and status remains accessible + +### Requirement: Management retains detail and recovery +Management SHALL expose status, connection recovery, bot configuration, pending approval decisions and access management. Existing close-button dialogs SHALL keep their backdrop dismissal policy. + +#### Scenario: Approve from management +- **WHEN** a channel has a pending request +- **THEN** its management entry indicates pending work and the user can open it to approve or reject the request + +### Requirement: Local platform identity +QQ, WeChat, Feishu, DingTalk, Telegram, Discord and LINE SHALL display recognizable locally bundled vendor icons in their cards and dialogs, across light and dark themes. + +#### Scenario: Offline assets +- **WHEN** the settings page loads without access to a public asset CDN +- **THEN** all seven platform icons still render from the application bundle diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/keyboard-shortcuts/spec.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/keyboard-shortcuts/spec.md new file mode 100644 index 00000000..d1bf5966 --- /dev/null +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/keyboard-shortcuts/spec.md @@ -0,0 +1,38 @@ +## Purpose + +Make frequent ACECode Web and Desktop actions discoverable and executable through a searchable keyboard shortcut directory while protecting text composition and contextual controls. + +## ADDED Requirements + +### Requirement: Searchable shortcut settings +Settings SHALL expose a 快捷键 section containing both existing and new shortcuts, searchable by action, description and key combination. The search bar SHALL remain visible at the top of the settings content scroll area. + +#### Scenario: Search while scrolling +- **WHEN** the user opens 快捷键, scrolls its list and searches for Ctrl or a function name +- **THEN** the opaque search bar stays at the top and the list filters to matching shortcuts + +### Requirement: Common application actions +The application SHALL support Ctrl+Alt+N for a new current-workspace session, Ctrl+Alt+I to focus its input without changing draft text, Ctrl+Alt+B/E for left/right panels, Ctrl+, for settings, Ctrl+/ for shortcut settings, and Ctrl+Shift+. for stopping current work. Existing search, console, find and contextual editing shortcuts SHALL retain their behavior. macOS SHALL display and accept corresponding Cmd/Option bindings except the existing Ctrl+backquote console binding. + +#### Scenario: Navigation history +- **WHEN** the user presses Ctrl+- or Ctrl+Shift+- +- **THEN** ACECode goes forward or back respectively through its navigation history, retains unsaved-file protections and does not zoom + +#### Scenario: Protected input contexts +- **WHEN** IME composition, a modal, a menu or a focused secondary editor owns input +- **THEN** unrelated application actions do not run behind it, and one keypress never executes an action twice + +#### Scenario: New conversation and stop +- **WHEN** the user requests a new session or stops a running session by keyboard +- **THEN** the corresponding existing action runs once in the active workspace/session and preserves drafts and cancellation behavior + +### Requirement: Incremental reasoning depth +Ctrl+Alt+> SHALL increase and Ctrl+Alt+< SHALL decrease the active conversation's supported reasoning depth. The physical Period/Comma keys with or without Shift SHALL be accepted. The effective default SHALL be respected, unsupported levels skipped, bounds clamped and in-flight changes guarded. + +#### Scenario: Running conversation +- **WHEN** a running conversation changes depth using the keyboard +- **THEN** its displayed selection updates via the existing reasoning control, future requests use the new depth, and the current request continues + +#### Scenario: Boundary or unsupported model +- **WHEN** the user tries to move beyond an extreme or a model has no reasoning selector +- **THEN** no wraparound or unsupported update is sent diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/tasks.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/tasks.md new file mode 100644 index 00000000..d8f27795 --- /dev/null +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/tasks.md @@ -0,0 +1,26 @@ +## 1. Keyboard actions + +- [x] 1.1 Add shared shortcut catalog, matching/context guards and reasoning stepping; verify pure tests cover modifiers, IME, platform labels, bounds and default depth. +- [x] 1.2 Wire App and active conversation handlers, terminal forwarding and shortcut hints; verify real keypresses trigger existing actions once and preserve navigation guards. +- [x] 1.3 Add searchable shortcut settings with sticky search and localized copy; verify search, settings discovery and scroll behavior. + +## 2. Message channels + +- [x] 2.1 Bundle all seven upstream platform icons and source records; verify assets render in cards/dialogs and production build. +- [x] 2.2 Stabilize card layout, move management before connection and retain approvals/recovery in management; verify state transitions preserve card height and existing operations. + +## 3. Integration validation + +- [x] 3.1 Run focused and full Web tests, i18n generation, build, strict OpenSpec validation and diff checks; record results. +- [x] 3.2 Run browser checks across desktop/narrow, light/dark and keyboard/overlay/IME contexts; record evidence and separate Web proof from installed Desktop verification. + +## Validation evidence (2026-10-07) + +- Focused Node tests: appShortcuts, channelsSettings, settingsNavigation and browserDefaults passed. +- `pnpm i18n:catalog`, final `pnpm test`, final `pnpm build`, strict OpenSpec validation and `git diff --check` passed. Build compatibility scan checked 4518 regex literals with no lookbehind. +- `node scripts/test-busy-reasoning.mjs` passed existing busy-update, failure rollback, late response and default restoration scenarios. +- `node scripts/test-shortcuts-and-channels.mjs` passed nine browser groups using actual App/Settings/ChatView and isolated daemon/WebSocket fixtures: search/sticky/focus, create/history/drafts, panels, reasoning/IME/menus, busy/stop, xterm forwarding, channel state/approval/connect controls, narrow English search and four viewport/theme combinations. +- Final screenshots and browser results: `C:/Users/shao/AppData/Local/Temp/ace-shortcuts-channels-27LTYw/`. Full logs: `%TEMP%/ace-shortcuts-web-tests-final.log` and `%TEMP%/ace-shortcuts-web-build-final.log`. +- Browser executables reused through `ACE_PLAYWRIGHT_MODULE` and `ACE_CHROMIUM_EXECUTABLE`; no daemon, real credentials or vendor messages were used. +- First visual pass found narrow card titles collapsing and low-contrast monochrome Feishu artwork; fixed both and confirmed in the final pass. Impeccable detector returned no findings. +- Concurrent update-button relocation changes in App/TopBar/Sidebar and unrelated style/modal-test edits were preserved. This change has not been committed, published or packaged into the installed Desktop; native Windows/macOS accelerator integration remains outside the Web proof. diff --git a/openspec/changes/make-updates-cancellable-with-progress/.openspec.yaml b/openspec/changes/make-updates-cancellable-with-progress/.openspec.yaml new file mode 100644 index 00000000..a8821c74 --- /dev/null +++ b/openspec/changes/make-updates-cancellable-with-progress/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/make-updates-cancellable-with-progress/design.md b/openspec/changes/make-updates-cancellable-with-progress/design.md new file mode 100644 index 00000000..88920782 --- /dev/null +++ b/openspec/changes/make-updates-cancellable-with-progress/design.md @@ -0,0 +1,68 @@ +## 背景 + +当前 Web/Desktop 共用的升级入口通过 `/api/update/start` 创建后台任务,前端通用请求层默认在 30 秒后中止请求,升级核心又把 `upgrade.timeout_ms` 同时用于清单和安装包下载。更新任务仅能轮询,后端没有取消信号,前端关闭对话框也只会隐藏任务。顶部入口始终使用完整主色背景,无法表达进行中的百分比。 + +升级跨越网络下载、校验、解压和安装替换。下载前中止是安全的;一旦进入安装替换,强行中断可能留下不完整安装,因此取消能力必须有明确边界。 + +## 目标 / 非目标 + +**目标:** + +- 升级过程不再因为固定 30 秒总时限被误判为失败。 +- 用户可以取消尚未进入安装替换的图形界面升级任务。 +- 取消状态可轮询、可恢复展示,且不会触发重启。 +- 顶部按钮保持现有尺寸和轮廓,以填充比例和正确的双色文字表达进度。 + +**非目标:** + +- 不允许在已经开始替换安装文件后强制终止。 +- 不改变命令行升级的交互方式,也不新增外部依赖。 +- 不改变更新清单格式、包校验和回滚机制。 + +## 技术决策 + +### 1. 升级网络请求使用无限总时限 + +升级清单与安装包请求不再传入固定总时限;连接断开、HTTP 错误、写盘错误仍按原逻辑失败。前端启动升级的请求显式使用无超时模式,避免通用 30 秒默认值中止它。 + +保留现有配置字段的兼容读取,避免旧配置失效;该字段不再限制自升级下载,仍可供当前共用的反馈上传逻辑使用。 + +### 2. 使用协作式取消,不杀死升级线程 + +升级核心接收一个线程安全的取消检查回调。下载写入回调在收到取消请求后返回失败以中止传输;校验、解压及安装前的阶段边界也检查取消状态。取消使用专用退出码与结果标志,避免把用户操作显示成普通故障。 + +不采用强制终止线程,因为它可能打断文件写入、资源清理或安装事务。 + +### 3. 安装开始后关闭取消窗口 + +任务状态增加 `cancel_requested` 与派生的 `can_cancel`。`POST /api/update/jobs/:id/cancel` 只对当前活动任务生效:安全阶段返回 202 并设置取消标志;进入 `installing` 后返回 409。升级核心在发布 `installing` 之前执行最后一次取消检查,从而保证前后端边界一致。 + +取消成功后任务进入终态 `cancelled`,清理未使用的下载与暂存目录,`restart_required` 保持为 `false`。重复取消已经取消的同一任务按幂等成功处理。 + +### 4. 顶部进度按钮使用同位双色裁切层 + +按钮外壳继续使用现有高度、最小宽度、水平内边距与圆角。进行中状态渲染三个完全重合的层:未填充底层显示主色文字;主色背景按百分比从左向右裁切;白色文字副本使用同一裁切范围。这样每个字形落在主色背景上的部分为白色,落在透明背景上的部分为主色,并且按钮在任意百分比下都保持可读。 + +进度值沿用现有全生命周期映射并限制在 0 到 100;按钮提供包含百分比的无障碍标签。 + +### 5. 2026-10-07 更新入口迁移到侧边栏底部 + +用户截图指定的底栏位置覆盖前述顶部位置约定。App 将现有更新状态和打开弹框回调传给 Sidebar,按钮位于底部设置菜单与问题反馈按钮之间,紧邻反馈按钮左侧并靠右排列。TopBar 移除更新属性与渲染;原有 24px 高度改用按钮自身的 `h-6`,避免依赖顶部栏局部 CSS 变量和顶部偏移。 + +保留新版可用时才显示、启动时禁用、更新中进度填充、已更新标签和点击打开更新弹框的行为。侧栏折叠时隐藏该入口,重新展开后展示当前状态;设置菜单检查更新、后端任务、下载、取消与重启流程不变。 + +## 风险 / 权衡 + +- [服务端长时间无响应且用户尚未取得任务编号] → 启动 API 仍先做可用性确认;前端不再误报超时,实际下载开始后可随时取消。后续如需取消清单探测,可再把预检完全移入任务线程。 +- [取消与安装开始同时发生竞态] → 后端在同一任务锁下判断阶段并写入取消标志,升级线程在进入安装前执行最后检查;进入安装后统一拒绝取消。 +- [底层 HTTP 库把主动中止报告为网络错误] → 下载结果单独记录 `cancelled`,升级核心优先映射为用户取消。 +- [短暂阶段进度跳变] → 继续使用现有单调进度映射,顶部和对话框读取同一结果。 + +## 迁移方案 + +本次无需数据迁移。旧配置中的 `upgrade.timeout_ms` 继续可读取,不影响启动;新前端会使用新增取消接口与状态字段。回滚时旧前端会忽略新增 JSON 字段,旧后端则不会收到新前端版本。 + +## 待确认事项 + +无。 + diff --git a/openspec/changes/make-updates-cancellable-with-progress/proposal.md b/openspec/changes/make-updates-cancellable-with-progress/proposal.md new file mode 100644 index 00000000..07c149a7 --- /dev/null +++ b/openspec/changes/make-updates-cancellable-with-progress/proposal.md @@ -0,0 +1,29 @@ +## 背景 + +图形界面发起升级后,前端请求与升级下载均受固定 30 秒超时限制,大包或慢速网络会被误判为失败;同时用户无法主动停止尚未进入文件替换阶段的升级,顶部入口也不能直观展示当前进度。 + +## 变更内容 + +- 取消图形界面升级启动请求以及升级清单、安装包下载的固定 30 秒超时限制。 +- 为升级任务增加可取消状态与取消接口,允许用户在下载和安装准备阶段安全取消;真正开始替换程序文件后不再接受取消。 +- 在升级对话框中提供取消操作,并明确展示取消中、已取消及不可取消阶段。 +- 保持顶部“更新中”按钮的尺寸与圆角形状不变,以主色填充表示进度;填充区文字使用白色,未填充区文字使用主色。 +- 2026-10-07 位置调整:将上述更新入口整体移到侧边栏底部、问题反馈按钮左侧;顶部不再显示更新按钮。 + +## 能力 + +### 新增能力 + +无。 + +### 修改能力 + +- `self-upgrade`:升级任务不再因固定 30 秒限制失败,并支持在安全阶段由用户取消,同时向图形界面公开进度与可取消状态。 + +## 影响范围 + +- 升级下载与执行逻辑:`src/upgrade/` +- Web 更新任务 API 与状态模型:`src/web/` +- Web 更新对话框、顶部栏与轮询逻辑:`web/src/` +- 升级 HTTP、Web 路由及前端行为测试 + diff --git a/openspec/changes/make-updates-cancellable-with-progress/specs/self-upgrade/spec.md b/openspec/changes/make-updates-cancellable-with-progress/specs/self-upgrade/spec.md new file mode 100644 index 00000000..a3e32899 --- /dev/null +++ b/openspec/changes/make-updates-cancellable-with-progress/specs/self-upgrade/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: 升级网络传输不受固定总时限限制 +系统 SHALL 在检查升级清单和下载安装包时不设置固定的 30 秒总时限,并 SHALL 继续报告真实的连接、HTTP 与文件写入错误。 + +#### Scenario: 慢速升级持续超过三十秒 +- **WHEN** 升级服务持续正常传输但清单请求或安装包下载超过 30 秒 +- **THEN** 系统继续升级流程且不以超时错误终止任务 + +#### Scenario: 图形界面启动请求等待较久 +- **WHEN** 图形界面发起升级且启动请求超过通用请求层的默认 30 秒时限 +- **THEN** 前端继续等待后端响应且不主动中止该请求 + +#### Scenario: 升级服务返回实际错误 +- **WHEN** 升级服务断开连接、返回失败 HTTP 状态或安装包无法写入 +- **THEN** 系统以对应的实际错误结束升级,而不是将其伪装成固定时限错误 + +### Requirement: 用户可在安全阶段取消图形界面升级 +系统 SHALL 为活动的图形界面升级任务提供取消操作,并 SHALL 只在尚未开始替换安装文件时接受取消。 + +#### Scenario: 用户在下载时取消升级 +- **WHEN** 用户在任务下载安装包时请求取消 +- **THEN** 系统中止下载、清理未使用的升级暂存内容,并把任务标记为 `cancelled` + +#### Scenario: 用户在校验或解压阶段取消升级 +- **WHEN** 用户在校验、解压或安装准备阶段请求取消 +- **THEN** 系统在下一个安全阶段边界停止流程,并且不修改当前安装 + +#### Scenario: 用户在安装替换开始后请求取消 +- **WHEN** 任务已进入替换安装文件的阶段 +- **THEN** 系统拒绝取消请求并继续受事务与回滚保护的安装流程 + +#### Scenario: 重复取消已取消任务 +- **WHEN** 用户再次取消同一个已经处于 `cancelled` 终态的任务 +- **THEN** 系统按幂等成功返回该任务状态 + +#### Scenario: 取消后恢复或重开界面 +- **WHEN** 用户取消任务后重新加载或重新打开更新界面 +- **THEN** 系统返回可恢复展示的 `cancelled` 状态,且不要求重启 + +### Requirement: 侧边栏底部更新入口展示任务进度 +系统 SHALL 将更新按钮放在侧边栏底部的问题反馈按钮左侧,顶部 SHALL 不再显示该入口。系统 SHALL 在升级进行期间保持更新按钮的既有形状,并以从左向右的主色背景填充展示 0 到 100 的任务进度。 + +#### Scenario: 检测到新版 +- **WHEN** 侧边栏展开且检测到可用更新 +- **THEN** 底栏在问题反馈按钮左侧显示更新按钮,点击打开既有更新弹框 + +#### Scenario: 没有可用更新或侧栏折叠 +- **WHEN** 未检测到可用更新或侧边栏折叠 +- **THEN** 不显示底栏更新按钮,顶部也不显示更新按钮 + +#### Scenario: 进度只填充按钮的一部分 +- **WHEN** 升级进度介于 0 与 100 之间 +- **THEN** 按钮主色填充区上的文字部分为白色,未填充透明区域上的文字部分为主色 + +#### Scenario: 进度发生变化 +- **WHEN** 后端任务从检查、下载、校验、解压推进到安装 +- **THEN** 底栏按钮使用与更新对话框一致的单调进度值更新背景填充 + +#### Scenario: 辅助技术读取进行中按钮 +- **WHEN** 底栏按钮显示正在升级 +- **THEN** 按钮的无障碍名称包含当前升级百分比 + diff --git a/openspec/changes/make-updates-cancellable-with-progress/tasks.md b/openspec/changes/make-updates-cancellable-with-progress/tasks.md new file mode 100644 index 00000000..7acf9fdd --- /dev/null +++ b/openspec/changes/make-updates-cancellable-with-progress/tasks.md @@ -0,0 +1,37 @@ +## 1. 升级核心 + +- [x] 1.1 取消升级清单、安装包下载及前端启动请求的固定 30 秒时限 +- [x] 1.2 为升级核心与 HTTP 下载增加协作式取消信号、专用取消结果和暂存清理 +- [x] 1.3 补充升级 HTTP 与阶段边界取消测试 + +## 2. 更新任务 API + +- [x] 2.1 扩展更新任务状态与执行器依赖,公开 `cancel_requested` 和 `can_cancel` +- [x] 2.2 实现经过认证且遵守安装安全边界的取消接口 +- [x] 2.3 补充取消成功、幂等、错误任务及安装阶段拒绝的 Web 路由测试 + +## 3. Web 与 Desktop 界面 + +- [x] 3.1 在 API 客户端、任务状态工具和应用轮询中接入取消状态 +- [x] 3.2 在更新对话框中增加取消、取消中、已取消及不可取消反馈 +- [x] 3.3 保持顶部按钮形状并实现进度填充、双色前景与百分比无障碍名称 +- [x] 3.4 补充前端 API、状态模型和渲染架构测试 + +## 4. 验证 + +- [x] 4.1 运行升级与 Web 后端相关单元测试 +- [x] 4.2 运行 Web 测试、国际化审计和生产构建 +- [x] 4.3 在浏览器中验证顶部进度按钮和取消交互 + +## 5. 2026-10-07 更新入口位置调整 + +- [x] 5.1 将更新按钮和状态传递移到侧边栏底部反馈按钮左侧,移除顶部入口及专属样式 +- [x] 5.2 调整已有前端回归断言,验证可用更新、进行中、已更新、禁用、无更新及折叠状态和点击行为 +- [x] 5.3 运行 Web 测试、生产构建、浏览器位置检查、OpenSpec 严格校验及差异检查 + +### 5 验证结果 + +- 已通过:`modalPresentationArchitecture`、`updateJob`、`topBarWindowDrag`、`sidebarAlignmentArchitecture`、`sidebarNavigation` 定向测试;`pnpm build`(含正则兼容检查);OpenSpec 严格校验及本次文件 `git diff --check`。 +- 浏览器:真实 Sidebar / TopBar / UpdateDialog 组件配合模拟 API,在 Edge 中完成浅色/深色、360px/1280px 视口及 200px/270px 侧栏的 29 项检查,覆盖底栏位置、24px 高度、键盘打开弹框、37% 双色裁切、已更新、禁用、无更新、折叠与重新展开状态。无浏览器运行时错误。首次窄侧栏检查在 250ms 宽度动画结束前测量而失败,等待动画完成后整轮通过。 +- `pnpm test` 已运行但未全量通过:执行期间其他工作新增 `web/src/lib/appShortcuts.js`,其文案尚未进入翻译目录,导致 `sourceCatalog.test.js` 失败;未改动或覆盖并发快捷键工作,不将全量测试记为通过。 +- Impeccable 检查仅报告 `globals.css` 中本次未修改的既有样式问题;更新按钮迁移没有新增相关检测项。未重新打包或验证安装中的 Desktop。 diff --git a/openspec/changes/open-html-preview-in-browser/.openspec.yaml b/openspec/changes/open-html-preview-in-browser/.openspec.yaml new file mode 100644 index 00000000..01660bab --- /dev/null +++ b/openspec/changes/open-html-preview-in-browser/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/open-html-preview-in-browser/design.md b/openspec/changes/open-html-preview-in-browser/design.md new file mode 100644 index 00000000..bda7a286 --- /dev/null +++ b/openspec/changes/open-html-preview-in-browser/design.md @@ -0,0 +1,19 @@ +## Context + +See proposal.md for motivation. FilePreviewContent renders the source toolbar; PreviewDetailsPanel supplies file context; ChatView owns browser page creation and the unsaved-file guard. The existing Agent Browser accepts encoded local file URLs. + +## Goals / Non-Goals + +Reuse preview path resolution, native browser ownership, toolbar styling and unsaved-file handling. No system-browser launch, temporary HTML copy, HTTP server or implicit disk write is introduced. + +## Decisions + +- Resolve the file URL in a pure helper using previewAbsolutePath and normalizeAgentBrowserAddress. Restrict input to HTML/HTM and absolute resolved paths so a missing root cannot become a web search. +- Pass the existing browser-opening callback through PreviewDetailsPanel to FilePreviewContent. Extend it with an optional URL and navigate the newly created page using the existing bridge. This preserves session ownership instead of bypassing ChatView from a leaf component. +- Reuse the globe icon and ace-code-action-btn styling before the wrap action. Hide the action in runtimes without native browser support. +- Reuse save/discard/cancel before opening. The browser reads the saved disk file, never an unsaved draft. Guard session changes across asynchronous work and prevent duplicate opens while pending. + +## Risks / Trade-offs + +- Local file availability depends on the Desktop host filesystem. Browser navigation errors use the existing toast mechanism. +- Native rendering differs by platform. Browser fixtures verify bridge arguments and tab behavior; Windows packaged acceptance verifies the final release. macOS package CI remains a separate gate. diff --git a/openspec/changes/open-html-preview-in-browser/proposal.md b/openspec/changes/open-html-preview-in-browser/proposal.md new file mode 100644 index 00000000..90966e82 --- /dev/null +++ b/openspec/changes/open-html-preview-in-browser/proposal.md @@ -0,0 +1,23 @@ +## Why + +HTML files currently show source without a direct way to view the local page. Users need a browser action next to the preview's wrap and copy controls. + +## What Changes + +- Add a globe action for `.html` and `.htm` previews, case-insensitive. +- Open the saved local file through a correctly encoded `file://` URL in a new ACECode browser tab owned by the current session. +- Preserve the existing unsaved-file save/discard/cancel guard and report browser failures. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `side-preview-rendering`: Local HTML preview toolbar action. + +## Impact + +Web preview components, browser-tab coordination, path conversion tests and browser fixtures. No new dependency or native bridge protocol is needed. Included in the user-requested v1.0.0 release. diff --git a/openspec/changes/open-html-preview-in-browser/specs/side-preview-rendering/spec.md b/openspec/changes/open-html-preview-in-browser/specs/side-preview-rendering/spec.md new file mode 100644 index 00000000..8da780cc --- /dev/null +++ b/openspec/changes/open-html-preview-in-browser/specs/side-preview-rendering/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Local HTML preview opens in the integrated browser +The file source preview SHALL offer a keyboard-accessible browser icon before the wrap action for `.html` and `.htm` files, ignoring extension case, when the integrated browser is available. Activating it SHALL open the saved local file in a new browser tab belonging to the current session. + +#### Scenario: Resolve and encode a local file +- **WHEN** the user activates the browser action for an HTML preview +- **THEN** the address uses the file's absolute path or its preview working directory to resolve a relative path +- **AND** the address uses `file://`, encoding spaces, Unicode, percent signs, hash signs and question marks as path characters +- **AND** Windows drives, UNC shares and POSIX paths are supported + +#### Scenario: Preserve unsaved work +- **WHEN** the current preview has unsaved edits and the user activates the browser action +- **THEN** the existing save/discard/cancel guard runs before creating a browser page +- **AND** cancellation or a failed save keeps the editor active without creating a page + +#### Scenario: Unsupported preview or runtime +- **WHEN** the preview is not HTML/HTM, has no resolvable absolute path, or the integrated browser is unavailable +- **THEN** the browser action is not offered + +#### Scenario: Browser failure +- **WHEN** browser page creation or navigation fails +- **THEN** the application displays the failure and retains the source file tab diff --git a/openspec/changes/open-html-preview-in-browser/tasks.md b/openspec/changes/open-html-preview-in-browser/tasks.md new file mode 100644 index 00000000..209ecedc --- /dev/null +++ b/openspec/changes/open-html-preview-in-browser/tasks.md @@ -0,0 +1,17 @@ +## 1. Implementation + +- [x] 1.1 Add local HTML URL conversion and test extensions, workspace roots, Windows/UNC/POSIX paths and reserved characters. Passed `node web/src/lib/htmlPreview.test.js`. +- [x] 1.2 Add the toolbar action and integrate session-owned browser navigation, unsaved-file guard and failure handling; verified against the real App/ChatView with isolated native bridge and filesystem fixtures. + +## 2. Validation + +- [x] 2.1 Run Web tests/build, strict OpenSpec validation, diff checks and focused browser checks; all passed on 2026-10-08. + +## Validation evidence + +- `node web/src/lib/htmlPreview.test.js`: passed HTML/HTM case matching, absolute and relative roots, Windows/UNC/POSIX paths, Unicode and reserved-character encoding, and unsupported paths. +- `pnpm i18n:catalog`, `pnpm test`, `pnpm build`: passed. Production compatibility check found no lookbehind among 4519 regular expressions. +- `openspec validate open-html-preview-in-browser --strict`, `git diff --check`: passed. +- Real App/ChatView browser fixture passed toolbar ordering, keyboard activation, session ownership, encoded navigation, unsaved cancel/save, non-HTML exclusion, and creation/navigation error reporting. Evidence: `%TEMP%/ace-html-browser-pTOugM/`. +- Initial fixture attempts selected a non-button Files tab and a collapsed file panel; corrected the fixture to reveal the panel through the real shortcut. No product change was needed. +- Browser proof uses an isolated native bridge fixture. Native package and cross-platform release verification are tracked separately for v1.0.0. diff --git a/openspec/changes/replicate-current-codex-compaction/design.md b/openspec/changes/replicate-current-codex-compaction/design.md index 8365c618..e9ddde7c 100644 --- a/openspec/changes/replicate-current-codex-compaction/design.md +++ b/openspec/changes/replicate-current-codex-compaction/design.md @@ -89,7 +89,7 @@ Leaving the reducers dormant was rejected because later code paths could reactiv Before the first sampling iteration, ACECode SHALL estimate the request with the pending user input included, but SHALL run compaction against only the already-recorded model history. The pending input is recorded after a successful or failed pre-turn compact attempt and is never copied into that attempt's summary request. The first sampling iteration reuses that completed preflight decision; only later tool-follow-up iterations may compact the active turn. -For every normal provider request, rebuilt session, time/CWD, hook, plan-mode, and todo context SHALL remain separate messages. They SHALL be inserted immediately before the last real user message, or before the compact summary when no real user message remains. They SHALL never be merged into the summary text or appended after a mid-turn summary. This preserves the exact summary prefix and keeps the summary as the final model-history item after mid-turn compaction, while pre-turn compaction naturally leaves the newly recorded user input last. +Superseded on 2026-10-07 by `stabilize-prompt-cache-prefix`: normal requests use a persisted hidden context snapshot fixed at the start of the compaction window. Context changes and hooks append hidden updates without moving or rewriting earlier messages. Todo state is restored once when the window is created. Successful compaction checkpoints a fresh snapshot before the replacement history, keeping the compact summary as the final model-history item at a mid-turn boundary. Context is never merged into the summary text. Temporarily removing an already-persisted pending input during compaction was rejected because the checkpoint would then be appended after that input and resume could not replay the input as a post-checkpoint suffix without duplication. diff --git a/openspec/changes/stabilize-prompt-cache-prefix/.openspec.yaml b/openspec/changes/stabilize-prompt-cache-prefix/.openspec.yaml new file mode 100644 index 00000000..a26662f7 --- /dev/null +++ b/openspec/changes/stabilize-prompt-cache-prefix/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/stabilize-prompt-cache-prefix/design.md b/openspec/changes/stabilize-prompt-cache-prefix/design.md new file mode 100644 index 00000000..64b243b2 --- /dev/null +++ b/openspec/changes/stabilize-prompt-cache-prefix/design.md @@ -0,0 +1,36 @@ +## Context + +The user approved all five decisions on 2026-10-07. `ApiRequestBuilder::build` currently repositions context on every call. Hidden session records and compact checkpoints already provide append-only persistence. Ordinary/star spawning leaves an unspecified model empty, while mesh already inherits the parent. OpenAI-compatible transports currently omit cache keys. + +## Goals / Non-Goals + +Goals: preserve identical model-facing prefixes across unchanged turns and reloads, keep context updates effective without rewriting prior input, and preserve all summary/tool safety checks. See proposal.md for the five approved changes. + +Non-goals: changing user-visible transcript layout, claiming a measured cache hit percentage, changing provider/model pricing, adding model-specific GLM/Qwen features outside the five decisions, or publishing a release. + +## Decisions + +1. Persist versioned hidden request-context records in session history. A snapshot supplies a frozen skill index and initial session context at the beginning of the current window; subsequent session/hook/plan/security updates are append-only hidden user context. Internal records remain hidden to clients and are not real user turns. Recognized records survive effective-history reconstruction; unrelated meta records remain excluded. This replaces the moving-context rule in `replicate-current-codex-compaction/design.md` section 10. An in-memory-only pin was rejected because restart would change the prefix. +2. A successful compaction installs a new snapshot together with the replacement history/checkpoint, before retained user messages and the final compact summary. Current todos are included once in that new window (and when bootstrapping a legacy history that has no snapshot); normal TodoWrite results remain the source of incremental checklist state. Failed compaction does not reset the window. +3. The request builder exposes one compaction request assembly path using the same frozen snapshot, model-facing history transformation, system prompt, and tool definitions as normal requests. Summarization appends only its instruction. OpenAI-compatible providers reuse tools; `tool_choice: none` is emitted only where supported. Native/textual tool calls invalidate a summary and trigger the existing no-tool fallback. Other providers retain their safe legacy path. +4. Ordinary/star subagents without a model selection inherit the parent's current saved model. Explicit selection wins; absent parent state retains the existing configured fallback. Mesh behavior stays equivalent and gains regression coverage. +5. Cache keys use the real session ID (a stable per-loop fallback only without a session), carried by value in each request rather than set on shared providers. Only verified HTTPS official OpenAI/Mistral endpoints enable `prompt_cache_key` by default. Unknown/custom endpoints receive no extra field. A clear unsupported-field 400/422 removes only that field for one retry and remembers the decision for that endpoint/model in the provider instance. Authentication, quota, cancellation and unrelated failures keep existing semantics. Provider capability state is synchronized for concurrent readers. + +## Risks / Trade-offs + +- Older instructions are farther from the newest message -> explicit updates describe replacement scope; permission enforcement remains authoritative in tool execution. +- Hidden records could leak into UI or user-turn predicates -> reuse meta hiding, recognize only versioned internal records, and test persistence/reload and compaction. +- Reusing tool schemas can provoke a tool response -> disable choice where supported and validate both native and textual tool diagnostics before accepting a summary. +- Cache keys cannot ensure server residency -> verify serialized requests and usage-compatible behavior locally; do not invent numerical savings. + +## Migration Plan + +Existing histories receive one initial snapshot on the next normal request. New histories/checkpoints persist their snapshot; reload uses its bytes. No bulk rewrite of stored sessions is required. Implementation remains on the current checkout, with narrow edits preserving unrelated Web work. Validate targeted regressions, then the Windows unit suite and static/OpenSpec checks. + +## Verified implementation boundaries + +- Explicit memory enable/disable starts a new context epoch. Disabling memory removes earlier automatically injected memory from outgoing context, taking priority over cache reuse. Old session records remain on disk; conversation messages and hook events are preserved. The latest valid snapshot supersedes earlier snapshot/state-update records in the model projection. +- Skill usage/dormancy changes do not alter the frozen system index. A real catalog or tool-policy change appends a replacement index alongside other context updates; it does not silently hide newly configured expert skills until compaction. +- Only validated versioned records participate in context reconstruction. Corrupt fields and transcript-only snapshots cannot replace a valid epoch or cause typed JSON reads to throw. +- A context record must be durably appended before it enters live history or a normal provider request. A compact checkpoint must be durable before publishing replacement history, window counters or success notices. Fresh-window preparation uses separate cache state so failed persistence does not refresh the live window. +- Workspace/model/tool-schema changes can still change their earlier prompt sections. The byte-stability guarantee covers unchanged configuration and append-only context updates, not every possible model or workspace switch. diff --git a/openspec/changes/stabilize-prompt-cache-prefix/proposal.md b/openspec/changes/stabilize-prompt-cache-prefix/proposal.md new file mode 100644 index 00000000..c60beaef --- /dev/null +++ b/openspec/changes/stabilize-prompt-cache-prefix/proposal.md @@ -0,0 +1,23 @@ +## Why + +Normal requests currently move rebuilt session context before the newest user message, breaking shared prefixes across turns. Repeated todo injection, unrelated compaction request layouts, default-model subagents, and missing session cache keys further reduce reuse. + +## What Changes + +- Persist a hidden context snapshot per compaction window; preserve its initial position and append subsequent context updates without rewriting old messages. Freeze the skill index for the window. +- Stop reinjecting todos on every request; preserve tool results and restore the current checklist once after compaction. +- Reuse the normal request prefix and tool definitions for local compaction when supported, retaining summary validation and the existing tool-free fallback. +- Default unspecified subagent models to the current parent's saved model while preserving explicit overrides. +- Use stable session cache keys only for verified supporting endpoints; downgrade once on an explicit unsupported-field rejection. + +## Capabilities + +### New Capabilities +- `prompt-cache-stability`: Persisted request-prefix stability, context updates, subagent model inheritance, and compatible session cache routing. + +### Modified Capabilities +- `context-compaction`: Reuse the normal model-facing prefix for summarization with safe tool-free fallback and fresh window context after success. + +## Impact + +Agent request assembly, session history reconstruction, local compaction, OpenAI-compatible provider transport, subagent spawning, and C++ regression tests. This supersedes the per-request moving-context requirement in `replicate-current-codex-compaction/design.md` section 10. Existing Web edits remain outside scope. No paid model experiment, release, or remote publication is required; measured cache-rate gains are not claimed without usage evidence. diff --git a/openspec/changes/stabilize-prompt-cache-prefix/specs/context-compaction/spec.md b/openspec/changes/stabilize-prompt-cache-prefix/specs/context-compaction/spec.md new file mode 100644 index 00000000..cd05b09f --- /dev/null +++ b/openspec/changes/stabilize-prompt-cache-prefix/specs/context-compaction/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: LLM-based summarization for compression +The system SHALL use the LLM provider to generate a summary that preserves technical details, including file paths, variable names, decisions and relevant code. Where supported, the summarization request SHALL reuse the normal request's model-facing message prefix and tool definitions, appending the summary instruction only at the end. Tool choice SHALL be disabled where supported. Native or textual tool calls MUST NOT be accepted as a summary; a rejected prefix-reusing request SHALL fall back to the existing tool-free summarization path. + +#### Scenario: Summary generation +- **WHEN** local compaction runs through a provider supporting prefix reuse +- **THEN** its first request preserves the normal request prefix and tool schemas, adds the summary instruction, and installs the validated result using the current compact-summary format + +#### Scenario: Model emits a tool call +- **WHEN** the prefix-reusing summary response contains native or textual tool calls +- **THEN** no tools are executed, that response is not installed, and summarization retries without tools + +#### Scenario: Summary API call fails +- **WHEN** summarization fails after the existing retry and fallback policy +- **THEN** no invalid summary is persisted and existing compaction failure handling remains in force + +## ADDED Requirements + +### Requirement: Successful compaction refreshes the context window +The system SHALL install a fresh hidden context snapshot with a successful replacement history and persist it in the checkpoint. The compact summary SHALL remain the final model-history item at a mid-turn compaction boundary. + +#### Scenario: Reload after compaction +- **WHEN** the session resumes from a new compact checkpoint +- **THEN** it restores the same initial context snapshot and checklist without moving or duplicating them + +#### Scenario: Checkpoint storage fails +- **WHEN** a generated summary and its fresh snapshot cannot be persisted +- **THEN** the prior history and window identity remain in effect and no compaction success is reported diff --git a/openspec/changes/stabilize-prompt-cache-prefix/specs/prompt-cache-stability/spec.md b/openspec/changes/stabilize-prompt-cache-prefix/specs/prompt-cache-stability/spec.md new file mode 100644 index 00000000..3f21fb8f --- /dev/null +++ b/openspec/changes/stabilize-prompt-cache-prefix/specs/prompt-cache-stability/spec.md @@ -0,0 +1,65 @@ +## Purpose + +Keep reusable model request prefixes stable across turns, session reloads and compaction windows while retaining fresh context and compatible provider routing. + +## ADDED Requirements + +### Requirement: Context snapshots remain stable within a window +The system SHALL persist a hidden initial context snapshot for each compaction window and preserve its model-facing position and bytes across unchanged turns and session reloads. Changed context SHALL be appended without rewriting previously sent context. Internal records MUST NOT appear as user turns or visible transcript messages. + +#### Scenario: Another turn and reload reuse the prefix +- **WHEN** a session receives a second user input or reloads with unchanged context +- **THEN** its existing context and conversation prefix remain identical and new input is appended + +#### Scenario: Instructions or runtime context changes +- **WHEN** project instructions, expert context, hooks or plan state changes +- **THEN** the model receives hidden appended context describing the new state and existing messages remain unchanged + +#### Scenario: Memory is explicitly disabled +- **WHEN** an existing session disables memory +- **THEN** outgoing requests exclude the earlier automatically injected memory by starting a new context epoch, even before the next normal turn + +#### Scenario: Explicit skill catalog change +- **WHEN** the available skill catalog changes through configuration or expert selection +- **THEN** the model receives an appended replacement index while the existing system index stays unchanged + +#### Scenario: Context storage fails +- **WHEN** the new hidden context cannot be persisted +- **THEN** no normal provider request uses that uncommitted context and the user receives an error + +### Requirement: Todo state does not rewrite the request prefix +The system SHALL rely on TodoWrite tool results for normal checklist updates and restore the current checklist once when a fresh compaction window is installed. + +#### Scenario: Todo changes inside a turn +- **WHEN** TodoWrite returns an updated list +- **THEN** subsequent requests retain their earlier prefix and do not reinject a changed checklist before it + +#### Scenario: Compaction replaces the history +- **WHEN** compaction succeeds +- **THEN** the next window contains the complete current checklist and does not repeat it on every request + +### Requirement: Unspecified subagent model inherits the parent +The system SHALL use the parent's current saved model when creating a subagent without an explicit model. Explicit model selections SHALL take precedence and missing parent model state SHALL retain the configured fallback. + +#### Scenario: Parent changed models before spawning +- **WHEN** the parent switches to a saved model and spawns a child without a model +- **THEN** the child uses that current model rather than the global default + +#### Scenario: Explicit child selection +- **WHEN** spawning specifies a model +- **THEN** the specified model is used regardless of the parent's model + +### Requirement: Cache routing remains session stable and endpoint compatible +The system SHALL send a stable session cache key to verified supporting endpoints and omit it for unknown endpoints. Explicit unsupported-cache-field rejection SHALL cause at most one retry without that field and prevent its repeated use for the same endpoint/model in that provider instance. + +#### Scenario: Provider is recreated for a session +- **WHEN** a supported provider is rebuilt or a saved session resumes +- **THEN** subsequent requests retain the same session cache key + +#### Scenario: Strict gateway or unrelated error +- **WHEN** the endpoint is unknown or returns an unrelated error +- **THEN** unknown endpoints receive no cache field and unrelated errors preserve normal error handling + +#### Scenario: Cache field rejected +- **WHEN** a supported endpoint explicitly rejects the cache key field +- **THEN** the request is retried once without that field and future requests in that provider instance omit it diff --git a/openspec/changes/stabilize-prompt-cache-prefix/tasks.md b/openspec/changes/stabilize-prompt-cache-prefix/tasks.md new file mode 100644 index 00000000..89966b8f --- /dev/null +++ b/openspec/changes/stabilize-prompt-cache-prefix/tasks.md @@ -0,0 +1,18 @@ +## 1. Stable context and checklist + +- [x] 1.1 Implement versioned hidden context snapshots and appended updates; verify multi-turn, hook persistence, changed instructions, plan/security changes and session reload regressions. +- [x] 1.2 Freeze the skill index and remove repeated todo injection; install fresh context and current todos with successful compact checkpoints; verify unchanged prefixes, checklist restoration and final-summary ordering. + +## 2. Compaction and child models + +- [x] 2.1 Reuse the normal model-facing prefix/tools for supported compaction with validated tool-free fallback; verify native/text tool rejection, overflow/cancellation and automatic/manual request equivalence. +- [x] 2.2 Inherit the current parent model for unspecified ordinary/star subagents, preserve explicit override and fallback, document behavior and verify mesh parity tests. + +## 3. Compatible cache routing + +- [x] 3.1 Add stable session keys on verified endpoint allowlists, synchronized provider state and narrow unsupported-field downgrade; verify streaming/nonstreaming transport, endpoint matching, rebuild stability and error/cancellation preservation. + +## 4. Integrated validation + +- [x] 4.1 Run the targeted native regression suites, then Windows full unit tests and applicable static checks; record exact commands/results and fix task-related failures. +- [x] 4.2 Validate OpenSpec strictly, review all task diffs with git diff --check and audit the five requirements against tests, preserving unrelated work. diff --git a/openspec/changes/stabilize-prompt-cache-prefix/verification.md b/openspec/changes/stabilize-prompt-cache-prefix/verification.md new file mode 100644 index 00000000..626b7e9f --- /dev/null +++ b/openspec/changes/stabilize-prompt-cache-prefix/verification.md @@ -0,0 +1,33 @@ +# Verification + +Windows, current master checkout, 2026-10-07. Existing unrelated Web edits were preserved. New provider regressions use local HTTP servers and stub models. + +- `cmake --build build --target acecode_unit_tests --config Release --parallel 1`: final native target built successfully (exit 0), log `%TEMP%/acecode-cache-prefix-build-final-proof.log`. +- Targeted GoogleTest filter covered request assembly, normal turns, memory, compaction, parent models, session persistence/reload, TUI replay, side questions and provider cache transport: **268 tests, 268 passed**, XML `%TEMP%/acecode-cache-prefix-targeted-final.xml`. +- Added checks include cross-turn prefix equality; persisted/reloaded snapshots; append-only project/expert/catalog/plan/hook updates; complete four-status checklist restoration; automatic/manual GLM/DeepSeek/GPT prefix equality; tool-response/overflow/field-rejection fallbacks; parent model selection; same-provider concurrent per-request keys; explicit memory disable; invalid record types; and failed snapshot/checkpoint writes. +- Early validation failures were resolved rather than waived: moving-context expectations were replaced with append-only assertions, transcript-only snapshots cannot replace a valid epoch, and Windows junction-aware test fixtures use the actual SessionManager project directory. +- `openspec validate stabilize-prompt-cache-prefix --strict`: passed. +- Layer/ownership checks include new files via a temporary Git index, preserving the user's real index. Both strict checks reported zero findings; `git diff --check` passed. +- Script checks: `ctest --test-dir build -C Release -R '^(verify_package_python_unit|dev_desktop_script_unit|macos_portable_package_unit)$' --output-on-failure`: **3/3 passed**. +- The first full native run exposed 16 outdated user-message/context assertions and an uninitialized session fixture. Those were corrected while retaining payload, ordering, permissions and retry assertions. The follow-up set passed 54/55 initially; the remaining expert test incorrectly required identical static system guidance despite an explicit tool-policy change. Its context/history assertions were retained and the corrected case passed separately. + +## Requirement audit + +| Approved item | Current implementation | Direct regression evidence | +| --- | --- | --- | +| 1. Stable context window | Versioned hidden snapshots, append-only state/hook updates, frozen skill system index, durable restore and explicit memory-disable epoch | `ApiRequestBuilder.PrefixSurvivesNewTurnsSkillActivityAndReload`, `AgentLoopTermination.RequestPrefixIsByteStableAcrossUserTurns`, `HookAgentLoop.*`, `CompactCheckpoint.*`, `SessionResumeRestore.*` | +| 2. Todo state | No per-request checklist rewrite; four-state complete list restored once in a checkpoint | `ApiRequestBuilder.TodoWriteDoesNotReplaceEarlierChecklist`, `AgentLoopContextGolden.TodoWriteChecklistIsRestoredOnceInDurableCompactWindow` | +| 3. Compaction reuse | Common main request projection, same tools where supported, bounded validated tool-free fallback and same per-call cache key | `CacheStability/AgentLoopCompactionPrefix.*`, `CompactCore.*`, `OpenAiPromptCache.CompactionOptionsAreRequestScoped` | +| 4. Parent model | Current parent saved model when omitted, explicit selection and missing-parent fallback preserved | `SpawnSubagentTool.UnspecifiedModelUsesParentsCurrentSelectionAfterSwitch`, `SpawnSubagentTool.ExplicitModelOverridesParentsCurrentSelection`, `SpawnSubagentTool.InheritsModelFromTuiParentOutsideRegistry`, `MeshAgentServiceTest.*` | +| 5. Cache routing | Exact official HTTPS endpoint allowlist, stable session key in per-call options, narrow one-time unsupported-field fallback | `OpenAiPromptCache.*` (14 tests), `ApiRequestBuilder.SessionCacheKeySurvivesRebuildAndRemainsPerRequest` | + +## Final integrated result + +- `build/tests/Release/acecode_unit_tests.exe --gtest_output=xml:%TEMP%/acecode-cache-prefix-full-final.xml`: **6,170 executed, 6,161 passed, 9 skipped, 0 failures, exit 0**, 668.370 seconds. One additional pre-existing disabled test was not executed. +- The skipped tests require optional bridge/dependency installation, manual native notification opt-in, real model/search endpoints, a private historical fixture, or POSIX behavior. They were not represented as passing. +- The nine skips are `ChannelBridge.RealBridgeStartsWithoutConnectingAnAccount`, `ChannelSetup.RealDependencyInstallationIsOptionalAndUsesTemporaryState`, `NativeNotifications.OptInDeliversSelfDrawnToast`, `SystemPromptTest.PosixPromptStaysCleanOfWindowsGuidance`, both `ImageGenerationNetworkSmoke` cases, `RssSearchBackendLive.HostedEndpointReturnsSearchableResults`, `SessionReplayRealJsonl.UserSessionDbcoding5_2026_04_26`, and `Utf8PathTest.ExtendedLengthPathIsIdentityOnPosix`. +- Final full-run log/exit marker: `%TEMP%/acecode-cache-prefix-full-final.log`, `%TEMP%/acecode-cache-prefix-full-final.exit`. +- SHA-256 manifest of all 50 changed/new source and test files matched after the full run: no code changed underneath the final validation. +- Strict layer/ownership gates reported zero findings, script checks passed 3/3, OpenSpec strict validation and diff checks passed. The five approved requirements are mapped to direct regressions above. + +No live provider cache-hit percentage, cross-platform runtime verification, or installed Desktop update is claimed. The running development Desktop/daemon executable is left undisturbed. diff --git a/src/adapters/provider/codex_provider.cpp b/src/adapters/provider/codex_provider.cpp index ada2f5a7..ef91f0c3 100644 --- a/src/adapters/provider/codex_provider.cpp +++ b/src/adapters/provider/codex_provider.cpp @@ -3,6 +3,7 @@ #include "provider/codex/codex_app_server_client.hpp" #include "session/attachment_prompt_context.hpp" #include "session/attachment_store.hpp" +#include "session/request_context_record.hpp" #include "utils/logger.hpp" #include "utils/utf8_path.hpp" @@ -23,6 +24,7 @@ std::string current_cwd_utf8() { } std::string role_label(const ChatMessage& message) { + if (is_request_context_record(message)) return "Context"; if (message.role == "system") return "System"; if (message.role == "assistant") return "Assistant"; if (message.role == "tool") return "Tool"; @@ -58,7 +60,7 @@ std::string build_codex_input_text(const std::vector& messages) { out << "Continue this ACECode conversation. Preserve the user's latest " "request as the active task.\n\n"; for (const auto& message : messages) { - if (message.is_meta) continue; + if (message.is_meta && !is_request_context_record(message)) continue; const auto file_references = user_file_reference_texts(message); // A user message may carry only file parts (a paste stored as a // file with nothing typed next to it); it must not be skipped. diff --git a/src/adapters/provider/openai_prompt_cache.cpp b/src/adapters/provider/openai_prompt_cache.cpp new file mode 100644 index 00000000..42bd7fb7 --- /dev/null +++ b/src/adapters/provider/openai_prompt_cache.cpp @@ -0,0 +1,224 @@ +#include "openai_provider.hpp" +#include "utils/logger.hpp" +#include "config/request_headers.hpp" + +#include +#include + +namespace acecode { +namespace { + +std::string ascii_lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::string json_string_or_empty(const nlohmann::json& value, const char* key) { + if (!value.is_object() || !value.contains(key) || !value[key].is_string()) return {}; + return value[key].get(); +} + +bool known_cache_key_endpoint(const std::string& url) { + const auto authority_end = url.find('/', 8); + if (ascii_lower(url.substr(0, 8)) != "https://" || + authority_end == std::string::npos) return false; + auto authority = ascii_lower(url.substr(8, authority_end - 8)); + if (authority.size() > 4 && authority.compare(authority.size() - 4, 4, ":443") == 0) { + authority.resize(authority.size() - 4); + } + const auto path_end = url.find_first_of("?#", authority_end); + const auto path = url.substr(authority_end, path_end - authority_end); + return (authority == "api.openai.com" || authority == "api.mistral.ai") && + path == "/v1/chat/completions"; +} + +bool unsupported_cache_key_message(const std::string& message) { + // Match an explicit field rejection, not a generic invalid request that + // happens to echo the entire body (including prompt_cache_key). + std::string normalized; + for (unsigned char c : message) { + if (std::isalnum(c) || c == '_') { + normalized.push_back(static_cast(std::tolower(c))); + } else if (!normalized.empty() && normalized.back() != ' ') { + normalized.push_back(' '); + } + } + normalized = " " + normalized + " "; + for (const char* pattern : { + " unknown parameter prompt_cache_key ", + " unsupported parameter prompt_cache_key ", + " unrecognized parameter prompt_cache_key ", + " unknown field prompt_cache_key ", + " unsupported field prompt_cache_key ", + " unrecognized field prompt_cache_key ", + " unrecognized request argument supplied prompt_cache_key ", + " unexpected keyword argument prompt_cache_key ", + " prompt_cache_key is not supported ", + " prompt_cache_key is unsupported ", + " prompt_cache_key is not allowed "}) { + if (normalized.find(pattern) != std::string::npos) return true; + } + return false; +} + +bool explicit_cache_key_rejection(int status, const std::string& body) { + if (status != 400 && status != 422) return false; + const auto error = nlohmann::json::parse(body, nullptr, false); + if (!error.is_object()) return unsupported_cache_key_message(body); + const auto error_it = error.find("error"); + const auto& detail = error_it != error.end() && error_it->is_object() + ? *error_it : error; + const auto parameter = json_string_or_empty(detail, "param"); + const auto code = ascii_lower(json_string_or_empty(detail, "code")); + if (!parameter.empty() && parameter != "prompt_cache_key") return false; + if (code == "invalid_value" || code == "invalid_api_key" || + code == "insufficient_quota" || code == "context_length_exceeded") return false; + if (parameter == "prompt_cache_key" && + (code == "unsupported_parameter" || code == "unknown_parameter" || + code == "unrecognized_parameter")) return true; + if (unsupported_cache_key_message(json_string_or_empty(detail, "message"))) return true; + if (error_it != error.end() && error_it->is_string() && + unsupported_cache_key_message(error_it->get())) return true; + const auto validation = error.find("detail"); + if (validation != error.end() && validation->is_array()) { + for (const auto& item : *validation) { + if (json_string_or_empty(item, "type") != "extra_forbidden") continue; + const auto loc = item.find("loc"); + if (loc != item.end() && loc->is_array() && + ((loc->size() == 1 && (*loc)[0] == "prompt_cache_key") || + (loc->size() == 2 && (*loc)[0] == "body" && (*loc)[1] == "prompt_cache_key"))) return true; + } + } + return false; +} + +} // namespace + +void OpenAiCompatProvider::apply_call_options(nlohmann::json& body, + const std::vector& tools, bool for_compaction, + const ChatRequestOptions* call_options) const { + if (supports_prompt_cache_key()) { + const auto scope = std::make_pair(request_url(), model_); + std::lock_guard lock(prompt_cache_mu_); + const auto& key = call_options ? call_options->prompt_cache_key : prompt_cache_key_; + if (!key.empty() && unsupported_prompt_cache_scopes_.count(scope) == 0) { + body["prompt_cache_key"] = key; + } + } + if (for_compaction && !tools.empty() && supports_compaction_tool_choice_none()) { + body["tool_choice"] = "none"; + } +} + +void OpenAiCompatProvider::set_prompt_cache_key(const std::string& key) { + std::lock_guard lock(prompt_cache_mu_); + prompt_cache_key_ = key; +} + +bool OpenAiCompatProvider::supports_prompt_cache_key() const { + return name() == "openai" && known_cache_key_endpoint(request_url()); +} + +bool OpenAiCompatProvider::supports_compaction_tool_choice_none() const { + return name() == "openai" && known_cache_key_endpoint(request_url()); +} + +bool OpenAiCompatProvider::remove_rejected_prompt_cache_key( + nlohmann::json& body, const std::string& url, + int status_code, const std::string& error_body) { + if (!supports_prompt_cache_key() || !body.contains("prompt_cache_key") || + !explicit_cache_key_rejection(status_code, error_body)) return false; + { + std::lock_guard lock(prompt_cache_mu_); + unsupported_prompt_cache_scopes_.emplace(url, json_string_or_empty(body, "model")); + } + body.erase("prompt_cache_key"); + LOG_INFO("Retrying request without unsupported prompt_cache_key"); + return true; +} + +ChatResponse OpenAiCompatProvider::chat_for_compaction( + const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) { + if (!supports_compaction_prefix_reuse()) { + return LlmProvider::chat_for_compaction(messages, tools, abort_flag); + } + return chat_cancellable_impl(messages, tools, abort_flag, true); +} + +ChatResponse OpenAiCompatProvider::chat_cancellable( + const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) { + return chat_cancellable_impl(messages, tools, abort_flag, false); +} + +ChatResponse OpenAiCompatProvider::chat_with_options( + const std::vector& messages, + const std::vector& tools, + const ChatRequestOptions& options, + const std::atomic* abort_flag) { + if (name() != "openai") { + return LlmProvider::chat_with_options(messages, tools, options, abort_flag); + } + return chat_cancellable_impl(messages, tools, abort_flag, + options.for_compaction, &options); +} + +void OpenAiCompatProvider::chat_stream( + const std::vector& messages, + const std::vector& tools, + const StreamCallback& callback, + std::atomic* abort_flag +) { + chat_stream_impl(messages, tools, callback, abort_flag, nullptr); +} + +void OpenAiCompatProvider::chat_stream_with_options( + const std::vector& messages, + const std::vector& tools, + const ChatRequestOptions& options, + const StreamCallback& callback, + std::atomic* abort_flag) { + if (name() != "openai") { + LlmProvider::chat_stream_with_options(messages, tools, options, callback, abort_flag); + return; + } + chat_stream_impl(messages, tools, callback, abort_flag, &options); +} + +void OpenAiCompatProvider::chat_stream_impl( + const std::vector& messages, + const std::vector& tools, + const StreamCallback& callback, + std::atomic* abort_flag, + const ChatRequestOptions* call_options) { + nlohmann::json body = build_request_body(messages, tools, true, + call_options && call_options->for_compaction, call_options); + std::string url = request_url(); + + std::map extra_headers; + if (!api_key_.empty()) { + extra_headers["Authorization"] = "Bearer " + api_key_; + } + std::string header_error; + auto resolved_headers = resolve_request_headers(request_headers_, header_error); + if (!resolved_headers.has_value()) { + LOG_ERROR("OpenAI request_headers resolution failed: " + header_error); + StreamEvent evt; + evt.type = StreamEventType::Error; + evt.error = header_error; + callback(evt); + return; + } + for (const auto& [k, v] : *resolved_headers) { + extra_headers[k] = v; + } + + parse_sse_stream(url, body, extra_headers, callback, abort_flag); +} + +} // namespace acecode diff --git a/src/adapters/provider/openai_provider.cpp b/src/adapters/provider/openai_provider.cpp index e07d36cd..77282360 100644 --- a/src/adapters/provider/openai_provider.cpp +++ b/src/adapters/provider/openai_provider.cpp @@ -879,10 +879,13 @@ nlohmann::json openai_content_for_message(const ChatMessage& msg, nlohmann::json OpenAiCompatProvider::build_request_body( const std::vector& messages, const std::vector& tools, - bool stream + bool stream, + bool for_compaction, + const ChatRequestOptions* call_options ) const { nlohmann::json body; body["model"] = model_; + apply_call_options(body, tools, for_compaction, call_options); if (stream) { body["stream"] = true; body["stream_options"] = {{"include_usage", true}}; @@ -1221,17 +1224,19 @@ ChatResponse OpenAiCompatProvider::parse_response(const nlohmann::json& j) { return resp; } -ChatResponse OpenAiCompatProvider::chat_cancellable( +ChatResponse OpenAiCompatProvider::chat_cancellable_impl( const std::vector& messages, const std::vector& tools, - const std::atomic* abort_flag + const std::atomic* abort_flag, + bool for_compaction, + const ChatRequestOptions* call_options ) { if (abort_flag && abort_flag->load()) { return make_chat_error_response(make_provider_error( ProviderErrorKind::UserCancelled, 0, name(), model_, {}, {}, "[Interrupted]", false)); } - nlohmann::json body = build_request_body(messages, tools, false); + nlohmann::json body = build_request_body(messages, tools, false, for_compaction, call_options); std::string url = request_url(); @@ -1259,19 +1264,21 @@ ChatResponse OpenAiCompatProvider::chat_cancellable( } auto proxy_opts = network::proxy_options_for(url); - cpr::Response r = cpr::Post( - cpr::Url{url}, - headers, - cpr::Body{body.dump()}, - network::build_ssl_options(proxy_opts), - proxy_opts.proxies, - proxy_opts.auth, - cpr::Timeout{stream_timeout_ms_}, - cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, - cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { - return !abort_flag || !abort_flag->load(); - }} - ); + cpr::Response r; + do { + if (abort_flag && abort_flag->load()) break; + r = cpr::Post( + cpr::Url{url}, headers, cpr::Body{body.dump()}, + network::build_ssl_options(proxy_opts), proxy_opts.proxies, proxy_opts.auth, + cpr::Timeout{stream_timeout_ms_}, + cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !abort_flag || !abort_flag->load(); + }}); + // Removing the field bounds this compatibility retry to one request. + } while ((!abort_flag || !abort_flag->load()) && + remove_rejected_prompt_cache_key(body, url, + static_cast(r.status_code), r.text)); if (abort_flag && abort_flag->load()) { return make_chat_error_response(make_provider_error( @@ -1433,6 +1440,7 @@ ChatResponse OpenAiCompatProvider::parse_sse_stream( ChatResponse last_accumulated; last_accumulated.finish_reason = "stop"; const auto dsml_tools = request_tool_defs(body); + nlohmann::json request_body = body; for (std::uint64_t attempt = 1; ; attempt = attempt == (std::numeric_limits::max)() @@ -1814,7 +1822,7 @@ ChatResponse OpenAiCompatProvider::parse_sse_stream( auto proxy_opts = network::proxy_options_for(url); stream_session.SetOption(cpr::Url{url}); stream_session.SetOption(headers); - stream_session.SetOption(cpr::Body{body.dump()}); + stream_session.SetOption(cpr::Body{request_body.dump()}); stream_session.SetOption(cpr::ConnectTimeout{ (std::min)(stream_idle_timeout_ms, kStreamConnectTimeoutCapMs)}); stream_session.SetOption(network::build_ssl_options(proxy_opts)); @@ -1952,6 +1960,15 @@ ChatResponse OpenAiCompatProvider::parse_sse_stream( return accumulated; } + if ((!abort_flag || !abort_flag->load()) && + !saw_done && accumulated.tool_calls.empty() && + accumulated.content.empty() && accumulated.reasoning_content.empty() && + pending_tools.empty() && + remove_rejected_prompt_cache_key(request_body, url, + error_info.status_code, error_info.raw_body)) { + continue; + } + if (error_info.retryable) { const int delay_ms = retry_after_delay_ms(r.header, attempt); error_info.retry_attempt = saturating_retry_attempt(attempt); @@ -1992,34 +2009,4 @@ ChatResponse OpenAiCompatProvider::parse_sse_stream( return last_accumulated; } -void OpenAiCompatProvider::chat_stream( - const std::vector& messages, - const std::vector& tools, - const StreamCallback& callback, - std::atomic* abort_flag -) { - nlohmann::json body = build_request_body(messages, tools, true); - std::string url = request_url(); - - std::map extra_headers; - if (!api_key_.empty()) { - extra_headers["Authorization"] = "Bearer " + api_key_; - } - std::string header_error; - auto resolved_headers = resolve_request_headers(request_headers_, header_error); - if (!resolved_headers.has_value()) { - LOG_ERROR("OpenAI request_headers resolution failed: " + header_error); - StreamEvent evt; - evt.type = StreamEventType::Error; - evt.error = header_error; - callback(evt); - return; - } - for (const auto& [k, v] : *resolved_headers) { - extra_headers[k] = v; - } - - parse_sse_stream(url, body, extra_headers, callback, abort_flag); -} - } // namespace acecode diff --git a/src/adapters/provider/openai_provider.hpp b/src/adapters/provider/openai_provider.hpp index efbf5c25..acd34d55 100644 --- a/src/adapters/provider/openai_provider.hpp +++ b/src/adapters/provider/openai_provider.hpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include @@ -38,6 +40,27 @@ class OpenAiCompatProvider : public LlmProvider { std::atomic* abort_flag = nullptr ) override; + void set_prompt_cache_key(const std::string& key) override; + bool supports_compaction_prefix_reuse() const override { + // Copilot and Grok own different transport/authentication paths. + return name() == "openai"; + } + ChatResponse chat_for_compaction( + const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) override; + ChatResponse chat_with_options( + const std::vector& messages, + const std::vector& tools, + const ChatRequestOptions& options, + const std::atomic* abort_flag) override; + void chat_stream_with_options( + const std::vector& messages, + const std::vector& tools, + const ChatRequestOptions& options, + const StreamCallback& callback, + std::atomic* abort_flag = nullptr) override; + std::string name() const override { return "openai"; } bool is_authenticated() override { return true; } std::string model() const override { return model_; } @@ -114,9 +137,16 @@ class OpenAiCompatProvider : public LlmProvider { nlohmann::json build_request_body( const std::vector& messages, const std::vector& tools, - bool stream = false + bool stream = false, + bool for_compaction = false, + const ChatRequestOptions* call_options = nullptr ) const; + // Capability seams also let protocol adapters use an explicit policy; + // the default implementation recognizes verified official endpoints only. + virtual bool supports_prompt_cache_key() const; + virtual bool supports_compaction_tool_choice_none() const; + // Parse a chat completions response JSON (reusable by CopilotProvider) static ChatResponse parse_response(const nlohmann::json& j); @@ -137,6 +167,31 @@ class OpenAiCompatProvider : public LlmProvider { int stream_timeout_ms_ = OpenAiConfig::kDefaultStreamTimeoutMs; bool model_has_vision_ = true; // fail-open,见 set_vision_routing bool any_vision_model_available_ = false; + +private: + void apply_call_options(nlohmann::json& body, + const std::vector& tools, bool for_compaction, + const ChatRequestOptions* call_options) const; + ChatResponse chat_cancellable_impl( + const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag, + bool for_compaction, + const ChatRequestOptions* call_options = nullptr); + void chat_stream_impl( + const std::vector& messages, + const std::vector& tools, + const StreamCallback& callback, + std::atomic* abort_flag, + const ChatRequestOptions* call_options); + bool remove_rejected_prompt_cache_key( + nlohmann::json& body, const std::string& url, + int status_code, const std::string& error_body); + + // Leaf lock: copies request state only; never held during I/O/callbacks. + mutable std::mutex prompt_cache_mu_; + std::string prompt_cache_key_; + std::set> unsupported_prompt_cache_scopes_; }; // 把一条 ChatMessage 的 content_parts 转成 OpenAI-compatible content payload。 diff --git a/src/apps/tui/resume/session_replay.cpp b/src/apps/tui/resume/session_replay.cpp index 0fa495e7..fbf42f62 100644 --- a/src/apps/tui/resume/session_replay.cpp +++ b/src/apps/tui/resume/session_replay.cpp @@ -5,6 +5,7 @@ #include "session/compact_checkpoint.hpp" #include "session/compact_notice.hpp" #include "session/inter_agent_message.hpp" +#include "session/request_context_record.hpp" #include "session/session_rewind.hpp" #include "session/tool_metadata_codec.hpp" #include "session/tool_result_storage.hpp" @@ -68,6 +69,9 @@ std::vector replay_session_messages( }; for (const auto& msg : messages) { + if (is_request_context_metadata(msg)) { + continue; + } if (is_file_checkpoint_message(msg)) { continue; } diff --git a/src/apps/tui/resume/session_resume_restore.cpp b/src/apps/tui/resume/session_resume_restore.cpp index f9d0ecbc..cda541af 100644 --- a/src/apps/tui/resume/session_resume_restore.cpp +++ b/src/apps/tui/resume/session_resume_restore.cpp @@ -1,6 +1,7 @@ #include "session_resume_restore.hpp" #include "session/compact_checkpoint.hpp" +#include "session/request_context_record.hpp" #include "session_replay.hpp" #include "tool/file_state_restore.hpp" #include "session/session_rewind.hpp" @@ -42,6 +43,7 @@ void append_agent_model_messages(const std::vector& messages, AgentLoop& agent_loop) { for (std::size_t i = 0; i < messages.size(); ++i) { const auto& msg = messages[i]; + if (is_request_context_metadata(msg) && !is_request_context_record(msg)) continue; if (is_file_checkpoint_message(msg)) continue; if (is_content_replacement_message(msg)) continue; if (is_turn_timing_message(msg)) continue; diff --git a/src/domain/llm/llm_provider.hpp b/src/domain/llm/llm_provider.hpp index 02d2949d..145caefa 100644 --- a/src/domain/llm/llm_provider.hpp +++ b/src/domain/llm/llm_provider.hpp @@ -95,6 +95,13 @@ struct ToolDef { nlohmann::json parameters; // JSON Schema object }; +// Owned per-call hints. Passing them with the request keeps shared providers +// from mixing identities when independent sessions sample concurrently. +struct ChatRequestOptions { + std::string prompt_cache_key; + bool for_compaction = false; +}; + enum class ProviderErrorKind { None, UserCancelled, @@ -259,6 +266,39 @@ class LlmProvider { // tool list. Detached read-only side chat must reject those runtimes. virtual bool supports_tool_free_chat() const { return true; } + // Optional session routing hint. Callers supply a stable opaque identity; + // providers must copy it and synchronize updates with concurrent requests. + virtual void set_prompt_cache_key(const std::string&) {} + + // Compaction may preserve the main request's schemas without executing + // tools. Providers opt in only when they can preserve that wire prefix. + virtual bool supports_compaction_prefix_reuse() const { return false; } + virtual ChatResponse chat_for_compaction( + const std::vector& messages, + const std::vector&, + const std::atomic* abort_flag) { + return chat_cancellable(messages, {}, abort_flag); + } + + virtual ChatResponse chat_with_options( + const std::vector& messages, + const std::vector& tools, + const ChatRequestOptions& options, + const std::atomic* abort_flag) { + return options.for_compaction + ? chat_for_compaction(messages, tools, abort_flag) + : chat_cancellable(messages, tools, abort_flag); + } + + virtual void chat_stream_with_options( + const std::vector& messages, + const std::vector& tools, + const ChatRequestOptions&, + const StreamCallback& callback, + std::atomic* abort_flag = nullptr) { + chat_stream(messages, tools, callback, abort_flag); + } + virtual std::string model() const = 0; virtual void set_model(const std::string& m) = 0; diff --git a/src/domain/session/compact_checkpoint.cpp b/src/domain/session/compact_checkpoint.cpp index 228b334d..51429ce9 100644 --- a/src/domain/session/compact_checkpoint.cpp +++ b/src/domain/session/compact_checkpoint.cpp @@ -1,6 +1,7 @@ #include "compact_checkpoint.hpp" #include "session_history_recovery.hpp" #include "session_serializer.hpp" +#include "request_context_record.hpp" #include "utils/uuid.hpp" #include @@ -118,9 +119,16 @@ std::optional decode_compact_checkpoint(const ChatMessage& ms std::vector provider_relevant_messages(const std::vector& messages) { std::vector result; result.reserve(messages.size()); - for (const auto& msg : messages) { + std::size_t latest_snapshot = 0; + for (std::size_t i = 0; i < messages.size(); ++i) { + if (is_request_context_snapshot(messages[i])) latest_snapshot = i; + } + for (std::size_t i = 0; i < messages.size(); ++i) { + const auto& msg = messages[i]; + if (i < latest_snapshot && is_request_context_record(msg) && + (is_request_context_snapshot(msg) || msg.metadata.contains("context_state"))) continue; if (is_compact_checkpoint_message(msg)) continue; - if (msg.is_meta) continue; + if (msg.is_meta && !is_request_context_record(msg)) continue; if (is_transcript_only_message(msg)) continue; if (!is_provider_role(msg.role)) continue; result.push_back(msg); @@ -163,7 +171,7 @@ ProviderHistoryRecoveryResult reconstruct_effective_model_history_with_recovery( auto one = provider_relevant_messages({msg}); effective.insert(effective.end(), one.begin(), one.end()); } - return recover_provider_history(effective); + return recover_provider_history(provider_relevant_messages(effective)); } } // namespace acecode diff --git a/src/domain/session/request_context_record.hpp b/src/domain/session/request_context_record.hpp new file mode 100644 index 00000000..e8cd1284 --- /dev/null +++ b/src/domain/session/request_context_record.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "llm/llm_provider.hpp" + +#include +#include + +namespace acecode { + +// Persisted but hidden from transcript clients. These are the only meta records +// deliberately retained in the effective model history. +inline constexpr const char* kRequestContextSnapshot = "request_context_snapshot"; +inline constexpr const char* kRequestContextUpdate = "request_context_update"; + +// Display classification must not depend on whether this version understands +// the payload. Future or damaged internal records still stay hidden. +inline bool is_request_context_metadata(const ChatMessage& message) { + return message.is_meta && + (message.subtype == kRequestContextSnapshot || message.subtype == kRequestContextUpdate); +} + +inline bool is_request_context_record(const ChatMessage& message) { + if (!is_request_context_metadata(message) || message.role != "user" || + !message.metadata.is_object()) return false; + const auto& metadata = message.metadata; + const auto version = metadata.find("request_context_version"); + if (version == metadata.end() || !version->is_number_integer() || *version != 1) return false; + + // A damaged, future or foreign record must stay ordinary hidden metadata; + // it cannot reach the request builder's typed reads or state merging. + const auto state = metadata.find("context_state"); + if (state != metadata.end()) { + if (!state->is_object()) return false; + for (const auto& value : *state) { + if (!value.is_string()) return false; + } + } + const auto skills = metadata.find("skills"); + if (skills != metadata.end() && !skills->is_string()) return false; + const auto catalog = metadata.find("skills_catalog_key"); + if (catalog != metadata.end() && !catalog->is_string()) return false; + const auto bytes = metadata.find("project_rules_bytes"); + if (bytes != metadata.end() && + (!bytes->is_number_integer() || *bytes < 0 || + *bytes > (std::numeric_limits::max)())) return false; + const auto memory = metadata.find("memory_active"); + if (memory != metadata.end() && !memory->is_boolean()) return false; + const auto transcript = metadata.find("transcript_only"); + if (transcript != metadata.end() && (!transcript->is_boolean() || transcript->get())) return false; + return true; +} + +inline bool is_request_context_snapshot(const ChatMessage& message) { + return is_request_context_record(message) && message.subtype == kRequestContextSnapshot; +} + +} // namespace acecode diff --git a/src/domain/session/session_history_page.cpp b/src/domain/session/session_history_page.cpp index 5e562224..8cfb94d1 100644 --- a/src/domain/session/session_history_page.cpp +++ b/src/domain/session/session_history_page.cpp @@ -1,6 +1,7 @@ #include "session_history_page.hpp" #include "session_load_metrics.hpp" #include "session_serializer.hpp" +#include "request_context_record.hpp" #include "utils/sha1.hpp" #include @@ -160,6 +161,7 @@ JsonlRecord validate_history_cursor(const SessionFileReader& reader, const Histo } bool is_visible_paged_history_message(const ChatMessage& message) { + if (is_request_context_metadata(message)) return false; if (message.is_meta && (message.subtype == "file_checkpoint" || message.subtype == "compact_checkpoint")) return false; const auto hidden = message.metadata.find("hidden_goal_context"); return !message.metadata.is_object() || hidden == message.metadata.end() || diff --git a/src/domain/session/session_manager.cpp b/src/domain/session/session_manager.cpp index 43ea68f5..0a67e586 100644 --- a/src/domain/session/session_manager.cpp +++ b/src/domain/session/session_manager.cpp @@ -339,11 +339,10 @@ bool SessionManager::ensure_created() { return true; } -void SessionManager::on_message(const ChatMessage& msg) { +bool SessionManager::try_on_message(const ChatMessage& msg) { std::lock_guard lk(mu_); - if (!started_) return; - - if (!ensure_created()) return; + last_error_.clear(); + if (!started_ || !ensure_created()) return false; const int message_ordinal = message_count_; const auto before_index_signature = @@ -353,7 +352,7 @@ void SessionManager::on_message(const ChatMessage& msg) { if (!SessionStorage::append_message(jsonl_path_, msg)) { last_error_ = "failed to append session message"; LOG_WARN("[session] " + last_error_ + " session=" + session_id_); - return; + return false; } message_count_++; if (is_visible_user_turn_message(msg)) { @@ -381,6 +380,7 @@ void SessionManager::on_message(const ChatMessage& msg) { } update_meta(SessionStorage::now_iso8601()); + return true; } bool SessionManager::replace_active_messages(const std::vector& messages) { diff --git a/src/domain/session/session_manager.hpp b/src/domain/session/session_manager.hpp index 15df2b15..58d4b6cd 100644 --- a/src/domain/session/session_manager.hpp +++ b/src/domain/session/session_manager.hpp @@ -51,6 +51,9 @@ class SessionManager { // Called for each message produced during conversation. // Appends to JSONL and periodically updates metadata. void on_message(const ChatMessage& msg); + // Checked append for state that must be durable before it is used by a + // provider request. False leaves message/turn counters unchanged. + bool try_on_message(const ChatMessage& msg); // Replace the current active JSONL transcript, preserving checkpoint // metadata for user turns that remain in the supplied message list. diff --git a/src/domain/session/session_manager_history.cpp b/src/domain/session/session_manager_history.cpp index fcc00f0e..ffb08d61 100644 --- a/src/domain/session/session_manager_history.cpp +++ b/src/domain/session/session_manager_history.cpp @@ -8,6 +8,10 @@ namespace acecode { namespace fs = std::filesystem; +void SessionManager::on_message(const ChatMessage& msg) { + (void)try_on_message(msg); +} + void SessionManager::record_turn_outcome(const std::string& outcome) { if (!is_valid_turn_timing_status(outcome)) return; std::lock_guard lock(mu_); diff --git a/src/domain/session/todo_state.cpp b/src/domain/session/todo_state.cpp index a8eb3d55..a2963c97 100644 --- a/src/domain/session/todo_state.cpp +++ b/src/domain/session/todo_state.cpp @@ -187,13 +187,15 @@ std::string format_todo_injection(const std::vector& items) { bool wrote_header = false; for (const auto& item : items) { const std::string status = normalize_todo_status(item.status); - if (status != "pending" && status != "in_progress") continue; + if (!is_valid_todo_status(status)) continue; if (!wrote_header) { - oss << "[Your active task list was preserved across context compression]\n"; + oss << "[Task list snapshot; later TodoWrite results replace this list]\n"; wrote_header = true; } std::string marker = "[ ]"; if (status == "in_progress") marker = "[>]"; + if (status == "completed") marker = "[x]"; + if (status == "cancelled") marker = "[-]"; oss << "- " << marker << " " << item.id << ". " << item.content << " (" << status << ")\n"; } diff --git a/src/engine/agent/compaction/compact.cpp b/src/engine/agent/compaction/compact.cpp index ad89c628..0fb36269 100644 --- a/src/engine/agent/compaction/compact.cpp +++ b/src/engine/agent/compaction/compact.cpp @@ -3,6 +3,7 @@ #include "compact_prompt.hpp" #include "session/compact_checkpoint.hpp" #include "session/session_history_recovery.hpp" +#include "session/request_context_record.hpp" #include "llm/tool_protocol_names.hpp" #include "pa/pa_quirks.hpp" #include "provider/text_tool_call_recovery.hpp" @@ -39,6 +40,17 @@ std::string provider_error_search_text(const acecode::ProviderErrorInfo& info) { info.pretty_json); } +bool compaction_tools_rejected(const acecode::ProviderErrorInfo& info) { + if (info.kind != acecode::ProviderErrorKind::Http || + (info.status_code != 400 && info.status_code != 422)) return false; + const auto text = provider_error_search_text(info); + return contains_any(text, {"tool_choice", "tools"}) && + contains_any(text, {"unsupported", "not supported", "does not support", + "unknown", "unrecognized", "not allowed", + "not permitted", "unexpected", "extra inputs", + "extra fields", "extra_forbidden"}); +} + bool has_context_overflow_code(const nlohmann::json& value) { if (value.is_object()) { for (auto it = value.begin(); it != value.end(); ++it) { @@ -383,9 +395,14 @@ void insert_context_before_last_real_user_or_summary( } std::string compact_summary_rejection_reason(const ChatResponse& response) { - // 压缩请求不带工具表,仍有模型(实测 dots3)接着历史里的 tool_calls「做下一步」: + // 即使禁止调用工具,仍有模型接着历史里的 tool_calls「做下一步」: // 要么真的回原生 tool_calls,要么把调用写成正文。两种都不是摘要,绝不能落盘。 if (response.has_tool_calls()) return "tool_calls"; + // Providers can remove invalid textual calls from the visible body. Their + // diagnostic must still reject a contaminated reply that retains prose. + if (response.text_tool_calls.outcome != TextToolCallDiagnostic::Outcome::None) { + return "tool_call_markup"; + } const bool blank = std::all_of( response.content.begin(), response.content.end(), [](char c) { return std::isspace(static_cast(c)) != 0; @@ -404,7 +421,8 @@ CompactResult compact_messages( const std::vector& initial_context, bool is_auto, std::atomic* abort_flag, - CompactRetryCallback on_retry) { + CompactRetryCallback on_retry, + const CompactRequestPrefix* request_prefix) { CompactResult result; const std::vector original_history = normalize_messages_for_api(messages); @@ -412,6 +430,44 @@ CompactResult compact_messages( // 被总结的模型同样不该看到旧的文本工具调用样本(与主请求同一套清洗), // 否则 dots 这类模型会接着把调用写进摘要(yubo2 现场)。 sanitize_text_tool_call_history(request_history, get_compact_summary_prefix()); + std::vector stable_context = normalize_messages_for_api(initial_context); + const bool projected_prefix = request_prefix && !request_prefix->messages.empty(); + bool reuse_prefix = projected_prefix && provider.supports_compaction_prefix_reuse(); + if (projected_prefix) { + // The tool-free retry keeps the same frozen window and model-facing + // history. Rebuilding initial_context would duplicate the persisted + // snapshot and could reintroduce different skill/context bytes. The + // same projection is required for legacy providers after memory off; + // capability only controls whether tool schemas accompany the input. + const auto history_begin = std::find_if_not(request_prefix->messages.begin(), + request_prefix->messages.end(), [](const ChatMessage& message) { + return message.role == "system" || is_request_context_snapshot(message); + }); + stable_context.assign(request_prefix->messages.begin(), history_begin); + request_history.assign(history_begin, request_prefix->messages.end()); + } else if (const auto snapshot = std::find_if(request_history.begin(), request_history.end(), + is_request_context_snapshot); + snapshot != request_history.end()) { + // Legacy providers retain their native-name history path. Persisted + // window context replaces the separately rebuilt user context. + stable_context.erase(std::remove_if(stable_context.begin(), stable_context.end(), + [](const ChatMessage& message) { + return message.role != "system" || + (message.metadata.is_object() && + message.metadata.value("request_local_skill_context", false)); + }), + stable_context.end()); + const auto skill_index = snapshot->metadata.value("skills", std::string{}); + if (!skill_index.empty()) { + ChatMessage skill_system; + skill_system.role = "system"; + skill_system.content = skill_index; + stable_context.push_back(std::move(skill_system)); + } + stable_context.push_back(*snapshot); + request_history.erase(std::remove_if(request_history.begin(), request_history.end(), + is_request_context_snapshot), request_history.end()); + } if (provider.supports_native_compaction()) { LOG_WARN("Provider advertises native compaction but the active LlmProvider contract " @@ -420,13 +476,14 @@ CompactResult compact_messages( LOG_INFO("Compact start; trigger=" + compact_trigger_name(is_auto) + " history_items=" + std::to_string(original_history.size()) + - " initial_context_items=" + std::to_string(initial_context.size())); + " initial_context_items=" + std::to_string(stable_context.size())); std::string summary_suffix; std::uint64_t transient_retries = 0; // 与上下文溢出、瞬时错误的重试互不共享计数。 int invalid_summary_retries = 0; int overflow_retries = 0; + const std::vector no_tools; for (;;) { if (abort_flag && abort_flag->load()) { result.error = "Compaction cancelled."; @@ -434,10 +491,13 @@ CompactResult compact_messages( } std::vector request; - request.reserve(initial_context.size() + request_history.size() + 1); - auto stable_context = normalize_messages_for_api(initial_context); - request.insert(request.end(), stable_context.begin(), stable_context.end()); - request.insert(request.end(), request_history.begin(), request_history.end()); + if (reuse_prefix) { + request = request_prefix->messages; + } else { + request.reserve(stable_context.size() + request_history.size() + 1); + request.insert(request.end(), stable_context.begin(), stable_context.end()); + request.insert(request.end(), request_history.begin(), request_history.end()); + } ChatMessage prompt; prompt.role = "user"; @@ -455,7 +515,11 @@ CompactResult compact_messages( std::to_string(request_history.size()) + " removed_items=" + std::to_string(result.compaction_request_items_removed)); - ChatResponse response = provider.chat(request, {}); + auto request_options = request_prefix ? request_prefix->request_options + : ChatRequestOptions{}; + request_options.for_compaction = reuse_prefix; + ChatResponse response = provider.chat_with_options(request, + reuse_prefix ? request_prefix->tools : no_tools, request_options, abort_flag); if (abort_flag && abort_flag->load()) { result.error = "Compaction cancelled."; @@ -467,6 +531,16 @@ CompactResult compact_messages( const bool context_overflow = has_structured_error ? is_context_overflow_error(response.provider_error) : is_context_overflow_error(response.content); + if (reuse_prefix && (context_overflow || + compaction_tools_rejected(response.provider_error))) { + // Removing the schemas may itself make the request fit. + // Preserve every history item until the legacy path also + // reports an overflow, then apply its existing pruning. + reuse_prefix = false; + transient_retries = 0; + LOG_WARN("Compact prefix request rejected; retrying without tools"); + continue; + } if (context_overflow && !request_history.empty()) { const int removed = shrink_history_for_overflow( @@ -542,6 +616,7 @@ CompactResult compact_messages( truncate_utf8_prefix(response.content, 300)); if (invalid_summary_retries < kMaxInvalidCompactSummaryRetries) { ++invalid_summary_retries; + reuse_prefix = false; continue; } // 不安装任何摘要:自动压缩由 AgentLoop 现有的丢弃最旧历史兜底接手。 @@ -556,6 +631,12 @@ CompactResult compact_messages( break; } catch (const std::exception& error) { const std::string message = error.what(); + if (reuse_prefix && is_context_overflow_error(message)) { + reuse_prefix = false; + transient_retries = 0; + LOG_WARN("Compact prefix exceeded context; retrying without tools"); + continue; + } if (is_context_overflow_error(message) && !request_history.empty()) { const int removed = shrink_history_for_overflow( request_history, ++overflow_retries); diff --git a/src/engine/agent/compaction/compact.hpp b/src/engine/agent/compaction/compact.hpp index 8ae71490..7a53d46c 100644 --- a/src/engine/agent/compaction/compact.hpp +++ b/src/engine/agent/compaction/compact.hpp @@ -44,7 +44,8 @@ bool is_retryable_compaction_error(const ProviderErrorInfo& info); // summary. Returns "" when acceptable, otherwise a short reason: // "tool_calls" (native tool calls), "empty" (blank after trimming) or // "tool_call_markup" (tool-call tags anywhere outside code fences/inline -// code). There is intentionally no minimum length: a valid Chinese summary +// code, or provider diagnostics for filtered textual calls). There is +// intentionally no minimum length: a valid Chinese summary // can be only a few characters. std::string compact_summary_rejection_reason(const ChatResponse& response); @@ -55,6 +56,16 @@ constexpr int kMaxInvalidCompactSummaryRetries = 2; using CompactRetryCallback = std::function; +// Already-built model-facing input. Keep its ordering, tool aliases, reasoning +// and structured parts unchanged; only the summary instruction is appended. +// Tool-free fallback retains these same bytes; initial system/snapshot messages +// stay fixed while overflow recovery may prune the remaining history. +struct CompactRequestPrefix { + std::vector messages; + std::vector tools; + ChatRequestOptions request_options; +}; + // Insert rebuilt request-local context at Codex's handoff boundary: before the // last real user message, or before the compact summary when no real user // message remains. Existing history content is never rewritten. @@ -64,12 +75,15 @@ void insert_context_before_last_real_user_or_summary( // Run Codex-compatible local compaction. initial_context contains stable // base/session instructions that are always retained during overflow retries. +// A supported request_prefix is used verbatim, then retried without tools on +// invalid summaries or incompatibility before applying normal overflow pruning. CompactResult compact_messages( LlmProvider& provider, const std::vector& messages, const std::vector& initial_context = {}, bool is_auto = false, std::atomic* abort_flag = nullptr, - CompactRetryCallback on_retry = {}); + CompactRetryCallback on_retry = {}, + const CompactRequestPrefix* request_prefix = nullptr); } // namespace acecode diff --git a/src/engine/agent/compaction/compaction_controller.cpp b/src/engine/agent/compaction/compaction_controller.cpp index b82817a2..6e2d2a0d 100644 --- a/src/engine/agent/compaction/compaction_controller.cpp +++ b/src/engine/agent/compaction/compaction_controller.cpp @@ -104,12 +104,8 @@ bool CompactionController::mechanical_fallback( bool CompactionController::run_auto(const CompactionInputs& inputs) { const int context_window = ActiveModelView(inputs.provider, inputs.request.context_window, environment_).effective_window(); auto initial_context = requests_.initial_context(inputs.request); - const auto active_history = - recovered_provider_messages(history_.view(), "auto-compact"); - auto estimated_request = initial_context; - estimated_request.insert( - estimated_request.end(), active_history.begin(), active_history.end()); - const int pre_tokens = estimate_message_tokens(estimated_request); + auto request_bundle = requests_.compaction_request(inputs.request, history_.view()); + const int pre_tokens = estimate_message_tokens(request_bundle.messages_with_system); const int threshold = get_auto_compact_threshold(context_window); LOG_INFO("Auto-compact preflight; messages=" + std::to_string(history_.view().size()) + " current_request_estimated_tokens=" + std::to_string(pre_tokens) + @@ -152,6 +148,10 @@ bool CompactionController::run_auto(const CompactionInputs& inputs) { LOG_INFO("Auto full compact starting; messages=" + std::to_string(history_.view().size()) + " active_estimated_tokens=" + std::to_string(pre_tokens) + " threshold=" + std::to_string(threshold)); + CompactRequestPrefix request_prefix; + request_prefix.request_options = request_bundle.request_options; + request_prefix.messages = std::move(request_bundle.messages_with_system); + request_prefix.tools = std::move(request_bundle.tool_defs); agent::ActiveProviderScope active_provider(active_provider_, provider_snapshot); CompactResult result = compact_messages( *provider_snapshot, @@ -163,7 +163,7 @@ bool CompactionController::run_auto(const CompactionInputs& inputs) { owner.with([&info, waiting](CompactionController& controller) { controller.retry_.standard(info, waiting, true); }); - }); + }, &request_prefix); active_provider.reset(); if (!result.performed) { @@ -195,7 +195,7 @@ bool CompactionController::run_auto(const CompactionInputs& inputs) { " estimated_tokens_saved=" + std::to_string(result.estimated_tokens_saved) + " compacted_estimated_tokens=" + std::to_string(compacted_tokens)); - apply_result(inputs, result, "auto", compact_notice_id); + if (!apply_result(inputs, result, "auto", compact_notice_id)) return false; if (inputs.hooks) { auto fields = hooks_.common_fields(kCodexHookEventPostCompact, inputs.session); auto payload = build_compact_hook_payload(fields, "auto"); @@ -255,6 +255,11 @@ void CompactionController::run_manual(const CompactionInputs& inputs) { return; } + CompactRequestPrefix request_prefix; + auto bundle = requests_.compaction_request(inputs.request, history_.view()); + request_prefix.request_options = bundle.request_options; + request_prefix.messages = std::move(bundle.messages_with_system); + request_prefix.tools = std::move(bundle.tool_defs); agent::ActiveProviderScope active_provider(active_provider_, provider_snapshot); CompactResult result = compact_messages( *provider_snapshot, @@ -266,7 +271,7 @@ void CompactionController::run_manual(const CompactionInputs& inputs) { owner.with([&info, waiting](CompactionController& controller) { controller.retry_.standard(info, waiting, true); }); - }); + }, &request_prefix); active_provider.reset(); if (!result.performed) { @@ -276,7 +281,10 @@ void CompactionController::run_manual(const CompactionInputs& inputs) { return; } - apply_result(inputs, result, "manual", compact_notice_id); + if (!apply_result(inputs, result, "manual", compact_notice_id)) { + finish(); + return; + } if (inputs.hooks) { auto fields = hooks_.common_fields(kCodexHookEventPostCompact, inputs.session); auto payload = build_compact_hook_payload(fields, "manual"); diff --git a/src/engine/agent/compaction/compaction_controller.hpp b/src/engine/agent/compaction/compaction_controller.hpp index 00eca384..2b431304 100644 --- a/src/engine/agent/compaction/compaction_controller.hpp +++ b/src/engine/agent/compaction/compaction_controller.hpp @@ -56,7 +56,7 @@ class CompactionController { bool mechanical_fallback(const CompactionInputs& inputs, int request_tokens, int context_window, const std::string& notice_id, const std::string& error); void initialize_window(const CompactionInputs& inputs); - void apply_result(const CompactionInputs& inputs, const CompactResult& result, + bool apply_result(const CompactionInputs& inputs, const CompactResult& result, const std::string& trigger, const std::string& notice_id); void finish_busy(LifetimeRef terminal); diff --git a/src/engine/agent/compaction/compaction_window.cpp b/src/engine/agent/compaction/compaction_window.cpp index 5a37ce3d..0035072b 100644 --- a/src/engine/agent/compaction/compaction_window.cpp +++ b/src/engine/agent/compaction/compaction_window.cpp @@ -37,8 +37,7 @@ using utils::now_epoch_ms; bool CompactionController::exceeds_auto_threshold( const CompactionInputs& inputs, const UserInput* pending_input) const { - auto request = requests_.initial_context(inputs.request); - auto history = recovered_provider_messages(history_.view(), "token-estimate"); + auto history = history_.view(); if (pending_input && !pending_input->empty()) { ChatMessage pending; pending.role = "user"; @@ -47,7 +46,7 @@ bool CompactionController::exceeds_auto_threshold( pending.metadata = pending_input->metadata; history.push_back(std::move(pending)); } - request.insert(request.end(), history.begin(), history.end()); + const auto request = requests_.compaction_request(inputs.request, history).messages_with_system; return should_auto_compact( ActiveModelView(inputs.provider, inputs.request.context_window, environment_).effective_window(), last_api_total_tokens_.load(std::memory_order_relaxed), @@ -80,35 +79,33 @@ void CompactionController::initialize_window(const CompactionInputs& inputs) { } } -void CompactionController::apply_result( +bool CompactionController::apply_result( const CompactionInputs& inputs, const CompactResult& result, const std::string& trigger, const std::string& compact_notice_id) { - auto initial_context = requests_.initial_context(inputs.request); - auto pre_history = recovered_provider_messages(history_.view(), "compact-input"); - auto pre_request = initial_context; - pre_request.insert(pre_request.end(), pre_history.begin(), pre_history.end()); + const auto pre_request = requests_.compaction_request( + inputs.request, history_.view()).messages_with_system; const int pre_tokens = estimate_message_tokens(pre_request); std::vector replacement_history = recovered_provider_messages(result.compacted_messages, "compact-output"); - auto post_request = initial_context; - post_request.insert( - post_request.end(), replacement_history.begin(), replacement_history.end()); + replacement_history.insert(replacement_history.begin(), + requests_.fresh_window_snapshot(inputs.request, replacement_history)); + const auto post_request = requests_.compaction_request( + inputs.request, replacement_history).messages_with_system; const int post_tokens = estimate_message_tokens(post_request); initialize_window(inputs); const std::string previous_window_id = compact_current_window_id_; - if (compact_window_number_ < + auto next_window_number = compact_window_number_; + if (next_window_number < std::numeric_limits::max()) { - ++compact_window_number_; - } - compact_current_window_id_ = generate_uuid_v7(); - if (compact_first_window_id_.empty()) { - compact_first_window_id_ = previous_window_id.empty() - ? compact_current_window_id_ - : previous_window_id; + ++next_window_number; } + const auto next_window_id = generate_uuid_v7(); + const auto first_window_id = compact_first_window_id_.empty() + ? (previous_window_id.empty() ? next_window_id : previous_window_id) + : compact_first_window_id_; bool checkpoint_persisted = false; if (inputs.session) { @@ -119,18 +116,26 @@ void CompactionController::apply_result( checkpoint.estimated_tokens_saved = result.estimated_tokens_saved; checkpoint.pre_tokens = pre_tokens; checkpoint.post_tokens = post_tokens; - checkpoint.window_number = compact_window_number_; - checkpoint.first_window_id = compact_first_window_id_; + checkpoint.window_number = next_window_number; + checkpoint.first_window_id = first_window_id; checkpoint.previous_window_id = previous_window_id; - checkpoint.window_id = compact_current_window_id_; + checkpoint.window_id = next_window_id; checkpoint.replacement_history = replacement_history; checkpoint_persisted = inputs.session->append_compact_checkpoint(checkpoint); + if (!checkpoint_persisted) { + transcript_.dispatch_message("error", "[Compact] Could not persist checkpoint; history was kept.", + false, nlohmann::json::object(), nlohmann::json::array()); + return false; + } } + compact_window_number_ = next_window_number; + compact_current_window_id_ = next_window_id; + compact_first_window_id_ = first_window_id; history_.replace(std::move(replacement_history)); last_api_total_tokens_.store(post_tokens, std::memory_order_relaxed); environment_.mtime_tracker().clear_read_observations(); compact_generation_.fetch_add(1, std::memory_order_relaxed); - requests_.invalidate_memory_snapshot(); // 压缩后记忆快照按磁盘重建 + requests_.invalidate_memory_snapshot(); const std::string notice_id = compact_notice_id.empty() ? generate_uuid_v7() @@ -155,6 +160,7 @@ void CompactionController::apply_result( if (!error.empty()) LOG_WARN("[task-suggestion] " + error); } } + return true; } diff --git a/src/engine/agent/model_step/provider_stream_collector.cpp b/src/engine/agent/model_step/provider_stream_collector.cpp index a50ea368..016475b0 100644 --- a/src/engine/agent/model_step/provider_stream_collector.cpp +++ b/src/engine/agent/model_step/provider_stream_collector.cpp @@ -273,8 +273,8 @@ ProviderCallResult ProviderStreamCollector::collect( "model_waiting", "正在等待模型响应", std::string{}, std::string{}, std::string{}, -1, true); agent::ActiveProviderScope active_provider(active_provider_, provider); - provider->chat_stream(bundle.messages_with_system, bundle.tool_defs, - stream_callback, &abort_.flag_for_legacy_api()); + provider->chat_stream_with_options(bundle.messages_with_system, bundle.tool_defs, + bundle.request_options, stream_callback, &abort_.flag_for_legacy_api()); active_provider.reset(); call.lifetime.revoke(); // 流结束:把扫描器扣住的字节结清(半截开标签按正文放行,没闭合的标签 diff --git a/src/engine/agent/request/api_request_builder.cpp b/src/engine/agent/request/api_request_builder.cpp index c5252bed..c346208a 100644 --- a/src/engine/agent/request/api_request_builder.cpp +++ b/src/engine/agent/request/api_request_builder.cpp @@ -9,6 +9,7 @@ #include "skills/skill_registry.hpp" #include "skills/skill_activation.hpp" #include "skills/skill_usage_store.hpp" +#include "session/request_context_record.hpp" #include "utils/logger.hpp" #include #include @@ -33,6 +34,39 @@ void remove_memory_tools(std::vector& defs) { return def.name == read_name || def.name == write_name; }), defs.end()); } + +nlohmann::json context_state(const RequestBuildInputs& inputs) { + return {{"session", inputs.session.content}, {"swarm", inputs.swarm_context}, + {"plan", inputs.plan_context}, {"execution", inputs.execution_context}}; +} + +std::string render_context_state(const nlohmann::json& state, bool update) { + std::string content = update + ? "\nThe following sections replace their earlier values. " + "Unmentioned sections remain in effect.\n" + : "\n"; + for (auto it = state.begin(); it != state.end(); ++it) { + if (!it.value().is_string()) continue; + const auto text = it.value().get(); + if (text.empty() && !update) continue; + content += "<" + it.key() + ">\n"; + content += text.empty() ? "This section is no longer active.\n" : text + "\n"; + content += "\n"; + } + content += update ? "" : ""; + return content; +} + +ChatMessage context_record(const char* subtype, std::string content) { + ChatMessage message; + message.role = "user"; + message.content = std::move(content); + message.is_meta = true; + message.subtype = subtype; + message.uuid = generate_uuid(); + message.metadata = {{"request_context_version", 1}}; + return message; +} } bool ApiRequestBuilder::memory_active(const RequestContextOptions& options) { @@ -59,7 +93,10 @@ const PromptContextBlock& ApiRequestBuilder::frozen_memory_snapshot( return cache_.memory_snapshot(); } -void ApiRequestBuilder::invalidate_memory_snapshot() { cache_.invalidate_memory(); } +void ApiRequestBuilder::invalidate_memory_snapshot() { + cache_.invalidate_memory(); + cache_.invalidate_git(); +} std::set ApiRequestBuilder::dormant_skills( const SkillRegistry* registry, SkillUsageStore* store, int idle_days) { @@ -82,13 +119,15 @@ std::set ApiRequestBuilder::dormant_skills( } std::string ApiRequestBuilder::static_system_prompt(const RequestContextOptions& options) const { + SystemPromptSandboxState stable_sandbox; + stable_sandbox.description = "see the latest execution context; tool permission checks are authoritative"; std::string system_prompt = build_system_prompt( tools_, options.cwd, options.skills.get(), /*memory=*/nullptr, ptr(options.memory_config), ptr(options.project_config), &options.tool_policy, &options.worktree, options.can_read_images, - &options.environment, &options.sandbox, &options.model, + &options.environment, &stable_sandbox, &options.model, &options.folders); if (options.loop_active && !options.loop_context.empty()) { @@ -158,6 +197,12 @@ RequestBuildInputs ApiRequestBuilder::capture( bool emergency_profile) { RequestBuildInputs inputs; inputs.emergency_profile = emergency_profile; + inputs.session_cache_key = options.memory_session_key.empty() + ? fallback_session_key_ : options.memory_session_key; + inputs.memory_active = memory_active(options); + inputs.plan_context = options.plan_context; + inputs.todos = options.todos; + inputs.execution_context = "Shell sandbox: " + options.sandbox.description; inputs.system_prompt = static_system_prompt(options); LOG_DEBUG("System prompt length: " + std::to_string(inputs.system_prompt.size())); std::unordered_set loaded_skills; @@ -233,6 +278,12 @@ RequestBuildInputs ApiRequestBuilder::capture( inputs.skills = build_skills_index_context_prompt( options.skills.get(), options.context_window, skill_view_available, skills_list_available, &dormant); + const std::set no_dormant; + // Explicit catalog/policy changes must still reach the model. Idle + // timestamps and other sessions' usage do not change this identity. + inputs.skills_catalog_key = build_skills_index_context_prompt( + options.skills.get(), options.context_window, skill_view_available, + skills_list_available, &no_dormant).cache_key; inputs.session = build_session_context_prompt( options.cwd, &frozen_memory_snapshot(options), ptr(options.project_config), options.skills.get(), options.context_window, ptr(options.custom_config), cache_.cached_git(), @@ -257,64 +308,113 @@ RequestBuildInputs ApiRequestBuilder::capture( ApiRequestBundle ApiRequestBuilder::build(RequestBuildInputs inputs) { ApiRequestBundle bundle; bundle.tool_defs = std::move(inputs.tool_defs); - auto api_messages = detail::model_facing_provider_messages(inputs.history, "provider-request"); + bundle.request_options.prompt_cache_key = inputs.session_cache_key; auto context_category_bytes = inputs.category_bytes; - const std::string skill_context = inputs.emergency_profile ? std::string{} : cache_.skills(inputs.skills); - const std::string session_context = inputs.emergency_profile ? std::string{} : cache_.session(inputs.session); - const std::string& swarm_mode_context = inputs.swarm_context; - const std::string& hook_context = inputs.hook_context; - const std::string& plan_mode_context = inputs.plan_context; - const auto& todo_context_items = inputs.todos; - context_category_bytes.skills = skill_context.size(); - std::vector mutable_context_messages; - detail::append_request_context_for_api(mutable_context_messages, session_context); - detail::append_request_context_for_api(mutable_context_messages, swarm_mode_context); - detail::append_request_context_for_api(mutable_context_messages, hook_context); - detail::append_plan_mode_context_for_api(mutable_context_messages, plan_mode_context); - detail::append_todo_context_for_api(mutable_context_messages, todo_context_items); - - ChatMessage skill_system_message; - if (!skill_context.empty()) { - skill_system_message.role = "system"; - skill_system_message.content = skill_context; - skill_system_message.metadata = - nlohmann::json{{"request_local_skill_context", true}}; + std::string skill_context; + std::vector api_history; + std::vector context_messages; + if (inputs.emergency_profile) { + for (const auto& message : inputs.history) { + if (!is_request_context_record(message)) api_history.push_back(message); + } + } else { + const auto last_snapshot = std::find_if(inputs.history.rbegin(), inputs.history.rend(), + is_request_context_snapshot); + const auto snapshot_it = last_snapshot == inputs.history.rend() + ? inputs.history.end() : std::prev(last_snapshot.base()); + // Explicit memory disable must remove previously injected memory from + // outgoing requests. That privacy boundary starts a fresh context epoch. + const bool reset_memory = snapshot_it != inputs.history.end() && + snapshot_it->metadata.value("memory_active", inputs.memory_active) != inputs.memory_active; + ChatMessage snapshot; + nlohmann::json previous = nlohmann::json::object(); + const auto current = context_state(inputs); + if (snapshot_it == inputs.history.end() || reset_memory) { + snapshot = context_record(kRequestContextSnapshot, render_context_state(current, false)); + const auto todos = format_todo_injection(inputs.todos); + if (!todos.empty()) snapshot.content += "\n" + todos; + snapshot.metadata["context_state"] = current; + snapshot.metadata["skills"] = inputs.skills.content; + snapshot.metadata["skills_catalog_key"] = inputs.skills_catalog_key; + snapshot.metadata["project_rules_bytes"] = inputs.category_bytes.project_rules; + snapshot.metadata["memory_active"] = inputs.memory_active; + bundle.context_records.push_back(snapshot); + previous = current; + } else { + snapshot = *snapshot_it; + previous = snapshot.metadata.value("context_state", nlohmann::json::object()); + } + skill_context = snapshot.metadata.value("skills", std::string{}); + auto previous_skills_key = snapshot.metadata.value("skills_catalog_key", inputs.skills_catalog_key); + context_category_bytes.project_rules = snapshot.metadata.value( + "project_rules_bytes", context_category_bytes.project_rules); + // The initial snapshot occupies the same position even though its + // append-only storage record follows the first user message. + api_history.push_back(snapshot); + bool current_epoch = snapshot_it == inputs.history.end(); + for (auto it = inputs.history.begin(); it != inputs.history.end(); ++it) { + const auto& message = *it; + if (it == snapshot_it) current_epoch = true; + if (is_request_context_snapshot(message)) continue; + if (is_request_context_record(message) && message.metadata.contains("context_state") && + (reset_memory || !current_epoch)) continue; + api_history.push_back(message); + if (is_request_context_record(message) && message.metadata.contains("context_state")) { + previous.update(message.metadata["context_state"]); + previous_skills_key = message.metadata.value("skills_catalog_key", previous_skills_key); + } + } + nlohmann::json changed = nlohmann::json::object(); + for (auto it = current.begin(); it != current.end(); ++it) { + if (!previous.contains(it.key()) || previous[it.key()] != it.value()) { + changed[it.key()] = it.value(); + } + } + if (!inputs.skills_catalog_key.empty() && inputs.skills_catalog_key != previous_skills_key) { + changed["skills"] = inputs.skills.content; + } + if (!changed.empty()) { + auto update = context_record(kRequestContextUpdate, render_context_state(changed, true)); + update.metadata["context_state"] = std::move(changed); + update.metadata["skills_catalog_key"] = inputs.skills_catalog_key; + api_history.push_back(update); + bundle.context_records.push_back(std::move(update)); + } + if (!inputs.hook_context.empty()) { + auto hook = context_record(kRequestContextUpdate, inputs.hook_context); + api_history.push_back(hook); + bundle.context_records.push_back(std::move(hook)); + } } - std::vector estimated_context_messages = - mutable_context_messages; - if (!skill_system_message.content.empty()) { - estimated_context_messages.insert( - estimated_context_messages.begin(), skill_system_message); + auto api_messages = detail::model_facing_provider_messages(api_history, "provider-request"); + std::vector conversation; + for (const auto& message : api_messages) { + (is_request_context_record(message) ? context_messages : conversation).push_back(message); } - bundle.context_usage_estimate = estimate_context_usage_breakdown( - inputs.system_prompt, - api_messages, - estimated_context_messages, - context_category_bytes.project_rules, - context_category_bytes.skills, - inputs.builtin_tool_defs, - inputs.mcp_tool_defs); - - insert_context_before_last_real_user_or_summary( - api_messages, std::move(mutable_context_messages)); - ChatMessage sys_msg; sys_msg.role = "system"; sys_msg.content = inputs.system_prompt; bundle.messages_with_system.push_back(sys_msg); - if (!skill_system_message.content.empty()) { - bundle.messages_with_system.push_back(std::move(skill_system_message)); + if (!skill_context.empty()) { + ChatMessage skill_system; + skill_system.role = "system"; + skill_system.content = skill_context; + skill_system.metadata = {{"request_local_skill_context", true}}; + context_messages.push_back(skill_system); + bundle.messages_with_system.push_back(std::move(skill_system)); } bundle.messages_with_system.insert(bundle.messages_with_system.end(), api_messages.begin(), api_messages.end()); + bundle.context_usage_estimate = estimate_context_usage_breakdown( + inputs.system_prompt, conversation, context_messages, + context_category_bytes.project_rules, skill_context.size(), + inputs.builtin_tool_defs, inputs.mcp_tool_defs); + std::string context_text; + for (const auto& message : context_messages) context_text += message.content + "\n"; auto prompt_diag = build_prompt_cache_diagnostics( - inputs.system_prompt, - skill_context + "\n" + session_context + "\n" + swarm_mode_context + "\n" + - plan_mode_context + "\n" + hook_context + "\n" + - format_todo_injection(todo_context_items), - bundle.tool_defs); + inputs.system_prompt, context_text, bundle.tool_defs); bundle.prompt_diag = { {"system", prompt_diag.static_system_prompt_hash}, {"context", prompt_diag.mutable_context_hash}, @@ -327,5 +427,45 @@ ApiRequestBundle ApiRequestBuilder::build(RequestBuildInputs inputs) { return bundle; } +ApiRequestBundle ApiRequestBuilder::compaction_request( + const RequestContextOptions& options, const std::vector& history) { + auto inputs = capture(options, history, false); + nlohmann::json state = nlohmann::json::object(); + bool has_snapshot = false; + bool snapshot_memory_active = inputs.memory_active; + auto skills_catalog_key = inputs.skills_catalog_key; + for (const auto& message : history) { + if (!is_request_context_record(message)) continue; + if (is_request_context_snapshot(message)) { + has_snapshot = true; + state = nlohmann::json::object(); + snapshot_memory_active = message.metadata.value("memory_active", inputs.memory_active); + } + if (message.metadata.contains("context_state")) state.update(message.metadata["context_state"]); + skills_catalog_key = message.metadata.value("skills_catalog_key", skills_catalog_key); + } + if (has_snapshot && snapshot_memory_active == inputs.memory_active) { + // Summarize exactly the already-sent context, without consuming hooks + // or inventing uncommitted updates at the compaction boundary. + inputs.session.content = state.value("session", std::string{}); + inputs.swarm_context = state.value("swarm", std::string{}); + inputs.plan_context = state.value("plan", std::string{}); + inputs.execution_context = state.value("execution", std::string{}); + inputs.skills_catalog_key = skills_catalog_key; + } + return build(std::move(inputs)); +} + +ChatMessage ApiRequestBuilder::fresh_window_snapshot( + const RequestContextOptions& options, const std::vector& replacement_history) { + // Prepare off to the side: a failed checkpoint must not refresh the live + // window's memory/git pins before the replacement is committed. + PromptContextCache window_cache; + ApiRequestBuilder window_builder(tools_, window_cache); + auto inputs = window_builder.capture(options, replacement_history, false); + inputs.history.erase(std::remove_if(inputs.history.begin(), inputs.history.end(), + is_request_context_record), inputs.history.end()); + return window_builder.build(std::move(inputs)).context_records.front(); +} } // namespace acecode::agent diff --git a/src/engine/agent/request/api_request_builder.hpp b/src/engine/agent/request/api_request_builder.hpp index d1bf37cb..4420cf6f 100644 --- a/src/engine/agent/request/api_request_builder.hpp +++ b/src/engine/agent/request/api_request_builder.hpp @@ -6,6 +6,7 @@ #include "experts/expert_registry.hpp" #include "prompt/system_prompt.hpp" #include "session/todo_state.hpp" +#include "utils/uuid.hpp" #include #include @@ -42,23 +43,29 @@ struct RequestContextOptions { SystemPromptWorkspaceFolders folders; SkillUsageStore* skill_usage = nullptr; // nullable, borrowed for this call int skill_idle_days = 30; + std::string plan_context; + std::vector todos; }; // Every mutable request input is a value. Hook draining and session queries // happen before build; the builder cannot consume a loop-owned queue. struct RequestBuildInputs { bool emergency_profile = false; + std::string session_cache_key; + bool memory_active = false; std::string system_prompt; std::vector history; std::vector tool_defs; std::vector builtin_tool_defs; std::vector mcp_tool_defs; PromptContextBlock skills; + std::string skills_catalog_key; PromptContextBlock session; PromptContextCategoryBytes category_bytes; std::string swarm_context; std::string hook_context; std::string plan_context; + std::string execution_context; std::vector todos; }; @@ -69,6 +76,12 @@ class ApiRequestBuilder { RequestBuildInputs capture(const RequestContextOptions& options, std::vector history, bool emergency_profile); ApiRequestBundle build(RequestBuildInputs inputs); + ApiRequestBundle compaction_request(const RequestContextOptions& options, + const std::vector& history); + // Called only when installing a successful replacement history. + ChatMessage fresh_window_snapshot(const RequestContextOptions& options, + const std::vector& replacement_history); + const std::string& fallback_session_key() const { return fallback_session_key_; } std::vector initial_context(const RequestContextOptions& options) const; std::string static_system_prompt(const RequestContextOptions& options) const; static std::set dormant_skills(const SkillRegistry* registry, @@ -81,6 +94,7 @@ class ApiRequestBuilder { const PromptContextBlock& frozen_memory_snapshot(const RequestContextOptions& options); ToolExecutor& tools_; PromptContextCache& cache_; + const std::string fallback_session_key_ = generate_uuid(); }; } // namespace acecode::agent diff --git a/src/engine/agent/request/request_context_factory.cpp b/src/engine/agent/request/request_context_factory.cpp index 93a91c60..8dfdfc49 100644 --- a/src/engine/agent/request/request_context_factory.cpp +++ b/src/engine/agent/request/request_context_factory.cpp @@ -11,6 +11,7 @@ #include "memory/memory_service.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" +#include namespace acecode::agent { RequestContextOptions RequestContextFactory::options( @@ -33,6 +34,7 @@ RequestContextOptions RequestContextFactory::options( } else if (!options.cwd.empty()) { options.memory_project_dir = SessionStorage::get_project_dir(options.cwd); } + if (options.memory_session_key.empty()) options.memory_session_key = builder_.fallback_session_key(); options.project_config = source_.prompt_config.project_instructions; options.custom_config = source_.prompt_config.custom_instructions; options.git_config = source_.prompt_config.git_context; @@ -56,6 +58,12 @@ RequestContextOptions RequestContextFactory::options( } options.skill_usage = source_.skill_usage; options.skill_idle_days = source_.skill_idle_days; + if (permissions_.mode() == PermissionMode::Plan) { + options.plan_context = detail::build_plan_mode_context_prompt( + session_manager_, tools_.is_allowed("AskUserQuestion", &source_.tool_policy), + tools_.is_allowed("ExitPlanMode", &source_.tool_policy), source_.runtime.mtime_tracker()); + } + if (session_manager_) options.todos = session_manager_->current_todos(); return options; } @@ -65,13 +73,14 @@ ApiRequestBundle RequestContextFactory::build( history_.view(), emergency_profile); if (!emergency_profile) { inputs.hook_context = hooks_.drain_context(); - if (permissions_.mode() == PermissionMode::Plan) { - inputs.plan_context = agent::detail::build_plan_mode_context_prompt( - session_manager_, tools_.is_allowed("AskUserQuestion", &source_.tool_policy), - tools_.is_allowed("ExitPlanMode", &source_.tool_policy), source_.runtime.mtime_tracker()); + } + auto bundle = builder_.build(std::move(inputs)); + for (const auto& record : bundle.context_records) { + if (session_manager_ && !session_manager_->try_on_message(record)) { + throw std::runtime_error("Could not persist request context; request was not sent."); } - if (session_manager_) inputs.todos = session_manager_->current_todos(); + history_.append(record); } - return builder_.build(std::move(inputs)); + return bundle; } } // namespace acecode::agent diff --git a/src/engine/agent/side_question/agent_side_question.cpp b/src/engine/agent/side_question/agent_side_question.cpp index e40d5cae..c6ea8b82 100644 --- a/src/engine/agent/side_question/agent_side_question.cpp +++ b/src/engine/agent/side_question/agent_side_question.cpp @@ -1,10 +1,14 @@ #include "agent/agent_loop.hpp" #include "side_question_service.hpp" #include "side_chat_tools.hpp" +#include "agent/request/api_request_builder.hpp" #include "agent/request/provider_history.hpp" #include "agent/transcript/conversation_history.hpp" +#include "session/request_context_record.hpp" #include "utils/logger.hpp" +#include + namespace acecode { using agent::detail::model_facing_provider_messages; @@ -21,6 +25,16 @@ void AgentLoop::prime_side_question_context() { return; } + if (std::any_of(history_->view().begin(), history_->view().end(), + is_request_context_snapshot)) { + const auto provider = provider_accessor_ ? provider_accessor_() : nullptr; + auto request = request_builder_->compaction_request( + request_context_options(provider), history_->view()); + // Reuse the stored window and skill index. Side requests still obtain + // their own restricted toolset through side_chat_toolset(). + publish_side_question_context(request.messages_with_system); + return; + } auto context = build_compaction_initial_context(); auto history = model_facing_provider_messages(history_->view(), "side-question-prime"); context.insert(context.end(), history.begin(), history.end()); diff --git a/src/engine/agent/turn/turn_types.hpp b/src/engine/agent/turn/turn_types.hpp index 96d3dd85..ecdae6b5 100644 --- a/src/engine/agent/turn/turn_types.hpp +++ b/src/engine/agent/turn/turn_types.hpp @@ -28,7 +28,10 @@ struct UserTurnInfo { struct ApiRequestBundle { std::vector messages_with_system; + // New hidden records to commit once before the provider is invoked. + std::vector context_records; std::vector tool_defs; + ChatRequestOptions request_options; ContextUsageBreakdown context_usage_estimate; nlohmann::json prompt_diag; // simplified: store as raw json }; diff --git a/src/host/session_host/session_registry_history.cpp b/src/host/session_host/session_registry_history.cpp index 938c88d8..63d17f68 100644 --- a/src/host/session_host/session_registry_history.cpp +++ b/src/host/session_host/session_registry_history.cpp @@ -3,6 +3,7 @@ #include "session_registry.hpp" #include "session/compact_checkpoint.hpp" +#include "session/request_context_record.hpp" #include "session/session_rewind.hpp" #include "session/tool_result_storage.hpp" #include "session/turn_timing.hpp" @@ -40,6 +41,7 @@ void append_model_messages_to_loop(AgentLoop& loop, const std::vector& messages) { for (std::size_t i = 0; i < messages.size(); ++i) { const auto& msg = messages[i]; + if (is_request_context_metadata(msg) && !is_request_context_record(msg)) continue; if (is_file_checkpoint_message(msg)) continue; if (is_content_replacement_message(msg)) continue; if (is_turn_timing_message(msg)) continue; diff --git a/src/host/session_host/tools/spawn_subagent_tool.cpp b/src/host/session_host/tools/spawn_subagent_tool.cpp index 765c4fb0..09771cf8 100644 --- a/src/host/session_host/tools/spawn_subagent_tool.cpp +++ b/src/host/session_host/tools/spawn_subagent_tool.cpp @@ -308,7 +308,7 @@ ToolImpl create_spawn_subagent_tool(std::shared_ptr deps) { {"model", json{ {"type", "string"}, {"description", "Optional saved model name for the sub-agent " - "session (defaults to the daemon default)."}}}, + "session (defaults to the parent's current model)."}}}, {"expert_member", json{ {"type", "string"}, {"description", "Optional selected expert ID from the current " @@ -422,6 +422,11 @@ ToolImpl create_spawn_subagent_tool(std::shared_ptr deps) { } } opts.model_name = model_name; + if (opts.model_name.empty() && ctx.session_manager) { + // Read the live parent selection, including TUI parents outside the + // registry. An empty preset preserves the configured fallback. + opts.model_name = ctx.session_manager->current_model_preset(); + } opts.permission_mode = parent_permission_mode; opts.subagent_depth = 1; // 父会话 id 持久化到子会话 meta:子会话从常规列表隐藏,归入父会话 diff --git a/src/host/session_host/tools/spawn_subagent_tool.hpp b/src/host/session_host/tools/spawn_subagent_tool.hpp index 9bc97699..9d915556 100644 --- a/src/host/session_host/tools/spawn_subagent_tool.hpp +++ b/src/host/session_host/tools/spawn_subagent_tool.hpp @@ -7,6 +7,7 @@ // AgentLoop / PermissionManager / SessionModelBinding),上下文与父会话完全隔离; // 它出现在侧栏里,用户可随时点进去围观或接管。 // - spawn_subagent(prompt, wait=true):创建子会话并注入首条消息。 +// model 缺省继承父会话当前 saved model;显式 model 优先,无父模型时沿用默认。 // wait=true 阻塞至子会话本轮结束,把最终 assistant 答复带回父上下文 // (父上下文只多这一条摘要,不吃子会话的中间过程); // wait=false 点火即返(fire-and-forget),用于流水线接力 —— 阶段 A 结束 diff --git a/tests/agent/agent_loop_ask_user_question_parallel_test.cpp b/tests/agent/agent_loop_ask_user_question_parallel_test.cpp index 8e1384cd..838b4066 100644 --- a/tests/agent/agent_loop_ask_user_question_parallel_test.cpp +++ b/tests/agent/agent_loop_ask_user_question_parallel_test.cpp @@ -1,4 +1,5 @@ #include "test_support/agent/agent_loop_fixture.hpp" +#include "test_support/agent_loop/characterization_fixture.hpp" // 验证 AgentLoop 在并行 read-only batch 路径下,把 AskUserQuestionPrompter // 包成 ToolContext::ask_user_questions 回调正确注入到每个并行任务。 // @@ -152,7 +153,12 @@ class AskParallelHarness { auto provider_accessor = [this]() -> std::shared_ptr { return provider_; }; - if (with_session_manager) session_manager_ = std::make_unique(); + if (with_session_manager) { + session_manager_ = std::make_unique(); + // Real session hosts initialize storage before allowing a turn. + // A non-null but unstarted manager cannot persist request context. + session_manager_->start_session(".", provider_->name(), provider_->model()); + } loop_ = std::make_unique( acecode_test::AgentLoopFixture::dependencies(provider_accessor, tools_, cb, perms_, session_manager_.get()), acecode_test::AgentLoopFixture::configuration(/*cwd=*/".")); @@ -324,6 +330,7 @@ TEST(AgentLoopAskUserQuestionParallel, NoPrompterStillFailsFast) { // batch → 复用并行 ctx 构造段。Probe 工具记录 ctx.track_file_write_before // 是否非空。要求 构造注入 SessionManager(非空) 才会触发 if 分支。 TEST(AgentLoopAskUserQuestionParallel, ParallelCtxHasTrackFileWriteBefore) { + acecode_test::characterization::Isolation isolation; AskParallelHarness h(/*with_prompter=*/true, /*with_session_manager=*/true); // 单 turn 同时返回两个 read-only tool_calls,触发并行 batch 路径。 diff --git a/tests/agent/agent_loop_memory_test.cpp b/tests/agent/agent_loop_memory_test.cpp index f3028542..309bc401 100644 --- a/tests/agent/agent_loop_memory_test.cpp +++ b/tests/agent/agent_loop_memory_test.cpp @@ -32,13 +32,17 @@ using acecode_test::MemoryTestHome; // 压缩走非流式 chat:返回一段摘要,让摘要压缩真正成功(而不是退化成机械修剪)。 class CompactingStub : public acecode_test::StubLlmProvider { public: - acecode::ChatResponse chat(const std::vector&, - const std::vector&) override { + acecode::ChatResponse chat(const std::vector& messages, + const std::vector& tools) override { + compact_requests.push_back(messages); + compact_tools.push_back(tools); acecode::ChatResponse response; response.content = "Summary of the earlier conversation."; response.finish_reason = "stop"; return response; } + std::vector> compact_requests; + std::vector> compact_tools; }; void wait_done(acecode::AgentLoop& loop, const std::function& action) { @@ -169,6 +173,79 @@ TEST(AgentLoopMemoryTest, SessionMemoryOffRemovesContextAndTools) { EXPECT_NE(on_request.front().content.find("# Memory\n"), std::string::npos); } +TEST(AgentLoopMemoryTest, TurningMemoryOffRemovesEarlierInjectedMemoryUntilReenabled) { + MemoryTestHome home("agentloop-memory-toggle-context"); + MemoryLoopHarness h(home); + const std::string original = "memory_toggle_original_unique_marker"; + const std::string later = "memory_toggle_later_unique_marker"; + h.write_global(original); + h.turn("first task"); + const auto first = h.provider->messages_for_turn(0); + ASSERT_NE(session_context(first).find(original), std::string::npos); + EXPECT_TRUE(has_tool(h.provider->tools_for_turn(0), "memory_read")); + EXPECT_TRUE(has_tool(h.provider->tools_for_turn(0), "memory_write")); + + h.session.set_memory_enabled(false); + h.turn("continue without memory"); + h.write_global(later); + h.turn("continue while memory stays disabled"); + for (int index : {1, 2}) { + const auto request = h.provider->messages_for_turn(index); + ASSERT_FALSE(request.empty()); + // Check every context/history row: inspecting only the newest update + // misses the original frozen snapshot retained before it. + for (const auto& message : request) { + EXPECT_EQ(message.content.find(original), std::string::npos); + EXPECT_EQ(message.content.find(later), std::string::npos); + } + EXPECT_FALSE(has_tool(h.provider->tools_for_turn(index), "memory_read")); + EXPECT_FALSE(has_tool(h.provider->tools_for_turn(index), "memory_write")); + EXPECT_EQ(request.front().content.find("# Memory\n"), std::string::npos); + } + + h.session.set_memory_enabled(true); + h.turn("use memory again"); + const auto enabled = h.provider->messages_for_turn(3); + ASSERT_FALSE(enabled.empty()); + const auto context = session_context(enabled); + EXPECT_NE(context.find(original), std::string::npos); + EXPECT_NE(context.find(later), std::string::npos); + EXPECT_TRUE(has_tool(h.provider->tools_for_turn(3), "memory_read")); + EXPECT_TRUE(has_tool(h.provider->tools_for_turn(3), "memory_write")); + EXPECT_NE(enabled.front().content.find("# Memory\n"), std::string::npos); +} + +TEST(AgentLoopMemoryTest, ImmediateToolFreeCompactionAfterMemoryOffExcludesPriorEpoch) { + MemoryTestHome home("agentloop-memory-off-before-compact"); + MemoryLoopHarness h(home); + const std::string marker = "PRIVATE_MEMORY_BEFORE_COMPACT_UNIQUE"; + h.write_global(marker); + h.turn("start the task"); + ASSERT_NE(session_context(h.provider->messages_for_turn(0)).find(marker), std::string::npos); + ASSERT_FALSE(h.provider->supports_compaction_prefix_reuse()); + + // No ordinary request occurs after this toggle: the compaction controller + // must use the updated projection instead of rebuilding from the old row. + h.session.set_memory_enabled(false); + wait_done(*h.loop, [&] { h.loop->submit_compact(); }); + h.turn("continue without private memory"); + ASSERT_EQ(h.provider->compact_requests.size(), 1u); + ASSERT_EQ(h.provider->compact_tools.size(), 1u); + EXPECT_TRUE(h.provider->compact_tools.front().empty()); + for (const auto& message : h.provider->compact_requests.front()) { + EXPECT_EQ(message.content.find(marker), std::string::npos); + EXPECT_EQ(message.content.find("# Memory\n"), std::string::npos); + } + const auto checkpoint = h.session.load_latest_compact_checkpoint(); + ASSERT_TRUE(checkpoint.has_value()); + for (const auto& message : checkpoint->replacement_history) { + EXPECT_EQ(message.content.find(marker), std::string::npos); + } + for (const auto& message : h.provider->messages_for_turn(1)) { + EXPECT_EQ(message.content.find(marker), std::string::npos); + } +} + // 场景:会话执行 /memory off 后被关闭,之后恢复这个会话。 // 期望:开关随 meta 持久化(memory_mode: off),恢复后仍是关闭。 TEST(AgentLoopMemoryTest, SessionMemoryOffSurvivesResume) { diff --git a/tests/agent/agent_loop_termination_test.cpp b/tests/agent/agent_loop_termination_test.cpp index b0d2b5a0..09df70ab 100644 --- a/tests/agent/agent_loop_termination_test.cpp +++ b/tests/agent/agent_loop_termination_test.cpp @@ -1116,8 +1116,28 @@ TEST(AgentLoopTermination, RequestPrefixIsByteStableAcrossIterationsInATurn) { EXPECT_EQ(first.front().content.find("[当前环境状态]"), std::string::npos); } -// 场景:注入的可变上下文只进 API 消息,不落会话历史。 -TEST(AgentLoopTermination, InjectedContextIsApiOnlyAtHandoffBoundary) { +TEST(AgentLoopTermination, RequestPrefixIsByteStableAcrossUserTurns) { + AgentLoopHarness h; + h.push_tool_call("noop", "{}", "cross-turn-tool"); + h.push_text("first answer"); + ASSERT_TRUE(h.submit_and_wait("first user input")); + h.push_text("second answer"); + ASSERT_TRUE(h.submit_and_wait("second user input")); + const auto previous = h.request_messages_for_turn(1); + const auto next = h.request_messages_for_turn(2); + ASSERT_GT(next.size(), previous.size()); + for (std::size_t i = 0; i < previous.size(); ++i) { + EXPECT_EQ(previous[i].role, next[i].role) << i; + EXPECT_EQ(previous[i].content, next[i].content) << i; + EXPECT_EQ(previous[i].tool_calls, next[i].tool_calls) << i; + EXPECT_EQ(previous[i].tool_call_id, next[i].tool_call_id) << i; + EXPECT_EQ(previous[i].reasoning_content, next[i].reasoning_content) << i; + } + EXPECT_EQ(next.back().content, "second user input"); +} + +// Context is persisted as hidden meta records and never becomes a user turn. +TEST(AgentLoopTermination, InjectedContextIsPersistedHiddenAtHandoffBoundary) { AgentLoopHarness h; h.push_text("ok"); @@ -1135,13 +1155,16 @@ TEST(AgentLoopTermination, InjectedContextIsApiOnlyAtHandoffBoundary) { EXPECT_EQ(persisted[0].role, "user"); EXPECT_EQ(persisted[0].content, "what time is it?"); for (const auto& msg : persisted) { - EXPECT_EQ(msg.content.find(""), std::string::npos); + if (!msg.is_meta) EXPECT_EQ(msg.content.find(""), std::string::npos); } + EXPECT_TRUE(std::any_of(persisted.begin(), persisted.end(), [](const ChatMessage& msg) { + return msg.is_meta && msg.subtype == "request_context_snapshot"; + })); } // 场景:Project Instructions / User Memory 从静态 system prompt 移到 // provider-facing session context,且不进入持久历史。 -TEST(AgentLoopTermination, SessionContextIsApiOnlyAndStaticPromptStaysClean) { +TEST(AgentLoopTermination, SessionContextIsHiddenAndStaticPromptStaysClean) { TempHomeGuard home("acecode-agentloop-context"); fs::path repo = home.root() / "repo"; write_file(repo / "AGENTS.md", "# repo rules\nuse goroutines\n"); @@ -1197,6 +1220,7 @@ TEST(AgentLoopTermination, SessionContextIsApiOnlyAndStaticPromptStaysClean) { auto persisted = h.persisted_messages(); for (const auto& msg : persisted) { + if (msg.is_meta) continue; EXPECT_EQ(msg.content.find("# Project Instructions"), std::string::npos); EXPECT_EQ(msg.content.find("## Global memory"), std::string::npos); EXPECT_EQ(msg.content.find(""), std::string::npos); @@ -2441,7 +2465,12 @@ TEST(AgentLoopTermination, CustomInstructionSaveAffectsOnlyTheNextTurn) { EXPECT_TRUE(contains(first, "SNAPSHOT_BEFORE")); EXPECT_FALSE(contains(continued, "SNAPSHOT_AFTER")); EXPECT_TRUE(contains(next, "SNAPSHOT_AFTER")); - EXPECT_FALSE(contains(next, "SNAPSHOT_BEFORE")); + ASSERT_GE(next.size(), continued.size()); + for (std::size_t i = 0; i < continued.size(); ++i) { + EXPECT_EQ(next[i].content, continued[i].content); + } + EXPECT_NE(next.back().content.find("SNAPSHOT_AFTER"), std::string::npos); + EXPECT_EQ(next.back().content.find("SNAPSHOT_BEFORE"), std::string::npos); } // 场景:会话空闲时从关闭记忆切换为开启。期望下一回合立即采用新值; diff --git a/tests/agent/agent_loop_turn_steering_test.cpp b/tests/agent/agent_loop_turn_steering_test.cpp index e9359e4a..a2f38ce7 100644 --- a/tests/agent/agent_loop_turn_steering_test.cpp +++ b/tests/agent/agent_loop_turn_steering_test.cpp @@ -3,6 +3,7 @@ #include #include "agent/agent_loop.hpp" +#include "llm/message_predicates.hpp" #include "permissions/permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" @@ -153,7 +154,7 @@ std::vector request_user_texts( const std::vector& messages) { std::vector texts; for (const auto& message : messages) { - if (message.role == "user") texts.push_back(message.content); + if (acecode::is_real_user_message(message)) texts.push_back(message.content); } return texts; } @@ -197,7 +198,7 @@ TEST(AgentLoopUserMessageRetry, ReusesStructuredTailWithoutDuplicatingUser) { EXPECT_EQ(request_user_texts(request), std::vector{user.content}); const auto persisted = h.session_manager().load_active_messages(); EXPECT_EQ(std::count_if(persisted.begin(), persisted.end(), [](const auto& message) { - return message.role == "user"; + return acecode::is_real_user_message(message); }), 1); for (const auto& event : h.events()) { EXPECT_FALSE(event.kind == acecode::SessionEventKind::Message && @@ -323,7 +324,7 @@ TEST(AgentLoopUserMessageRetry, RetriesConfirmedUserAbortWithoutDuplicatingUser) std::vector{"original request"}); const auto persisted = h.session_manager().load_active_messages(); EXPECT_EQ(std::count_if(persisted.begin(), persisted.end(), [](const auto& message) { - return message.role == "user"; + return acecode::is_real_user_message(message); }), 1); EXPECT_FALSE(h.loop().retry_last_user_message(id, error)); } @@ -463,13 +464,21 @@ TEST(AgentLoopUserMessageRetry, PreservesToolResultsAndAppendsOriginalStructured EXPECT_EQ(request[request.size() - 2].role, "tool"); EXPECT_EQ(request[request.size() - 2].tool_call_id, "retry-tool-call"); EXPECT_EQ(request_user_texts(request), (std::vector{input.text, input.text})); - for (std::size_t i = 1; i < request.size(); ++i) { - EXPECT_FALSE(request[i - 1].role == "user" && request[i].role == "user"); - EXPECT_NE(request[i].content, "[Interrupted]"); + const acecode::ChatMessage* previous = nullptr; + for (const auto& message : request) { + EXPECT_NE(message.content, "[Interrupted]"); + if (message.is_meta) continue; + // Hidden persisted context must neither count as a user retry nor + // conceal consecutive duplicate user messages in the conversation. + if (previous) { + EXPECT_FALSE(acecode::is_real_user_message(*previous) && + acecode::is_real_user_message(message)); + } + previous = &message; } const auto persisted = h.session_manager().load_active_messages(); EXPECT_EQ(std::count_if(persisted.begin(), persisted.end(), [](const auto& message) { - return message.role == "user"; + return acecode::is_real_user_message(message); }), 2); } @@ -589,7 +598,7 @@ TEST(AgentLoopTurnSteering, CommitsStructuredInputsInFifoAndContinuesSameTurn) { EXPECT_LT(start, first_guidance); EXPECT_LT(first_guidance, second_guidance); EXPECT_EQ(second_guidance, user_texts.end() - 1) - << "mutable API-only context must be inserted before the final real user input"; + << "the last accepted guidance must remain the final real user input"; std::vector guided; for (const auto& message : h.loop().messages()) { diff --git a/tests/agent/characterization_context_test.cpp b/tests/agent/characterization_context_test.cpp index 3da58f7b..5fe6538a 100644 --- a/tests/agent/characterization_context_test.cpp +++ b/tests/agent/characterization_context_test.cpp @@ -1,5 +1,8 @@ #include "test_support/agent_loop/characterization_fixture.hpp" #include "agent/compaction/compact_prompt.hpp" +#include "session/request_context_record.hpp" +#include "session/session_serializer.hpp" +#include "tool/todo_write_tool.hpp" #include @@ -14,14 +17,23 @@ class CompactProbe : public acecode_test::StubLlmProvider { std::lock_guard lock(mutex); compact_requests.push_back(messages); compact_tool_counts.push_back(tools.size()); + compact_tools.push_back(tools); ChatResponse response; response.content = "Golden compact summary."; response.finish_reason = "stop"; return response; } + bool supports_compaction_prefix_reuse() const override { return prefix_capability; } + ChatResponse chat_for_compaction(const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) override { + return chat_cancellable(messages, tools, abort_flag); + } + bool prefix_capability = false; std::mutex mutex; std::vector> compact_requests; std::vector compact_tool_counts; + std::vector> compact_tools; }; ChatMessage message(std::string role, std::string content) { @@ -72,9 +84,305 @@ INSTANTIATE_TEST_SUITE_P(P0_11, AgentLoopStaticPromptGolden, PromptCase{"OtherManual", "stub-1", false}), [](const testing::TestParamInfo& info) { return info.param.name; }); -// 场景:历史的原生工具名被映射为模型别名。期望:主请求使用别名,压缩仍接收原生 -// tool_calls 及原始结果正文;回归会把 model_facing 误套到压缩,或改写工具输出的数据。 -TEST(AgentLoopContextGolden, CompactionReceivesNativeHistoryWhileMainRequestUsesModelAliases) { +class AgentLoopCompactionPrefix : public testing::TestWithParam {}; + +// Exercise controller wiring, not just compact_messages: automatic/manual +// compaction must preserve every previously sent model-facing message and +// schema, including aliases, structured attachments and reasoning history. +TEST_P(AgentLoopCompactionPrefix, ReusesCompleteMainRequestBeforeSummaryInstruction) { + Isolation isolation; + ScopedModelToolNameMappings mappings{{"file_read", "InspectDocument"}}; + auto provider = std::make_shared(); + provider->prefix_capability = true; + provider->set_model(GetParam().model); + Harness h(isolation, GetParam().name, provider); + h.tools.register_tool(h.probe("file_read", true)); + h.loop->set_context_window(1000000); + h.permissions.set_mode(PermissionMode::Plan); + h.install_hooks({"UserPromptSubmit", "PreCompact"}, [](const Json& payload) { + const std::string context = payload.value("hook_event_name", "") == "PreCompact" + ? "precompact queued hook" + : (payload.value("prompt", "") == "pending new input" + ? "pending user hook" : "existing request hook"); + return Json{{"hookSpecificOutput", {{"additionalContext", context}}}}; + }); + auto user = message("user", "read original attachment"); + user.content_parts = Json::array({ + {{"type", "text"}, {"text", user.content}}, + {{"type", "file"}, {"path", "/tmp/original.txt"}}, + }); + auto call = message("assistant", "inspect the document"); + call.reasoning_content = "Nonempty reasoning retained for GLM and DeepSeek."; + call.tool_calls = Json::array({{{"id", "historical-read"}, {"type", "function"}, + {"function", {{"name", "file_read"}, {"arguments", "{\"path\":\"original.txt\"}"}}}}}); + auto output = message("tool", "literal file_read output is unchanged"); + output.tool_call_id = "historical-read"; + h.loop->push_message(user); + h.loop->push_message(call); + h.loop->push_message(output); + + StreamEvent answer; + answer.type = StreamEventType::Delta; + answer.content = "completed before compaction"; + StreamEvent usage; + usage.type = StreamEventType::Usage; + usage.usage.has_data = true; + usage.usage.prompt_tokens = GetParam().automatic ? 950000 : 100; + usage.usage.total_tokens = usage.usage.prompt_tokens + 10; + StreamEvent done; + done.type = StreamEventType::Done; + provider->push_events({answer, usage, done}); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("finish the inspection"); })); + const auto main = provider->messages_for_turn(0); + const auto main_tools = provider->tools_for_turn(0); + ASSERT_FALSE(main.empty()); + ASSERT_FALSE(main_tools.empty()); + const auto contains = [](const std::vector& messages, const std::string& text) { + return std::any_of(messages.begin(), messages.end(), [&](const ChatMessage& item) { + return item.content.find(text) != std::string::npos; + }); + }; + EXPECT_TRUE(contains(main, "")); + EXPECT_TRUE(contains(main, "existing request hook")); + if (GetParam().automatic) { + provider->push_text("continued after compaction"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("pending new input"); })); + } else { + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit_compact(); })); + provider->push_text("continued after manual compaction"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("after manual compaction"); })); + } + const auto continued = provider->messages_for_turn(1); + EXPECT_TRUE(contains(continued, "precompact queued hook")); + if (GetParam().automatic) EXPECT_TRUE(contains(continued, "pending user hook")); + + std::lock_guard lock(provider->mutex); + ASSERT_EQ(provider->compact_requests.size(), 1u); + const auto& compact = provider->compact_requests[0]; + ASSERT_EQ(compact.size(), main.size() + 2u); + for (std::size_t i = 0; i < main.size(); ++i) { + SCOPED_TRACE(i); + EXPECT_EQ(compact[i].role, main[i].role); + EXPECT_EQ(compact[i].content, main[i].content); + EXPECT_EQ(compact[i].content_parts, main[i].content_parts); + EXPECT_EQ(compact[i].reasoning_content, main[i].reasoning_content); + EXPECT_EQ(compact[i].tool_calls, main[i].tool_calls); + EXPECT_EQ(compact[i].tool_call_id, main[i].tool_call_id); + } + EXPECT_EQ(compact[main.size()].content, "completed before compaction"); + EXPECT_EQ(compact.back().content, get_compact_prompt()); + EXPECT_FALSE(contains(compact, "precompact queued hook")); + EXPECT_FALSE(contains(compact, "pending user hook")); + const auto compact_call = std::find_if(compact.begin(), compact.end(), [](const ChatMessage& item) { + return item.tool_calls.is_array() && !item.tool_calls.empty(); + }); + ASSERT_NE(compact_call, compact.end()); + EXPECT_EQ(compact_call->tool_calls[0]["function"]["name"], "InspectDocument"); + EXPECT_EQ(compact_call->reasoning_content, call.reasoning_content); + ASSERT_EQ(provider->compact_tools[0].size(), main_tools.size()); + for (std::size_t i = 0; i < main_tools.size(); ++i) { + SCOPED_TRACE(i); + EXPECT_EQ(provider->compact_tools[0][i].name, main_tools[i].name); + EXPECT_EQ(provider->compact_tools[0][i].description, main_tools[i].description); + EXPECT_EQ(provider->compact_tools[0][i].parameters, main_tools[i].parameters); + } + for (const auto& item : compact) EXPECT_NE(item.content, "pending new input"); +} + +INSTANTIATE_TEST_SUITE_P(CacheStability, AgentLoopCompactionPrefix, + testing::Values(PromptCase{"GlmAutomatic", "glm-4.6", true}, + PromptCase{"GlmManual", "glm-4.6", false}, + PromptCase{"DeepSeekAutomatic", "deepseek-reasoner", true}, + PromptCase{"DeepSeekManual", "deepseek-reasoner", false}, + PromptCase{"GptAutomatic", "gpt-5", true}, + PromptCase{"GptManual", "gpt-5", false}), + [](const testing::TestParamInfo& info) { return info.param.name; }); + +TEST(AgentLoopContextGolden, FailedCheckpointAppendKeepsHistoryAndWindowUntilDurableRetry) { + Isolation isolation; + auto provider = std::make_shared(); + provider->prefix_capability = true; + Harness h(isolation, "failed-compact-append", provider); + h.loop->set_context_window(1000000); + provider->push_text("initial answer"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("task that must survive"); })); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit_compact(); })); + const auto checkpoint_before = h.session->load_latest_compact_checkpoint(); + ASSERT_TRUE(checkpoint_before.has_value()); + provider->push_text("new detailed answer not yet summarized"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("new work after checkpoint"); })); + const auto history_before = h.loop->messages(); + std::size_t compaction_calls_before = 0; + { + std::lock_guard lock(provider->mutex); + compaction_calls_before = provider->compact_requests.size(); + } + std::size_t events_before = 0; + { + std::lock_guard lock(h.observed->mutex); + events_before = h.observed->events.size(); + } + + const auto path = std::filesystem::path(SessionStorage::session_path( + h.session->current_project_dir(), h.session->current_session_id())); + const auto relative = std::filesystem::canonical(path).lexically_relative( + std::filesystem::canonical(isolation.directory.path)); + ASSERT_FALSE(relative.empty()); + ASSERT_FALSE(relative.is_absolute()); + ASSERT_NE(*relative.begin(), std::filesystem::path("..")); + const auto saved = path.string() + ".saved-for-append-failure"; + ASSERT_FALSE(std::filesystem::exists(saved)); + std::filesystem::rename(path, saved); + ASSERT_TRUE(std::filesystem::create_directory(path)); + + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit_compact(); })); + EXPECT_FALSE(h.loop->is_busy()); + { + std::lock_guard lock(provider->mutex); + EXPECT_EQ(provider->compact_requests.size(), compaction_calls_before + 1); + } + EXPECT_EQ(h.session->last_error(), "failed to append compact checkpoint"); + const auto history_after = h.loop->messages(); + ASSERT_EQ(history_after.size(), history_before.size()); + for (std::size_t i = 0; i < history_before.size(); ++i) { + SCOPED_TRACE(i); + EXPECT_EQ(serialize_message(history_after[i]), serialize_message(history_before[i])); + } + bool saw_error = false; + bool finished_busy = false; + { + std::lock_guard lock(h.observed->mutex); + for (std::size_t i = events_before; i < h.observed->events.size(); ++i) { + const auto& event = h.observed->events[i]; + if (event.kind == SessionEventKind::BusyChanged && !event.payload.value("busy", true)) { + finished_busy = true; + } + if (event.kind != SessionEventKind::Message) continue; + saw_error = saw_error || event.payload.value("role", "") == "error"; + const auto text = event.payload.value("content", std::string{}); + EXPECT_EQ(text.find("[Compact Checkpoint]"), std::string::npos); + EXPECT_EQ(text.find("[Conversation summary]"), std::string::npos); + } + } + EXPECT_TRUE(saw_error); + EXPECT_TRUE(finished_busy); + + ASSERT_TRUE(std::filesystem::remove(path)); + std::filesystem::rename(saved, path); + const auto persisted_after_failure = h.session->load_latest_compact_checkpoint(); + ASSERT_TRUE(persisted_after_failure.has_value()); + EXPECT_EQ(persisted_after_failure->window_number, checkpoint_before->window_number); + EXPECT_EQ(persisted_after_failure->window_id, checkpoint_before->window_id); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit_compact(); })); + const auto retried = h.session->load_latest_compact_checkpoint(); + ASSERT_TRUE(retried.has_value()); + EXPECT_EQ(retried->window_number, checkpoint_before->window_number + 1); + EXPECT_EQ(retried->previous_window_id, checkpoint_before->window_id); + EXPECT_EQ(retried->first_window_id, checkpoint_before->first_window_id); + EXPECT_NE(retried->window_id, checkpoint_before->window_id); + EXPECT_FALSE(h.loop->is_busy()); +} + +TEST(AgentLoopContextGolden, FailedSnapshotAppendStopsBeforeProviderWithoutPhantomContext) { + Isolation isolation; + auto provider = std::make_shared(); + Harness h(isolation, "failed-request-snapshot-append", provider); + h.loop->set_context_window(1000000); + ASSERT_TRUE(h.session->try_on_message(message("user", "existing persisted transcript"))); + const auto path = std::filesystem::path(SessionStorage::session_path( + h.session->current_project_dir(), h.session->current_session_id())); + const auto relative = std::filesystem::canonical(path).lexically_relative( + std::filesystem::canonical(isolation.directory.path)); + ASSERT_FALSE(relative.empty()); + ASSERT_FALSE(relative.is_absolute()); + ASSERT_NE(*relative.begin(), std::filesystem::path("..")); + const auto saved = path.string() + ".saved-for-snapshot-failure"; + ASSERT_FALSE(std::filesystem::exists(saved)); + std::filesystem::rename(path, saved); + ASSERT_TRUE(std::filesystem::create_directory(path)); + + provider->push_text("must not be requested before context is durable"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("first request needs a snapshot"); })); + EXPECT_EQ(provider->turn_count(), 0); + EXPECT_FALSE(h.loop->is_busy()); + for (const auto& item : h.loop->messages()) { + EXPECT_FALSE(is_request_context_record(item)); + } + bool saw_context_error = false; + bool finished_busy = false; + { + std::lock_guard lock(h.observed->mutex); + for (const auto& event : h.observed->events) { + if (event.kind == SessionEventKind::BusyChanged && !event.payload.value("busy", true)) { + finished_busy = true; + } + if (event.kind == SessionEventKind::Message && event.payload.value("role", "") == "error") { + saw_context_error = saw_context_error || + event.payload.value("content", std::string{}).find( + "Could not persist request context; request was not sent.") != std::string::npos; + } + } + } + EXPECT_TRUE(saw_context_error); + EXPECT_TRUE(finished_busy); + + ASSERT_TRUE(std::filesystem::remove(path)); + std::filesystem::rename(saved, path); + const auto persisted = h.session->load_active_messages(); + ASSERT_EQ(persisted.size(), 1u); + EXPECT_EQ(persisted.front().content, "existing persisted transcript"); +} + +TEST(AgentLoopContextGolden, TodoWriteChecklistIsRestoredOnceInDurableCompactWindow) { + Isolation isolation; + auto provider = std::make_shared(); + provider->prefix_capability = true; + Harness h(isolation, "compact-todo-checklist", provider); + h.loop->set_context_window(1000000); + ASSERT_TRUE(h.tools.register_tool(create_todo_write_tool())); + provider->push_tool_call("TodoWrite", Json{{"todos", Json::array({ + {{"id", "done"}, {"content", "UNIQUE_COMPLETED_CHECKLIST_ITEM"}, {"status", "completed"}}, + {{"id", "active"}, {"content", "UNIQUE_ACTIVE_CHECKLIST_ITEM"}, {"status", "in_progress"}}, + {{"id", "later"}, {"content", "UNIQUE_PENDING_CHECKLIST_ITEM"}, {"status", "pending"}}, + {{"id", "cancelled"}, {"content", "UNIQUE_CANCELLED_CHECKLIST_ITEM"}, {"status", "cancelled"}}, + })}}.dump(), "save-checklist"); + provider->push_text("checklist saved"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("track the work"); })); + const auto todos = h.session->current_todos(); + ASSERT_EQ(todos.size(), 4u); + const auto complete_list = format_todo_injection(todos); + ASSERT_FALSE(complete_list.empty()); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit_compact(); })); + const auto checkpoint = h.session->load_latest_compact_checkpoint(); + ASSERT_TRUE(checkpoint.has_value()); + ASSERT_FALSE(checkpoint->replacement_history.empty()); + const auto& snapshot = checkpoint->replacement_history.front(); + EXPECT_TRUE(is_request_context_snapshot(snapshot)); + EXPECT_NE(snapshot.content.find(complete_list), std::string::npos); + EXPECT_EQ(checkpoint->replacement_history.back().content, + get_compact_summary_prefix() + "\nGolden compact summary."); + const auto reloaded = reconstruct_effective_model_history(h.session->load_active_messages()); + ASSERT_FALSE(reloaded.empty()); + EXPECT_EQ(serialize_message(reloaded.front()), serialize_message(snapshot)); + + provider->push_text("continued once"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("continue the checklist"); })); + provider->push_text("continued twice"); + ASSERT_TRUE(h.perform([loop = h.loop.get()] { loop->submit("continue again"); })); + for (int turn : {2, 3}) { + const auto request = provider->messages_for_turn(turn); + ASSERT_FALSE(request.empty()); + for (const auto& todo : todos) { + EXPECT_EQ(std::count_if(request.begin(), request.end(), [&](const ChatMessage& item) { + return item.content.find(todo.content) != std::string::npos; + }), 1) << todo.id; + } + } +} + +// Legacy providers still omit tools, but consume the same model-facing context +// projection as normal requests. Stored tool names and result data stay intact. +TEST(AgentLoopContextGolden, ToolFreeCompactionUsesModelAliasesWithoutRewritingStoredHistory) { Isolation isolation; ScopedModelToolNameMappings mapped{{"file_read", "inspect_document"}}; auto provider = std::make_shared(); @@ -112,7 +420,7 @@ TEST(AgentLoopContextGolden, CompactionReceivesNativeHistoryWhileMainRequestUses const auto& compact = provider->compact_requests[0]; const auto compact_call = find_call(compact); ASSERT_NE(compact_call, compact.end()); - EXPECT_EQ(compact_call->tool_calls, call.tool_calls); + EXPECT_EQ(compact_call->tool_calls, main_call->tool_calls); EXPECT_EQ(compact_call->content, call.content); const auto compact_result = std::find_if(compact.begin(), compact.end(), [](const ChatMessage& item) { return item.role == "tool" && item.tool_call_id == "historical-read"; diff --git a/tests/agent/characterization_lifecycle_test.cpp b/tests/agent/characterization_lifecycle_test.cpp index 5356bcfc..1777847a 100644 --- a/tests/agent/characterization_lifecycle_test.cpp +++ b/tests/agent/characterization_lifecycle_test.cpp @@ -219,9 +219,17 @@ TEST(AgentLoopLifecycleGolden, SwarmModeFollowsSessionContextBetweenTurns) { }); }; EXPECT_EQ(count_swarm(first), 1); - EXPECT_EQ(count_swarm(second), 0); + // The earlier snapshot stays unchanged; the appended replacement clears + // swarm state even if incoming user metadata still contains an old flag. + EXPECT_EQ(count_swarm(second), 1); ASSERT_FALSE(first.empty()); ASSERT_FALSE(second.empty()); + EXPECT_EQ(second.back().metadata.at("context_state").at("swarm"), ""); + EXPECT_NE(second.back().content.find("no longer active"), std::string::npos); + ASSERT_GE(second.size(), first.size()); + for (std::size_t i = 0; i < first.size(); ++i) { + EXPECT_EQ(first[i].content, second[i].content); + } EXPECT_EQ(first[0].role, "system"); EXPECT_EQ(first[0].content, second[0].content); } diff --git a/tests/agent/compact_core_test.cpp b/tests/agent/compact_core_test.cpp index 898de60f..1e841227 100644 --- a/tests/agent/compact_core_test.cpp +++ b/tests/agent/compact_core_test.cpp @@ -2,7 +2,9 @@ #include "agent/compaction/compact.hpp" #include "agent/compaction/compact_prompt.hpp" +#include "session/request_context_record.hpp" +#include #include #include #include @@ -13,8 +15,9 @@ class ChatStubProvider : public acecode::LlmProvider { public: acecode::ChatResponse chat( const std::vector& messages, - const std::vector&) override { + const std::vector& tools) override { calls.push_back(messages); + tool_tables.push_back(tools); if (!exceptions.empty()) { const std::string error = exceptions.front(); exceptions.pop_front(); @@ -43,6 +46,24 @@ class ChatStubProvider : public acecode::LlmProvider { bool supports_native_compaction() const override { return native_capability; } + bool supports_compaction_prefix_reuse() const override { + return prefix_capability; + } + acecode::ChatResponse chat_for_compaction( + const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) override { + ++prefix_calls; + return chat_cancellable(messages, tools, abort_flag); + } + acecode::ChatResponse chat_with_options( + const std::vector& messages, + const std::vector& tools, + const acecode::ChatRequestOptions& options, + const std::atomic* abort_flag) override { + call_options.push_back(options); + return LlmProvider::chat_with_options(messages, tools, options, abort_flag); + } static acecode::ChatResponse response(std::string content, std::string finish_reason = "stop") { @@ -53,9 +74,13 @@ class ChatStubProvider : public acecode::LlmProvider { } std::vector> calls; + std::vector> tool_tables; + std::vector call_options; std::deque responses; std::deque exceptions; bool native_capability = false; + bool prefix_capability = false; + int prefix_calls = 0; }; acecode::ChatMessage msg(std::string role, @@ -114,11 +139,215 @@ void expect_same_request(const std::vector& lhs, << "message index " << i; EXPECT_EQ(lhs[i].tool_calls, rhs[i].tool_calls) << "message index " << i; + EXPECT_EQ(lhs[i].content_parts, rhs[i].content_parts) + << "message index " << i; + EXPECT_EQ(lhs[i].reasoning_content, rhs[i].reasoning_content) + << "message index " << i; } } } // namespace +TEST(CompactCore, ReusesExactModelFacingPrefixIncludingReasoningAndSchemas) { + ChatStubProvider provider; + provider.prefix_capability = true; + auto call = tool_call_message("read-1"); + call.tool_calls[0]["function"]["name"] = "InspectDocument"; + call.reasoning_content = "Preserved GLM/DeepSeek reasoning."; + auto user = msg("user", "read the attached document"); + user.content_parts = nlohmann::json::array({ + {{"type", "text"}, {"text", user.content}}, + {{"type", "file"}, {"path", "/tmp/document.txt"}}, + }); + acecode::CompactRequestPrefix prefix{ + {msg("system", "frozen main system"), + msg("system", "frozen skill index"), + msg("user", "frozen session context"), user, call, + tool_output_message("read-1"), msg("user", "appended context update")}, + {{"InspectDocument", "Inspect a file", {{"type", "object"}}}}, + }; + prefix.request_options.prompt_cache_key = "fixed-session-key"; + const auto original_prefix = prefix; + auto result = acecode::compact_messages(provider, {user, call, tool_output_message("read-1")}, + {msg("system", "legacy context")}, false, nullptr, {}, &prefix); + + ASSERT_TRUE(result.performed) << result.error; + ASSERT_EQ(provider.calls.size(), 1u); + EXPECT_EQ(provider.prefix_calls, 1); + EXPECT_EQ(provider.call_options[0].prompt_cache_key, "fixed-session-key"); + EXPECT_TRUE(provider.call_options[0].for_compaction); + auto expected = prefix.messages; + expected.push_back(msg("user", acecode::get_compact_prompt())); + expect_same_request(provider.calls[0], expected); + expect_same_request(prefix.messages, original_prefix.messages); + ASSERT_EQ(provider.tool_tables[0].size(), 1u); + EXPECT_EQ(provider.tool_tables[0][0].name, prefix.tools[0].name); + EXPECT_EQ(provider.tool_tables[0][0].description, prefix.tools[0].description); + EXPECT_EQ(provider.tool_tables[0][0].parameters, prefix.tools[0].parameters); +} + +TEST(CompactCore, UnsupportedProviderUsesCurrentProjectionWithoutTools) { + ChatStubProvider provider; + acecode::CompactRequestPrefix prefix{ + {msg("system", "different prefix")}, {{"probe", "probe", {}}}}; + auto result = acecode::compact_messages(provider, {msg("user", "original history")}, + {msg("system", "legacy context")}, false, nullptr, {}, &prefix); + ASSERT_TRUE(result.performed) << result.error; + EXPECT_EQ(provider.prefix_calls, 0); + ASSERT_EQ(provider.calls.size(), 1u); + EXPECT_EQ(provider.calls[0][0].content, "different prefix"); + EXPECT_EQ(provider.calls[0][1].content, acecode::get_compact_prompt()); + EXPECT_TRUE(provider.tool_tables[0].empty()); +} + +TEST(CompactCore, InvalidPrefixRepliesFallBackWithoutToolsOrInstallingContamination) { + std::vector invalid{ + ChatStubProvider::response("probe({})"), + ChatStubProvider::response(" \n"), + ChatStubProvider::response("clean-looking remainder"), + ChatStubProvider::response("", "tool_calls"), + }; + invalid[2].text_tool_calls.outcome = acecode::TextToolCallDiagnostic::Outcome::Rejected; + invalid[2].text_tool_calls.format = "dsml"; + invalid[3].tool_calls.push_back({"bad-call", "probe", "{}"}); + for (const auto& response : invalid) { + SCOPED_TRACE(acecode::compact_summary_rejection_reason(response)); + ChatStubProvider provider; + provider.prefix_capability = true; + provider.responses = {response, ChatStubProvider::response("Valid summary.")}; + acecode::CompactRequestPrefix prefix{ + {msg("system", "main prefix"), msg("user", "history")}, + {{"probe", "probe", {{"type", "object"}}}}}; + prefix.request_options.prompt_cache_key = "retry-session-key"; + auto result = acecode::compact_messages(provider, {msg("user", "history")}, + {msg("system", "legacy context")}, false, nullptr, {}, &prefix); + ASSERT_TRUE(result.performed) << result.error; + ASSERT_EQ(provider.calls.size(), 2u); + EXPECT_EQ(provider.prefix_calls, 1); + EXPECT_EQ(provider.call_options[0].prompt_cache_key, "retry-session-key"); + EXPECT_EQ(provider.call_options[1].prompt_cache_key, "retry-session-key"); + EXPECT_TRUE(provider.call_options[0].for_compaction); + EXPECT_FALSE(provider.call_options[1].for_compaction); + ASSERT_EQ(provider.tool_tables[0].size(), 1u); + EXPECT_TRUE(provider.tool_tables[1].empty()); + EXPECT_EQ(provider.calls[1].front().content, "main prefix"); + EXPECT_EQ(provider.calls[1].back().content, + acecode::get_compact_prompt() + "\n\n" + acecode::get_compact_invalid_summary_reminder()); + EXPECT_EQ(result.summary_text, "Valid summary."); + EXPECT_EQ(result.compaction_request_items_removed, 0); + } +} + +TEST(CompactCore, LegacyProviderKeepsFrozenSnapshotOnceThroughOverflowPruning) { + ChatStubProvider provider; + provider.responses.push_back(provider_error_response(acecode::ProviderErrorKind::Http, + 400, "maximum context length exceeded", false)); + auto snapshot = msg("user", "frozen project rules and checklist"); + snapshot.is_meta = true; + snapshot.subtype = acecode::kRequestContextSnapshot; + snapshot.metadata = {{"request_context_version", 1}, {"skills", "frozen skills"}}; + auto rebuilt_skills = msg("system", "new uncommitted skills"); + rebuilt_skills.metadata = {{"request_local_skill_context", true}}; + auto result = acecode::compact_messages(provider, + {msg("user", "old input"), snapshot, msg("assistant", "old reply")}, + {msg("system", "base system"), rebuilt_skills, msg("user", "duplicate rebuilt context")}); + ASSERT_TRUE(result.performed) << result.error; + ASSERT_EQ(provider.calls.size(), 2u); + for (const auto& request : provider.calls) { + EXPECT_EQ(request[0].content, "base system"); + EXPECT_EQ(request[1].content, "frozen skills"); + EXPECT_EQ(request[2].content, snapshot.content); + EXPECT_EQ(std::count_if(request.begin(), request.end(), [&](const acecode::ChatMessage& item) { + return item.content == snapshot.content; + }), 1); + EXPECT_FALSE(std::any_of(request.begin(), request.end(), [](const acecode::ChatMessage& item) { + return item.content == "duplicate rebuilt context" || item.content == "new uncommitted skills"; + })); + } + EXPECT_EQ(provider.calls[1][3].content, "old reply"); + EXPECT_EQ(result.compaction_request_items_removed, 1); +} + +TEST(CompactCore, PrefixOverflowTriesToolFreeRequestBeforePruningHistory) { + ChatStubProvider provider; + provider.prefix_capability = true; + const auto overflow = provider_error_response(acecode::ProviderErrorKind::Http, 400, + "maximum context length exceeded", false); + provider.responses = {overflow, overflow, ChatStubProvider::response("Valid summary.")}; + const std::vector history{ + msg("user", "old input"), msg("assistant", "old reply"), msg("user", "latest")}; + acecode::CompactRequestPrefix prefix{history, {{"probe", "probe", {}}}}; + prefix.messages.insert(prefix.messages.begin(), msg("system", "legacy context")); + auto result = acecode::compact_messages(provider, history, + {msg("system", "legacy context")}, false, nullptr, {}, &prefix); + ASSERT_TRUE(result.performed) << result.error; + ASSERT_EQ(provider.calls.size(), 3u); + EXPECT_EQ(provider.prefix_calls, 1); + EXPECT_EQ(provider.calls[1][1].content, "old input"); + EXPECT_EQ(provider.calls[2][1].content, "old reply"); + EXPECT_EQ(result.compaction_request_items_removed, 1); + EXPECT_TRUE(provider.tool_tables[1].empty()); + EXPECT_TRUE(provider.tool_tables[2].empty()); +} + +TEST(CompactCore, ExplicitUnsupportedToolFieldsFallBackButOtherErrorsRemainErrors) { + struct Case { int status; const char* message; bool fallback; }; + const Case cases[] = { + {400, "unknown field tool_choice", true}, + {422, "tools are not supported", true}, + {401, "tools are not supported for this credential", false}, + {400, "invalid tools JSON schema", false}, + {400, "unknown field unrelated_parameter", false}, + }; + for (const auto& item : cases) { + SCOPED_TRACE(item.message); + ChatStubProvider provider; + provider.prefix_capability = true; + provider.responses.push_back(provider_error_response(acecode::ProviderErrorKind::Http, + item.status, item.message, false)); + acecode::CompactRequestPrefix prefix{ + {msg("user", "history")}, {{"probe", "probe", {}}}}; + auto result = acecode::compact_messages(provider, prefix.messages, {}, + false, nullptr, {}, &prefix); + EXPECT_EQ(result.performed, item.fallback); + EXPECT_EQ(provider.calls.size(), item.fallback ? 2u : 1u); + EXPECT_EQ(provider.prefix_calls, 1); + if (item.fallback) EXPECT_TRUE(provider.tool_tables.back().empty()); + } +} + +TEST(CompactCore, TransientFailureRetriesIdenticalPrefixAndCancellationPreventsFallback) { + ChatStubProvider provider; + provider.prefix_capability = true; + provider.responses.push_back(provider_error_response(acecode::ProviderErrorKind::Http, + 429, "rate limited", true, {}, 0)); + acecode::CompactRequestPrefix prefix{ + {msg("system", "main system"), msg("user", "history")}, {{"probe", "probe", {}}}}; + auto result = acecode::compact_messages(provider, {msg("user", "history")}, {}, + false, nullptr, {}, &prefix); + ASSERT_TRUE(result.performed) << result.error; + ASSERT_EQ(provider.calls.size(), 2u); + EXPECT_EQ(provider.prefix_calls, 2); + expect_same_request(provider.calls[0], provider.calls[1]); + EXPECT_EQ(provider.tool_tables[0].size(), provider.tool_tables[1].size()); + EXPECT_EQ(result.compaction_request_retries, 1); + + ChatStubProvider cancelled; + cancelled.prefix_capability = true; + cancelled.responses.push_back(provider_error_response(acecode::ProviderErrorKind::Http, + 429, "rate limited", true, {}, 0)); + std::atomic abort{false}; + auto cancelled_result = acecode::compact_messages(cancelled, prefix.messages, {}, + false, &abort, [&abort](const acecode::ProviderErrorInfo&, bool waiting) { + if (waiting) abort.store(true); + }, &prefix); + EXPECT_FALSE(cancelled_result.performed); + EXPECT_EQ(cancelled_result.error, "Compaction cancelled."); + EXPECT_EQ(cancelled.calls.size(), 1u); + EXPECT_EQ(cancelled.prefix_calls, 1); + EXPECT_TRUE(cancelled_result.compacted_messages.empty()); +} + // 注意:这里比较的是 get_compact_prompt() 的返回值。提示词从 fix-feedback-0924 // 起在末尾有意偏离 Codex 原文(追加「Output requirements」一段,禁止调工具 / // 输出调用标签),见 PromptForbidsToolCallsButAllowsQuotingCommands。 diff --git a/tests/agent/request/api_request_builder_test.cpp b/tests/agent/request/api_request_builder_test.cpp index 2857c8ae..cc74e3f4 100644 --- a/tests/agent/request/api_request_builder_test.cpp +++ b/tests/agent/request/api_request_builder_test.cpp @@ -2,6 +2,9 @@ #include "agent/request/api_request_builder.hpp" #include "agent/request/prompt_context_cache.hpp" #include "skills/skill_activation.hpp" +#include "session/compact_checkpoint.hpp" +#include "session/request_context_record.hpp" +#include "session/session_serializer.hpp" TEST(PromptContextCache, CwdChangePreservesSkillAndSessionPins) { // cwd 失效只针对 Git;相同内容键下重采的动态文本仍钉住原字节。 @@ -78,3 +81,174 @@ TEST(ApiRequestBuilder, DeferredSkillToolsStaySessionLocalAndRespectPolicyAfterR options.tool_policy.builtin_tools = std::unordered_set{}; EXPECT_TRUE(builder.capture(options, {message}, false).tool_defs.empty()); } + +namespace { +acecode::ChatMessage message(const std::string& role, const std::string& text) { + acecode::ChatMessage result; + result.role = role; + result.content = text; + return result; +} + +void expect_prefix(const std::vector& before, + const std::vector& after) { + ASSERT_GE(after.size(), before.size()); + for (std::size_t i = 0; i < before.size(); ++i) { + EXPECT_EQ(acecode::serialize_message_json(before[i]), + acecode::serialize_message_json(after[i])) << "message " << i; + } +} + +acecode::agent::RequestBuildInputs context_inputs() { + acecode::agent::RequestBuildInputs inputs; + inputs.system_prompt = "fixed system"; + inputs.skills = {"frozen skills", "skills-v1", ""}; + inputs.session = {"project rules v1", "session-v1", ""}; + inputs.history = {message("user", "first request")}; + return inputs; +} + +void commit(acecode::agent::RequestBuildInputs& inputs, + const acecode::agent::ApiRequestBundle& request) { + inputs.history.insert(inputs.history.end(), request.context_records.begin(), request.context_records.end()); + inputs.hook_context.clear(); +} +} + +TEST(ApiRequestBuilder, PrefixSurvivesNewTurnsSkillActivityAndReload) { + acecode::ToolExecutor tools; + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + auto inputs = context_inputs(); + const auto first = builder.build(inputs); + ASSERT_EQ(first.context_records.size(), 1u); + EXPECT_TRUE(acecode::is_request_context_snapshot(first.context_records.front())); + commit(inputs, first); + inputs.history.push_back(message("assistant", "answer")); + inputs.history.push_back(message("user", "second request")); + inputs.skills = {"changed by another session", "skills-v2", ""}; + const auto second = builder.build(inputs); + expect_prefix(first.messages_with_system, second.messages_with_system); + EXPECT_TRUE(second.context_records.empty()); + + // Reload actual serialized records into a new builder/cache. + for (auto& item : inputs.history) { + item = acecode::deserialize_message_json(acecode::serialize_message_json(item)); + } + inputs.history = acecode::reconstruct_effective_model_history(inputs.history); + acecode::agent::PromptContextCache reloaded_cache; + acecode::agent::ApiRequestBuilder reloaded(tools, reloaded_cache); + const auto resumed = reloaded.build(inputs); + expect_prefix(second.messages_with_system, resumed.messages_with_system); + EXPECT_EQ(second.messages_with_system.size(), resumed.messages_with_system.size()); + EXPECT_TRUE(resumed.context_records.empty()); +} + +TEST(ApiRequestBuilder, UpdatesAndHooksAppendOnceAndNeverDisappear) { + acecode::ToolExecutor tools; + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + auto inputs = context_inputs(); + inputs.plan_context = "plan active"; + inputs.execution_context = "read-only sandbox"; + inputs.hook_context = "hook says preserve this fact"; + const auto first = builder.build(inputs); + ASSERT_EQ(first.context_records.size(), 2u); + commit(inputs, first); + inputs.history.push_back(message("assistant", "answer")); + inputs.history.push_back(message("user", "continue")); + inputs.session = {"project rules v2", "session-v2", ""}; + inputs.plan_context.clear(); + inputs.execution_context = "workspace-write sandbox"; + const auto updated = builder.build(inputs); + expect_prefix(first.messages_with_system, updated.messages_with_system); + ASSERT_EQ(updated.context_records.size(), 1u); + const auto& update = updated.context_records.front(); + EXPECT_NE(update.content.find("project rules v2"), std::string::npos); + EXPECT_NE(update.content.find("no longer active"), std::string::npos); + EXPECT_NE(update.content.find("workspace-write"), std::string::npos); + commit(inputs, updated); + const auto repeated = builder.build(inputs); + expect_prefix(updated.messages_with_system, repeated.messages_with_system); + EXPECT_TRUE(repeated.context_records.empty()); +} + +TEST(ApiRequestBuilder, TodoWriteDoesNotReplaceEarlierChecklist) { + acecode::ToolExecutor tools; + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + auto inputs = context_inputs(); + inputs.todos = {{"one", "first task", "pending"}}; + const auto first = builder.build(inputs); + commit(inputs, first); + auto call = message("assistant", ""); + call.tool_calls = nlohmann::json::array({{{"id", "todo1"}, {"type", "function"}, + {"function", {{"name", "TodoWrite"}, {"arguments", "{}"}}}}}); + inputs.history.push_back(call); + auto result = message("tool", "first task completed"); + result.tool_call_id = "todo1"; + inputs.history.push_back(result); + inputs.todos.front().status = "completed"; + const auto next = builder.build(inputs); + expect_prefix(first.messages_with_system, next.messages_with_system); + EXPECT_TRUE(next.context_records.empty()); + EXPECT_EQ(next.messages_with_system.back().content, "first task completed"); +} + +TEST(ApiRequestBuilder, FreshWindowRestoresChecklistBeforeFinalSummary) { + acecode::ToolExecutor tools; + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + acecode::agent::RequestContextOptions options; + options.git_config.emplace(); + options.git_config->enabled = false; + options.project_config.emplace(); + options.project_config->enabled = false; + options.todos = {{"task", "restored task", "in_progress"}}; + auto summary = message("user", "summary"); + summary.is_compact_summary = true; + std::vector replacement{summary}; + replacement.insert(replacement.begin(), builder.fresh_window_snapshot(options, replacement)); + const auto first = builder.compaction_request(options, replacement); + ASSERT_FALSE(first.messages_with_system.empty()); + EXPECT_TRUE(first.context_records.empty()); + EXPECT_EQ(first.messages_with_system.back().content, "summary"); + EXPECT_NE(replacement.front().content.find("restored task"), std::string::npos); + options.todos.front().status = "completed"; + const auto next = builder.compaction_request(options, replacement); + expect_prefix(first.messages_with_system, next.messages_with_system); + EXPECT_TRUE(next.context_records.empty()); +} + +TEST(ApiRequestBuilder, PermissionChangeLeavesStaticSystemPromptIdentical) { + acecode::ToolExecutor tools; + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + acecode::agent::RequestContextOptions options; + options.sandbox.description = "read-only"; + const auto initial = builder.static_system_prompt(options); + options.sandbox.description = "workspace-write"; + EXPECT_EQ(initial, builder.static_system_prompt(options)); +} + +TEST(ApiRequestBuilder, SessionCacheKeySurvivesRebuildAndRemainsPerRequest) { + acecode::ToolExecutor tools; + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + acecode::agent::RequestContextOptions options; + options.git_config.emplace(); + options.git_config->enabled = false; + options.project_config.emplace(); + options.project_config->enabled = false; + options.memory_session_key = "saved-session-a"; + const auto first = builder.build(builder.capture(options, {}, false)); + options.memory_session_key = "saved-session-b"; + const auto other = builder.build(builder.capture(options, {}, false)); + EXPECT_EQ(first.request_options.prompt_cache_key, "saved-session-a"); + EXPECT_EQ(other.request_options.prompt_cache_key, "saved-session-b"); + acecode::agent::PromptContextCache next_cache; + acecode::agent::ApiRequestBuilder next_builder(tools, next_cache); + options.memory_session_key = "saved-session-a"; + const auto resumed = next_builder.compaction_request(options, first.context_records); + EXPECT_EQ(first.request_options.prompt_cache_key, resumed.request_options.prompt_cache_key); +} diff --git a/tests/agent/request/prompt_config_snapshot_test.cpp b/tests/agent/request/prompt_config_snapshot_test.cpp index 8ea9509b..738ab298 100644 --- a/tests/agent/request/prompt_config_snapshot_test.cpp +++ b/tests/agent/request/prompt_config_snapshot_test.cpp @@ -88,7 +88,15 @@ TEST(PromptConfigSnapshot, ExpertAndSkillsSwitchAtTheFollowingTurn) { EXPECT_FALSE(contains(continued, "after-skill")); EXPECT_TRUE(contains(next, "EXPERT_AFTER")); EXPECT_TRUE(contains(next, "after-skill")); - EXPECT_FALSE(contains(next, "EXPERT_BEFORE")); + // Catalog/expert changes append their replacement instructions, while the + // original skill system message and context remain byte-stable. + ASSERT_FALSE(next.empty()); + EXPECT_NE(next.back().content.find("EXPERT_AFTER"), std::string::npos); + EXPECT_EQ(next.back().content.find("EXPERT_BEFORE"), std::string::npos); + ASSERT_GE(next.size(), continued.size()); + for (std::size_t i = 0; i < continued.size(); ++i) { + EXPECT_EQ(next[i].content, continued[i].content); + } } // 场景:空闲时发布设置后 prime 侧问上下文。期望每次 prime 只取一次当前快照, diff --git a/tests/agent/side_question/side_question_service_test.cpp b/tests/agent/side_question/side_question_service_test.cpp index 0e3ee7b0..394280db 100644 --- a/tests/agent/side_question/side_question_service_test.cpp +++ b/tests/agent/side_question/side_question_service_test.cpp @@ -1,6 +1,10 @@ #include #include "agent/side_question/side_question_service.hpp" #include "test_support/agent/stub_provider.hpp" +#include "test_support/agent_loop/characterization_fixture.hpp" +#include "session/request_context_record.hpp" +#include "session/session_serializer.hpp" +#include #include #include #include @@ -101,6 +105,86 @@ TEST(SideQuestionService, PublishedContextIsAnIndependentValue) { EXPECT_EQ(service.ask(" ").status, acecode::SideQuestionStatus::InvalidQuestion); } +TEST(SideQuestionService, PrimedRequestReusesFrozenContextAndRetainsReadOnlyToolWhitelist) { + acecode_test::characterization::Isolation isolation; + acecode_test::characterization::Harness harness( + isolation, "side-context", {}, true, [] { + acecode::SessionPromptConfig config; + config.custom_instructions.emplace(); + config.custom_instructions->set_text("UNSENT_LIVE_CONTEXT"); + return config; + }); + harness.tools.register_tool(harness.probe("file_read", true)); + harness.tools.register_tool(harness.probe("bash", true)); + harness.tools.register_tool(harness.probe("file_write", false)); + + acecode::ChatMessage user; + user.role = "user"; + user.content = "original task"; + acecode::ChatMessage snapshot; + snapshot.role = "user"; + snapshot.is_meta = true; + snapshot.subtype = acecode::kRequestContextSnapshot; + snapshot.content = "FROZEN_REQUEST_CONTEXT"; + snapshot.metadata = { + {"request_context_version", 1}, {"skills", "FROZEN_SKILL_INDEX"}, + {"context_state", {{"session", "FROZEN_REQUEST_CONTEXT"}, + {"swarm", ""}, {"plan", ""}, {"execution", ""}}}, + }; + auto update = snapshot; + update.subtype = acecode::kRequestContextUpdate; + update.content = "APPENDED_REQUEST_CONTEXT"; + update.metadata = { + {"request_context_version", 1}, + {"context_state", {{"session", "APPENDED_REQUEST_CONTEXT"}}}, + }; + // The first request snapshot is physically appended after its user input. + const std::vector stored = {user, snapshot, update}; + for (const auto& message : stored) harness.loop->push_message(message); + harness.loop->prime_side_question_context(); + const auto context = harness.loop->side_question_context_snapshot(); + const auto count_content = [](const std::vector& messages, + const std::string& needle) { + return std::count_if(messages.begin(), messages.end(), [&](const auto& message) { + return message.content.find(needle) != std::string::npos; + }); + }; + EXPECT_EQ(count_content(context, "FROZEN_REQUEST_CONTEXT"), 1); + EXPECT_EQ(count_content(context, "APPENDED_REQUEST_CONTEXT"), 1); + EXPECT_EQ(count_content(context, "FROZEN_SKILL_INDEX"), 1); + EXPECT_EQ(count_content(context, "UNSENT_LIVE_CONTEXT"), 0); + ASSERT_EQ(context.size(), 5u); + EXPECT_EQ(context[1].content, "FROZEN_SKILL_INDEX"); + EXPECT_EQ(context[2].content, snapshot.content); + EXPECT_EQ(context[3].content, user.content); + EXPECT_EQ(context[4].content, update.content); + + // Even a provider that asks for a tool present in the main context cannot + // execute it outside the side-chat whitelist. + harness.provider->push_tool_call("bash", "{}"); + harness.provider->push_text("side answer"); + const auto answer = harness.loop->ask_side_question("explain the task"); + EXPECT_EQ(answer.status, acecode::SideQuestionStatus::Ok) << answer.error; + EXPECT_EQ(answer.answer, "side answer"); + for (int index = 0; index < 2; ++index) { + const auto definitions = harness.provider->tools_for_turn(index); + ASSERT_EQ(definitions.size(), 1u); + EXPECT_EQ(definitions.front().name, "file_read"); + } + EXPECT_EQ(count_content(harness.provider->messages_for_turn(1), + "not available in this read-only side chat"), 1); + { + std::lock_guard lock(harness.observed->mutex); + EXPECT_TRUE(harness.observed->executions.empty()); + } + const auto& after = harness.loop->messages(); + ASSERT_EQ(after.size(), stored.size()); + for (std::size_t index = 0; index < stored.size(); ++index) { + EXPECT_EQ(acecode::serialize_message(after[index]), + acecode::serialize_message(stored[index])); + } +} + // 场景:侧问的结果回调已经进入,此时关停。期望关停等待该回调退出, // 并拒绝后续请求;仅检查 stopped 的旧逻辑无法表示在途回调寿命。 TEST(SideQuestionService, JoinWaitsForAnAlreadyAdmittedCallback) { diff --git a/tests/experts/expert_registry_test.cpp b/tests/experts/expert_registry_test.cpp index 2c408595..cc744b9d 100644 --- a/tests/experts/expert_registry_test.cpp +++ b/tests/experts/expert_registry_test.cpp @@ -6,6 +6,7 @@ #include "prompt/system_prompt.hpp" #include "session_host/session_registry.hpp" #include "session/session_storage.hpp" +#include "session/request_context_record.hpp" #include "tool/tool_executor.hpp" #include "utils/utf8_path.hpp" #include "skills/skill_command_expander.hpp" @@ -1498,10 +1499,10 @@ TEST(ExpertRegistry, } return result; }; - const std::string first_request = - request_text(provider->messages_for_turn(0)); - const std::string second_request = - request_text(provider->messages_for_turn(1)); + const auto first_messages = provider->messages_for_turn(0); + const auto second_messages = provider->messages_for_turn(1); + const std::string first_request = request_text(first_messages); + const std::string second_request = request_text(second_messages); EXPECT_NE(first_request.find("Review code carefully."), std::string::npos); EXPECT_NE(first_request.find("review-skill"), std::string::npos); @@ -1509,7 +1510,36 @@ TEST(ExpertRegistry, EXPECT_NE(second_request.find("Write the requested implementation."), std::string::npos); EXPECT_NE(second_request.find("write-skill"), std::string::npos); - EXPECT_EQ(second_request.find("review-skill"), std::string::npos); + + // Explicit tool-policy changes rebuild the first system message. Frozen + // skills, persisted context and conversation entries remain unchanged; + // replacement expert sections are appended at the queued boundary. + ASSERT_FALSE(first_messages.empty()); + ASSERT_GT(second_messages.size(), first_messages.size()); + EXPECT_EQ(first_messages.front().role, "system"); + EXPECT_EQ(second_messages.front().role, "system"); + for (std::size_t i = 1; i < first_messages.size(); ++i) { + EXPECT_EQ(second_messages[i].role, first_messages[i].role) << i; + EXPECT_EQ(second_messages[i].content, first_messages[i].content) << i; + EXPECT_EQ(second_messages[i].content_parts, first_messages[i].content_parts) << i; + } + const auto updated = std::find_if( + second_messages.begin() + first_messages.size(), second_messages.end(), + [](const acecode::ChatMessage& message) { + return acecode::is_request_context_record(message) && + message.subtype == acecode::kRequestContextUpdate && + message.metadata.contains("context_state") && + message.metadata["context_state"].contains("session") && + message.metadata["context_state"].contains("skills"); + }); + ASSERT_NE(updated, second_messages.end()); + const auto& updated_state = updated->metadata["context_state"]; + const auto updated_session = updated_state["session"].get(); + const auto updated_skills = updated_state["skills"].get(); + EXPECT_NE(updated_session.find("Write the requested implementation."), std::string::npos); + EXPECT_EQ(updated_session.find("Review code carefully."), std::string::npos); + EXPECT_NE(updated_skills.find("write-skill"), std::string::npos); + EXPECT_EQ(updated_skills.find("review-skill"), std::string::npos); EXPECT_EQ(entry->expert_id, "writer"); EXPECT_EQ(entry->sm->current_input_draft(), "writer draft"); diff --git a/tests/hooks/hook_agent_loop_test.cpp b/tests/hooks/hook_agent_loop_test.cpp index d32956f7..dfaa4cb7 100644 --- a/tests/hooks/hook_agent_loop_test.cpp +++ b/tests/hooks/hook_agent_loop_test.cpp @@ -7,6 +7,7 @@ #include "hooks/hook_manager.hpp" #include "hooks/hook_runtime.hpp" #include "permissions/permissions.hpp" +#include "session/request_context_record.hpp" #include "tool/tool_executor.hpp" #include "test_support/agent/stub_provider.hpp" @@ -208,6 +209,18 @@ acecode::HookProcessResult hook_json(const std::string& json) { return r; } +std::size_t hook_context_occurrences(const std::vector& messages, + const std::string& context) { + std::size_t count = 0; + for (const auto& message : messages) { + for (auto pos = message.content.find(context); pos != std::string::npos; + pos = message.content.find(context, pos + context.size())) { + ++count; + } + } + return count; +} + } // namespace TEST(HookAgentLoop, DispatchesAssistantCompletedAfterTextMessageCommit) { @@ -330,65 +343,83 @@ TEST(HookAgentLoop, UserPromptSubmitBlockPreventsPersistenceAndProviderCall) { terminal_events->busy["usage"], terminal_events->done["usage"]); } -TEST(HookAgentLoop, UserPromptSubmitAdditionalContextReachesNextRequestOnly) { +TEST(HookAgentLoop, UserPromptSubmitAdditionalContextPersistsOncePerHookEvent) { auto provider = std::make_shared(); + provider->push_tool_call("probe", R"({"value":"first"})", "hook-probe-first"); provider->push_text("done"); - - + std::atomic hook_calls{0}; acecode::HookManager hooks( registry_with({make_codex_hook( "prompt-context", acecode::kCodexHookEventUserPromptSubmit)}), acecode::HookProcessRunner{}, - [](const std::string&, const std::string&, int, const std::string&) { + [&hook_calls](const std::string&, const std::string&, int, const std::string&) { + ++hook_calls; return hook_json(R"({"hookSpecificOutput":{"additionalContext":"hook context value"}})"); }); LoopHarness h(provider, acecode::PermissionResult::Deny, &hooks); - - + h.tools.register_tool(make_probe_tool("probe", true, nullptr)); ASSERT_TRUE(h.submit_and_wait("hello")); - ASSERT_EQ(provider->turn_count(), 1); - auto request = provider->messages_for_turn(0); - bool request_has_context = false; - for (const auto& msg : request) { + ASSERT_EQ(provider->turn_count(), 2); + EXPECT_EQ(hook_calls.load(), 1); + for (int request : {0, 1}) { + EXPECT_EQ(hook_context_occurrences(provider->messages_for_turn(request), "hook context value"), 1u); + } + EXPECT_EQ(hook_context_occurrences(h.loop->messages(), "hook context value"), 1u); + for (const auto& msg : h.loop->messages()) { if (msg.content.find("hook context value") != std::string::npos) { - request_has_context = true; + EXPECT_TRUE(acecode::is_request_context_record(msg)); } } - EXPECT_TRUE(request_has_context); - for (const auto& msg : h.loop->messages()) { - EXPECT_EQ(msg.content.find("hook context value"), std::string::npos); + + // A new user event may return the same text again. Each event contributes + // exactly one durable row; another model step must not reinject either. + provider->push_tool_call("probe", R"({"value":"second"})", "hook-probe-second"); + provider->push_text("done again"); + ASSERT_TRUE(h.submit_and_wait("another request")); + ASSERT_EQ(provider->turn_count(), 4); + EXPECT_EQ(hook_calls.load(), 2); + for (int request : {2, 3}) { + EXPECT_EQ(hook_context_occurrences(provider->messages_for_turn(request), "hook context value"), 2u); } + EXPECT_EQ(hook_context_occurrences(h.loop->messages(), "hook context value"), 2u); } -TEST(HookAgentLoop, SessionStartAdditionalContextReachesNextRequestOnly) { +TEST(HookAgentLoop, SessionStartAdditionalContextPersistsAcrossRequestsWithoutReinjection) { auto provider = std::make_shared(); + provider->push_tool_call("probe", R"({"value":"first"})", "session-hook-probe"); provider->push_text("done"); - - + std::atomic hook_calls{0}; acecode::HookManager hooks( registry_with({make_codex_hook( "session-context", acecode::kCodexHookEventSessionStart, "startup")}), acecode::HookProcessRunner{}, - [](const std::string&, const std::string&, int, const std::string&) { + [&hook_calls](const std::string&, const std::string&, int, const std::string&) { + ++hook_calls; return hook_json(R"({"hookSpecificOutput":{"additionalContext":"session hook context"}})"); }); LoopHarness h(provider, acecode::PermissionResult::Deny, &hooks); - + h.tools.register_tool(make_probe_tool("probe", true, nullptr)); h.loop->dispatch_session_start_hook("startup"); ASSERT_TRUE(h.submit_and_wait("hello")); - ASSERT_EQ(provider->turn_count(), 1); - auto request = provider->messages_for_turn(0); - bool request_has_context = false; - for (const auto& msg : request) { - if (msg.content.find("session hook context") != std::string::npos) { - request_has_context = true; - } + ASSERT_EQ(provider->turn_count(), 2); + EXPECT_EQ(hook_calls.load(), 1); + for (int request : {0, 1}) { + EXPECT_EQ(hook_context_occurrences(provider->messages_for_turn(request), "session hook context"), 1u); } - EXPECT_TRUE(request_has_context); + EXPECT_EQ(hook_context_occurrences(h.loop->messages(), "session hook context"), 1u); for (const auto& msg : h.loop->messages()) { - EXPECT_EQ(msg.content.find("session hook context"), std::string::npos); + if (msg.content.find("session hook context") != std::string::npos) { + EXPECT_TRUE(acecode::is_request_context_record(msg)); + } } + + provider->push_text("later response"); + ASSERT_TRUE(h.submit_and_wait("later user turn")); + ASSERT_EQ(provider->turn_count(), 3); + EXPECT_EQ(hook_calls.load(), 1); + EXPECT_EQ(hook_context_occurrences(provider->messages_for_turn(2), "session hook context"), 1u); + EXPECT_EQ(hook_context_occurrences(h.loop->messages(), "session hook context"), 1u); } TEST(HookAgentLoop, PreToolUseDenySkipsToolExecution) { diff --git a/tests/provider/codex_input_text_test.cpp b/tests/provider/codex_input_text_test.cpp index 28c01719..23786152 100644 --- a/tests/provider/codex_input_text_test.cpp +++ b/tests/provider/codex_input_text_test.cpp @@ -2,6 +2,7 @@ #include "provider/codex_provider.hpp" #include "llm/tool_protocol_names.hpp" +#include "session/request_context_record.hpp" #include #include @@ -115,3 +116,32 @@ TEST(CodexInputText, PlainMessagesAreUnchanged) { "### Tool tool_call_id=call_1\ntool output\n\n"; EXPECT_EQ(acecode::codex_detail::build_codex_input_text(messages), expected); } + +TEST(CodexInputText, PersistedRequestContextReachesModelWithoutBecomingUserSpeech) { + auto snapshot = message("user", "frozen project instructions"); + snapshot.is_meta = true; + snapshot.subtype = acecode::kRequestContextSnapshot; + snapshot.metadata = {{"request_context_version", 1}}; + auto update = message("user", "updated plan instructions"); + update.is_meta = true; + update.subtype = acecode::kRequestContextUpdate; + update.metadata = {{"request_context_version", 1}}; + auto unsupported = snapshot; + unsupported.content = "unrecognized internal record"; + unsupported.metadata["request_context_version"] = 999; + auto checkpoint = message("system", "transcript-only checkpoint"); + checkpoint.is_meta = true; + checkpoint.subtype = "compact_checkpoint"; + + const auto text = acecode::codex_detail::build_codex_input_text({ + message("system", "base system"), snapshot, message("user", "real user request"), + update, unsupported, checkpoint}); + const std::string expected = + "Continue this ACECode conversation. Preserve the user's latest " + "request as the active task.\n\n" + "### System\nbase system\n\n" + "### Context\nfrozen project instructions\n\n" + "### User\nreal user request\n\n" + "### Context\nupdated plan instructions\n\n"; + EXPECT_EQ(text, expected); +} diff --git a/tests/provider/openai_provider_prompt_cache_test.cpp b/tests/provider/openai_provider_prompt_cache_test.cpp new file mode 100644 index 00000000..2665cf99 --- /dev/null +++ b/tests/provider/openai_provider_prompt_cache_test.cpp @@ -0,0 +1,489 @@ +#include + +#include "provider/openai_provider.hpp" +#include "utils/joining_thread.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace acecode; +using Json = nlohmann::json; + +class ExposedProvider : public OpenAiCompatProvider { +public: + using OpenAiCompatProvider::OpenAiCompatProvider; + using OpenAiCompatProvider::build_request_body; +}; + +// Exercise the production transport against an explicitly capable local +// endpoint without making an external model request or changing the allowlist. +class LocalCapableProvider : public ExposedProvider { +public: + using ExposedProvider::ExposedProvider; +protected: + bool supports_prompt_cache_key() const override { return true; } + bool supports_compaction_tool_choice_none() const override { return true; } +}; + +struct RequestRecorder { + std::mutex mu; + std::vector bodies; + std::size_t record(const httplib::Request& request) { + std::lock_guard lock(mu); + bodies.push_back(Json::parse(request.body)); + return bodies.size(); + } + std::vector snapshot() { + std::lock_guard lock(mu); + return bodies; + } +}; + +struct LocalHttpServer { + httplib::Server server; + int port = 0; + JoiningThread worker; + + explicit LocalHttpServer(std::function setup) { + setup(server); + port = server.bind_to_any_port("127.0.0.1"); + // The owner stops and joins the worker before destroying its server. + worker = JoiningThread([this] { server.listen_after_bind(); }); + for (int i = 0; i < 50 && !server.is_running(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + ~LocalHttpServer() { + server.stop(); + worker.join(); + } + std::string url() const { return "http://127.0.0.1:" + std::to_string(port); } +}; + +std::vector messages() { + ChatMessage user; + user.role = "user"; + user.content = "test"; + return {user}; +} + +std::vector tools() { + ToolDef tool; + tool.name = "read_file"; + tool.description = "Read a file"; + tool.parameters = {{"type", "object"}, + {"properties", {{"path", {{"type", "string"}}}}}}; + return {tool}; +} + +void success(httplib::Response& response, bool stream = false) { + response.set_content(stream + ? "data: {\"choices\":[{\"delta\":{\"content\":\"summary\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n" + : R"({"choices":[{"message":{"role":"assistant","content":"summary"},"finish_reason":"stop"}]})", + stream ? "text/event-stream" : "application/json"); +} + +const char* rejection = R"({"error":{"message":"Unsupported parameter: 'prompt_cache_key'","param":"prompt_cache_key","code":"unsupported_parameter"}})"; + +TEST(OpenAiPromptCache, ExactOfficialEndpointAllowlist) { + for (const auto& base : {"https://api.openai.com/v1", "https://api.mistral.ai/v1", + "https://API.MISTRAL.AI:443/v1/"}) { + ExposedProvider provider(base, "", "model"); + provider.set_prompt_cache_key("session-1"); + EXPECT_EQ(provider.build_request_body(messages(), {})["prompt_cache_key"], "session-1") << base; + } + for (const auto& base : {"http://api.openai.com/v1", "https://api.openai.com.evil.test/v1", + "https://api.openai.com@evil.test/v1", "https://evil.test/api.openai.com/v1", + "https://api.openai.com:8443/v1", "https://api.openai.com/custom", + "https://api.deepseek.com/v1", "https://api.z.ai/api/paas/v4", + "https://open.bigmodel.cn/api/paas/v4", "http://127.0.0.1/v1"}) { + ExposedProvider provider(base, "", "model"); + provider.set_prompt_cache_key("session-1"); + EXPECT_FALSE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")) << base; + } + ProviderRequestOptions options; + options.endpoint_mode = "full_url"; + ExposedProvider provider("https://api.mistral.ai/v1/chat/completions?test=1", "", "model", + OpenAiConfig::kDefaultStreamTimeoutMs, {}, options); + provider.set_prompt_cache_key("session-1"); + EXPECT_EQ(provider.build_request_body(messages(), {})["prompt_cache_key"], "session-1"); +} + +TEST(OpenAiPromptCache, StableIdentityAcrossRequestsReconfigurationAndReconstruction) { + ExposedProvider provider("https://api.mistral.ai/v1", "", "model"); + EXPECT_FALSE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); + provider.set_prompt_cache_key("session-1"); + EXPECT_EQ(provider.build_request_body(messages(), {}, true)["prompt_cache_key"], "session-1"); + provider.reconfigure("https://api.openai.com/v1", ""); + provider.set_model("another-model"); + EXPECT_EQ(provider.build_request_body(messages(), {})["prompt_cache_key"], "session-1"); + ExposedProvider rebuilt("https://api.mistral.ai/v1", "", "model"); + rebuilt.set_prompt_cache_key("session-1"); + EXPECT_EQ(rebuilt.build_request_body(messages(), {})["prompt_cache_key"], "session-1"); + rebuilt.set_prompt_cache_key("session-2"); + EXPECT_EQ(rebuilt.build_request_body(messages(), {})["prompt_cache_key"], "session-2"); + rebuilt.set_prompt_cache_key(""); + EXPECT_FALSE(rebuilt.build_request_body(messages(), {}).contains("prompt_cache_key")); +} + +TEST(OpenAiPromptCache, CompactionKeepsSchemasAndDisablesChoiceOnlyWhereVerified) { + for (const auto& base : {"https://api.mistral.ai/v1", "https://api.openai.com/v1", + "https://api.z.ai/api/paas/v4", "https://custom.test/v1"}) { + ExposedProvider provider(base, "", "model"); + const auto normal = provider.build_request_body(messages(), tools()); + const auto compact = provider.build_request_body(messages(), tools(), false, true); + EXPECT_TRUE(provider.supports_compaction_prefix_reuse()); + EXPECT_EQ(normal["messages"], compact["messages"]); + EXPECT_EQ(normal["tools"], compact["tools"]); + EXPECT_FALSE(normal.contains("tool_choice")); + const bool official = std::string(base).find("mistral") != std::string::npos || + std::string(base).find("openai") != std::string::npos; + EXPECT_EQ(compact.contains("tool_choice"), official); + if (official) EXPECT_EQ(compact["tool_choice"], "none"); + EXPECT_FALSE(provider.build_request_body(messages(), {}, false, true).contains("tool_choice")); + } +} + +TEST(OpenAiPromptCache, DerivedProtocolsKeepLegacyCompactionDispatch) { + class SpecializedProvider final : public ExposedProvider { + public: + SpecializedProvider() : ExposedProvider("https://api.openai.com/v1", "", "model") {} + std::string name() const override { return "specialized"; } + ChatResponse chat_cancellable(const std::vector&, + const std::vector& passed_tools, const std::atomic*) override { + EXPECT_TRUE(passed_tools.empty()); + ChatResponse result; + result.content = "legacy"; + return result; + } + void chat_stream(const std::vector&, + const std::vector&, const StreamCallback& callback, + std::atomic*) override { + StreamEvent event; + event.type = StreamEventType::Done; + event.content = "legacy-stream"; + callback(event); + } + } provider; + provider.set_prompt_cache_key("session-1"); + EXPECT_FALSE(provider.supports_compaction_prefix_reuse()); + EXPECT_FALSE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); + EXPECT_EQ(provider.chat_for_compaction(messages(), tools(), nullptr).content, "legacy"); + ChatRequestOptions options{"session-2", true}; + EXPECT_EQ(provider.chat_with_options(messages(), tools(), options, nullptr).content, "legacy"); + std::string result; + provider.chat_stream_with_options(messages(), tools(), options, + [&](const StreamEvent& event) { result = event.content; }); + EXPECT_EQ(result, "legacy-stream"); +} + +TEST(OpenAiPromptCache, NonStreamingDowngradesOnceAndRemembersPerEndpointModel) { + RequestRecorder recorder; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + const auto index = recorder.record(request); + if (index == 1) { + response.status = 400; + response.set_content(rejection, "application/json"); + } else success(response); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("session-1"); + EXPECT_EQ(provider.chat(messages(), tools()).content, "summary"); + EXPECT_EQ(provider.chat(messages(), tools()).content, "summary"); + provider.set_model("another-model"); + EXPECT_EQ(provider.chat(messages(), tools()).content, "summary"); + provider.set_model("model"); + EXPECT_EQ(provider.chat(messages(), tools()).content, "summary"); + const auto bodies = recorder.snapshot(); + ASSERT_EQ(bodies.size(), 5u); + EXPECT_EQ(bodies[0]["prompt_cache_key"], "session-1"); + auto expected_retry = bodies[0]; + expected_retry.erase("prompt_cache_key"); + EXPECT_EQ(bodies[1], expected_retry); + EXPECT_FALSE(bodies[2].contains("prompt_cache_key")); + EXPECT_EQ(bodies[3]["prompt_cache_key"], "session-1"); + EXPECT_FALSE(bodies[4].contains("prompt_cache_key")); + provider.reconfigure(server.url() + "/other", ""); + EXPECT_TRUE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); + provider.reconfigure(server.url(), ""); + EXPECT_FALSE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); +} + +TEST(OpenAiPromptCache, StreamingDowngradeIsSilentAndSharedWithNonStreaming) { + RequestRecorder recorder; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + const auto index = recorder.record(request); + if (index == 1) { + response.status = 422; + response.set_content(R"({"detail":[{"type":"extra_forbidden","loc":["body","prompt_cache_key"],"msg":"Extra inputs are not permitted"}]})", "application/json"); + } else success(response, Json::parse(request.body).value("stream", false)); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("session-1"); + std::vector events; + std::atomic abort{false}; + provider.chat_stream(messages(), tools(), [&](const StreamEvent& event) { + events.push_back(event); + if (event.type == StreamEventType::Retry) abort = true; + }, &abort); + EXPECT_FALSE(abort.load()); + EXPECT_EQ(provider.chat(messages(), tools()).content, "summary"); + int done = 0; + for (const auto& event : events) { + EXPECT_NE(event.type, StreamEventType::Error); + if (event.type == StreamEventType::Done) ++done; + } + EXPECT_EQ(done, 1); + const auto bodies = recorder.snapshot(); + ASSERT_EQ(bodies.size(), 3u); + EXPECT_EQ(bodies[0]["prompt_cache_key"], "session-1"); + EXPECT_FALSE(bodies[1].contains("prompt_cache_key")); + EXPECT_FALSE(bodies[2].contains("prompt_cache_key")); +} + +TEST(OpenAiPromptCache, UnrelatedFailuresPreserveOriginalErrorAndDoNotDisableKey) { + const std::vector> failures = { + {400, R"({"error":{"message":"Unknown parameter: 'temperature'","param":"temperature","code":"unsupported_parameter"},"request":{"prompt_cache_key":"session-1"}})"}, + {400, R"({"error":{"message":"prompt_cache_key must be shorter","param":"prompt_cache_key","code":"invalid_value"}})"}, + {400, R"({"error":{"message":"prompt_cache_key is not allowed to contain spaces","param":"prompt_cache_key","code":"invalid_value"}})"}, + {400, R"({"error":{"message":"Unsupported parameter: prompt_cache_key","param":"temperature","code":"unsupported_parameter"}})"}, + {422, R"({"detail":[{"type":"extra_forbidden","loc":["body","messages",0,"prompt_cache_key"]}]})"}, + {400, "Unknown parameter: prompt_cache_key_extra"}, + {401, rejection}, + {429, R"({"error":{"code":"insufficient_quota","message":"Unsupported parameter: prompt_cache_key"}})"}, + }; + for (const auto& failure : failures) { + RequestRecorder recorder; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + recorder.record(request); + response.status = failure.first; + response.set_content(failure.second, "application/json"); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("session-1"); + const auto response = provider.chat(messages(), {}); + EXPECT_EQ(recorder.snapshot().size(), 1u) << failure.second; + EXPECT_EQ(response.provider_error.status_code, failure.first); + EXPECT_EQ(response.provider_error.raw_body, failure.second); + EXPECT_TRUE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); + } +} + +TEST(OpenAiPromptCache, SecondRejectionRemainsTerminal) { + RequestRecorder recorder; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + recorder.record(request); + response.status = 400; + response.set_content(rejection, "application/json"); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("session-1"); + const auto response = provider.chat(messages(), {}); + EXPECT_EQ(recorder.snapshot().size(), 2u); + EXPECT_EQ(response.provider_error.status_code, 400); + EXPECT_EQ(response.provider_error.raw_body, rejection); +} + +TEST(OpenAiPromptCache, CancellationDuringRejectionNeverRetriesOrDisablesKey) { + for (const bool stream : {false, true}) { + RequestRecorder recorder; + std::atomic abort{false}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + recorder.record(request); + response.status = 400; + response.set_content(rejection, "application/json"); + abort = true; + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("session-1"); + if (stream) { + ProviderErrorInfo error; + provider.chat_stream(messages(), {}, [&](const StreamEvent& event) { + if (event.type == StreamEventType::Error) error = event.provider_error; + }, &abort); + EXPECT_EQ(error.kind, ProviderErrorKind::UserCancelled); + } else { + EXPECT_EQ(provider.chat_cancellable(messages(), {}, &abort).provider_error.kind, + ProviderErrorKind::UserCancelled); + } + EXPECT_EQ(recorder.snapshot().size(), 1u); + EXPECT_TRUE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); + } +} + +TEST(OpenAiPromptCache, CompactionOptionsAreRequestScoped) { + RequestRecorder recorder; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + recorder.record(request); + success(response); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("session-1"); + EXPECT_EQ(provider.chat_for_compaction(messages(), tools(), nullptr).content, "summary"); + EXPECT_EQ(provider.chat(messages(), tools()).content, "summary"); + const auto bodies = recorder.snapshot(); + ASSERT_EQ(bodies.size(), 2u); + EXPECT_EQ(bodies[0]["tool_choice"], "none"); + auto compact = bodies[0]; + compact.erase("tool_choice"); + EXPECT_EQ(compact, bodies[1]); +} + +TEST(OpenAiPromptCache, StreamingErrorAfterOutputNeverSilentlyReplays) { + RequestRecorder recorder; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + recorder.record(request); + response.set_content( + "data: {\"choices\":[{\"delta\":{\"content\":\"partial output\"}}]}\n\n" + "data: {\"status_code\":400,\"error\":{\"message\":\"Unsupported parameter: prompt_cache_key\"}}\n\n", + "text/event-stream"); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("session-1"); + std::atomic abort{false}; + ProviderErrorInfo error; + std::string content; + provider.chat_stream(messages(), {}, [&](const StreamEvent& event) { + if (event.type == StreamEventType::Delta) content += event.content; + if (event.type == StreamEventType::Error) error = event.provider_error; + if (event.type == StreamEventType::Retry) abort = true; + }, &abort); + EXPECT_EQ(recorder.snapshot().size(), 1u); + EXPECT_EQ(content, "partial output"); + EXPECT_EQ(error.status_code, 400); + EXPECT_FALSE(abort.load()); + EXPECT_TRUE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); +} + +TEST(OpenAiPromptCache, ConcurrentKeyUpdatesProduceWholeSnapshots) { + ExposedProvider provider("https://api.mistral.ai/v1", "", "model"); + const std::string first(64, 'a'); + const std::string second(64, 'b'); + provider.set_prompt_cache_key(first); + JoiningThread writer([&provider, first, second] { + for (int i = 0; i < 500; ++i) provider.set_prompt_cache_key(i % 2 ? first : second); + }); + for (int i = 0; i < 500; ++i) { + const auto key = provider.build_request_body(messages(), {})["prompt_cache_key"].get(); + EXPECT_TRUE(key == first || key == second); + } + writer.join(); +} + +TEST(OpenAiPromptCache, ConcurrentSessionsKeepPerCallIdentityAndCompactionOptions) { + RequestRecorder recorder; + std::mutex arrival_mu; + std::condition_variable arrival_cv; + int arrivals = 0; + std::atomic overlap_timed_out{false}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + recorder.record(request); + { + std::unique_lock lock(arrival_mu); + ++arrivals; + arrival_cv.notify_all(); + if (!arrival_cv.wait_for(lock, std::chrono::seconds(5), [&] { return arrivals == 2; })) { + overlap_timed_out = true; + response.status = 400; + response.set_content("concurrent request did not arrive", "text/plain"); + return; + } + } + success(response, Json::parse(request.body).value("stream", false)); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + provider.set_prompt_cache_key("legacy-key"); + std::string streamed; + ChatResponse compacted; + std::atomic abort{false}; + JoiningThread first([&] { + auto input = messages(); + input[0].content = "session-a"; + provider.chat_stream_with_options(input, tools(), {"session-a", false}, + [&](const StreamEvent& event) { + if (event.type == StreamEventType::Delta) streamed += event.content; + if (event.type == StreamEventType::Retry) abort = true; + }, &abort); + }); + JoiningThread second([&] { + auto input = messages(); + input[0].content = "session-b"; + compacted = provider.chat_with_options(input, tools(), {"session-b", true}, nullptr); + }); + first.join(); + second.join(); + EXPECT_FALSE(overlap_timed_out.load()); + EXPECT_FALSE(abort.load()); + EXPECT_EQ(streamed, "summary"); + EXPECT_EQ(compacted.content, "summary"); + const auto bodies = recorder.snapshot(); + ASSERT_EQ(bodies.size(), 2u); + for (const auto& body : bodies) { + const auto session = body["messages"][0]["content"].get(); + EXPECT_EQ(body["prompt_cache_key"], session); + EXPECT_EQ(body.contains("tool_choice"), session == "session-b"); + } + EXPECT_EQ(provider.build_request_body(messages(), {})["prompt_cache_key"], "legacy-key"); + const ChatRequestOptions no_key; + EXPECT_FALSE(provider.build_request_body(messages(), {}, false, false, &no_key).contains("prompt_cache_key")); +} + +TEST(OpenAiPromptCache, DowngradePreservesTheFinalUpstreamFailure) { + for (const bool stream : {false, true}) { + RequestRecorder recorder; + const std::string final_body = R"({"error":{"message":"credential expired","code":"invalid_api_key"}})"; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/chat/completions", [&](const httplib::Request& request, httplib::Response& response) { + const auto index = recorder.record(request); + response.status = index == 1 ? 400 : 401; + response.set_header("x-request-id", index == 1 ? "first-rejection" : "final-failure"); + response.set_content(index == 1 ? rejection : final_body, "application/json"); + }); + }); + LocalCapableProvider provider(server.url(), "", "model"); + ProviderErrorInfo error; + const ChatRequestOptions options{"session-1", false}; + std::atomic abort{false}; + if (stream) { + provider.chat_stream_with_options(messages(), {}, options, [&](const StreamEvent& event) { + if (event.type == StreamEventType::Error) error = event.provider_error; + if (event.type == StreamEventType::Retry) abort = true; + }, &abort); + } else { + error = provider.chat_with_options(messages(), {}, options, &abort).provider_error; + } + EXPECT_EQ(recorder.snapshot().size(), 2u); + EXPECT_EQ(error.status_code, 401); + EXPECT_EQ(error.request_id, "final-failure"); + EXPECT_EQ(error.raw_body, final_body); + EXPECT_FALSE(abort.load()); + } +} + +} // namespace diff --git a/tests/session/compact_checkpoint_test.cpp b/tests/session/compact_checkpoint_test.cpp index 9dcb5162..c4a35480 100644 --- a/tests/session/compact_checkpoint_test.cpp +++ b/tests/session/compact_checkpoint_test.cpp @@ -1,8 +1,18 @@ #include #include "session/compact_checkpoint.hpp" +#include "llm/message_predicates.hpp" +#include "session/request_context_record.hpp" +#include "session/session_history_page.hpp" +#include "session/session_manager.hpp" +#include "session/session_recent_activity.hpp" +#include "session/session_rewind.hpp" +#include "session/session_serializer.hpp" +#include "session/session_user_message_search.hpp" +#include "utils/uuid.hpp" #include +#include #include #include #include @@ -33,6 +43,40 @@ acecode::ChatMessage malformed_checkpoint() { return m; } +acecode::ChatMessage request_context(const char* subtype, std::string content) { + auto m = msg("user", std::move(content)); + m.is_meta = true; + m.subtype = subtype; + m.uuid = acecode::generate_uuid(); + m.metadata = {{"request_context_version", 1}, + {"skills", "unchanged skill index"}, + {"context_state", {{"session", "project instructions"}}}}; + return m; +} + +struct RequestContextSession { + const std::filesystem::path cwd = std::filesystem::temp_directory_path() / + ("acecode_context_checkpoint_" + acecode::generate_uuid()); + std::string project_dir; + const std::string session_id = acecode::SessionStorage::generate_session_id(); + acecode::SessionManager session; + + RequestContextSession() { + std::filesystem::create_directories(cwd); + session.start_session(cwd.string(), "stub", "stub-model", session_id); + // Windows resolves drive aliases/junctions only after cwd exists. Use + // the same resolved project identity as the manager that writes rows. + project_dir = session.current_project_dir(); + } + + ~RequestContextSession() { + session.end_current_session(); + std::error_code ec; + std::filesystem::remove_all(project_dir, ec); + std::filesystem::remove_all(cwd, ec); + } +}; + } // namespace TEST(CompactCheckpoint, RoundTripsReplacementHistory) { @@ -137,6 +181,218 @@ TEST(CompactCheckpoint, ProviderRelevantMessagesFiltersHiddenRows) { EXPECT_EQ(filtered[1].content, "visible assistant"); } +TEST(CompactCheckpoint, PreservesOnlySupportedRequestContextRecords) { + const auto snapshot = request_context(acecode::kRequestContextSnapshot, "frozen snapshot"); + const auto update = request_context(acecode::kRequestContextUpdate, "appended update"); + auto unknown = snapshot; + unknown.subtype = "unrelated_meta"; + auto future_version = snapshot; + future_version.metadata["request_context_version"] = 2; + auto unversioned = snapshot; + unversioned.metadata.erase("request_context_version"); + auto invalid_version = snapshot; + invalid_version.metadata["request_context_version"] = "1"; + auto wrong_role = snapshot; + wrong_role.role = "system"; + auto transcript = snapshot; + transcript.metadata["transcript_only"] = true; + auto malformed_metadata = snapshot; + malformed_metadata.metadata = nlohmann::json::array({1}); + + const auto effective = acecode::reconstruct_effective_model_history({ + snapshot, unknown, future_version, unversioned, invalid_version, + wrong_role, transcript, malformed_metadata, msg("user", "visible"), update, + }); + ASSERT_EQ(effective.size(), 3u); + EXPECT_EQ(acecode::serialize_message(effective[0]), acecode::serialize_message(snapshot)); + EXPECT_EQ(effective[1].content, "visible"); + EXPECT_EQ(acecode::serialize_message(effective[2]), acecode::serialize_message(update)); +} + +TEST(CompactCheckpoint, MalformedRequestContextFieldsStayExcludedFromModelHistory) { + const std::vector invalid_fields = { + {{"request_context_version", 1.0}}, + {{"context_state", nlohmann::json::array()}}, + {{"context_state", {{"session", nlohmann::json::object()}}}}, + {{"skills", nlohmann::json::array()}}, + {{"project_rules_bytes", -1}}, + {{"project_rules_bytes", 1.5}}, + {{"project_rules_bytes", "12"}}, + {{"memory_active", "false"}}, + {{"transcript_only", "false"}}, + }; + for (const char* subtype : {acecode::kRequestContextSnapshot, acecode::kRequestContextUpdate}) { + for (const auto& fields : invalid_fields) { + SCOPED_TRACE(fields.dump()); + auto record = request_context(subtype, "untrusted malformed context"); + record.metadata.update(fields); + EXPECT_FALSE(acecode::is_request_context_record(record)); + const auto reloaded = acecode::deserialize_message(acecode::serialize_message(record)); + const auto effective = acecode::reconstruct_effective_model_history( + {reloaded, msg("user", "visible task")}); + ASSERT_EQ(effective.size(), 1u); + EXPECT_EQ(effective.front().content, "visible task"); + } + } +} + +TEST(CompactCheckpoint, CheckedContextAppendReportsFailureAndCanRetryWithoutPhantomRows) { + RequestContextSession fixture; + auto user = msg("user", "visible task"); + ASSERT_TRUE(fixture.session.try_on_message(user)); + const auto path = std::filesystem::path(acecode::SessionStorage::session_path( + fixture.project_dir, fixture.session_id)); + const auto saved_path = path.string() + ".saved"; + // Only this fixture's generated transcript is moved. A directory in its + // place gives a deterministic append failure on Windows and Unix alike. + std::filesystem::rename(path, saved_path); + ASSERT_TRUE(std::filesystem::create_directory(path)); + const auto record = request_context(acecode::kRequestContextSnapshot, "durable context"); + EXPECT_FALSE(fixture.session.try_on_message(record)); + EXPECT_EQ(fixture.session.last_error(), "failed to append session message"); + EXPECT_EQ(fixture.session.load_session_meta(fixture.session_id).message_count, 1); + EXPECT_EQ(fixture.session.display_snapshot().turn_count, 1); + + ASSERT_TRUE(std::filesystem::remove(path)); + std::filesystem::rename(saved_path, path); + ASSERT_TRUE(fixture.session.try_on_message(record)); + EXPECT_TRUE(fixture.session.last_error().empty()); + const auto persisted = fixture.session.load_active_messages(); + ASSERT_EQ(persisted.size(), 2u); + EXPECT_EQ(persisted.front().content, user.content); + EXPECT_EQ(acecode::serialize_message(persisted.back()), acecode::serialize_message(record)); + EXPECT_EQ(fixture.session.load_session_meta(fixture.session_id).message_count, 2); + EXPECT_EQ(fixture.session.display_snapshot().turn_count, 1); +} + +TEST(CompactCheckpoint, RequestContextIsNeitherVisibleInputNorARewindTarget) { + const auto snapshot = request_context(acecode::kRequestContextSnapshot, "frozen snapshot"); + const auto update = request_context(acecode::kRequestContextUpdate, "appended update"); + auto user = msg("user", "real user input"); + user.uuid = "real-user"; + user.timestamp = "2026-10-07T08:00:00Z"; + for (const auto& hidden : {snapshot, update}) { + EXPECT_FALSE(acecode::is_real_user_message(hidden)); + EXPECT_FALSE(acecode::is_searchable_visible_user_message(hidden)); + EXPECT_FALSE(acecode::is_recent_user_message(hidden)); + EXPECT_FALSE(acecode::is_visible_paged_history_message(hidden)); + EXPECT_FALSE(acecode::is_rewind_selectable_user_message(hidden)); + } + const std::vector history = {snapshot, user, update}; + const auto targets = acecode::collect_rewind_targets(history); + ASSERT_EQ(targets.size(), 1u); + EXPECT_EQ(targets[0].message_uuid, user.uuid); + EXPECT_EQ(targets[0].message_index, 1u); + EXPECT_EQ(acecode::recent_activity_from_messages(history).last_user_message_at, + user.timestamp); + EXPECT_EQ(acecode::resolve_fork_anchor_index(history, 2), 1u); +} + +TEST(CompactCheckpoint, SessionResumePreservesContextBytesAndVisibleTurnState) { + RequestContextSession fixture; + const auto snapshot = request_context(acecode::kRequestContextSnapshot, "frozen\n context bytes"); + auto user = msg("user", "visible task"); + user.uuid = "visible-task-id"; + user.timestamp = "2026-10-07T08:00:00Z"; + auto update = request_context(acecode::kRequestContextUpdate, "new project instruction"); + update.timestamp = "2026-10-07T09:00:00Z"; + fixture.session.on_message(snapshot); + fixture.session.on_message(user); + fixture.session.on_message(update); + EXPECT_EQ(fixture.session.display_snapshot().turn_count, 1); + EXPECT_EQ(fixture.session.display_snapshot().last_user_message_at, user.timestamp); + fixture.session.end_current_session(); + + acecode::SessionManager resumed; + resumed.start_session(fixture.cwd.string(), "stub", "stub-model"); + const auto raw = resumed.resume_session(fixture.session_id, true); + ASSERT_TRUE(resumed.last_error().empty()) << resumed.last_error(); + const auto effective = acecode::reconstruct_effective_model_history(raw); + ASSERT_EQ(effective.size(), 3u); + EXPECT_EQ(acecode::serialize_message(effective[0]), acecode::serialize_message(snapshot)); + EXPECT_EQ(effective[1].uuid, user.uuid); + EXPECT_EQ(acecode::serialize_message(effective[2]), acecode::serialize_message(update)); + EXPECT_EQ(resumed.display_snapshot().turn_count, 1); + EXPECT_EQ(resumed.display_snapshot().last_user_message_at, user.timestamp); + EXPECT_EQ(resumed.load_session_meta(fixture.session_id).summary, "visible task"); + + const auto path = acecode::SessionStorage::session_path(fixture.project_dir, fixture.session_id); + ASSERT_TRUE(std::filesystem::is_regular_file(path)); + const auto page = acecode::load_session_history_page(path, {}); + ASSERT_EQ(page.messages.size(), 1u); + EXPECT_EQ(page.messages.front().message.uuid, user.uuid); +} + +TEST(CompactCheckpoint, CheckpointResumeKeepsFreshContextAndAppendedUpdate) { + RequestContextSession fixture; + const auto old_snapshot = request_context(acecode::kRequestContextSnapshot, "old snapshot"); + const auto fresh = request_context(acecode::kRequestContextSnapshot, "fresh snapshot and todos"); + auto summary = msg("user", "compact summary"); + summary.is_compact_summary = true; + auto user = msg("user", "retained task"); + user.uuid = "retained-user"; + const auto update = request_context(acecode::kRequestContextUpdate, "after compact update"); + auto unknown = request_context("unrelated_meta", "must remain hidden from model"); + fixture.session.on_message(old_snapshot); + fixture.session.on_message(user); + acecode::CompactCheckpoint checkpoint; + checkpoint.replacement_history = {fresh, unknown, user, summary}; + ASSERT_TRUE(fixture.session.append_compact_checkpoint(checkpoint)); + fixture.session.on_message(update); + fixture.session.end_current_session(); + + acecode::SessionManager resumed; + resumed.start_session(fixture.cwd.string(), "stub", "stub-model"); + const auto raw = resumed.resume_session(fixture.session_id, true); + ASSERT_TRUE(resumed.last_error().empty()) << resumed.last_error(); + const auto effective = acecode::reconstruct_effective_model_history(raw); + ASSERT_EQ(effective.size(), 4u); + EXPECT_EQ(acecode::serialize_message(effective[0]), acecode::serialize_message(fresh)); + EXPECT_EQ(effective[1].uuid, user.uuid); + EXPECT_TRUE(effective[2].is_compact_summary); + EXPECT_EQ(acecode::serialize_message(effective[3]), acecode::serialize_message(update)); + EXPECT_EQ(resumed.display_snapshot().turn_count, 1); +} + +TEST(CompactCheckpoint, RewindAndForkPreserveRetainedRequestContextOnly) { + RequestContextSession fixture; + const auto snapshot = request_context(acecode::kRequestContextSnapshot, "frozen snapshot"); + auto first = msg("user", "first task"); + first.uuid = "first-user"; + const auto update = request_context(acecode::kRequestContextUpdate, "context before second task"); + auto second = msg("user", "second task"); + second.uuid = "second-user"; + const auto later = request_context(acecode::kRequestContextUpdate, "context after second task"); + for (const auto& message : {snapshot, first, update, second, later}) { + fixture.session.on_message(message); + } + const auto prefix = acecode::retained_prefix_before_index( + fixture.session.load_active_messages(), 3); + ASSERT_EQ(prefix.size(), 3u); + ASSERT_TRUE(fixture.session.replace_active_messages(prefix)); + EXPECT_EQ(fixture.session.display_snapshot().turn_count, 1); + const auto fork_id = fixture.session.fork_session_to_new_id( + prefix, "fork", fixture.session_id, first.uuid); + ASSERT_FALSE(fork_id.empty()); + const auto fork_path = acecode::SessionStorage::session_path(fixture.project_dir, fork_id); + ASSERT_TRUE(std::filesystem::is_regular_file(fork_path)); + const auto forked = acecode::reconstruct_effective_model_history( + acecode::SessionStorage::load_messages(fork_path)); + ASSERT_EQ(forked.size(), 3u); + EXPECT_EQ(acecode::serialize_message(forked[0]), acecode::serialize_message(snapshot)); + EXPECT_EQ(forked[1].uuid, first.uuid); + EXPECT_EQ(acecode::serialize_message(forked[2]), acecode::serialize_message(update)); + EXPECT_EQ(fixture.session.load_session_meta(fork_id).turn_count, 1); + + ASSERT_FALSE(fixture.session.fork_active_session(prefix).empty()); + const auto active_fork = acecode::reconstruct_effective_model_history( + fixture.session.load_active_messages()); + ASSERT_EQ(active_fork.size(), 3u); + EXPECT_EQ(acecode::serialize_message(active_fork[0]), acecode::serialize_message(snapshot)); + EXPECT_EQ(acecode::serialize_message(active_fork[2]), acecode::serialize_message(update)); + EXPECT_EQ(fixture.session.display_snapshot().turn_count, 1); +} + TEST(CompactCheckpoint, ReconstructsFromLatestCheckpointAndSuffix) { acecode::CompactCheckpoint first; first.trigger = "manual"; diff --git a/tests/session/session_history_page_test.cpp b/tests/session/session_history_page_test.cpp index 5923d67d..60ea09f7 100644 --- a/tests/session/session_history_page_test.cpp +++ b/tests/session/session_history_page_test.cpp @@ -1,6 +1,7 @@ #include "session/session_history_page.hpp" #include "session/session_load_metrics.hpp" #include "session/session_serializer.hpp" +#include "session/request_context_record.hpp" #include "utils/uuid.hpp" #include #include @@ -78,6 +79,40 @@ TEST(SessionHistoryPage, TailAndBeforePagesExactlyReconstructVisibleHistory) { } } +TEST(SessionHistoryPage, FutureAndDamagedRequestContextRecordsNeverConsumeVisiblePageSlots) { + HistoryFixture fixture; + auto future = message("user", "future internal request context"); + future.is_meta = true; + future.subtype = acecode::kRequestContextSnapshot; + future.metadata = {{"request_context_version", 2}}; + auto damaged = message("user", "damaged internal request context"); + damaged.is_meta = true; + damaged.subtype = acecode::kRequestContextUpdate; + damaged.metadata = {{"request_context_version", 1}, {"skills", nlohmann::json::array()}}; + for (const auto& hidden : {future, damaged}) { + EXPECT_TRUE(acecode::is_request_context_metadata(hidden)); + EXPECT_FALSE(acecode::is_request_context_record(hidden)); + EXPECT_FALSE(acecode::is_visible_paged_history_message(hidden)); + } + fixture.write({future, message("user", "visible task"), damaged, future}); + acecode::SessionHistoryRequest request; + request.limit = 1; + const auto tail = acecode::load_session_history_page(fixture.path.string(), request); + ASSERT_EQ(tail.messages.size(), 1u); + EXPECT_EQ(tail.messages.front().message.content, "visible task"); + EXPECT_FALSE(tail.has_more); + + request.after = acecode::decode_history_cursor(tail.after); + ASSERT_TRUE(request.after.has_value()); + ASSERT_TRUE(acecode::SessionStorage::append_message(fixture.path.string(), damaged)); + EXPECT_TRUE(acecode::load_session_history_page(fixture.path.string(), request).messages.empty()); + ASSERT_TRUE(acecode::SessionStorage::append_message( + fixture.path.string(), message("assistant", "visible answer"))); + const auto next = acecode::load_session_history_page(fixture.path.string(), request); + ASSERT_EQ(next.messages.size(), 1u); + EXPECT_EQ(next.messages.front().message.content, "visible answer"); +} + TEST(SessionHistoryPage, CursorSurvivesAppendButNotRewriteEvenWhenAnchorIsUnchanged) { HistoryFixture fixture; const std::vector original{message("user", "one"), message("user", "two"), message("user", "three")}; diff --git a/tests/session_host/mesh_agent_service_test.cpp b/tests/session_host/mesh_agent_service_test.cpp index 1799d627..d406536f 100644 --- a/tests/session_host/mesh_agent_service_test.cpp +++ b/tests/session_host/mesh_agent_service_test.cpp @@ -412,6 +412,37 @@ TEST_F(MeshAgentServiceTest, SpawnValidatesCallerAndArguments) { EXPECT_EQ(registry->size(), sessions_before + 1); } +TEST_F(MeshAgentServiceTest, SpawnInheritsCurrentParentModelAndPreservesExplicitOverride) { + acecode::ModelProfile explicit_model; + explicit_model.name = "explicit-child"; + explicit_model.provider = "openai"; + explicit_model.model = "explicit-child-model"; + config.saved_models.push_back(explicit_model); + start_service(); + create_root(); + auto root = registry->acquire(root_id); + ASSERT_TRUE(root); + ASSERT_TRUE(root->sm->set_active_provider( + provider->name(), provider->model(), "parent-current")); + + const auto inherited = spawn(root_id, "inherited", "use the current parent model"); + ASSERT_EQ(inherited.error, ""); + auto inherited_child = registry->acquire(inherited.session_id); + ASSERT_TRUE(inherited_child); + EXPECT_EQ(inherited_child->sm->current_model_preset(), "parent-current"); + + acecode::mesh::SpawnArgs args; + args.task_name = "overridden"; + args.message = "use the explicit model"; + args.model = "explicit-child"; + const auto overridden = service->spawn(ctx_for(root_id), args); + ASSERT_EQ(overridden.error, ""); + auto overridden_child = registry->acquire(overridden.session_id); + ASSERT_TRUE(overridden_child); + EXPECT_EQ(overridden_child->sm->current_model_preset(), "explicit-child"); + EXPECT_EQ(root->sm->current_model_preset(), "parent-current"); +} + // 场景:并发上限 3(根占 1 个名额,子 agent 最多驻留 2 个),依次派出 a、b、c。 // 期望:派 c 时换出最久没有活动且已完成的 a(Codex V2Residency 的 LRU);a 不再出现在 // agent_list 里,但树里仍有记录;对 a 发 followup_task 会从磁盘恢复它(这次换出 b), diff --git a/tests/session_host/session_registry_test.cpp b/tests/session_host/session_registry_test.cpp index 88a829a0..e22db80f 100644 --- a/tests/session_host/session_registry_test.cpp +++ b/tests/session_host/session_registry_test.cpp @@ -20,6 +20,7 @@ #include "hooks/hook_runtime.hpp" #include "prompt/system_prompt.hpp" #include "session/compact_checkpoint.hpp" +#include "session/request_context_record.hpp" #include "session_host/local_session_client.hpp" #include "session/session_manager.hpp" #include "session_host/session_registry.hpp" @@ -2679,6 +2680,49 @@ TEST(SessionRegistry, ResumeDiskSessionUsesCompactCheckpointForLoopHistory) { std::filesystem::remove_all(cwd); } +TEST(SessionRegistry, ResumeSkipsInvalidInternalRequestMetadataBeforeTypedReads) { + const auto cwd = temp_cwd("resume_context_metadata"); + const auto project_dir = SessionStorage::get_project_dir(cwd.string()); + acecode::ScopeExit cleanup([cwd, project_dir] { + std::error_code ec; + std::filesystem::remove_all(project_dir, ec); + std::filesystem::remove_all(cwd, ec); + }); + const auto id = SessionStorage::generate_session_id(); + auto snapshot = registry_msg("user", "valid internal context"); + snapshot.is_meta = true; + snapshot.subtype = acecode::kRequestContextSnapshot; + snapshot.metadata = {{"request_context_version", 1}}; + auto damaged = snapshot; + damaged.subtype = acecode::kRequestContextUpdate; + damaged.metadata["transcript_only"] = "false"; + auto future = snapshot; + future.metadata["request_context_version"] = 2; + const auto user = registry_msg("user", "visible task"); + { + acecode::SessionManager writer; + writer.start_session(cwd.string(), "stub", "stub-model", id); + writer.on_message(user); + acecode::CompactCheckpoint checkpoint; + checkpoint.replacement_history = {snapshot, user, damaged}; + ASSERT_TRUE(writer.append_compact_checkpoint(checkpoint)); + writer.on_message(future); + writer.finalize(); + } + TestFixture fixture; + SessionOptions options; + options.cwd = cwd.string(); + bool resumed = false; + ASSERT_NO_THROW(resumed = fixture.registry.resume(id, options)); + ASSERT_TRUE(resumed); + const auto entry = fixture.registry.acquire(id); + ASSERT_TRUE(entry); + ASSERT_EQ(entry->loop->messages().size(), 2u); + EXPECT_TRUE(acecode::is_request_context_snapshot(entry->loop->messages()[0])); + EXPECT_EQ(entry->loop->messages()[1].content, user.content); + EXPECT_EQ(entry->sm->load_active_messages().size(), 3u); +} + // 场景: daemon resume 历史会话时,若 meta.model_preset 指向已删除 saved model, // registry 应创建悬空 model state,并且不构造旧 provider。 TEST(SessionRegistry, ResumeDiskSessionMarksDeletedSavedModelPreset) { diff --git a/tests/session_host/spawn_subagent_tool_test.cpp b/tests/session_host/spawn_subagent_tool_test.cpp index 28eb34be..9a6b7625 100644 --- a/tests/session_host/spawn_subagent_tool_test.cpp +++ b/tests/session_host/spawn_subagent_tool_test.cpp @@ -137,11 +137,11 @@ struct SubagentFixture { acecode::LocalSessionClient client; std::shared_ptr deps; - SubagentFixture() + explicit SubagentFixture(bool resolve_saved_models = false) : cwd(fs::temp_directory_path() / ("acecode_subagent_test_" + std::to_string(std::random_device{}()))), experts(cwd / "global-experts"), - registry(make_deps(*this)), client(registry) { + registry(make_deps(*this, resolve_saved_models)), client(registry) { fs::create_directories(cwd); deps = std::make_shared(); deps->registry = ®istry; @@ -156,16 +156,31 @@ struct SubagentFixture { fs::remove_all(cwd, ec); } - static acecode::SessionRegistryDeps make_deps(SubagentFixture& self) { + static acecode::SessionRegistryDeps make_deps(SubagentFixture& self, + bool resolve_saved_models) { acecode::SessionRegistryDeps d; d.provider_accessor = [&self] { return self.provider; }; d.tools = &self.tools; d.cwd = "/tmp/subagent_test_registry"; d.expert_registry = &self.experts; d.template_permissions = &self.permissions; + if (resolve_saved_models) d.config = &self.config; return d; } + void configure_models() { + config.default_model_name = "daemon-default"; + for (const char* name : {"daemon-default", "parent-current", "explicit-child"}) { + acecode::ModelProfile profile; + profile.name = name; + profile.provider = "openai"; + profile.model = std::string(name) + "-model"; + profile.base_url = "http://127.0.0.1:9/v1"; + profile.api_key = "test-key"; + config.saved_models.push_back(std::move(profile)); + } + } + acecode::ToolContext ctx_for(const std::string& session_id) { acecode::ToolContext ctx; ctx.cwd = cwd.string(); @@ -218,6 +233,110 @@ TEST(SpawnSubagentTool, FireAndForgetCreatesIsolatedSession) { fx.registry.destroy(child_id); } +// Exercise the actual saved-model resolver with a global default that differs +// from the parent. RecordingSessionClient prevents any provider request. +TEST(SpawnSubagentTool, UnspecifiedModelUsesParentsCurrentSelectionAfterSwitch) { + SubagentFixture fx(true); + fx.configure_models(); + RecordingSessionClient recording_client; + fx.deps->client = &recording_client; + acecode::SessionOptions parent_options; + parent_options.cwd = fx.cwd.string(); + parent_options.swarm_mode = "star"; + const auto parent_id = fx.registry.create(parent_options); + const auto initial = fx.registry.current_model_state(parent_id); + ASSERT_TRUE(initial.has_value()); + EXPECT_EQ(initial->name, "daemon-default"); + + std::string error; + ASSERT_TRUE(fx.registry.switch_model( + parent_id, fx.config.saved_models[1], nullptr, &error)) << error; + for (const char* arguments : { + R"({"prompt":"inherit","wait":false})", + R"({"prompt":"inherit empty","wait":false,"model":""})"}) { + const auto result = fx.tools.execute( + "spawn_subagent", arguments, fx.ctx_for(parent_id)); + ASSERT_TRUE(result.success) << result.output; + const auto child_id = result.metadata["subagent_session_id"].get(); + const auto state = fx.registry.current_model_state(child_id); + ASSERT_TRUE(state.has_value()); + EXPECT_EQ(state->name, "parent-current"); + EXPECT_EQ(state->provider, "openai"); + EXPECT_EQ(state->model, "parent-current-model"); + fx.registry.destroy(child_id); + } + fx.registry.destroy(parent_id); +} + +TEST(SpawnSubagentTool, ExplicitModelOverridesParentsCurrentSelection) { + SubagentFixture fx(true); + fx.configure_models(); + RecordingSessionClient recording_client; + fx.deps->client = &recording_client; + acecode::SessionOptions parent_options; + parent_options.cwd = fx.cwd.string(); + parent_options.model_name = "parent-current"; + const auto parent_id = fx.registry.create(parent_options); + const auto result = fx.tools.execute( + "spawn_subagent", + R"({"prompt":"override","wait":false,"model":"explicit-child"})", + fx.ctx_for(parent_id)); + ASSERT_TRUE(result.success) << result.output; + const auto child_id = result.metadata["subagent_session_id"].get(); + const auto state = fx.registry.current_model_state(child_id); + ASSERT_TRUE(state.has_value()); + EXPECT_EQ(state->name, "explicit-child"); + EXPECT_EQ(state->model, "explicit-child-model"); + fx.registry.destroy(child_id); + fx.registry.destroy(parent_id); +} + +TEST(SpawnSubagentTool, InheritsModelFromTuiParentOutsideRegistry) { + SubagentFixture fx(true); + fx.configure_models(); + RecordingSessionClient recording_client; + fx.deps->client = &recording_client; + acecode::SessionManager parent; + parent.start_session(fx.cwd.string(), "openai", "parent-current-model", + "", "parent-current", "tui"); + const auto parent_id = parent.ensure_active_session_id(); + ASSERT_FALSE(parent_id.empty()); + EXPECT_EQ(fx.registry.acquire(parent_id), nullptr); + auto ctx = fx.ctx_for(""); + ctx.session_manager = &parent; + const auto result = fx.tools.execute( + "spawn_subagent", R"({"prompt":"external parent","wait":false})", ctx); + ASSERT_TRUE(result.success) << result.output; + const auto child_id = result.metadata["subagent_session_id"].get(); + const auto state = fx.registry.current_model_state(child_id); + ASSERT_TRUE(state.has_value()); + EXPECT_EQ(state->name, "parent-current"); + EXPECT_EQ(state->model, "parent-current-model"); + fx.registry.destroy(child_id); +} + +TEST(SpawnSubagentTool, MissingParentModelRetainsConfiguredDefault) { + SubagentFixture fx(true); + fx.configure_models(); + RecordingSessionClient recording_client; + fx.deps->client = &recording_client; + acecode::SessionManager legacy_parent; + legacy_parent.start_session(fx.cwd.string(), "openai", "legacy-model"); + for (auto* parent : {static_cast(nullptr), &legacy_parent}) { + auto ctx = fx.ctx_for(""); + ctx.session_manager = parent; + const auto result = fx.tools.execute( + "spawn_subagent", R"({"prompt":"fallback","wait":false})", ctx); + ASSERT_TRUE(result.success) << result.output; + const auto child_id = result.metadata["subagent_session_id"].get(); + const auto state = fx.registry.current_model_state(child_id); + ASSERT_TRUE(state.has_value()); + EXPECT_EQ(state->name, "daemon-default"); + EXPECT_EQ(state->model, "daemon-default-model"); + fx.registry.destroy(child_id); + } +} + // 场景: daemon 要在子会话首条输入入队前安装 tracking 监听器,否则 child // worker 可能抢先 emit busy=true / permission_request,主会话 UI 仍然发现不了 // 这个后台任务。用 fake SessionClient 确定性验证 on_spawn 早于 send_input。 @@ -339,6 +458,7 @@ TEST(SpawnSubagentTool, TeamLeadCanSpawnOnlyDeclaredExpertMember) { acecode::SessionOptions parent_options; parent_options.cwd = fx.cwd.string(); parent_options.expert_id = team.id; + parent_options.model_name = "team-current"; const std::string parent_id = fx.registry.create(parent_options); const std::size_t before = fx.registry.size(); @@ -363,6 +483,7 @@ TEST(SpawnSubagentTool, TeamLeadCanSpawnOnlyDeclaredExpertMember) { ASSERT_NE(child, nullptr); EXPECT_EQ(child->expert_id, team.id); EXPECT_EQ(child->expert_member_id, "tester"); + EXPECT_EQ(child->sm->current_model_preset(), "team-current"); ASSERT_TRUE(child->expert.has_value()); ASSERT_NE(child->expert->selected_agent("tester"), nullptr); EXPECT_EQ(child->expert->selected_agent("tester")->instructions, "Test the work."); diff --git a/tests/tui/resume/session_replay_test.cpp b/tests/tui/resume/session_replay_test.cpp index d7e948f0..9a8ea319 100644 --- a/tests/tui/resume/session_replay_test.cpp +++ b/tests/tui/resume/session_replay_test.cpp @@ -10,6 +10,7 @@ #include "tui/resume/session_replay.hpp" #include "session/compact_notice.hpp" #include "session/file_checkpoint_store.hpp" +#include "session/request_context_record.hpp" #include "session/tool_metadata_codec.hpp" #include "session/turn_net_diff.hpp" #include "tool/ask_user_question_tool.hpp" @@ -94,6 +95,43 @@ TEST(SessionReplay, UserPassthrough) { EXPECT_FALSE(out[0].is_tool); } +TEST(SessionReplay, RequestContextSnapshotsAndUpdatesStayHidden) { + ChatMessage snapshot; + snapshot.role = "user"; + snapshot.content = "internal frozen project instructions"; + snapshot.subtype = acecode::kRequestContextSnapshot; + snapshot.is_meta = true; + snapshot.metadata = {{"request_context_version", 1}}; + auto update = snapshot; + update.subtype = acecode::kRequestContextUpdate; + update.content = "internal appended context change"; + auto future = snapshot; + future.metadata["request_context_version"] = 2; + auto damaged = update; + damaged.metadata["skills"] = nlohmann::json::array(); + auto wrong_role = snapshot; + wrong_role.role = "system"; + for (const auto& hidden : {future, damaged, wrong_role}) { + EXPECT_TRUE(acecode::is_request_context_metadata(hidden)); + EXPECT_FALSE(acecode::is_request_context_record(hidden)); + } + ChatMessage user; + user.role = "user"; + user.content = "visible task"; + ChatMessage assistant; + assistant.role = "assistant"; + assistant.content = "visible answer"; + + ToolExecutor tools; + const auto out = replay_session_messages( + {snapshot, future, user, update, damaged, wrong_role, assistant}, tools); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0].role, "user"); + EXPECT_EQ(out[0].content, user.content); + EXPECT_EQ(out[1].role, "assistant"); + EXPECT_EQ(out[1].content, assistant.content); +} + // system 消息不变换。 TEST(SessionReplay, SystemPassthrough) { ChatMessage m; diff --git a/tests/tui/resume/session_resume_restore_test.cpp b/tests/tui/resume/session_resume_restore_test.cpp index abcaef37..d7d6f113 100644 --- a/tests/tui/resume/session_resume_restore_test.cpp +++ b/tests/tui/resume/session_resume_restore_test.cpp @@ -8,6 +8,7 @@ #include "agent/agent_loop.hpp" #include "permissions/permissions.hpp" #include "session/compact_checkpoint.hpp" +#include "session/request_context_record.hpp" #include "tui/resume/session_resume_restore.hpp" #include "tool/mtime_tracker.hpp" #include "tool/tool_executor.hpp" @@ -245,6 +246,40 @@ TEST(SessionResumeRestore, CompactCheckpointKeepsFullTuiTranscriptButRestoresEff EXPECT_EQ(h.loop_.messages()[1].content, "new prompt"); } +TEST(SessionResumeRestore, InvalidInternalRequestMetadataIsSkippedBeforeTypedReads) { + acecode::ChatMessage snapshot; + snapshot.role = "user"; + snapshot.is_meta = true; + snapshot.subtype = acecode::kRequestContextSnapshot; + snapshot.content = "valid internal context"; + snapshot.metadata = {{"request_context_version", 1}}; + auto damaged = snapshot; + damaged.subtype = acecode::kRequestContextUpdate; + damaged.metadata["transcript_only"] = "false"; + auto future = snapshot; + future.metadata["request_context_version"] = 2; + acecode::ChatMessage user; + user.role = "user"; + user.content = "visible task"; + for (const bool checkpointed : {false, true}) { + SCOPED_TRACE(checkpointed); + ResumeRestoreHarness h; + std::vector records{snapshot, user, damaged, future}; + if (checkpointed) { + acecode::CompactCheckpoint checkpoint; + checkpoint.replacement_history = records; + records = {user, acecode::encode_compact_checkpoint(checkpoint), damaged, future}; + } + ASSERT_NO_THROW(acecode::append_resumed_session_messages( + records, h.state, h.loop_, h.tools_)); + ASSERT_EQ(h.loop_.messages().size(), 2u); + EXPECT_TRUE(acecode::is_request_context_snapshot(h.loop_.messages()[0])); + EXPECT_EQ(h.loop_.messages()[1].content, user.content); + ASSERT_EQ(h.state.conversation.size(), 1u); + EXPECT_EQ(h.state.conversation[0].content, user.content); + } +} + TEST(SessionResumeRestore, RestoresFullFileReadBaselineFromTranscript) { ResumeRestoreHarness h; auto p = make_temp_file("alpha\nbeta\n"); diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index a273c626..1759b03f 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -38,6 +38,7 @@ #include "loop/loop_store.hpp" #include "prompt/system_prompt.hpp" #include "llm/tool_protocol_names.hpp" +#include "llm/message_predicates.hpp" #include "tool/tool_rewrites.hpp" #include "provider/cwd_model_override.hpp" #include "provider/models_dev_registry.hpp" @@ -3425,7 +3426,7 @@ TEST(WebServerHttp, SessionSlashExpansionHonorsExplicitExpertSkillScope) { while (std::chrono::steady_clock::now() < deadline) { std::size_t count = 0; for (const auto& message : entry->loop->messages()) { - if (message.role == "user") ++count; + if (acecode::is_real_user_message(message)) ++count; } if (count >= expected_user_count && !entry->loop->is_busy()) { break; @@ -3436,7 +3437,7 @@ TEST(WebServerHttp, SessionSlashExpansionHonorsExplicitExpertSkillScope) { auto user_messages = [&] { std::vector result; for (const auto& message : entry->loop->messages()) { - if (message.role == "user") result.push_back(message); + if (acecode::is_real_user_message(message)) result.push_back(message); } return result; }; @@ -6787,8 +6788,13 @@ TEST(WebServerHttp, RetryLastUserMessageValidatesRequestAndPreservesInput) { EXPECT_EQ(provider->turn_count(), 1); const auto messages = entry->sm->load_active_messages(); EXPECT_EQ(std::count_if(messages.begin(), messages.end(), [](const auto& message) { - return message.role == "user"; + return acecode::is_real_user_message(message); }), 1); + const auto persisted_user = std::find_if( + messages.begin(), messages.end(), acecode::is_real_user_message); + ASSERT_NE(persisted_user, messages.end()); + EXPECT_EQ(persisted_user->uuid, user.uuid); + EXPECT_EQ(persisted_user->content, user.content); EXPECT_EQ(post(request).status_code, 409); } @@ -12397,7 +12403,8 @@ TEST(WebServerHttp, ComposerContentForkRetainsEarlierUploadsAfterSourceAttachmen namespace { -// 轮询会话里第 expected_count 条 user 消息(AgentLoop 内存历史),等回合结束。 +// Count real user submissions, excluding hidden context snapshots/updates. +// Wait for the expected submission count and the end of the agent turn. std::vector wait_for_user_messages( acecode::SessionEntry& entry, std::size_t expected_count) { std::vector users; @@ -12405,7 +12412,7 @@ std::vector wait_for_user_messages( while (std::chrono::steady_clock::now() < deadline) { users.clear(); for (const auto& message : entry.loop->messages()) { - if (message.role == "user") users.push_back(message); + if (acecode::is_real_user_message(message)) users.push_back(message); } if (users.size() >= expected_count && !entry.loop->is_busy()) break; std::this_thread::sleep_for(10ms); diff --git a/web/public/vs-icons/Keyboard.svg b/web/public/vs-icons/Keyboard.svg new file mode 100644 index 00000000..6707d41d --- /dev/null +++ b/web/public/vs-icons/Keyboard.svg @@ -0,0 +1 @@ + diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index c6ea9d22..6f7e29a1 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,53 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '打开设置': 'Open settings', + '在内置浏览器中打开': 'Open in built-in browser', + '发送消息': 'Send message', + '思考深度 ({{p0}} / {{p1}})': 'Reasoning depth ({{p0}} / {{p1}})', + 'keyboard shortcuts hotkeys 快捷键 键盘 搜索 前进 后退 思维深度 Ctrl Alt Cmd': 'keyboard shortcuts hotkeys search forward back reasoning depth Ctrl Alt Cmd', + '快捷键': 'Keyboard shortcuts', + '键盘快捷键': 'Keyboard shortcuts', + '搜索快捷键': 'Search shortcuts', + '没有找到匹配的快捷键': 'No matching shortcuts', + '会话操作': 'Conversations', + '界面导航': 'Navigation', + '输入与编辑': 'Input and editing', + '在当前工作区新建会话': 'Create a conversation in the current workspace', + '聚焦消息输入框': 'Focus message input', + '回到当前输入框,保留草稿': 'Return to the message input and keep your draft', + '停止当前会话执行': 'Stop the current conversation', + '等同当前会话的停止按钮': 'Same as the current conversation’s Stop button', + '增加思维深度': 'Increase reasoning depth', + '降低思维深度': 'Decrease reasoning depth', + '逐档增加,到最高档停止': 'Increase one level, up to the highest supported depth', + '逐档降低,到最低档停止': 'Decrease one level, down to the lowest supported depth', + '前往导航历史中的下一项': 'Go forward in navigation history', + '返回导航历史中的上一项': 'Go back in navigation history', + '全局搜索': 'Global search', + '搜索会话、工作区和设置': 'Search conversations, workspaces and settings', + '展开/收起左侧项目栏': 'Toggle project sidebar', + '切换项目和会话列表的显示': 'Show or hide the project and conversation list', + '展开/收起右侧面板': 'Toggle right panel', + '切换文件及预览面板的显示': 'Show or hide the file and preview panels', + '展开/收起控制台': 'Toggle console', + '控制台可用时生效': 'Available when the console is enabled', + '打开设置窗口': 'Open the settings window', + '打开快捷键页签': 'Open keyboard shortcuts', + '进入快捷键并聚焦搜索栏': 'Open keyboard shortcuts and focus search', + '当前会话内查找': 'Find in conversation', + '在打开的会话中查找文本': 'Find text in the open conversation', + '在消息输入框中生效': 'Available in the message input', + '输入框换行': 'Insert a new line', + '浏览输入历史': 'Browse input history', + '输入框为空时向上浏览,历史浏览中可向下返回': 'Browse up from an empty input; move down while browsing history', + '保存正在编辑的文件': 'Save the file being edited', + '文件编辑器聚焦时生效': 'Available when the file editor has focus', + '关闭当前浮层/弹窗': 'Close the current popup or dialog', + '优先关闭最上层可关闭的浮层': 'Close the topmost dismissible popup first', + '尚未配置机器人': 'No bot configured', + '配置机器人': 'Configure bot', + '管理,{{p0}} 个请求待批准': 'Manage, {{p0}} requests awaiting approval', '虚拟办公室': 'Virtual Office', '显示虚拟办公室': 'Show Virtual Office', '隐藏虚拟办公室': 'Hide Virtual Office', diff --git a/web/scripts/test-shortcuts-and-channels.mjs b/web/scripts/test-shortcuts-and-channels.mjs new file mode 100644 index 00000000..917d73fb --- /dev/null +++ b/web/scripts/test-shortcuts-and-channels.mjs @@ -0,0 +1,293 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +// Real App/Settings/ChatView; all daemon requests and WebSockets are fixtures. +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = await fs.mkdtemp(path.join(os.tmpdir(), 'ace-shortcuts-channels-')); +const server = await createServer({ root: web, configFile: path.join(web, 'vite.config.js'), + logLevel: 'error', server: { host: '127.0.0.1', port: 0 } }); +const channels = ['qq', 'weixin', 'feishu', 'dingtalk', 'telegram', 'discord', 'line'].map(platform => ({ + platform, configured: false, enabled: false, state: 'disabled', contacts: [], pending: [], bindings: [], +})); +const sessions = []; +const calls = []; +const errors = []; +const checks = []; +const pass = (name) => { checks.push(name); console.log('[pass]', name); }; +const reasoning = { supported: true, default_enabled: true, supported_efforts: ['low', 'high', 'max'], default_effort: 'high' }; +const model = (effort = null) => ({ name: 'fixture', provider: 'openai', model: 'fixture-model', + reasoning: { ...reasoning, effort: effort || 'high' }, reasoning_effort: effort }); +const efforts = {}; +const drafts = {}; +let browser; +let page; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(() => { + window.keyRecords = []; + window.addEventListener('keydown', event => { + if (!event.ctrlKey && !event.metaKey) return; + const row = { code: event.code, key: event.key, alt: event.altKey, shift: event.shiftKey, composing: event.isComposing, + altGraph: event.getModifierState('AltGraph'), target: event.target.tagName, + overlays: [...document.querySelectorAll('[data-ace-native-overlay="blocking"]')].filter(el => el.getClientRects().length).map(el => el.outerHTML.slice(0, 100)) }; + window.keyRecords.push(row); + setTimeout(() => { row.prevented = event.defaultPrevented; }, 0); + }, true); + localStorage.setItem('ace.locale', 'zh-CN'); + class FixtureWebSocket extends EventTarget { + static OPEN = 1; static CLOSED = 3; + constructor() { super(); this.readyState = 1; setTimeout(() => this.dispatchEvent(new Event('open')), 0); } + send() {} close() { this.readyState = 3; } + } + window.WebSocket = FixtureWebSocket; + window.sentFrames = []; + FixtureWebSocket.prototype.send = data => { + try { window.sentFrames.push(JSON.parse(data)); } + catch { window.sentFrames.push({ terminalData: data }); } + }; + }); + await page.route('**/api/**', async route => { + const req = route.request(); + const pathname = new URL(req.url()).pathname; + const body = req.postData() ? req.postDataJSON() : null; + calls.push({ path: pathname, method: req.method(), body }); + const sid = pathname.match(/\/sessions\/([^/]+)/)?.[1]; + let data = {}; + if (pathname === '/api/health') data = { status: 'ok', cwd: 'C:/shortcut-fixture', version: 'fixture', console: { available: true } }; + else if (pathname === '/api/channels') data = { platforms: channels }; + else if (/\/channels\/[^/]+\/enabled$/.test(pathname)) { + const channel = channels.find(item => pathname.includes(`/${item.platform}/`)); + Object.assign(channel, { enabled: body.enabled, state: body.enabled ? 'connected' : 'disabled' }); + data = { platforms: channels }; + } else if (/\/channels\/.*\/requests\//.test(pathname)) { + channels.find(item => pathname.includes(`/${item.platform}/`)).pending = []; + data = { platforms: channels }; + } else if (pathname.endsWith('/sessions') && req.method() === 'POST') { + const session = { id: `shortcut-${sessions.length + 1}`, title: `Shortcut task ${sessions.length + 1}`, + created_at: '2026-10-07T08:00:00Z', cwd: 'C:/shortcut-fixture', model: 'fixture-model', provider: 'openai' }; + sessions.push(session); data = { ...session, session_id: session.id }; + } else if (pathname.endsWith('/sessions')) data = { sessions }; + else if (pathname.endsWith('/draft')) { + if (req.method() === 'PUT') drafts[sid] = body; + data = drafts[sid] || { text: '' }; + } + else if (pathname.endsWith('/reasoning')) { efforts[sid] = body.effort; data = model(body.effort); } + else if (pathname.endsWith('/model')) data = model(efforts[sid]); + else if (pathname.includes('/messages')) data = { messages: [], events: [], busy: false, status: 'idle' }; + else if (pathname.includes('models')) data = { models: [model()], profiles: [model()], default_model: 'fixture' }; + else if (pathname.includes('workspaces')) data = { workspaces: [] }; + else if (pathname.includes('history')) data = { items: [], history: [] }; + else if (pathname.includes('commands')) data = { commands: [] }; + else if (pathname.includes('skills')) data = { skills: [] }; + else if (pathname.includes('experts')) data = { experts: [] }; + else if (pathname.includes('subagents')) data = { tasks: [] }; + else if (pathname.includes('onboarding')) data = { dismissed: true, completed: true }; + else if (pathname.endsWith('/api/pty') && req.method() === 'POST') data = { id: 'fixture-pty' }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); + }); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}`, { waitUntil: 'networkidle' }); + await page.locator('.ace-topbar').waitFor(); + await page.keyboard.press('Control+/'); + const search = page.locator('[data-shortcut-search]'); + await search.waitFor(); + assert.equal(await search.evaluate(el => el === document.activeElement), true); + assert.equal(await page.locator('[data-shortcut-id]').count(), 19); + await search.fill('Ctrl+Shift+-'); + assert.equal(await page.locator('[data-shortcut-id]').count(), 1); + assert.equal(await page.locator('[data-shortcut-id]').getAttribute('data-shortcut-id'), 'back'); + await search.fill('思维'); + assert.equal(await page.locator('[data-shortcut-id]').count(), 2); + await search.fill('missing-shortcut'); + await page.getByText('没有找到匹配的快捷键').waitFor(); + await search.fill(''); + await page.locator('.ace-settings-content').evaluate(el => { el.scrollTop = 500; }); + const sticky = await page.locator('[data-shortcut-filters]').boundingBox(); + const content = await page.locator('.ace-settings-content').boundingBox(); + assert.ok(Math.abs(sticky.y - content.y) <= 2, JSON.stringify({ sticky, content })); + await page.waitForTimeout(250); + await page.screenshot({ path: path.join(output, 'shortcuts-sticky.png') }); + pass('shortcut settings: 19 entries, name/key search, empty state, focus and sticky search'); + + const beforeModal = sessions.length; + await page.keyboard.press('Control+Alt+n'); + assert.equal(sessions.length, beforeModal); + await page.keyboard.press('Escape'); + await search.waitFor({ state: 'detached' }); + await page.keyboard.press('Control+Alt+n'); + await page.waitForFunction(() => document.querySelector('.ace-topbar-session-title')?.textContent.includes('Shortcut task 1')); + await page.locator('[data-main-composer="true"] [contenteditable="true"]').waitFor(); + await page.waitForLoadState('networkidle'); + const composer = page.locator('[data-main-composer="true"] [contenteditable="true"]'); + assert.equal(await composer.evaluate(el => el === document.activeElement), true, 'new conversation focuses input'); + await page.keyboard.press('Control+Alt+i'); + await composer.fill('Keep this draft'); + await page.keyboard.press('Control+Alt+n'); + await page.waitForFunction(() => document.querySelector('.ace-topbar-session-title')?.textContent.includes('Shortcut task 2')); + await page.waitForLoadState('networkidle'); + await page.waitForFunction(() => ![...document.querySelectorAll('[data-ace-native-overlay="blocking"]')].some(el => el.getClientRects().length)); + await page.waitForTimeout(150); + await page.keyboard.press('Control+Shift+-'); + await page.waitForFunction(() => document.querySelector('.ace-topbar-session-title')?.textContent.includes('Shortcut task 1')); + await page.waitForFunction(() => document.querySelector('[data-main-composer="true"] [contenteditable="true"]')?.textContent.includes('Keep this draft')); + await page.keyboard.press('Control+-'); + await page.waitForFunction(() => document.querySelector('.ace-topbar-session-title')?.textContent.includes('Shortcut task 2')); + assert.equal(sessions.length, 2); + await page.waitForLoadState('networkidle'); + const resumed = calls.filter(call => call.path.endsWith('/resume')).length; + await page.keyboard.press('Control+-'); + assert.equal(calls.filter(call => call.path.endsWith('/resume')).length, resumed, 'forward boundary does not resume current session'); + pass('new session, input focus, draft preservation and requested history directions'); + + const side = page.locator('.ace-topbar button[title*="Ctrl+Alt+B"]'); + const sideState = await side.getAttribute('aria-pressed'); + await page.keyboard.press('Control+Alt+b'); + assert.notEqual(await side.getAttribute('aria-pressed'), sideState); + await page.keyboard.press('Control+Alt+b'); + const right = page.locator('.ace-topbar button[title*="Ctrl+Alt+E"]'); + const rightState = await right.getAttribute('aria-pressed'); + await page.keyboard.press('Control+Alt+e'); + assert.notEqual(await right.getAttribute('aria-pressed'), rightState); + await page.keyboard.press('Control+Alt+e'); + pass('left and right panel actions'); + + const selector = page.getByRole('button', { name: /^思考深度:/ }); + await selector.waitFor(); + await page.keyboard.press('Control+Alt+i'); + await page.keyboard.press('Control+Alt+Shift+>'); + await page.waitForFunction(() => document.querySelector('.ace-composer-reasoning-button')?.textContent.includes('最大')); + const reasoningCalls = () => calls.filter(call => call.path.endsWith('/reasoning')); + const ceiling = reasoningCalls().length; + await page.keyboard.press('Control+Alt+.'); + assert.equal(reasoningCalls().length, ceiling); + await page.keyboard.press('Control+Alt+,'); + await page.waitForFunction(() => document.querySelector('.ace-composer-reasoning-button')?.textContent.includes('高')); + await composer.evaluate(el => { + el.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keydown', { code: 'Comma', key: '<', ctrlKey: true, altKey: true, bubbles: true, isComposing: true })); + el.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })); + }); + assert.equal(reasoningCalls().length, ceiling + 1); + await selector.click(); + await page.keyboard.press('Control+Alt+,'); + assert.equal(reasoningCalls().length, ceiling + 1); + await page.keyboard.press('Escape'); + pass('reasoning physical/symbol keys, default depth, upper bound, IME and menu guards'); + + const emit = async (type, payload, session_id) => page.evaluate(async ({ type, payload, session_id }) => { + const { connection } = await import('/src/lib/connection.js'); + connection.dispatchEvent(new CustomEvent('message', { detail: { type, payload, session_id } })); + }, { type, payload, session_id }); + await emit('busy_changed', { busy: true, turn_id: 'active-turn' }, 'shortcut-2'); + await page.keyboard.press('Control+Alt+,'); + await page.waitForFunction(() => document.querySelector('.ace-composer-reasoning-button')?.textContent.includes('低')); + await page.keyboard.press('Control+Shift+.'); + await page.waitForTimeout(100); + assert.equal(await page.evaluate(() => window.sentFrames.filter(frame => frame.type === 'abort').length), 1); + await page.keyboard.press('Control+Shift+.'); + assert.equal(await page.evaluate(() => window.sentFrames.filter(frame => frame.type === 'abort').length), 1); + await emit('busy_changed', { busy: false }, 'shortcut-2'); + pass('busy reasoning updates and stop once'); + + const openConsole = page.getByRole('button', { name: '打开控制台 (Ctrl+`)', exact: true }); + if (await openConsole.count()) await page.keyboard.press('Control+Backquote'); + await page.getByRole('button', { name: '新建终端(默认 shell)', exact: true }).click(); + const terminal = page.locator('.xterm-helper-textarea'); + await terminal.waitFor({ state: 'attached' }); + await terminal.focus(); + await page.keyboard.press('Control+k'); + await page.locator('[data-search-palette]').waitFor(); + await page.keyboard.press('Control+k'); + await page.locator('[data-search-palette]').waitFor({ state: 'detached' }); + await terminal.focus(); + await page.keyboard.press('Control+Alt+i'); + assert.equal(await composer.evaluate(el => el === document.activeElement), true); + await terminal.focus(); + await page.keyboard.press('Control+Backquote'); + await page.getByRole('button', { name: '打开控制台 (Ctrl+`)', exact: true }).waitFor(); + pass('terminal forwards app search, input focus and console toggle shortcuts'); + + await page.keyboard.press('Control+k'); + await page.locator('[data-search-palette]').waitFor(); + await page.keyboard.press('Control+k'); + await page.locator('[data-search-palette]').waitFor({ state: 'detached' }); + await page.keyboard.press('Control+,'); + await page.locator('[data-settings-window]').waitFor(); + await page.keyboard.press('Control+/'); + await search.waitFor(); + await page.getByRole('button', { name: '消息通道', exact: true }).click(); + await page.keyboard.press('Control+/'); + await search.waitFor(); + await page.getByRole('button', { name: '消息通道', exact: true }).click(); + await page.locator('[data-channel-card]').first().waitFor(); + assert.equal(await page.locator('[data-channel-icon]').count(), 7); + assert.equal(await page.locator('[data-channel-icon]').evaluateAll(images => images.every(img => img.complete && img.naturalWidth > 0)), true); + const card = page.locator('[data-channel-card="telegram"]'); + const baseHeight = (await card.boundingBox()).height; + const platform = channels.find(item => item.platform === 'telegram'); + for (const state of ['disabled', 'connecting', 'connected', 'retrying', 'failed', 'standby']) { + Object.assign(platform, { configured: true, enabled: state !== 'disabled', state, owner: 'user:1', display_name: '@fixture_bot', + pending: [{ id: 'request-1', principal: 'user:2', kind: 'user', name: 'Test user', expires_in_s: 120 }] }); + await emit('channels_state', platform); + await page.waitForTimeout(30); + assert.equal((await card.boundingBox()).height, baseHeight, state); + assert.equal(await card.locator('[data-channel-connect]').innerText(), platform.enabled ? '取消连接' : '连接'); + const management = await card.locator('[data-channel-manage]').boundingBox(); + const connect = await card.locator('[data-channel-connect]').boundingBox(); + assert.ok(management.x + management.width <= connect.x); + } + await card.locator('[data-channel-manage]').click(); + await page.getByText('1 个请求待批准', { exact: true }).waitFor(); + await page.getByRole('button', { name: '批准', exact: true }).click(); + await page.getByText('1 个请求待批准', { exact: true }).waitFor({ state: 'detached' }); + await page.getByRole('button', { name: '完成', exact: true }).click(); + await card.locator('[data-channel-connect]').click(); + await page.waitForFunction(() => document.querySelector('[data-channel-card="telegram"] [data-channel-connect]')?.textContent.trim() === '连接'); + await card.locator('[data-channel-connect]').click(); + await page.waitForFunction(() => document.querySelector('[data-channel-card="telegram"] [data-channel-connect]')?.textContent.trim() === '取消连接'); + pass('all channel states preserve height, management precedes connection, approvals and reconnect work'); + + for (const [width, theme] of [[1440, 'light'], [1440, 'dark'], [640, 'light'], [420, 'dark']]) { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate(theme => document.documentElement.setAttribute('data-theme', theme), theme); + await page.waitForTimeout(80); + assert.ok(await page.locator('[data-channel-card]').evaluateAll(cards => cards.every(el => el.scrollWidth <= el.clientWidth)), `channel overflow ${width}`); + assert.ok(await page.locator('[data-channel-card] [title]').evaluateAll(elements => elements.filter(el => el.classList.contains('truncate')).every(el => el.clientWidth >= 60)), `platform names ${width}`); + await page.screenshot({ path: path.join(output, `channels-${width}-${theme}.png`) }); + } + await page.keyboard.press('Control+/'); + await search.waitFor(); + await search.fill('Ctrl'); + assert.equal(await page.locator('[data-shortcut-id]').count(), 15); + await page.screenshot({ path: path.join(output, 'shortcuts-420-dark.png') }); + await page.evaluate(async () => { await (await import('/src/i18n/index.js')).applyLocalePreference('en-US'); }); + await search.fill('reasoning'); + assert.equal(await page.locator('[data-shortcut-id]').count(), 2); + pass('narrow shortcuts and English name search'); + pass('desktop/narrow light/dark channel layouts and offline SVG assets'); + assert.deepEqual(errors, []); + await fs.writeFile(path.join(output, 'results.json'), JSON.stringify({ checks, errors }, null, 2)); + console.log('Evidence:', output); +} catch (error) { + await page?.screenshot({ path: path.join(output, 'failure.png') }); + console.error('Page state:', await page?.evaluate(() => ({ hash: location.hash, + overlays: [...document.querySelectorAll('[role="dialog"], [role="menu"], [data-ace-native-overlay="blocking"], [data-shortcut-menu]')].map(el => ({ html: el.outerHTML.slice(0, 250), visible: el.getClientRects().length })), + keys: window.keyRecords.slice(-12), text: document.body.innerText.slice(-1200) }))); + console.error('Writes:', calls.filter(call => call.method !== 'GET')); + console.error('Runtime errors:', errors); + console.error('Recent API requests:', calls.slice(-12)); + console.error('Evidence:', output); + throw error; +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/src/App.jsx b/web/src/App.jsx index 156c4e4a..155ed450 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -91,8 +91,8 @@ import { UI_PREFS_STORAGE_KEY, validateUiPrefs, } from './lib/uiPrefs.js'; -import { useGlobalShortcut } from './lib/useGlobalShortcut.js'; -import { isSearchPaletteShortcut } from './lib/searchPaletteShortcut.js'; +import { useAppShortcuts } from './lib/useAppShortcuts.js'; +import { appShortcutContextAllows } from './lib/appShortcuts.js'; import { TopBar } from './components/TopBar.jsx'; import { FeedbackForm } from './components/FeedbackForm.jsx'; import { Sidebar } from './components/Sidebar.jsx'; @@ -277,6 +277,7 @@ export function App() { const [showSettings, setShowSettings] = useState(false); const [showFeedback, setShowFeedback] = useState(false); const [settingsNavKey, setSettingsNavKey] = useState('general'); + const [settingsNavRequest, setSettingsNavRequest] = useState(0); // 从全局搜索面板(Ctrl+K)跳进设置时携带的搜索种子:{query, resultId, section, nonce}。 // 普通打开设置一律清空,否则上一次的搜索会在下次打开时重新出现。 const [settingsSearchSeed, setSettingsSearchSeed] = useState(null); @@ -858,6 +859,7 @@ export function App() { const openSettingsSection = useCallback((key = 'general') => { setSettingsNavKey(key || 'general'); + setSettingsNavRequest((request) => request + 1); setSettingsSearchSeed(null); setShowSettings(true); }, []); @@ -1183,14 +1185,6 @@ export function App() { return () => connection.removeEventListener('message', handler); }, [authState]); - // 全局 Ctrl/Cmd+K 切换搜索面板。键位定义、判定与提示文案都收在 - // lib/searchPaletteShortcut.js,控制台(xterm)也按同一判定放行冒泡。 - useGlobalShortcut( - isSearchPaletteShortcut, - () => setSearchOpen((o) => !o), - [], - ); - // Ctrl+` toggle 控制台(终端聚焦时 xterm 的 customKeyEventHandler 放行该 // 组合,事件照常冒泡到 window)。后端 console 不可用时快捷键惰化。 const consoleAvailable = !!health?.console?.available; @@ -1198,12 +1192,6 @@ export function App() { if (!consoleAvailable) return; setConsoleDock((prev) => ({ ...prev, open: !prev.open })); }, [consoleAvailable, setConsoleDock]); - useGlobalShortcut( - // e.code 是物理键位:中文 IME 下反引号键的 e.key 是 '·',精确匹配 key 会失效。 - (e) => (e.code === 'Backquote' || e.key === '`') && e.ctrlKey && !e.altKey && !e.metaKey, - toggleConsoleDock, - [toggleConsoleDock], - ); const setConsoleDockHeight = useCallback((next) => { setConsoleDock((prev) => ({ ...prev, @@ -1910,7 +1898,8 @@ export function App() { no_workspace: noWorkspace, }, }); - navigateToRef(next); + const opened = await navigateToRef(next); + return opened ? next : null; } catch (e) { toast({ kind: 'err', text: '新建会话失败:' + (e.message || '') }); } @@ -1969,6 +1958,33 @@ export function App() { }; }, [createDesktopTraySession]); + const shortcutNewSessionPending = useRef(false); + useAppShortcuts(authState === 'ok' && !configRecoveryBlocking ? { + search: () => setSearchOpen((open) => !open), + console: () => { if (!consoleAvailable) return false; toggleConsoleDock(); }, + settings: () => { if (!showSettings) openSettingsSection('general'); }, + shortcuts: () => { + openSettingsSection('shortcuts'); + requestAnimationFrame(() => document.querySelector('[data-shortcut-search]')?.focus()); + }, + toggleSidebar: toggleProjectSidebar, + toggleRightPanel: () => { void toggleSidePanel(); }, + forward: () => { if (navHistoryRef.current.forward.length) void goForwardActiveRef(); }, + back: () => { if (navHistoryRef.current.back.length) void goBackActiveRef(); }, + newSession: () => { + if (shortcutNewSessionPending.current) return; + shortcutNewSessionPending.current = true; + void createDesktopTraySession().then((next) => { + if (!next) return; + requestAnimationFrame(() => { + if (activeRefRef.current?.sessionId !== next.sessionId + || !appShortcutContextAllows('focusInput', document.activeElement)) return; + document.querySelector('[data-main-composer="true"] [contenteditable="true"]')?.focus(); + }); + }).finally(() => { shortcutNewSessionPending.current = false; }); + }, + } : {}); + const setSidebarWidth = useCallback((nextWidth, shellWidth = 0) => { const sidePanelVisible = !sidePanelNavigationCollapsed; setSingleLayout((prev) => { @@ -2233,12 +2249,6 @@ export function App() { onGoForward={goForwardActiveRef} canGoBack={navHistory.back.length > 0} canGoForward={navHistory.forward.length > 0} - updateStatus={updateStatus} - updateStarting={updateStarting} - updateRunning={updateJobIsActive(updateJob)} - updateReady={updateJob?.state === 'succeeded' && !!updateJob?.restart_required} - updateProgress={updateJobProgress(updateJob)} - onStartUpdate={openUpdateDialog} />
diff --git a/web/src/assets/channel-icons/discord.svg b/web/src/assets/channel-icons/discord.svg new file mode 100644 index 00000000..ff12e010 --- /dev/null +++ b/web/src/assets/channel-icons/discord.svg @@ -0,0 +1 @@ +Discord diff --git a/web/src/assets/channel-icons/feishu.svg b/web/src/assets/channel-icons/feishu.svg new file mode 100644 index 00000000..5522fcf8 --- /dev/null +++ b/web/src/assets/channel-icons/feishu.svg @@ -0,0 +1 @@ + diff --git a/web/src/assets/channel-icons/line.svg b/web/src/assets/channel-icons/line.svg new file mode 100644 index 00000000..733e7a42 --- /dev/null +++ b/web/src/assets/channel-icons/line.svg @@ -0,0 +1 @@ +LINE diff --git a/web/src/assets/channel-icons/qq.svg b/web/src/assets/channel-icons/qq.svg new file mode 100644 index 00000000..0cac656d --- /dev/null +++ b/web/src/assets/channel-icons/qq.svg @@ -0,0 +1 @@ +QQ diff --git a/web/src/assets/channel-icons/remixicon-LICENSE b/web/src/assets/channel-icons/remixicon-LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/web/src/assets/channel-icons/remixicon-LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/web/src/assets/channel-icons/semi-design-LICENSE b/web/src/assets/channel-icons/semi-design-LICENSE new file mode 100644 index 00000000..c21c5cc7 --- /dev/null +++ b/web/src/assets/channel-icons/semi-design-LICENSE @@ -0,0 +1,83 @@ +MIT License + +Copyright (c) 2021 DouyinFE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +The code implementation of the external library is referenced by DouyinFe are: + +- animate.css + + The MIT License (MIT) + + Copyright (c) 2020 Daniel Eden + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +- Ant Design + MIT LICENSE + + Copyright (c) 2015-present Ant UED, https://xtech.antfin.com/ + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +- rc-tree + + MIT LICENSE + + Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/web/src/assets/channel-icons/simple-icons-LICENSE.md b/web/src/assets/channel-icons/simple-icons-LICENSE.md new file mode 100644 index 00000000..70d4a7b6 --- /dev/null +++ b/web/src/assets/channel-icons/simple-icons-LICENSE.md @@ -0,0 +1,30 @@ +# CC0 1.0 Universal + +## Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose. + +4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +For more information, please see . diff --git a/web/src/assets/channel-icons/sources.json b/web/src/assets/channel-icons/sources.json new file mode 100644 index 00000000..0549d2c0 --- /dev/null +++ b/web/src/assets/channel-icons/sources.json @@ -0,0 +1,44 @@ +[ + { + "file": "qq.svg", + "source": "https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/qq.svg", + "sourceSha256": "3c94e923013fe13a5194c84d9a44e2d62225a7ea3537f3ad95a3cb72fdae1383", + "adaptation": "Brand fill color only" + }, + { + "file": "weixin.svg", + "source": "https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/wechat.svg", + "sourceSha256": "74b48b3d315337ce79cb644933b5f975e19996f564fcc4138ed67d45674c43ca", + "adaptation": "Brand fill color only" + }, + { + "file": "telegram.svg", + "source": "https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/telegram.svg", + "sourceSha256": "147fd8f8923e7e5f463fe98c0eb9913bead6b2ae59935728cda141002ec8a7c9", + "adaptation": "Brand fill color only" + }, + { + "file": "discord.svg", + "source": "https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/discord.svg", + "sourceSha256": "1d364b72c9eaf1fe37d17ca88cd8fb541308dc0f3b09e2ab3b824f380b3493d5", + "adaptation": "Brand fill color only" + }, + { + "file": "line.svg", + "source": "https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/line.svg", + "sourceSha256": "b62430462354f95914536d7205128647d5563cdb805bc9aca000300aaed816c8", + "adaptation": "Brand fill color only" + }, + { + "file": "feishu.svg", + "source": "https://raw.githubusercontent.com/DouyinFE/semi-design/v2.101.0/packages/semi-icons/src/svgs/feishu_logo.svg", + "sourceSha256": "5f9a5066c702ada4acdb752521a7d70293fbe5166ac4a7fa256cff8778670e2d", + "adaptation": "None" + }, + { + "file": "dingtalk.svg", + "source": "https://raw.githubusercontent.com/Remix-Design/RemixIcon/v4.6.0/icons/Logos/dingding-fill.svg", + "sourceSha256": "3ba4ab741fceb5f9adb4d3a0faab10cb961d37e351a82b231f2d1dc10e46ee3f", + "adaptation": "Brand fill color only" + } +] diff --git a/web/src/assets/channel-icons/telegram.svg b/web/src/assets/channel-icons/telegram.svg new file mode 100644 index 00000000..2d20b3e3 --- /dev/null +++ b/web/src/assets/channel-icons/telegram.svg @@ -0,0 +1 @@ +Telegram diff --git a/web/src/assets/channel-icons/weixin.svg b/web/src/assets/channel-icons/weixin.svg new file mode 100644 index 00000000..101bdf0d --- /dev/null +++ b/web/src/assets/channel-icons/weixin.svg @@ -0,0 +1 @@ +WeChat diff --git a/web/src/components/AnchoredMenu.jsx b/web/src/components/AnchoredMenu.jsx index 596f255f..8fff5f78 100644 --- a/web/src/components/AnchoredMenu.jsx +++ b/web/src/components/AnchoredMenu.jsx @@ -116,6 +116,7 @@ export function AnchoredMenu({ anchorRef, onClose, children, className = '', wid {...props} ref={menuRef} data-ace-native-overlay="overlap" + data-shortcut-menu="true" className={`ace-anchored-menu ace-scrollbar ${className}`} style={{ position: 'fixed', top: 0, left: 0, width, visibility: 'hidden' }} > diff --git a/web/src/components/ChannelPlatformIcon.jsx b/web/src/components/ChannelPlatformIcon.jsx new file mode 100644 index 00000000..e2c2b626 --- /dev/null +++ b/web/src/components/ChannelPlatformIcon.jsx @@ -0,0 +1,19 @@ +import { clsx } from '../lib/format.js'; +import qq from '../assets/channel-icons/qq.svg'; +import weixin from '../assets/channel-icons/weixin.svg'; +import feishu from '../assets/channel-icons/feishu.svg'; +import dingtalk from '../assets/channel-icons/dingtalk.svg'; +import telegram from '../assets/channel-icons/telegram.svg'; +import discord from '../assets/channel-icons/discord.svg'; +import line from '../assets/channel-icons/line.svg'; + +const icons = { qq, weixin, feishu, dingtalk, telegram, discord, line }; + +export function ChannelPlatformIcon({ platform, size = 24, box = 'h-9 w-9' }) { + return ( + + + + ); +} diff --git a/web/src/components/ChannelsSettings.jsx b/web/src/components/ChannelsSettings.jsx index da1d9a23..fa2dd07f 100644 --- a/web/src/components/ChannelsSettings.jsx +++ b/web/src/components/ChannelsSettings.jsx @@ -1,7 +1,6 @@ // 消息通道(设置 > 集成 > 消息通道,openspec add-desktop-im-channels / add-more-im-channels)。 // 页面只有两列平台卡片:「连接」在还没配好时打开分步向导,配好的直接连接;连上后按钮变成 -// 「取消连接」。卡片只显示一行简要状态和待批准请求;连接原因、隐私模式、收发计数等细节由 daemon -// 写进日志,不在页面展开(2026-10-05 验收反馈)。各平台向导的说明、字段与机主绑定方式在 +// 「取消连接」。卡片高度不随状态变化;连接状态、待批准请求和恢复操作放在管理中。各平台向导的说明、字段与机主绑定方式在 // lib/channelsSettings.js 的 platformSetup;状态变化经 WS(channels_*)实时合并。 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../lib/api.js'; @@ -33,6 +32,7 @@ import { copyTextToSystemClipboard } from '../lib/systemClipboard.js'; import { Modal } from './Modal.jsx'; import { VsIcon } from './Icon.jsx'; import { toast } from './Toast.jsx'; +import { ChannelPlatformIcon } from './ChannelPlatformIcon.jsx'; const inputClass = 'w-full h-8 px-2.5 text-[12px] rounded-md border border-border bg-surface-alt text-fg outline-none focus:border-accent transition disabled:opacity-50'; const primaryButtonClass = 'px-3 py-1 text-[12px] bg-accent text-white rounded disabled:opacity-60 shrink-0'; @@ -58,14 +58,6 @@ function StatusPill({ tone = 'mute', text }) { ); } -function PlatformIcon({ name, size = 18, box = 'h-9 w-9' }) { - return ( - - - - ); -} - // 二维码在亮/暗主题下都保持白底黑块:反色二维码不是所有手机都能扫。 function QrCode({ value, label }) { const model = useMemo(() => qrSvgModel(value), [value]); @@ -105,41 +97,32 @@ function PendingList({ requests, busy, onDecide }) { )); } -function ChannelCard({ card, platform, busy, onConnect, onDisconnect, onFix, onManage, onDecide }) { +function ChannelCard({ card, platform, busy, onConnect, onDisconnect, onManage }) { const view = channelCardView(platform, busy); const pending = pendingRows(platform); const connect = view.button.action === 'connect'; return (
-
- -
{card.title}
- +
+ +
{card.title}
+ +
+ + +

{card.description}

- {(view.status || platform.configured) && ( -
- {view.status ? : } - - {view.status?.action === 'fix' && ( - - )} - {platform.configured && ( - - )} - -
- )} - {pending.length > 0 && ( -
-
{`${pending.length} 个请求待批准`}
- -
- )}
); } @@ -585,7 +568,7 @@ function ConnectWizard({ platformKey, platform, bind, busy, run, onDecide, start
- +
{`连接 ${card.title}`}
+
+ + {view.status?.action === 'fix' && } +
机器人
- - + + + {pending.length > 0 && ( +
+
{`${pending.length} 个请求待批准`}
+ +
+ )}
机主与授权名单

机主可以切换到任意会话;其他人只能在自己创建的会话之间切换。

{contacts.length === 0 ? ( @@ -743,14 +738,12 @@ export function ChannelsSettings() { 加载中
) : ( -
+
{channelCards().map((card) => ( connect(card.platform)} onDisconnect={() => run(`${card.platform}-toggle`, () => api.setChannelEnabled(card.platform, false))} - onFix={() => setWizard({ platform: card.platform, startAt: 1, change: false })} - onManage={() => setManaging(card.platform)} - onDecide={(request, approve) => decide(card.platform, request, approve)} /> + onManage={() => setManaging(card.platform)} /> ))}
)} @@ -763,6 +756,11 @@ export function ChannelsSettings() { )} {managing && ( decide(managing, request, approve)} + onFix={() => { + setWizard({ platform: managing, startAt: 1, change: false }); + setManaging(''); + }} onRevoke={(contact) => setRevoking({ platform: managing, contact })} onChangeBot={() => { setWizard({ platform: managing, startAt: 0, change: true }); diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 4c33ecdd..ce952788 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -209,7 +209,8 @@ import { sessionModelReloadFeedback, withCreateSessionPreferences, } from '../lib/sessionModel.js'; -import { composerReasoningOptions } from '../lib/modelReasoning.js'; +import { composerReasoningOptions, nextReasoningEffort } from '../lib/modelReasoning.js'; +import { useAppShortcuts } from '../lib/useAppShortcuts.js'; import { requestSavedModelReasoningSync } from '../lib/modelReasoningSync.js'; import { normalizePermissionMode, permissionModeOption } from '../lib/permissionMode.js'; import { ATTACHMENT_HARD_LIMIT_BYTES, normalizeImageFile } from '../lib/imageNormalize.js'; @@ -284,6 +285,7 @@ import { closeAgentBrowserPage, createAgentBrowserPage, hasNativeAgentBrowser, + runAgentBrowserBridgeAction, selectAgentBrowserPage, } from '../lib/agentBrowser.js'; import { @@ -4086,6 +4088,28 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } }, [api, composerSubmitting, homeModelName, homeReasoningEffort, homeSubmitting, modelOptions, modelRefreshing, modelState, modelSwitching, reasoningSwitching, sid]); + const reasoningShortcutPending = useRef(false); + const shortcutComposerVisible = () => !!inputRef.current?.getElement()?.getClientRects().length + && !questionForView && permissionRequests.length === 0; + const stepReasoning = (direction) => { + if (!shortcutComposerVisible()) return false; + if (reasoningShortcutPending.current) return; + const model = sid ? modelState : modelOptions.find((option) => option.name === homeModelName); + const effort = nextReasoningEffort(model, direction, sid ? undefined : homeReasoningEffort); + if (!effort) return; + reasoningShortcutPending.current = true; + void changeComposerReasoning(effort).finally(() => { reasoningShortcutPending.current = false; }); + }; + useAppShortcuts({ + focusInput: () => { if (!shortcutComposerVisible()) return false; inputRef.current.focus(); }, + stop: () => { + if (!shortcutComposerVisible() || !busy || abortPending) return false; + stopCurrentWork(); + }, + reasoningUp: () => stepReasoning(1), + reasoningDown: () => stepReasoning(-1), + }); + const switchHomeDefaultPermissionMode = useCallback(async (mode) => { const nextMode = normalizePermissionMode(mode); const previousMode = normalizePermissionMode(permissionMode); @@ -5440,17 +5464,36 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); }, [onRevealPreviewPanel, previewScope, selectPreview, sid]); - const openBrowserPreview = useCallback(async () => { - if (!sid || !hasNativeAgentBrowser()) return; - if (!await requestActiveFileLeave()) return; - const created = await createAgentBrowserPage(agentBrowserOwnerForSession(ref)); - if (created?.ok === false || !created?.page_id) return; - if (sidRef.current !== sid) return; - showBrowserPage( - created.page_id, - created.title || defaultBrowserTabTitle(), - created.favicon, - ); + const browserOpenPendingRef = useRef(false); + const openBrowserPreview = useCallback(async (url = '') => { + if (!sid || !hasNativeAgentBrowser() || browserOpenPendingRef.current) return; + browserOpenPendingRef.current = true; + try { + if (!await requestActiveFileLeave() || sidRef.current !== sid) return; + const created = await createAgentBrowserPage(agentBrowserOwnerForSession(ref)); + if (created?.ok === false || !created?.page_id) { + throw new Error(created?.error || '浏览器操作失败'); + } + if (sidRef.current !== sid) { + await closeAgentBrowserPage(created.page_id); + return; + } + showBrowserPage( + created.page_id, + created.title || defaultBrowserTabTitle(), + created.favicon, + ); + if (typeof url === 'string' && url) { + const navigated = await runAgentBrowserBridgeAction('aceDesktop_agentBrowserNavigate', { + page_id: created.page_id, url, + }); + if (navigated?.ok === false) throw new Error(navigated.error || '浏览器操作失败'); + } + } catch (error) { + toast({ kind: 'err', text: error?.message || '浏览器操作失败' }); + } finally { + browserOpenPendingRef.current = false; + } }, [ref, requestActiveFileLeave, showBrowserPage, sid]); // 切会话时向 Desktop 对账一次 native 页面池;事件流已经在 App 级持续镜像。 @@ -5724,6 +5767,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses ) : ( { - if (ev.ctrlKey && !ev.altKey && !ev.metaKey && - (ev.code === 'Backquote' || ev.key === '`')) return false; - if (isSearchPaletteShortcut(ev)) return false; + const shortcut = matchAppShortcut(ev); + if (shortcut && !['stop', 'reasoningUp', 'reasoningDown'].includes(shortcut)) return false; return true; }); term.onData((data) => { diff --git a/web/src/components/FilePreviewContent.jsx b/web/src/components/FilePreviewContent.jsx index 04c1be31..3a5c7a75 100644 --- a/web/src/components/FilePreviewContent.jsx +++ b/web/src/components/FilePreviewContent.jsx @@ -27,6 +27,7 @@ import { CLEAR_PREVIEW_SELECTION_EVENT } from '../lib/inactiveSelection.js'; import { copyTextToSystemClipboard } from '../lib/systemClipboard.js'; import { clsx, formatBytes } from '../lib/format.js'; import { filePreviewKind, isBlobFilePreview } from '../lib/filePreviewKind.js'; +import { htmlPreviewFileUrl } from '../lib/htmlPreview.js'; import { editableFileConflict, editableFileError, @@ -179,6 +180,7 @@ export function FilePreviewContent({ onEditStateChange, onToggleWrapPreview, onRefresh, + onOpenBrowser, }) { const [state, setState] = useState({ status: 'idle', @@ -582,6 +584,7 @@ export function FilePreviewContent({ ).join('') }`; const wrapTitle = wrapPreview ? '关闭自动换行' : '开启自动换行'; + const htmlBrowserUrl = onOpenBrowser ? htmlPreviewFileUrl({ cwd, path }) : ''; const showMarkdownRendered = isMarkdown && !markdownSource; const markdownToggleTitle = markdownSource ? '渲染 Markdown' : '查看 Markdown 原文'; return ( @@ -592,6 +595,17 @@ export function FilePreviewContent({ data-wrap={wrapPreview ? 'true' : 'false'} actions={( <> + {htmlBrowserUrl && ( + + )} {isMarkdown && ( + )} diff --git a/web/src/components/TopBar.jsx b/web/src/components/TopBar.jsx index b07b10da..77719bae 100644 --- a/web/src/components/TopBar.jsx +++ b/web/src/components/TopBar.jsx @@ -5,6 +5,7 @@ import { clsx } from '../lib/format.js'; import { SESSION_HEADER_CONTEXT_MENU_DELEGATE } from '../lib/desktopContextMenu.js'; import { shouldInsetMacTopBar } from '../lib/desktopShellMode.js'; import { withSearchPaletteShortcutHint } from '../lib/searchPaletteShortcut.js'; +import { withAppShortcutHint } from '../lib/appShortcuts.js'; import { NavigationArrowIcon, PanelToggleIcon, VsIcon } from './Icon.jsx'; import { isTopBarDragBackdrop, isTopBarDragExcludedTarget, topBarWindowControlAt, topBarWindowDragAction } from '../lib/topBarWindowDrag.js'; import { @@ -61,29 +62,11 @@ export function TopBar({ onGoForward, canGoBack = false, canGoForward = false, - updateStatus = null, - updateStarting = false, - updateRunning = false, - updateReady = false, - updateProgress = 0, - onStartUpdate, sessionTitleRef, sessionActionsRef, }) { const { framelessDesktop, isMaximized, isFullscreen } = useFramelessWindowState(); const topBarRef = useRef(null); - const updateAvailable = !!updateStatus?.update_available; - const boundedUpdateProgress = Number.isFinite(Number(updateProgress)) - ? Math.max(0, Math.min(100, Math.round(Number(updateProgress)))) - : 0; - const updateLabel = updateReady ? '已更新' : updateRunning ? '更新中' : '更新'; - const updateTitle = updateAvailable - ? updateReady - ? '升级已安装,点击查看重启选项' - : updateRunning - ? `升级正在进行,${boundedUpdateProgress}%,点击查看进度` - : `发现新版 v${updateStatus.latest_version || ''}, 点击升级` - : ''; useEffect(() => { if (!framelessDesktop) return undefined; @@ -161,17 +144,17 @@ export function TopBar({ >
- + - + @@ -179,45 +162,6 @@ export function TopBar({
- {updateAvailable && ( - - )}
@@ -233,7 +177,7 @@ export function TopBar({ )} 消息通道处理", "s_e2a76ef1f12e147f": "终端", + "s_e2ad5101dbde2f62": "展开/收起左侧项目栏", "s_e2f12da7faa68217": "已下载", "s_e2fda204edef967e": "JSON 必须是对象", "s_e2fe182aad1924fd": "C# 工具", @@ -2339,6 +2376,7 @@ export const sourceCatalogs = { "s_e399eb07dc83ef19": "排队发送失败:", "s_e3a2fb921b49a4b6": "Grok 登录成功,但凭据保存失败", "s_e40a628ffec72072": "导出 CSV", + "s_e4354397844d3d18": "优先关闭最上层可关闭的浮层", "s_e4401e8c0c6984b7": "打开会话超时,请重试", "s_e48ef715d772a011": "无头模式无法确认", "s_e4a992c23ca3c60b": "正在运行 {{p0}} 个工具", @@ -2369,6 +2407,7 @@ export const sourceCatalogs = { "s_e6c8a78f71264985": "Copilot 模型列表格式错误", "s_e6e355a99aa51351": "沙箱内执行命令", "s_e6f04ffbaa424001": "代码", + "s_e6f6d875638eca07": "会话操作", "s_e72eccf6eba409dd": "浏览器地址", "s_e73064cd1cf58394": "当前工作区还没有记忆", "s_e734022c3ba754dc": "还没有全局记忆", @@ -2380,6 +2419,7 @@ export const sourceCatalogs = { "s_e788045df7db1869": "{{p0}}头像相对路径", "s_e78db524e7c1e3dd": "... 还有 {{p0}} 项", "s_e7a4ae94fa9dbad5": "将打开", + "s_e7bf600d2da7ec18": "返回导航历史中的上一项", "s_e804670f73f0874a": "Shift+Enter 换行", "s_e806fbbe8ec6e82d": "插件", "s_e833097b603cb745": "{{p0}} 个上下文", @@ -2419,6 +2459,7 @@ export const sourceCatalogs = { "s_eb93045599c0e25c": "进入 Plan 模式", "s_ebb240a1930e8bfb": "搜索图标", "s_ebb63196c7d3c318": "复制图片失败:", + "s_ebd8027daae319aa": "搜索会话、工作区和设置", "s_ec7a11c7061bc164": "正在迁移工作空间…", "s_ec869826ec67f839": "Agent Browser 的部分浏览器数据未能复制,重启后可能需要重新登录", "s_ec9bbc8139d8bdd1": "专", @@ -2441,6 +2482,7 @@ export const sourceCatalogs = { "s_ee00e57c07195431": "当前运行模式不支持自动重启,请完全退出后重新启动 ACECode", "s_ee1db622be4f2443": "关闭 {{p0}}", "s_ee1e5738be96819a": "已连接 {{p0}}", + "s_ee2638183d3ea860": "快捷键", "s_ee32f25f70508f9c": "清除搜索", "s_ee34c74d32e7548a": "最近创建", "s_ee529160390df1c4": "端点模式", @@ -2479,6 +2521,7 @@ export const sourceCatalogs = { "s_f1b0b119c244d295": "{{p0}} 个文件已更改", "s_f1b112d6acd841b1": "学士帽", "s_f1bdeeb9fb4cab81": "主题资源校验失败,无法导出", + "s_f1c10da84402db5d": "全局搜索", "s_f1e0dfb858e7fcfe": "钩子拒绝", "s_f21f25112af89630": "回调地址已登记。请在 LINE Developers 控制台的 Messaging API 页打开 Use webhook,打开后这里会自动变为已连接。", "s_f27627514a2cb542": "专家组件加载失败", @@ -2560,6 +2603,7 @@ export const sourceCatalogs = { "s_f8de71b07c4b412e": "无法写入所选文件夹", "s_f8e021a80d022c01": "无本地路径的文件数据不能超过 25 MiB", "s_f8f3247b4095d70d": "测试图片已生成", + "s_f906758c20ee15d5": "保存正在编辑的文件", "s_f912edbb9c42279f": "中止任务", "s_f91cbcbd90f70585": "opencode 会话导入完成", "s_f92c94c15b6e2e28": "升级已安装,请重新启动 ACECode。", @@ -2572,6 +2616,7 @@ export const sourceCatalogs = { "s_f9c08b25e6d154cb": "正在取消升级", "s_f9c4971818b3037a": "会话、记忆、技能等数据的存储位置", "s_f9d19345a067cbe1": "中断", + "s_fa153621ba3498cf": "停止当前会话执行", "s_fa2ff3c379a7c988": "查看全部", "s_fa319b472ff3f8ea": "", "s_fa48e89389404e60": "使用中", @@ -2584,6 +2629,7 @@ export const sourceCatalogs = { "s_fafb87bd1b75c9b0": "后台连接认证已失效,请重新连接 ACECode 后重试", "s_fb04addb4c2654f5": "输出", "s_fb04b4812e4e7970": "请求参数错误", + "s_fb28a6fff6c8a620": "切换项目和会话列表的显示", "s_fb33938685d49b6e": "有未读任务", "s_fb381883568c7c3f": "主题包已保存", "s_fb47db5e65ff7fa0": "已置顶", @@ -2614,6 +2660,7 @@ export const sourceCatalogs = { "s_fcea4baf388f169a": "运行核心测试,定位并修复可可靠复现的问题。", "s_fcf8b4bff0df782d": "打开文件夹", "s_fcffc107c99acbba": "已注册工作区", + "s_fd2d8286ae728ef1": "逐档增加,到最高档停止", "s_fd351bc95e9337e4": "发消息", "s_fd4b9e3b6c685bae": "继续编辑", "s_fd5fd3c4567938c7": "询问", @@ -2651,6 +2698,7 @@ export const sourceCatalogs = { "s_007dd7014d2b9d64": "Show only changes made by the file_edit / file_write / apply_patch tools", "s_0080bd7968c8f2ba": "Start with a template", "s_0087143bff352d9e": "Adjust left column width", + "s_008814832cf7026a": "Send message", "s_00a0cf27422e5fa3": "Embed", "s_00bd3960fea8ea98": "Expand", "s_0101bc1c6e1e1e9e": "Open tools", @@ -2732,6 +2780,7 @@ export const sourceCatalogs = { "s_08e5a3f74d4c6894": "Scan with QQ on your phone and follow the prompts to create and authorize the bot", "s_08e9d9ef7d382947": "writes only", "s_08f10fb5ebba9e65": "Use dark backgrounds; turn it off to use light backgrounds", + "s_092d1bb8c3f5739e": "Browse up from an empty input; move down while browsing history", "s_0970eb5b921e82c5": "{{p0}} line", "s_09755cc8c9127621": "Default permission mode setting failed:", "s_099ecc791841a897": "For example: my new project", @@ -2772,6 +2821,7 @@ export const sourceCatalogs = { "s_0bab7e5874ada580": "The provider's API key cannot be empty", "s_0bacefd48df8b523": "Sending failed:", "s_0bae6ae33d654f2a": "Please allow ACECode notifications in macOS system settings", + "s_0be399475cb70680": "Open in built-in browser", "s_0c1413f34b4a26e2": "No terminal open", "s_0c184871f658375a": "yesterday", "s_0c30c37c6ead953b": "font size", @@ -2845,6 +2895,7 @@ export const sourceCatalogs = { "s_13439ef69aeff4f2": "Copy the App ID and App Secret from Credentials & Basic Info and enter them below.", "s_134a1a3e2b52b04f": "Collapse the entire right panel", "s_134fdd6479c8036b": "Send (Enter)", + "s_1352e65452d12d5a": "Find in conversation", "s_13670a878cf6f01d": "ACECode upgrade", "s_136de7a8c46fc803": "reject", "s_1370a110fe716a5a": "Task completion notifications are enabled; confirm the macOS permission prompt", @@ -2877,6 +2928,7 @@ export const sourceCatalogs = { "s_15dec507959ec950": "Personal", "s_15e3ff968831e21b": "Default model setup failed:", "s_15f87d14cc419013": "Close import", + "s_162312ab042f99c5": "Go forward in navigation history", "s_1626260328ea5f98": "Update cancelled", "s_163226a2de129979": "Markdown supported; suspected secrets are replaced with [REDACTED] when saved", "s_165813565364b2d5": "Running {{p0}} agents", @@ -2967,6 +3019,7 @@ export const sourceCatalogs = { "s_1d4800bfa2d1c0f3": "Download National Day theme", "s_1d4ce5a56a78c984": "{{p0}} lines · {{p1}} characters", "s_1d662cbe70af4028": "Failed to load skills:", + "s_1d74dc41f6a79b29": "Toggle right panel", "s_1d84f1c673d8d0e5": "Requested to run outside the sandbox", "s_1dcfba0e1080d653": "wheel ·", "s_1dd7e04501431a63": "Drag to adjust the width of the left column", @@ -2978,6 +3031,7 @@ export const sourceCatalogs = { "s_1ef3de06b32e521b": "Clear", "s_1f0eb99b7ed094be": "Running", "s_1f2933de67836b07": "Field of expertise has not been filled in", + "s_1f42646549f922ad": "keyboard shortcuts hotkeys search forward back reasoning depth Ctrl Alt Cmd", "s_1f4b8e02fe0eb7ab": "You can select multiple models and fill in the new model form at once after confirmation.", "s_1f6c6927e129a5f5": "Please upgrade and restart the ACECode daemon and try again.", "s_1f79c9f362dc9d4b": "Could not save appearance settings; restored the last saved settings:", @@ -2993,6 +3047,7 @@ export const sourceCatalogs = { "s_2087c777c06fefe5": "enter", "s_209c47b5910f631d": "Vision", "s_20aaca92af97ede8": "Select a provider", + "s_20b73f567b2bc4b2": "Browse input history", "s_20bf3c0c8b2e30d3": "Unable to edit this file", "s_20c0d1de7d3b30c0": "Write the {{p0}} line", "s_20c393a9029f0004": "Select and go to the next question", @@ -3099,10 +3154,12 @@ export const sourceCatalogs = { "s_2b514a81a55f19e7": "Refresh saved models", "s_2b753320799bc433": "Duplicate of another rewrite name", "s_2b8ce0693bd34244": "Recently run:", + "s_2ba99f029a459bae": "Open the settings window", "s_2babe1bab7456b5b": "Could not save memory", "s_2be25ce2315f1633": "No matching saved models", "s_2bf3eb9adcd329f0": "Enter to send · Shift+Enter to wrap the line · Up and down keys to switch historical messages", "s_2c1e9b401f6403b8": "Selected models", + "s_2c66c2e3d49b5edf": "Open keyboard shortcuts", "s_2c7dfb7f0b25589e": "Search current conversation content", "s_2c843e836a4d4422": "Failed to save close-window settings:", "s_2c8855fc8ab8f5fb": "No usable non-local network address was found. Check your network adapter.", @@ -3178,6 +3235,7 @@ export const sourceCatalogs = { "s_33b6e1db1713541a": "You can switch between model and permission mode at the bottom of the input box, and confirm the current configuration at any time before starting the task.", "s_33e71e7397a405da": "The current environment does not support exiting ACECode", "s_33fdf547fb99a86d": "Summarize token consumption by workspace", + "s_342292808d6c0954": "Show or hide the file and preview panels", "s_34486c598efd2582": "Provider detection failed", "s_34492b04ea0ccbcc": "Allow the application shown by macOS for this ACECode launch, then return and refresh permissions.", "s_346114238816edc2": "Applies globally. New desktop instances can run alongside existing ones when enabled. Turning this off leaves open instances running.", @@ -3189,6 +3247,7 @@ export const sourceCatalogs = { "s_34b3cac2a496d263": "Model catalog", "s_34c3c3b2bd45600d": "Expand all diff", "s_34f7336d39831fca": "Close all", + "s_35001159773b1a1f": "No matching shortcuts", "s_350a7736d0d4cb03": "Set owner", "s_3548b099890e187f": "Model usage details", "s_3568d317c8161ad4": "This is an embedded browser.", @@ -3208,17 +3267,20 @@ export const sourceCatalogs = { "s_37554a94970cc4fb": "Theme validation failed. Export a complete theme ZIP from ACECode again", "s_375889fae2f7bbaa": "Add to session", "s_375f5091e213f094": "Paths are on the machine running the daemon", + "s_37625da38253be99": "Create a conversation in the current workspace", "s_377e4422342c71f7": "Problem feedback has been uploaded", "s_3785cba81d184679": "Current workspace memory", "s_378eaca774bb9551": "Scan to sign in", "s_37979829426b0cdb": "Workspace", "s_37a41b2e983b06ee": "Using a skill", + "s_37aa6ad6a36d46fa": "Open settings", "s_37c427d9f5dc817d": "The default model has been set to", "s_37d36b087f49c6d2": "Select expert component", "s_37e4e42c314102da": "image", "s_381117baedb79e1a": "After choosing a provider and model, only settings supported by that connection are shown.", "s_381796352dc0301d": "Settings were saved but could not be applied. Please try again", "s_3819d5e4aa5dba68": "Pen", + "s_382c0306b7f3e138": "Same as the current conversation’s Stop button", "s_38744132b501a1eb": "Question", "s_38c043e08502e710": "Personalization", "s_38cd88ed8b3ddbf1": "Copy full output", @@ -3397,6 +3459,7 @@ export const sourceCatalogs = { "s_490a88b8ae9d4a2e": "This model no longer exists, please refresh", "s_4927a53bcc886afb": "Loading…", "s_492edaa646797862": "Uses the existing ACECode GitHub device login and managed endpoint.", + "s_49504fdfc1b5a604": "Search shortcuts", "s_496124e1bfae76ff": "Delete “{{p0}}”? This removes only the profile and keeps all conversation history.", "s_497c1a65b2f98a63": "Demo preview. Your office will follow your conversations.", "s_499c729eb09aa2a6": "Context window tokens", @@ -3531,6 +3594,7 @@ export const sourceCatalogs = { "s_55742ea15e2aef12": "server filesystem", "s_5582fbb7f0901200": "Open directory is missing workspace information", "s_5588a8ec809e249c": "Search or filter by Tag to add multiple experts at once.", + "s_558cd5e9b875c5b3": "Available when the file editor has focus", "s_55a139f14f573ec7": "Inherit all globally available items", "s_55a2ce1324c18355": "Feedback", "s_55c4cb3d74643b57": "Alias", @@ -3609,6 +3673,7 @@ export const sourceCatalogs = { "s_5d02c355dcc5bc82": "Search tasks", "s_5d24aa8ce6bedc23": "Search tasks, projects, or settings", "s_5d31027802e2d944": "The current content has not changed", + "s_5d344e9b4013d978": "Open keyboard shortcuts and focus search", "s_5d3f86d033dabffe": "Hooks", "s_5d53a39c33ae87b8": "Off: new sessions get no memory context and no memory tools; existing memories stay on disk", "s_5d5cbbbef4de8fdc": "You can directly enter one or more model IDs (separated by commas), or you can select multiple models after detection.", @@ -3625,6 +3690,7 @@ export const sourceCatalogs = { "s_5e8e7af57abbcbcf": "Deleting…", "s_5eaefa4a3950e8a3": "Choose a new path", "s_5ebc2ffa60cfaf2b": "The image service has insufficient credits or is rate-limited. Check your credits and retry.", + "s_5ed9104315e191ce": "Configure bot", "s_5efd57456cbc23c5": "or run", "s_5f04b51460b1e8d6": "Allowed by headless YOLO", "s_5f2cfbfecf9fc4a6": "Quick access", @@ -3720,6 +3786,7 @@ export const sourceCatalogs = { "s_697d97803142d0a2": "The model and permissions are displayed at the bottom of the input box. You have not configured the model yet. After completing the instructions, you will be taken to model settings.", "s_69838ee7d0227767": "No API key required", "s_69a90b6256bc8ed2": "Keep the current expert for this turn; dispatch {{p0}} for the next turn", + "s_69c5dd2e6850a1b7": "Close the current popup or dialog", "s_69dd7a0556095426": "Task suggestions", "s_69e5cda9b515e5ed": "Resuming question request", "s_69ea313c529568b3": "Failed to create new session:", @@ -3747,6 +3814,7 @@ export const sourceCatalogs = { "s_6bff6a717a8c4fe8": "Home", "s_6c17550d1e5907ec": "Enter queue · Shift+Enter line break · up and down keys to switch historical messages", "s_6c3df58b0549b3c4": "No running records yet", + "s_6c4cddf16244bbcd": "Available when the console is enabled", "s_6c927491f75c08fe": "Failed to copy the verification code: {{p0}}", "s_6c9d4a908a310007": "Swarm mode (Mesh) enabled", "s_6cc617b39d0c9bbe": "Native filesystem unavailable", @@ -3999,6 +4067,7 @@ export const sourceCatalogs = { "s_82c4aa13b7ca109e": "maximize", "s_82ec71d0714dc013": "Connecting to Windows WebView2.", "s_8301f8304e5dd793": "Couldn't get a QR code. Retry, or enter the credentials manually", + "s_830ed44bef18eda5": "Focus message input", "s_836c3d5d6bc88f78": "Confirm before writing/executing operations", "s_837415ff50de9e5c": "match:", "s_837acaec000d9f38": "Saved. {{p0}} suspected secret(s) were replaced with [REDACTED]; the saved content is shown above.", @@ -4008,6 +4077,7 @@ export const sourceCatalogs = { "s_83ad87eee587826a": "Expand display", "s_83deaec053e921ce": "Close tab", "s_8407682b0b45bbb9": "· Connection unavailable", + "s_8429c4f81e54ed38": "Navigation", "s_843e5a96794f9e8c": "Testing generates one standard-quality image and consumes credits.", "s_844b8cc8dff7c1d8": "default", "s_84659f76de148bb0": "Go to file", @@ -4074,6 +4144,7 @@ export const sourceCatalogs = { "s_899432d83d98c3bf": "Quick operation", "s_8996cf52b5aacf6a": "Archiving failed:", "s_89c4766afc85125f": "Connection failed", + "s_89ce481a4b23d2a4": "Keyboard shortcuts", "s_89d3e8892ae8fd51": "More experts", "s_89db648162e7cea1": "Connection lost, retrying automatically…", "s_89e0ebd9d750f2c1": "The current backend does not support image generation. Update ACECode, restart, and try again.", @@ -4147,6 +4218,7 @@ export const sourceCatalogs = { "s_8fa4088e982d0fc9": "Potted plant", "s_8fd867ae44e0f4bf": "Invalid workshop address. Check the update source settings", "s_8ff756c4feb2a890": "work mode coding daily progress status loading", + "s_901a6d728b8cb75b": "No bot configured", "s_90253fc5741dc402": "Configure JSON (edit the mcp_servers section directly)", "s_906502a6a28e4eaa": "Apply after importing", "s_907fdb0785b5a284": "{{p0}} settings found", @@ -4237,6 +4309,7 @@ export const sourceCatalogs = { "s_9873be1ed93630a0": "Please wait while the page loads.", "s_9884e90a773101b9": "Global memory", "s_988c12f1398e3d9d": "Commands run by the AI execute inside an isolated sandbox; file, command and network policies follow the rules below.", + "s_98c4b439f2506ab2": "Available in the message input", "s_98cbc356f0e61a08": "Add a provider configuration to manage it here.", "s_98fc221b10e87c21": "Create app", "s_991bb7cfe5a81550": "Save changes", @@ -4295,6 +4368,7 @@ export const sourceCatalogs = { "s_9f14a4c73e4f3334": "Scroll down for later issues", "s_9f38ef9a265444d8": "Previous workspace", "s_9f3c756e11220434": "Memory scope", + "s_9f57c3620f0e89f6": "Increase reasoning depth", "s_9f58c22a7c726a76": "Standard quality model", "s_9f6b79ae99e1cde7": "Send only when explicitly supported by the provider", "s_9f9a95299a7588a3": "Desk globe", @@ -4316,6 +4390,7 @@ export const sourceCatalogs = { "s_a153567fbff7a035": "workspace dependencies", "s_a184d57717116620": "There is already a bypass question being answered, please wait.", "s_a1a9da9ee6ed0401": "unknown reason", + "s_a1beb78ea67743ca": "Toggle console", "s_a1dfbbbaa9f2fef9": "The bot has a webhook set; action needed", "s_a203f86cf6a6a841": "MCP servers", "s_a22774c08bc9187e": "Allow writes", @@ -4418,6 +4493,7 @@ export const sourceCatalogs = { "s_ab9084a640fbb864": "Deselect all", "s_abb6edd1093c5cf2": "Task terminated", "s_abe29e9399d0301c": "Failed to load more", + "s_abe83bd41de114f8": "Reasoning depth ({{p0}} / {{p1}})", "s_abee903c28f9e7cd": "Custom request header JSON is invalid", "s_ac02ecaeea7b6e7c": "A file picker is unavailable here. Enter the path manually.", "s_ac29e57a46f41c30": "minimize", @@ -4482,6 +4558,7 @@ export const sourceCatalogs = { "s_b28f2b46e181e999": "Open the Discord Developer Portal, create an application, and on the Bot page click Reset Token and copy it.", "s_b291a71042529666": "Contains {{p0}} local estimate records, the total may be different from the service provider bill", "s_b2d2f7bccbeeffa1": "Confirming…", + "s_b2e1f638ac7a818f": "Find text in the open conversation", "s_b31640e590dd972a": "There are unsaved changes", "s_b33181ed8c62a396": "Delete theme", "s_b3594eb35263f1eb": "Annotation {{p0}}, source text changed", @@ -4561,6 +4638,7 @@ export const sourceCatalogs = { "s_ba8b49f3fd10b338": "Image preview", "s_bad0ab59c33beb1d": "Every day {{p0}}", "s_baeb44eebe946b43": "Only letters, digits, underscores and hyphens, up to 64 characters", + "s_bb35ba51337fdea1": "Decrease one level, down to the lowest supported depth", "s_bb3e66754a5e03e3": "The Agent requests the following actions:", "s_bb6d995724f49a72": "Manage", "s_bb7cc334f9434dab": "After closing, the content and ability selection filled in this time will not be saved.", @@ -4579,6 +4657,7 @@ export const sourceCatalogs = { "s_bce2377283c2455a": "Clear", "s_bce3eb5a24deb419": "Could not save image generation settings", "s_bce6dd4701253496": "The selected file will open as a preview in the side panel.", + "s_bcf66cce7d5ce103": "Input and editing", "s_bd0b267bc9f5dc5f": "System architecture\nCode quality\nHigh concurrency", "s_bd2bbd87e2e2377d": "Search text failed", "s_bd4b9718f1f05dd4": "Previous", @@ -4752,6 +4831,7 @@ export const sourceCatalogs = { "s_ce5002ed24c3fa6e": "Connection reset", "s_ce5419d398b3aae8": "(Optional, leave blank to always take effect)", "s_ce5b1bb1da5e76f1": "Unzipping the installation package", + "s_ce70cddf9261401d": "Return to the message input and keep your draft", "s_ce99adb25bdca08b": "The background process will be stopped the next time you exit", "s_cea5fddb03aa0275": "Previous version notes from the upgrade service", "s_ceb33e0328a6f776": "Failed to save scheduled task", @@ -4772,6 +4852,7 @@ export const sourceCatalogs = { "s_cfbb88c4ba804b90": "Dumbbell", "s_cfe11a1e55711a0a": "Close the background task panel", "s_cff83f6ad777a1be": "No manager has been appointed yet", + "s_d03410080f147e13": "Decrease reasoning depth", "s_d035a28cd32d7901": "Connection failed. Check the global MCP settings", "s_d04d7e3884010cec": "Heart", "s_d04fcbda737fc0c6": "Loading", @@ -4838,6 +4919,7 @@ export const sourceCatalogs = { "s_d68bb14bb86a551b": "New expert options", "s_d696e50022d12267": "Automatically collapse messages", "s_d69766c856685234": "Beginner's Guide", + "s_d69fc417bf200e1e": "Manage, {{p0}} requests awaiting approval", "s_d6cd7e5f2a8a82cf": "This memory no longer exists. Refresh the list.", "s_d6d20f05ed0f96e0": "Single execution time", "s_d6e882143a3c3b83": "Scan to create bot", @@ -4864,6 +4946,7 @@ export const sourceCatalogs = { "s_d8b37bac2e8acb27": "Reorder {{p0}}", "s_d8b82d5765fa2fb4": "Every", "s_d8e0a082697905fe": "There is no running turn to interject into", + "s_d90b46a794c43495": "Insert a new line", "s_d93373f81363e3cc": "Support", "s_d94c3fb876621695": "API URL", "s_d94f9bafff59c06b": "Hooks enabled", @@ -4971,6 +5054,7 @@ export const sourceCatalogs = { "s_e26740162fbcb387": "Interject queued message into the active turn", "s_e2780ee23078ea51": "{{p0}}{{p1}} wants to use ACECode. Review it in Settings > Messaging channels", "s_e2a76ef1f12e147f": "Terminal", + "s_e2ad5101dbde2f62": "Toggle project sidebar", "s_e2f12da7faa68217": "Downloaded", "s_e2fda204edef967e": "JSON must be an object", "s_e2fe182aad1924fd": "C# tools", @@ -4981,6 +5065,7 @@ export const sourceCatalogs = { "s_e399eb07dc83ef19": "Queued send failed:", "s_e3a2fb921b49a4b6": "Grok sign-in succeeded, but the credentials could not be saved", "s_e40a628ffec72072": "Export CSV", + "s_e4354397844d3d18": "Close the topmost dismissible popup first", "s_e4401e8c0c6984b7": "Opening the session timed out. Please try again", "s_e48ef715d772a011": "Headless mode cannot confirm", "s_e4a992c23ca3c60b": "Running {{p0}} tools", @@ -5011,6 +5096,7 @@ export const sourceCatalogs = { "s_e6c8a78f71264985": "Copilot model list format error", "s_e6e355a99aa51351": "Command ran inside the sandbox", "s_e6f04ffbaa424001": "Code", + "s_e6f6d875638eca07": "Conversations", "s_e72eccf6eba409dd": "Browser address", "s_e73064cd1cf58394": "No memories in the current workspace yet", "s_e734022c3ba754dc": "No global memories yet", @@ -5022,6 +5108,7 @@ export const sourceCatalogs = { "s_e788045df7db1869": "{{p0}} avatar relative path", "s_e78db524e7c1e3dd": "...and {{p0}} items", "s_e7a4ae94fa9dbad5": "Will open", + "s_e7bf600d2da7ec18": "Go back in navigation history", "s_e804670f73f0874a": "Shift+Enter for a new line", "s_e806fbbe8ec6e82d": "Plugins", "s_e833097b603cb745": "{{p0}} contexts", @@ -5061,6 +5148,7 @@ export const sourceCatalogs = { "s_eb93045599c0e25c": "Enter Plan mode", "s_ebb240a1930e8bfb": "Search icons", "s_ebb63196c7d3c318": "Failed to copy image:", + "s_ebd8027daae319aa": "Search conversations, workspaces and settings", "s_ec7a11c7061bc164": "Migrating workspace…", "s_ec869826ec67f839": "Some Agent Browser data could not be copied. You may need to sign in again after restarting.", "s_ec9bbc8139d8bdd1": "Specialize", @@ -5083,6 +5171,7 @@ export const sourceCatalogs = { "s_ee00e57c07195431": "The current running mode does not support automatic restart. Please exit completely and then restart ACECode.", "s_ee1db622be4f2443": "Close {{p0}}", "s_ee1e5738be96819a": "Connected {{p0}}", + "s_ee2638183d3ea860": "Keyboard shortcuts", "s_ee32f25f70508f9c": "Clear search", "s_ee34c74d32e7548a": "Recently created", "s_ee529160390df1c4": "Endpoint mode", @@ -5121,6 +5210,7 @@ export const sourceCatalogs = { "s_f1b0b119c244d295": "{{p0}} files changed", "s_f1b112d6acd841b1": "Graduation cap", "s_f1bdeeb9fb4cab81": "Theme verification failed; the theme cannot be exported", + "s_f1c10da84402db5d": "Global search", "s_f1e0dfb858e7fcfe": "Denied by hook", "s_f21f25112af89630": "The webhook URL is registered. Turn on Use webhook on the Messaging API page of the LINE Developers console; this changes to connected automatically once it is on.", "s_f27627514a2cb542": "Expert component failed to load", @@ -5202,6 +5292,7 @@ export const sourceCatalogs = { "s_f8de71b07c4b412e": "Cannot write to the selected directory", "s_f8e021a80d022c01": "File data without a local path cannot exceed 25 MiB.", "s_f8f3247b4095d70d": "Test image generated", + "s_f906758c20ee15d5": "Save the file being edited", "s_f912edbb9c42279f": "Abort task", "s_f91cbcbd90f70585": "opencode session import completed", "s_f92c94c15b6e2e28": "The upgrade has been installed, please restart ACECode.", @@ -5214,6 +5305,7 @@ export const sourceCatalogs = { "s_f9c08b25e6d154cb": "Cancelling update", "s_f9c4971818b3037a": "Storage for conversations, memory, skills and other data", "s_f9d19345a067cbe1": "interrupt", + "s_fa153621ba3498cf": "Stop the current conversation", "s_fa2ff3c379a7c988": "View all", "s_fa319b472ff3f8ea": "", "s_fa48e89389404e60": "Active", @@ -5226,6 +5318,7 @@ export const sourceCatalogs = { "s_fafb87bd1b75c9b0": "Backend authentication has expired. Reconnect to ACECode and try again.", "s_fb04addb4c2654f5": "output", "s_fb04b4812e4e7970": "Request parameter error", + "s_fb28a6fff6c8a620": "Show or hide the project and conversation list", "s_fb33938685d49b6e": "There are unread tasks", "s_fb381883568c7c3f": "Theme package saved", "s_fb47db5e65ff7fa0": "Pinned", @@ -5256,6 +5349,7 @@ export const sourceCatalogs = { "s_fcea4baf388f169a": "Run core tests to locate and fix reliably reproducible issues.", "s_fcf8b4bff0df782d": "Open folder", "s_fcffc107c99acbba": "Registered workspaces", + "s_fd2d8286ae728ef1": "Increase one level, up to the highest supported depth", "s_fd351bc95e9337e4": "Sending a message", "s_fd4b9e3b6c685bae": "Continue editing", "s_fd5fd3c4567938c7": "Ask", diff --git a/web/src/lib/appShortcuts.js b/web/src/lib/appShortcuts.js new file mode 100644 index 00000000..61faa86e --- /dev/null +++ b/web/src/lib/appShortcuts.js @@ -0,0 +1,110 @@ +// Shared by keyboard dispatch, settings help and terminal forwarding. +const binding = (code, key, extra = {}) => ({ code, key, ...extra }); +export const APP_SHORTCUT_BROWSER_GUARD = Symbol('ace-shortcut-browser-guard'); +export const APP_SHORTCUTS = Object.freeze({ + newSession: binding('KeyN', 'N', { alt: true }), + focusInput: binding('KeyI', 'I', { alt: true }), + toggleSidebar: binding('KeyB', 'B', { alt: true }), + toggleRightPanel: binding('KeyE', 'E', { alt: true }), + settings: binding('Comma', ','), + shortcuts: binding('Slash', '/'), + stop: binding('Period', '.', { shift: true }), + forward: binding('Minus', '-'), + back: binding('Minus', '-', { shift: true }), + reasoningUp: binding('Period', '>', { alt: true, optionalShift: true }), + reasoningDown: binding('Comma', '<', { alt: true, optionalShift: true }), + search: binding('KeyK', 'K'), + console: binding('Backquote', '`', { control: true }), +}); + +export function isMacShortcutHost(win = globalThis.window) { + if (win?.__ACECODE_OS__) return win.__ACECODE_OS__ === 'macos'; + return /Mac|iPhone|iPad/i.test(win?.navigator?.platform || ''); +} + +export function matchAppShortcut(event) { + if (!event || event.isComposing || event.nativeEvent?.isComposing + || event.keyCode === 229 || event.which === 229 || event.getModifierState?.('AltGraph')) return null; + for (const [id, spec] of Object.entries(APP_SHORTCUTS)) { + if (spec.control ? (!event.ctrlKey || event.metaKey) : (!event.ctrlKey && !event.metaKey)) continue; + if (!spec.control && event.ctrlKey && event.metaKey) continue; + if (Boolean(event.altKey) !== Boolean(spec.alt)) continue; + if (!spec.optionalShift && Boolean(event.shiftKey) !== Boolean(spec.shift)) continue; + const key = String(event.key || '').toLowerCase(); + const punctuation = { Period: ['.', '>'], Comma: [',', '<'], Minus: ['-', '_'] }; + if (event.code === spec.code || key === spec.key.toLowerCase() + || punctuation[spec.code]?.includes(key)) return id; + } + return null; +} + +export function shortcutLabel(id, mac = isMacShortcutHost()) { + const spec = APP_SHORTCUTS[id]; + if (!spec) return ''; + return [spec.control || !mac ? 'Ctrl' : 'Cmd', spec.alt && (mac ? 'Option' : 'Alt'), + spec.shift && 'Shift', spec.key].filter(Boolean).join('+'); +} + +export function withAppShortcutHint(label, id) { + return `${label} (${shortcutLabel(id)})`; +} + +const visible = (element) => !element.hidden && element.getAttribute?.('aria-hidden') !== 'true' + && (!element.getClientRects || element.getClientRects().length > 0); + +export function appShortcutContextAllows(id, target, doc = globalThis.document) { + const overlays = doc?.querySelectorAll?.( + '[role="dialog"], [role="menu"], [data-ace-native-overlay="blocking"], [data-ace-native-overlay="overlap"][role="listbox"], [data-shortcut-menu="true"]', + ) || []; + for (const overlay of overlays) { + if (!visible(overlay)) continue; + if (['settings', 'shortcuts'].includes(id) + && (overlay.matches?.('[data-settings-mask], [data-settings-window]'))) continue; + if (id === 'search' && overlay.matches?.('[data-search-palette]')) continue; + return false; + } + if (['reasoningUp', 'reasoningDown', 'stop'].includes(id)) { + if (target?.closest?.('.xterm, [data-side-chat-window], [data-ace-editable-preview-text]')) return false; + const editor = target?.closest?.('input, textarea, select, [contenteditable="true"]'); + if (editor && !editor.closest?.('[data-main-composer="true"]')) return false; + } + return true; +} + +export function shortcutCatalog(mac = isMacShortcutHost()) { + const mod = mac ? 'Cmd' : 'Ctrl'; + const rows = [ + ['newSession', '会话操作', '新建会话', '在当前工作区新建会话'], + ['focusInput', '会话操作', '聚焦消息输入框', '回到当前输入框,保留草稿'], + ['stop', '会话操作', '停止当前会话执行', '等同当前会话的停止按钮'], + ['reasoningUp', '会话操作', '增加思维深度', '逐档增加,到最高档停止'], + ['reasoningDown', '会话操作', '降低思维深度', '逐档降低,到最低档停止'], + ['forward', '界面导航', '前进', '前往导航历史中的下一项'], + ['back', '界面导航', '后退', '返回导航历史中的上一项'], + ['search', '界面导航', '全局搜索', '搜索会话、工作区和设置'], + ['toggleSidebar', '界面导航', '展开/收起左侧项目栏', '切换项目和会话列表的显示'], + ['toggleRightPanel', '界面导航', '展开/收起右侧面板', '切换文件及预览面板的显示'], + ['console', '界面导航', '展开/收起控制台', '控制台可用时生效'], + ['settings', '界面导航', '打开设置', '打开设置窗口'], + ['shortcuts', '界面导航', '打开快捷键页签', '进入快捷键并聚焦搜索栏'], + ['find', '输入与编辑', '当前会话内查找', '在打开的会话中查找文本', `${mod}+F`], + ['send', '输入与编辑', '发送消息', '在消息输入框中生效', 'Enter'], + ['newline', '输入与编辑', '输入框换行', '在消息输入框中生效', 'Shift+Enter'], + ['history', '输入与编辑', '浏览输入历史', '输入框为空时向上浏览,历史浏览中可向下返回', '↑ / ↓'], + ['save', '输入与编辑', '保存正在编辑的文件', '文件编辑器聚焦时生效', `${mod}+S`], + ['dismiss', '输入与编辑', '关闭当前浮层/弹窗', '优先关闭最上层可关闭的浮层', 'Esc'], + ]; + return rows.map(([id, group, title, description, keys]) => ({ id, group, title, description, + keys: keys || shortcutLabel(id, mac) })); +} + +export function filterShortcuts(rows, query) { + const normalize = (value) => String(value || '').normalize('NFKC').toLowerCase() + .replace(/command|⌘/g, 'cmd').replace(/control|⌃/g, 'ctrl').replace(/⌥/g, 'option') + .replace(/\s*\+\s*/g, '+').trim(); + const words = normalize(query).split(/\s+/).filter(Boolean); + return rows.filter((row) => { + const text = normalize([row.title, row.description, row.group, row.keys, row.id].join(' ')); + return words.every((word) => text.includes(word)); + }); +} diff --git a/web/src/lib/appShortcuts.test.js b/web/src/lib/appShortcuts.test.js new file mode 100644 index 00000000..ef3c23a0 --- /dev/null +++ b/web/src/lib/appShortcuts.test.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { APP_SHORTCUTS, appShortcutContextAllows, filterShortcuts, matchAppShortcut, shortcutCatalog, shortcutLabel } from './appShortcuts.js'; +import { nextReasoningEffort } from './modelReasoning.js'; + +const key = (code, extra = {}) => ({ code, ctrlKey: true, ...extra }); +assert.equal(matchAppShortcut(key('Minus')), 'forward'); +assert.equal(matchAppShortcut(key('Minus', { key: '_', shiftKey: true })), 'back'); +assert.equal(matchAppShortcut(key('Minus', { altKey: true })), null); +assert.equal(matchAppShortcut(key('Period', { altKey: true })), 'reasoningUp'); +assert.equal(matchAppShortcut(key('Period', { altKey: true, shiftKey: true, key: '>' })), 'reasoningUp'); +assert.equal(matchAppShortcut(key('Comma', { altKey: true, key: '<' })), 'reasoningDown'); +assert.equal(matchAppShortcut({ ctrlKey: true, altKey: true, key: '>' }), 'reasoningUp'); +assert.equal(matchAppShortcut(key('Period', { shiftKey: true })), 'stop'); +assert.equal(matchAppShortcut(key('Period')), null); +assert.equal(matchAppShortcut(key('KeyK', { altKey: true })), null); +assert.equal(matchAppShortcut(key('KeyK', { shiftKey: true })), null); +for (const id of Object.keys(APP_SHORTCUTS)) { + const spec = APP_SHORTCUTS[id]; + const event = key(spec.code, { key: spec.key, altKey: !!spec.alt, shiftKey: !!spec.shift }); + assert.equal(matchAppShortcut(event), id); + assert.equal(matchAppShortcut({ ...event, isComposing: true }), null); + assert.equal(matchAppShortcut({ ...event, keyCode: 229 }), null); + assert.equal(matchAppShortcut({ ...event, getModifierState: () => true }), null); + assert.equal(matchAppShortcut({ ...event, ctrlKey: false, metaKey: true }), spec.control ? null : id); +} +assert.equal(shortcutLabel('back', false), 'Ctrl+Shift+-'); +assert.equal(shortcutLabel('reasoningUp', true), 'Cmd+Option+>'); +assert.equal(shortcutLabel('console', true), 'Ctrl+`'); +assert.deepEqual(filterShortcuts(shortcutCatalog(false), 'ctrl + shift + -').map((x) => x.id), ['back']); +assert.deepEqual(filterShortcuts(shortcutCatalog(false), '思维 深度').map((x) => x.id), ['reasoningUp', 'reasoningDown']); +assert.equal(filterShortcuts(shortcutCatalog(false), '不存在的功能').length, 0); +const overlay = (selector, show = true) => ({ matches: (s) => s.includes(selector), getClientRects: () => show ? [{}] : [] }); +const doc = (...overlays) => ({ querySelectorAll: () => overlays }); +assert.equal(appShortcutContextAllows('newSession', null, doc(overlay('dialog'))), false); +assert.equal(appShortcutContextAllows('newSession', null, doc(overlay('dialog', false))), true); +assert.equal(appShortcutContextAllows('shortcuts', null, doc(overlay('[data-settings-mask]'))), true); +assert.equal(appShortcutContextAllows('shortcuts', null, doc(overlay('[data-settings-mask]'), overlay('dialog'))), false); +assert.equal(appShortcutContextAllows('search', null, doc(overlay('[data-search-palette]'))), true); +const model = { provider: 'openai', reasoning: { supported: true, default_enabled: true, + supported_efforts: ['max', 'low', 'high'], default_effort: 'high' } }; +assert.equal(nextReasoningEffort(model, 1), 'max'); +assert.equal(nextReasoningEffort(model, -1), 'low'); +assert.equal(nextReasoningEffort(model, 1, 'max'), null); +assert.equal(nextReasoningEffort(model, -1, 'low'), null); +assert.equal(nextReasoningEffort(model, 1, 'low'), 'high'); +assert.equal(nextReasoningEffort({ ...model, reasoning_effort: 'max' }, -1), 'high'); +assert.equal(nextReasoningEffort(null, 1), null); +assert.equal(nextReasoningEffort({ ...model, reasoning: { ...model.reasoning, enabled: false } }, 1), null); +console.log('[pass] app shortcuts: exact bindings, platform labels, search, scopes and reasoning bounds'); diff --git a/web/src/lib/browserDefaults.js b/web/src/lib/browserDefaults.js index 663d2199..68b010df 100644 --- a/web/src/lib/browserDefaults.js +++ b/web/src/lib/browserDefaults.js @@ -3,6 +3,7 @@ import { isFindShortcut } from './globalFind.js'; import { isDesktopShell } from './desktopShellMode.js'; +import { APP_SHORTCUT_BROWSER_GUARD, matchAppShortcut } from './appShortcuts.js'; const ZOOM_SHORTCUT_KEYS = new Set(['+', '=', '-', '_', '0']); const ZOOM_SHORTCUT_CODES = new Set([ @@ -55,6 +56,13 @@ export function installBrowserDefaultGuards(target = globalThis.window) { return; } } + // These two zoom-shaped chords belong to ACECode history navigation. + const shortcut = matchAppShortcut(event); + if (shortcut === 'forward' || shortcut === 'back') { + event.preventDefault(); + event[APP_SHORTCUT_BROWSER_GUARD] = true; + return; + } if (isBlockedBrowserDefaultShortcut(event)) event.preventDefault(); }; diff --git a/web/src/lib/changeReviewDetailsArchitecture.test.js b/web/src/lib/changeReviewDetailsArchitecture.test.js index d397cd78..5bf87b89 100644 --- a/web/src/lib/changeReviewDetailsArchitecture.test.js +++ b/web/src/lib/changeReviewDetailsArchitecture.test.js @@ -185,7 +185,7 @@ run('Top bar keeps direct task search while new-conversation and loop stay in qu assert.doesNotMatch(topBar, /]*title="循环"/); assert.match( topBar, - /\s*/, + /\s*/, ); assert.match(source('SidebarQuickMenu.jsx'), /invokeTopBarQuickAction/); }); diff --git a/web/src/lib/channelsSettings.js b/web/src/lib/channelsSettings.js index c8b26168..593c77d3 100644 --- a/web/src/lib/channelsSettings.js +++ b/web/src/lib/channelsSettings.js @@ -172,10 +172,10 @@ export function applyChannelsEvent(state, envelope) { return state; } -// 平台卡片的固定内容。图标统一用单色图标,默认是聊天图标(用户拍板:不用彩色品牌 logo)。 +// 平台卡片的固定内容。品牌图标由 ChannelPlatformIcon 使用本地 SVG 提供。 // 写成函数:文案在渲染时求值,切换界面语言后跟着变。 export function channelCards() { - const card = (platform, title, description) => ({ platform, title, icon: 'chat', description }); + const card = (platform, title, description) => ({ platform, title, description }); return [ card('qq', 'QQ 机器人', '在 QQ 私聊或群里 @机器人 下达指令,结果回到聊天窗口。'), card('weixin', '微信', '在微信里私聊机器人下达指令,结果回到聊天窗口。'), diff --git a/web/src/lib/channelsSettings.test.js b/web/src/lib/channelsSettings.test.js index 054f6995..ccfcedb8 100644 --- a/web/src/lib/channelsSettings.test.js +++ b/web/src/lib/channelsSettings.test.js @@ -105,12 +105,10 @@ run('applyChannelsEvent 只合并消息通道事件', () => { }); // 场景:设置页的平台卡片。 -// 期望:七个平台按国内(QQ、微信、飞书、钉钉)再海外(Telegram、Discord、LINE)排列;图标统一是 -// 单色聊天图标(用户拍板:不用彩色品牌 logo);向导都是三步,第一步按平台区分。 +// 期望:七个平台按国内再海外排列;向导都是三步,第一步按平台区分。 run('channelCards 与向导步骤', () => { assert.deepEqual(channelCards().map((card) => card.platform), ['qq', 'weixin', 'feishu', 'dingtalk', 'telegram', 'discord', 'line']); - assert.ok(channelCards().every((card) => card.icon === 'chat')); assert.deepEqual(wizardSteps('qq'), ['扫码创建机器人', '连接', '绑定机主']); assert.deepEqual(wizardSteps('weixin'), ['扫码登录', '连接', '完成']); assert.deepEqual(wizardSteps('telegram'), ['创建机器人', '连接', '绑定机主']); @@ -295,7 +293,7 @@ run('telegramWebhookBlocked', () => { // 期望:页面组件不渲染原因 / 隐私模式 / 暂存数 / 绑定计数这些细节;卡片两列;不用开关,用连接按钮。 run('消息通道设置页保持简洁,细节只进日志', () => { const page = readFileSync(new URL('../components/ChannelsSettings.jsx', import.meta.url), 'utf8'); - assert.match(page, /grid grid-cols-2/); + assert.match(page, /grid grid-cols-1.*grid-cols-2/); assert.doesNotMatch(page, /platform\.detail|privacy_mode|\.held\b|bindingRows|binding\.stats/); assert.doesNotMatch(page, / { +run('update UI exposes safe cancellation and a dual-foreground sidebar progress pill', () => { const app = source('App.jsx'); const updateDialog = source('components/UpdateDialog.jsx'); const topBar = source('components/TopBar.jsx'); + const sidebar = source('components/Sidebar.jsx'); + const sidebarFooter = between(sidebar, 'ace-sidebar-footer', ''); assert.match(app, /api\.cancelUpdate\(updateJob\.job_id\)/); assert.match(updateDialog, /取消升级/); assert.match(updateDialog, /job\?\.can_cancel === false/); assert.match(updateDialog, /mode === 'cancelled'/); - assert.match(topBar, /updateProgress = 0/); - assert.match(topBar, /100 - boundedUpdateProgress/); - assert.match(topBar, /absolute inset-0 flex items-center justify-center text-accent/); - assert.match(topBar, /absolute inset-0 flex items-center justify-center text-white/); - assert.match(topBar, /ace-topbar-update-button relative min-w-\[44px\] overflow-hidden px-3 rounded-full/); + assert.match(sidebar, /updateProgress = 0/); + assert.match(sidebarFooter, /100 - boundedUpdateProgress/); + assert.match(sidebarFooter, /absolute inset-0 flex items-center justify-center text-accent/); + assert.match(sidebarFooter, /absolute inset-0 flex items-center justify-center text-white/); + assert.match(sidebarFooter, /ace-sidebar-update-button relative h-6 shrink-0 min-w-\[44px\] overflow-hidden px-3 rounded-full/); + assert.doesNotMatch(topBar, /updateStatus|onStartUpdate|ace-topbar-update-button/); }); diff --git a/web/src/lib/modelReasoning.js b/web/src/lib/modelReasoning.js index 2810a0a2..f85bf571 100644 --- a/web/src/lib/modelReasoning.js +++ b/web/src/lib/modelReasoning.js @@ -50,3 +50,16 @@ export function composerReasoningOptions(model, override = undefined) { ], }; } + +// Step through supported numeric depths, starting at the effective default. +// null means no available move; the default menu entry is not a depth. +export function nextReasoningEffort(model, direction, override = undefined) { + const options = composerReasoningOptions(model, override); + if (!options || ![-1, 1].includes(direction)) return null; + const levels = MODEL_REASONING_EFFORTS.filter((effort) => options.items.some((item) => item.effort === effort)); + const reasoning = normalizeModelReasoning(model.reasoning); + const current = options.selectedEffort || reasoning.effort || reasoning.default_effort; + const index = levels.indexOf(current); + if (index < 0) return direction > 0 ? levels[0] ?? null : levels[levels.length - 1] ?? null; + return levels[index + direction] ?? null; +} diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 51f2522a..7219ede0 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -1,4 +1,5 @@ import './desktopOffice.test.js'; +import './htmlPreview.test.js'; import './desktopOfficePreferences.test.js'; import './questionPicker.test.js'; import './turnCancellation.test.js'; @@ -227,6 +228,7 @@ import './systemClipboard.test.js'; import './searchSessions.test.js'; import './searchExperienceArchitecture.test.js'; import './useGlobalShortcut.test.js'; +import './appShortcuts.test.js'; import './searchPaletteShortcut.test.js'; import './browserCompatibility.test.js'; import './browserDefaults.test.js'; diff --git a/web/src/lib/searchExperienceArchitecture.test.js b/web/src/lib/searchExperienceArchitecture.test.js index fafebff7..5c81ff74 100644 --- a/web/src/lib/searchExperienceArchitecture.test.js +++ b/web/src/lib/searchExperienceArchitecture.test.js @@ -68,17 +68,18 @@ run('settings hits in the palette reuse the settings window index and never call }); run('the search palette shortcut has a single definition shared by every surface', () => { - // Ctrl+K 的判定只在 lib/searchPaletteShortcut.js:App 的全局监听、TopBar 提示、 + // Ctrl+K 的判定统一在 lib/appShortcuts.js:App 的全局监听、TopBar 提示、 // 快捷菜单 kbd、控制台(xterm)放行都引用它,不能各自再写一遍 e.key === 'k'。 const app = source('App.jsx'); const topBar = source('components/TopBar.jsx'); const consoleDock = source('components/ConsoleDock.jsx'); const quickActions = source('lib/topBarQuickActions.js'); const quickMenu = source('components/SidebarQuickMenu.jsx'); - assert.match(app, /useGlobalShortcut\(\s*isSearchPaletteShortcut,/); + assert.match(app, /useAppShortcuts\(/); + assert.match(source('lib/searchPaletteShortcut.js'), /matchAppShortcut\(event\) === 'search'/); assert.doesNotMatch(app, /toLowerCase\(\) === 'k'/); assert.match(topBar, /withSearchPaletteShortcutHint\('搜索任务'\)/); - assert.match(consoleDock, /if \(isSearchPaletteShortcut\(ev\)\) return false;/); + assert.match(consoleDock, /matchAppShortcut\(ev\)/); assert.match(quickActions, /searchPaletteShortcutLabel\(win\)/); assert.match(quickMenu, /topBarQuickActionShortcutLabel\(action\)/); }); diff --git a/web/src/lib/searchPaletteShortcut.js b/web/src/lib/searchPaletteShortcut.js index bef5a46e..86f30e6d 100644 --- a/web/src/lib/searchPaletteShortcut.js +++ b/web/src/lib/searchPaletteShortcut.js @@ -3,19 +3,16 @@ // 这是唯一的定义点 —— App.jsx 的全局监听、TopBar / 快捷菜单里的提示文案、 // 控制台(xterm)的放行名单都从这里取,改键位只改这一个文件。 -import { matchShortcut } from './useGlobalShortcut.js'; +import { APP_SHORTCUTS, matchAppShortcut } from './appShortcuts.js'; -export const SEARCH_PALETTE_SHORTCUT = Object.freeze({ key: 'k', ctrl: true, meta: true }); +export const SEARCH_PALETTE_SHORTCUT = Object.freeze({ key: APP_SHORTCUTS.search.key.toLowerCase(), ctrl: true, meta: true }); // 判定 keydown 是否为搜索面板快捷键。 // - Ctrl 与 ⌘ 任一即可(matchShortcut 的 ctrl/meta 语义),Alt / Shift 额外按下不算。 // - 兼容 e.code === 'KeyK':非拉丁键盘布局(如俄语)或部分输入法下 e.key 不是 'k', // 但物理键位不变 —— 与 Ctrl+` 用 e.code 兜底是同一个理由。 export function isSearchPaletteShortcut(event) { - if (!event || event.altKey || event.shiftKey) return false; - if (matchShortcut(event, SEARCH_PALETTE_SHORTCUT)) return true; - if (!event.ctrlKey && !event.metaKey) return false; - return event.code === 'KeyK'; + return matchAppShortcut(event) === 'search'; } function isMacHost(win) { diff --git a/web/src/lib/settingsNavigation.js b/web/src/lib/settingsNavigation.js index 04444d0f..03061d1d 100644 --- a/web/src/lib/settingsNavigation.js +++ b/web/src/lib/settingsNavigation.js @@ -5,6 +5,7 @@ export const SETTINGS_NAV_GROUPS = [ items: [ { key: 'general', label: '常规', icon: 'settings' }, { key: 'appearance', label: '外观', icon: 'brightness' }, + { key: 'shortcuts', label: '快捷键', icon: 'keyboard' }, { key: 'config', label: '配置', icon: 'terminal' }, { key: 'personalization', label: '个性化', icon: 'eye' }, { key: 'virtual-office', label: '虚拟办公室', icon: 'office' }, diff --git a/web/src/lib/settingsNavigation.test.js b/web/src/lib/settingsNavigation.test.js index 2e07fc14..f03157f8 100644 --- a/web/src/lib/settingsNavigation.test.js +++ b/web/src/lib/settingsNavigation.test.js @@ -27,7 +27,7 @@ test('settings navigation uses the confirmed Codex-style groups', () => { { key: 'personal', label: '个人', - items: ['常规', '外观', '配置', '个性化', '虚拟办公室', '使用情况'], + items: ['常规', '外观', '快捷键', '配置', '个性化', '虚拟办公室', '使用情况'], }, { key: 'integrations', @@ -58,6 +58,7 @@ test('flattened settings routes remain unique and complete', () => { assert.deepEqual(keys, [ 'general', 'appearance', + 'shortcuts', 'config', 'personalization', 'virtual-office', diff --git a/web/src/lib/settingsSearch.js b/web/src/lib/settingsSearch.js index 5430165e..9411e095 100644 --- a/web/src/lib/settingsSearch.js +++ b/web/src/lib/settingsSearch.js @@ -20,6 +20,7 @@ export function settingsSearchEntries(developerModeUnlocked = false) { ['appearance', '字体大小', 'font size'], ['appearance', '显示任务时间', 'sidebar task time timestamp'], ['appearance', '消息自动折叠', '会话 conversation messages auto collapse fold expand tools'], + ['shortcuts', '搜索快捷键', 'keyboard shortcuts hotkeys 快捷键 键盘 搜索 前进 后退 思维深度 Ctrl Alt Cmd'], ['config', '升级服务 URL', 'upgrade update service url'], ['config', 'Python 工具', 'python uv ruff mypy path directory'], ['config', 'Node.js 工具', 'node nodejs npm pnpm tsx path directory'], diff --git a/web/src/lib/topBarQuickActions.js b/web/src/lib/topBarQuickActions.js index 3dfbec1c..8a59e264 100644 --- a/web/src/lib/topBarQuickActions.js +++ b/web/src/lib/topBarQuickActions.js @@ -1,5 +1,6 @@ import { DEFAULT_SINGLE_LAYOUT } from './singleLayout.js'; import { searchPaletteShortcutLabel } from './searchPaletteShortcut.js'; +import { isMacShortcutHost, shortcutLabel } from './appShortcuts.js'; // 快捷操作里带默认快捷键的项:值是逻辑名而不是按键文案,按键文案随平台变 // (macOS 显示 ⌘K),渲染时经 topBarQuickActionShortcutLabel 解析。 @@ -38,6 +39,7 @@ export const TOPBAR_QUICK_ACTIONS = Object.freeze([ iconSize: 16, callback: 'onSettings', group: 'application', + shortcut: 'settings', }), Object.freeze({ id: 'virtual-office', @@ -99,6 +101,7 @@ export function topBarQuickActionShortcutLabel(action, win) { if (action?.shortcut === QUICK_ACTION_SHORTCUT_SEARCH_PALETTE) { return searchPaletteShortcutLabel(win); } + if (action?.shortcut === 'settings') return shortcutLabel('settings', isMacShortcutHost(win)); return ''; } diff --git a/web/src/lib/topBarQuickActions.test.js b/web/src/lib/topBarQuickActions.test.js index 4bcd8ed5..68cf9809 100644 --- a/web/src/lib/topBarQuickActions.test.js +++ b/web/src/lib/topBarQuickActions.test.js @@ -90,13 +90,18 @@ test('top-bar quick-actions menu uses the default sidebar width for invalid inpu test('only the find-content quick action carries the search-palette shortcut', () => { // 触发场景:侧栏快捷菜单渲染各项右侧的按键提示。 - // 期望:只有「查找内容」带 Ctrl+K(macOS 上 ⌘K),其它项返回空串以便不渲染 kbd。 + // 搜索保留 Ctrl+K,设置显示自己的已注册快捷键。 const findContent = TOPBAR_QUICK_ACTIONS.find((action) => action.id === 'find-content'); assert.equal(findContent.shortcut, QUICK_ACTION_SHORTCUT_SEARCH_PALETTE); assert.equal(topBarQuickActionShortcutLabel(findContent, { __ACECODE_OS__: 'windows' }), 'Ctrl+K'); assert.equal(topBarQuickActionShortcutLabel(findContent, { __ACECODE_OS__: 'macos' }), '⌘K'); for (const action of TOPBAR_QUICK_ACTIONS) { if (action.id === 'find-content') continue; + if (action.id === 'settings') { + assert.equal(topBarQuickActionShortcutLabel(action, { __ACECODE_OS__: 'windows' }), 'Ctrl+,'); + assert.equal(topBarQuickActionShortcutLabel(action, { __ACECODE_OS__: 'macos' }), 'Cmd+,'); + continue; + } assert.equal(action.shortcut, undefined); assert.equal(topBarQuickActionShortcutLabel(action, { __ACECODE_OS__: 'windows' }), ''); } diff --git a/web/src/lib/useAppShortcuts.js b/web/src/lib/useAppShortcuts.js new file mode 100644 index 00000000..53eb7be0 --- /dev/null +++ b/web/src/lib/useAppShortcuts.js @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; +import { APP_SHORTCUT_BROWSER_GUARD, appShortcutContextAllows, matchAppShortcut } from './appShortcuts.js'; + +// Actions stay with their React owner; one event is consumed at most once. +export function useAppShortcuts(handlers) { + const latest = useRef(handlers); + latest.current = handlers; + useEffect(() => { + let composing = false; + let settleTimer; + const start = () => { clearTimeout(settleTimer); composing = true; }; + const end = () => { settleTimer = setTimeout(() => { composing = false; }, 0); }; + const onKey = (event) => { + if ((event.defaultPrevented && !event[APP_SHORTCUT_BROWSER_GUARD]) || composing) return; + const id = matchAppShortcut(event); + const handler = latest.current[id]; + if (!handler || !appShortcutContextAllows(id, event.target)) return; + if (event.repeat) { event.preventDefault(); return; } + if (handler(event) === false) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + window.addEventListener('compositionstart', start, true); + window.addEventListener('compositionend', end, true); + window.addEventListener('keydown', onKey); + return () => { + clearTimeout(settleTimer); + window.removeEventListener('compositionstart', start, true); + window.removeEventListener('compositionend', end, true); + window.removeEventListener('keydown', onKey); + }; + }, []); +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index e875c96b..75d988e9 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -1805,8 +1805,7 @@ body { flex-shrink: 0; } .ace-topbar-navigation, -.ace-topbar-controls, -.ace-topbar-update-button { +.ace-topbar-controls { /* Keep controls aligned with native macOS lights when a long directory wraps. */ align-self: flex-start; margin-top: 3px; @@ -1838,9 +1837,6 @@ body { width: var(--ace-topbar-control-size); height: var(--ace-topbar-control-size); } -.ace-topbar-update-button { - height: var(--ace-topbar-control-size); -} .ace-topbar .ace-window-control { width: calc(var(--ace-topbar-control-size) * 1.2); height: var(--ace-topbar-control-size);