From 00be0363c6c0c3ed64c7c677d5bfa2c8670b314d Mon Sep 17 00:00:00 2001 From: Trae User Date: Fri, 18 Sep 2026 14:27:49 +0800 Subject: [PATCH 001/126] fix: align sidebar icons --- web/src/components/Sidebar.jsx | 14 +++++++------- web/src/components/TopBar.jsx | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index 76e49cd25..1afcf4035 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -505,7 +505,7 @@ function SidebarNavItem({ item, onClick }) { data-tour-target={item.id === 'new-task' ? 'sidebar-new-task' : undefined} onPointerDown={item.id === 'new-task' ? preserveHomeComposerFocus : undefined} onClick={onClick} - className="ace-sidebar-primary-text w-full flex items-center gap-[7px] px-3 py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition text-left" + className="ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-3 py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition text-left" > @@ -590,7 +590,7 @@ function CustomSidebarSection({ workspaceHash = '', onOpenSettingsSection, onOpe type="button" onClick={() => setExpanded((value) => !value)} data-sidebar-custom-section="true" - className="ace-sidebar-extensions-trigger ace-sidebar-primary-text w-full flex items-center gap-[7px] px-3 py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition" + className="ace-sidebar-extensions-trigger ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-3 py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition" aria-expanded={expanded} aria-controls={listId} > @@ -627,7 +627,7 @@ function SidebarSectionHeader({ sectionId, count, expanded, onToggle, actions = return (
{tabsOverflow && addButton}
- {sidePanelListCollapsed && onToggleSidePanelList && ( - - )} + {sidePanelListCollapsed && onToggleSidePanelList && ( + + )}
diff --git a/web/src/components/SettingsPage.jsx b/web/src/components/SettingsPage.jsx index c35bc37fe..8518693bd 100644 --- a/web/src/components/SettingsPage.jsx +++ b/web/src/components/SettingsPage.jsx @@ -3096,7 +3096,7 @@ function SectionUsage() { ]; return ( - <> +

使用情况

); @@ -251,8 +253,9 @@ section 内不同子分组之间: 写新 settings section / 类似管理面板时: - [ ] H2 用 `text-xl font-bold mb-5` -- [ ] 子分组主标 `text-[14px] font-semibold mb-1` + 描述 `text-[12px] text-fg-mute mb-3` -- [ ] 配置项用 § 4 行卡片(左 13px medium / 11px fg-mute,右 toggle/input/pill) +- [ ] 一级分组标题 `text-[14px] font-semibold mb-1` + 描述 `text-[12px] text-fg-mute mb-3` +- [ ] 仅主标题、一级标题可加粗;字段、卡片、选项、导航及更深层标题均为 400,强调使用文字颜色 +- [ ] 配置项用 § 4 行卡片(左 13px normal + fg / 11px fg-mute,右 toggle/input/pill) - [ ] 多选一用 § 4.1 row-radio,多选卡用 § 5 grid - [ ] input 走 § 6.0 / § 6.1 模板 - [ ] 状态指示走 § 8 绿点 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 8521aad2d..cb2a39eee 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -1227,7 +1227,8 @@ those messages does not depend on the source session's attachment storage. ### `POST /api/sessions/:id/messages/retry` -Retries the exact trailing user message in an idle live session. Body: +Retries the trailing user message, or the last user message of a manually +stopped turn, in an idle live session. Body: ```json {"expected_user_message_id":"persisted-user-message-id"} @@ -1237,10 +1238,25 @@ The request accepts only this field. The backend verifies the full visible transcript and model history end with that user message, with no active or queued work. It checks again when the worker starts. Hidden bookkeeping records do not count as transcript messages; assistant (including empty -messages), tool, system and error messages prevent retry. - -The original message, attachments and context are reused without appending -another user record. Returns `202 {"queued":true,"user_message_id":"..."}`; +messages), tool, system and error messages prevent ordinary retry. + +A completed manual stop persists a transcript-only system message with +`metadata.user_aborted: true` and `metadata.retry_user_message_id`. If that +marker is the final visible transcript entry, the backend may retry its +specified last user message even after partial assistant output or tool +results. A stop request alone, an interjection, or plain interruption text +does not grant this exception. Later visible events invalidate it. +Already streamed text is saved as an assistant message with +`metadata.transcript_only: true` and `metadata.interrupted_output: true`, so +history reloads retain it without sending incomplete output/tool calls back +to the provider. + +The original message, attachments and context are reused. If model history +still ends with that user, no user record is appended. If an aborted turn +already has assistant/tool records, they are preserved and the original +structured user content is appended with a new identity, without expanding +skills again or creating adjacent user messages. The stop marker is never +sent to the model. Returns `202 {"queued":true,"user_message_id":"..."}`; malformed requests return `400`, and unavailable sessions, stale message IDs or active/queued work return `409`. This endpoint does not create or resume a session, expand commands, or accept new input. @@ -2947,7 +2963,9 @@ Returns: "show_acecode_avatar": false, "theme": "system", "color_theme": "blue", - "font_size": "medium" + "font_size": "medium", + "sidebar_session_time": true, + "message_auto_collapse": true } ``` @@ -2957,6 +2975,11 @@ are stored in `~/.acecode/config.json`, so Desktop restores them even when its managed daemon uses a different loopback port. The avatar preference is kept for compatibility and is always normalized to `false`. +`message_auto_collapse` is a boolean, defaulting to `true` for new and legacy +configurations. When `false`, main and subagent conversations display messages +without activity/turn folding; individual tool calls remain collapsible. +The preference is persisted and included in the Desktop appearance bootstrap. + ### `PUT /api/config/ui-preferences` Body: diff --git a/docs/sandbox.md b/docs/sandbox.md index f63553fc8..08b6df386 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -201,8 +201,13 @@ forbidden > prompt > allow 合并;多段命令须每段都获 allow。 } ``` -除 `filesystem.deny` 示例外均为默认值,默认配置保存时不产生 sandbox 段。额外根必须为绝对路径 -或记号。`windows_backend` 取 `restricted-token`(默认)或 `mxc`。 +首次运行包含一次性沙盒迁移的版本时(包括新安装),应用会将 `sandbox.enabled` 设为 +`false`,并同时保存 `migrations.disable_sandbox_once: true`。此迁移只执行一次:之后在 +安全中心重新开启沙盒,重启、保存其他设置及后续升级都会保留开启状态。 + +上面的示例展示重新开启后的配置。除 `filesystem.deny` 示例外,其余为字段默认值; +保存时默认字段可省略,迁移完成标记始终保留。迁移不修改网络策略、路径清单或权限模式。 +额外根必须为绝对路径或记号。`windows_backend` 取 `restricted-token`(默认)或 `mxc`。 `/sandbox` 显示后端、原因、模式、可写/只读/可读路径、deny 名单、会话授权和网络能力。 `/sandbox off` / `/sandbox on` 只改变当前会话,不写配置;关闭后 Auto 未知命令仍需确认。 后端准备或启动失败会把本会话的沙盒标记为不可用(状态里显示原因);修好环境后 diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/design.md b/openspec/changes/add-task-suggestions-and-session-continuation/design.md index 938c54a80..40e9167d4 100644 --- a/openspec/changes/add-task-suggestions-and-session-continuation/design.md +++ b/openspec/changes/add-task-suggestions-and-session-continuation/design.md @@ -15,6 +15,15 @@ The domain service owns suggestion state, execution location and startup receipt 5. GET session suggestions returns persisted records, source/workspace busy and worktree availability. Accept/dismiss POST returns a suggestion envelope. Host lifecycle work handles queued acceptance independently of tab focus; reconnect also recovers interrupted phases. Expected failures are shown on the card with retry against the same target. 6. A compact non-modal card is attached to the chat's upper right, uses theme tokens, supports keyboard and narrow screens, and never steals focus. Apply the theme's large and ordinary shadow tokens to each card, and reserve padding inside the scrolling container so overflow does not clip the shadow while preserving the visible card position. Side tasks expose a split location action and stay in the source view; continuation starts in the same directory and navigates to its successor. Polling is bounded/single-flight and source-scoped, with stale responses ignored. Unsupported old backends silently omit this optional surface. +### 2026-09-19:建议卡片自动关闭 + +- 控制器为首次展示的 `pending` 建议记录绝对截止时间(30 秒),使用可注入时钟和定时器;轮询刷新不得重置倒计时,多张卡片各自计时,销毁控制器时清理所有定时器。 +- 到期沿用既有 dismiss 请求持久化关闭,不触发 accept。用户开始任务、手动关闭或建议进入排队/启动/失败状态后停止本次计时,防止到期取消已接受的任务;关闭失败保留卡片和重试操作,不自动反复请求。 +- 倒计时文字位于底部细线左上方,中文严格为“xx秒后关闭”,英文为“Closes in xx s”。进度由满到空,右端以小火苗随剩余长度左移,最后 5 秒略微提亮;火苗保持约 6×9px,以柔和外焰和微亮内芯轻微摆动、伸缩及明暗变化,不添加粒子或扩大光晕。沿用主题 token、原有标题、按钮和关闭图标。减少动态效果时按秒更新长度,关闭平滑移动、火苗闪动与光晕。 +- 倒计时只负责当前展示周期;切换会话销毁旧控制器,重新进入尚未关闭的建议时开始新的 30 秒展示。已成功关闭的建议仍由服务端过滤。 +- 任务位置菜单通过 `AnchoredMenu` 挂载至 `document.body`;调用方必须显式提供主题实底、边框、圆角、内边距、阴影和高于建议卡片的层级,避免选项透底或被卡片遮挡。保持既有定位、键盘选择及焦点恢复行为。 + + ## Risks / Trade-offs - A shared directory can also be modified by external editors or processes. Serialize known session work and tell the user the location is shared; existing file read guards still apply. diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md b/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md index 3f665f569..694e9460f 100644 --- a/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md +++ b/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md @@ -9,6 +9,7 @@ Side findings currently interrupt the main conversation or remain unactionable p - Offer a continuation after three successful summary compactions by default, with a configurable threshold and once-per-session dismissal. - Continue in a fresh conversation using bounded handoff context, a real source-session reference, the actual working directory, and inherited execution configuration. - Persist acceptance phases and the target session identity so retries do not create duplicate tasks. Keep old conversations available and link both directions. +- 待处理的两类建议卡片显示 30 秒自动关闭倒计时;文案位于底部进度条左上方,使用“xx秒后关闭”,进度条以细线和微光端点表现剩余时间。 ## Capabilities diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md b/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md index 84e9aed58..5f7513ecf 100644 --- a/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md +++ b/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md @@ -44,3 +44,19 @@ Web and desktop SHALL present suggestions using the existing theme, readable Chi #### Scenario: Source changes during a request - **WHEN** a user changes sessions while a suggestion response is in flight - **THEN** that response cannot replace suggestions for the newly selected session + +### Requirement: 建议卡片自动关闭倒计时 +Web 和桌面端 SHALL 为首次展示且尚未接受的支线任务建议和上下文续接建议提供 30 秒倒计时,并在到期后通过既有关闭接口关闭建议。文案 SHALL 位于底部进度条左上方,中文为“xx秒后关闭”。进度条 SHALL 为逐渐缩短的细线及随端点移动、轻微闪动的小火苗,最后 5 秒轻微提亮,并适配主题、中英文、窄屏和减少动态效果偏好。 + +#### Scenario: 倒计时到期且轮询更新 +- **WHEN** 待处理建议首次展示且用户未操作,期间收到同一建议的轮询结果 +- **THEN** 30 秒截止时间保持不变,到期仅发起一次关闭请求,不启动任务,成功关闭后刷新不再展示 + +#### Scenario: 用户已接受或关闭失败 +- **WHEN** 用户接受建议、主动关闭,或建议变为排队、启动或失败状态 +- **THEN** 停止自动关闭倒计时,不因到期取消排队中的任务 +- **AND** 关闭请求失败时保留错误提示和手动重试能力,不自动循环重试 + +#### Scenario: 多卡片及会话切换 +- **WHEN** 多张建议先后出现,或用户离开当前会话 +- **THEN** 每张卡片独立计时,旧会话定时器在销毁时清理,不关闭其他会话的建议 diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md b/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md index b6d2ccb32..20bf52cda 100644 --- a/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md +++ b/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md @@ -26,7 +26,24 @@ - [x] 5.1 Build native changes and execute focused native tests plus appropriate broader checks; review failures and document any platform limitations. - [x] 5.2 Review end-to-end behavior and final diff, run strict OpenSpec validation and git diff --check, and reconcile the completed tasks with actual evidence. -## Validation evidence +## 6. 建议卡片 30 秒自动关闭 + +- [x] 6.1 控制器维护独立截止时间,复用关闭接口,处理轮询、接受、失败及销毁竞态,并补充可控时钟测试。 +- [x] 6.2 按确认视觉实现进度条左上方“xx秒后关闭”、细线与微光端点,适配减少动态效果和中英文。 +- [x] 6.3 验证桌面及 390px 中英文/明暗/减少动态效果,运行前端测试、构建、严格 OpenSpec 校验及差异检查。 +- [x] 6.4 将端点微光调整为克制的小火苗闪动,验证普通/减少动态效果、明暗主题及前端测试构建。 +- [x] 6.5 修复任务位置下拉菜单缺失背景和层级,验证明暗主题、窄屏、中英文以及选择和 Escape 焦点恢复。 + +### 本次追加验证(2026-09-19) + +- 20 项建议控制器测试通过,新增覆盖两类建议到期关闭、轮询不重置、独立截止时间、接受与到期竞态、关闭失败、状态变化、重复关闭、延迟调度及销毁。 +- 完整 `pnpm test` 通过(2559 条通过记录),`pnpm build`、中英文目录生成及严格 OpenSpec 校验通过,`git diff --check` 无错误。 +- 真实组件与生产样式搭配模拟 API 的 Chromium 检查共 19 项通过:1100px/390px、中英文、明暗主题、普通/减少动态效果的 16 种组合,以及临界接受、关闭失败、切换会话 3 项交互。已核对真实截图中文案位于进度条左上方。 +- 本次仅验证前端与浏览器,未重建桌面安装包,未运行原生平台打包验证。 +- 小火苗追加验证:8 项 Chromium 组合检查通过(1100px/390px、明暗主题、普通/减少动态效果),实测外焰形变和双层明暗变化、减少动态效果时静止且无光晕,无横向溢出;已核对实际动画帧,完整 `pnpm test` 和 `pnpm build` 再次通过。 +- 下拉菜单追加验证:修复前实际计算样式为透明背景、无边框/圆角/阴影且层级为 auto,菜单项目被底层卡片遮挡。补齐调用方容器样式后,8 项 Chromium 组合检查通过(1100px/390px、明暗主题、中英文),验证实底、边框、阴影、层级、点击命中、切换位置不启动任务及 Escape 恢复焦点;完整前端测试、构建通过。 + +## 原有实现验证记录 - Windows Release builds passed for `acecode`, `acecode-desktop`, and `acecode_unit_tests` in `build/task-suggestions`; the latest Web bundle is embedded. - 496 native tests passed across suggestion, handoff, compaction, worktree, configuration, model binding, session persistence, and HTTP integration suites. The production dispatch path runs a fake provider through the real AgentLoop; no live provider account was used. diff --git a/openspec/changes/configure-message-auto-collapse/.openspec.yaml b/openspec/changes/configure-message-auto-collapse/.openspec.yaml new file mode 100644 index 000000000..eaa6b1cd4 --- /dev/null +++ b/openspec/changes/configure-message-auto-collapse/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/configure-message-auto-collapse/design.md b/openspec/changes/configure-message-auto-collapse/design.md new file mode 100644 index 000000000..95308f34b --- /dev/null +++ b/openspec/changes/configure-message-auto-collapse/design.md @@ -0,0 +1,21 @@ +## Context + +`projectCollapsedTranscriptItems` 负责工具配对、特殊分组、工具段与完成轮次折叠;子代理通过 `projectSubagentTranscriptItems` 复用。`TranscriptItems` 递归渲染两种会话。外观保存已有顺序写入、回滚和 Desktop 首帧注入机制。 + +## Goals / Non-Goals + +- 新开关默认 true,旧配置缺字段保持现有效果;false 在刷新、重启后保留。 +- 主会话与子代理使用同一投影策略;切换后立即重投影,历史与实时消息一致。 +- 关闭只保留工具详情的折叠,所有非工具正文和分组内容直接显示。 +- 不更改后端历史、工具执行、上下文压缩行为及长会话渐进加载。 + +## Decisions + +1. 以 `web_ui.message_auto_collapse` 为持久化字段,前端使用 `messageAutoCollapse`,复用现有完整外观快照的保存/失败回滚。 +2. 在共享投影函数分支跳过完成轮次与工具段汇总,继续使用工具标准化与完成总结生成;实时空状态仍保留不可折叠的状态行。 +3. 向主会话、子代理和共享渲染组件传递同一布尔值。图片/子代理组保留语义与跳转入口,但关闭折叠交互;系统通知正文直接展开。 +4. 设置复用主题 token 与现有 Toggle。一级标题“会话”可加粗,选项标题字重 400;补齐英文文案。 + +## Risks / Trade-offs + +关闭后同一轮可见行数增加,保留既有 transcript 窗口避免一次渲染全部历史。切换不修改原始消息,重新开启恢复已有折叠路径。测试覆盖工具包装去重、完成总结、运行提示及子代理只读过滤,防止简单绕过投影造成语义退化。 diff --git a/openspec/changes/configure-message-auto-collapse/proposal.md b/openspec/changes/configure-message-auto-collapse/proposal.md new file mode 100644 index 000000000..46782f8cd --- /dev/null +++ b/openspec/changes/configure-message-auto-collapse/proposal.md @@ -0,0 +1,21 @@ +## Why + +当前会话自动把过程消息、工具段和完成轮次收进摘要,用户无法选择始终展开消息。外观设置需要提供持久化开关,主会话与子代理一致遵守。 + +## What Changes + +- 在外观页新增一级分组“会话”,提供默认开启的“消息自动折叠”。 +- 开启沿用现有折叠;关闭后消息及整段活动直接显示,仅保留单个工具调用自身的折叠。 +- 图片与子代理分组直接展开,系统通知直接显示;完成总结、工具配对、子代理入口和运行提示继续可用。 +- 新增 `web_ui.message_auto_collapse`,经配置文件、偏好 API、Desktop 启动注入和前端共享偏好持久化。 + +## Capabilities + +### New Capabilities +- `message-auto-collapse-preference`: 可持久化、即时生效的会话折叠策略。 + +### Modified Capabilities + +## Impact + +涉及配置解析与序列化、UI preferences API、Desktop bootstrap、外观页及主会话/子代理共享 transcript 投影和渲染。默认值保持兼容,不修改会话历史数据。 diff --git a/openspec/changes/configure-message-auto-collapse/specs/message-auto-collapse-preference/spec.md b/openspec/changes/configure-message-auto-collapse/specs/message-auto-collapse-preference/spec.md new file mode 100644 index 000000000..358e4fc29 --- /dev/null +++ b/openspec/changes/configure-message-auto-collapse/specs/message-auto-collapse-preference/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: 会话样式设置与持久化 +系统 SHALL 在外观设置的一级分组“会话”中提供“消息自动折叠”,默认开启,选项标题使用普通字重。选择 SHALL 经现有外观偏好机制持久化并立即生效。 + +#### Scenario: 旧配置与首次打开 +- **WHEN** 配置中没有 `message_auto_collapse` +- **THEN** 开关开启,现有自动折叠行为保持不变 + +#### Scenario: 保存关闭状态并重启 +- **WHEN** 用户关闭消息自动折叠后重新打开应用 +- **THEN** API、Desktop 首帧与界面均恢复关闭状态,修改其它外观项不重置该值 + +#### Scenario: 无效输入及保存失败 +- **WHEN** 偏好 API 收到非布尔值或持久化失败 +- **THEN** 无效输入被原子拒绝,保存失败回滚至上次确认的偏好 + +### Requirement: 主会话与子代理共享折叠策略 +主会话与子代理 SHALL 共用同一投影和显示策略。关闭自动折叠时 SHALL 显示所有消息正文,不生成可折叠的工具段或完成轮次摘要,只保留单个工具调用自身的折叠交互。 + +#### Scenario: 完成轮次与历史消息 +- **WHEN** 关闭状态下展示包含过程回复、多个工具调用与完成总结的轮次 +- **THEN** 过程回复、工具行及完成总结按序显示,不出现“已处理”大折叠,工具配对去重仍生效 + +#### Scenario: 实时会话及特殊分组 +- **WHEN** 关闭状态下工具正在运行或会话含图片、子代理、系统通知 +- **THEN** 运行状态持续可见,图片与子代理内容直接显示,子代理入口可用,系统通知正文展开 + +#### Scenario: 来回切换 +- **WHEN** 用户从关闭切回开启 +- **THEN** 当前消息立即恢复原有折叠路径,原始消息数据不被修改 diff --git a/openspec/changes/configure-message-auto-collapse/tasks.md b/openspec/changes/configure-message-auto-collapse/tasks.md new file mode 100644 index 000000000..e2885730f --- /dev/null +++ b/openspec/changes/configure-message-auto-collapse/tasks.md @@ -0,0 +1,15 @@ +## 1. 外观配置 +- [x] 1.1 新增默认开启的配置字段、API 校验与落盘、Desktop 注入,补齐 API 文档及配置/API 测试。 +- [x] 1.2 接入前端偏好保存与回滚,在外观添加“会话 / 消息自动折叠”,补齐英文与默认值/持久化测试。 + +## 2. 共享消息显示 +- [x] 2.1 主会话和子代理接入共享投影开关,关闭非工具折叠并保留工具配对、总结、运行状态与特殊分组入口,覆盖投影测试。 + +## 3. 验证 +- [x] 3.1 完成定向后端验证、前端全量测试/构建、浏览器检查默认/关闭/恢复、中文/英文/窄屏,严格规范验证与差异检查。 + +验证记录: +- C++ `acecode_unit_tests` MinSizeRel 构建通过;配置、UI preferences API 及启动主题并发保护共 31 项定向测试通过。 +- `pnpm test` 2566 项通过,`pnpm build` 通过,`openspec validate configure-message-auto-collapse --strict` 与 `git diff --check` 通过。 +- 浏览器使用真实 SettingsPage、共享投影/TranscriptItems、SubagentPanel 和外观保存控制器,API 使用隔离数据。确认默认折叠、关闭后正文展开、单个工具展开/收起、图片显示、子代理导航、系统通知正文、运行状态、重新开启、保存失败回滚及刷新恢复关闭值。 +- 中英文、明暗主题、390px 窄屏及 reduced-motion 检查通过;选项字重 400、一级标题 600,无设置面板横向溢出。未重建或安装 Desktop 包。 diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/.openspec.yaml b/openspec/changes/disable-sandbox-once-on-upgrade/.openspec.yaml new file mode 100644 index 000000000..eaa6b1cd4 --- /dev/null +++ b/openspec/changes/disable-sandbox-once-on-upgrade/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/design.md b/openspec/changes/disable-sandbox-once-on-upgrade/design.md new file mode 100644 index 000000000..a14cac6b9 --- /dev/null +++ b/openspec/changes/disable-sandbox-once-on-upgrade/design.md @@ -0,0 +1,25 @@ +## Context + +`SandboxConfig.enabled` 的结构默认值为 true,普通保存会省略默认值。Desktop、TUI、daemon、headless 均调用 `load_config()`;`load_config_from_path()` 同时用于配置编辑和恢复候选校验。`config_mutation.cpp` 已有进程内锁与跨进程文件锁。 + +## Goals / Non-Goals + +目标是按用户配置执行一次迁移,之后保留用户选择。不改权限模式、会话级 `/sandbox on/off` 语义、后端隔离策略、配置恢复校验或发布版本号。 + +## Decisions + +- 在共享 `load_config()` 启动入口检测迁移,保留显式路径读取的原有语义,避免配置恢复候选校验产生迁移副作用。首次正常加载仍负责创建用户目录,保留首次初始化标志。 +- 使用顶层 `migrations.disable_sandbox_once` 布尔标记,作为 `AppConfig` 内独立字段参与读取和保存。标记不属于可被安全设置更新替换的 `SandboxConfig`。完成后固定使用此标记,不按版本反复重置。 +- 迁移函数复用 `config_mutation.cpp` 的锁,锁内重新读取并校验配置,补丁修改原 JSON 的开关和标记,用已有敏感文件原子写入工具一起提交。保留未知字段,不经整份配置重新序列化。 +- 成功后推进 last-good 备份;迁移失败向启动调用者报告错误,磁盘配置保持不变。启动重新加载时才应用环境变量,避免持久化进程环境覆盖。 +- 保留结构默认值 true。首次启动(包括新安装)由迁移设置为 false;用户后续启用即使保存时省略 enabled,标记仍可阻止再次关闭。 + +## Risks / Trade-offs + +- 标记被用户删除或旧版本保存时丢弃会被视为未迁移;普通新版本设置保存必须保留标记,并测试此路径。 +- 原子写入成功但备份写入失败沿用现有配置保存策略:记录日志,不谎报主配置写入失败。 +- 进程锁和文件锁复用现有配置变更边界;不扩展为全仓配置并发重构。 + +## Migration Plan + +随下个版本代码发布生效;当前运行的安装包不直接修改。用户需要恢复沙盒时可使用安全设置中的全局开关。回归覆盖缺省、显式开启/关闭、重新启用、普通保存、并发执行、写入失败和真实启动入口。 diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/proposal.md b/openspec/changes/disable-sandbox-once-on-upgrade/proposal.md new file mode 100644 index 000000000..2701d4f33 --- /dev/null +++ b/openspec/changes/disable-sandbox-once-on-upgrade/proposal.md @@ -0,0 +1,23 @@ +## Why + +下个版本需要为用户关闭一次沙盒,同时尊重用户之后主动重新启用的选择。仅修改默认值无法覆盖旧配置中的显式开启值,每次启动强制关闭则会覆盖用户后续选择。 + +## What Changes + +- 首次运行包含本变更的版本时,为当前用户配置关闭沙盒,并持久化固定的一次性迁移标记。 +- 已完成迁移后,正常启动、保存其他设置及后续升级均保留用户选择。 +- 保留其他沙盒策略、权限模式以及原配置中的其他字段。 + +## Capabilities + +### New Capabilities + +- `sandbox-once-migration`:一次性关闭沙盒及后续用户选择的持久化保护。 + +### Modified Capabilities + +无。 + +## Impact + +涉及共享启动配置入口、配置读写及已有配置变更锁,覆盖 Desktop、TUI、daemon 和 headless。增加配置回归测试并更新 `docs/sandbox.md`;不修改 UI、权限模式、版本号或发布流程。 diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/specs/sandbox-once-migration/spec.md b/openspec/changes/disable-sandbox-once-on-upgrade/specs/sandbox-once-migration/spec.md new file mode 100644 index 000000000..3fe31ce51 --- /dev/null +++ b/openspec/changes/disable-sandbox-once-on-upgrade/specs/sandbox-once-migration/spec.md @@ -0,0 +1,42 @@ +## Purpose + +让用户首次运行包含本变更的版本时获得关闭沙盒的配置,并在后续启动、修改设置和升级中持续尊重用户手动重新启用的选择。通过持久化的一次性状态区分首次迁移与正常配置读取。 + +## ADDED Requirements + +### Requirement: 首次启动只迁移一次 + +系统 SHALL 在尚未完成本次迁移的用户配置上关闭沙盒,并将完成状态与关闭值一起持久化;迁移 MUST 保留其他配置内容。 + +#### Scenario: 旧配置首次升级 +- **WHEN** 用户首次运行包含本变更的版本,配置尚无有效的迁移完成标记 +- **THEN** 无论原配置显式开启、关闭或缺少沙盒段,沙盒均处于关闭状态,且记录迁移完成 +- **THEN** 网络策略、路径清单、权限模式及其他字段保持原有值 + +#### Scenario: 新用户首次启动 +- **WHEN** 用户配置尚不存在 +- **THEN** 正常初始化配置并完成同一迁移,沙盒关闭 + +### Requirement: 持续保留后续用户选择 + +迁移完成后系统 MUST 尊重用户持久化的沙盒开关值,普通设置保存 SHALL 保留迁移标记,后续版本 SHALL 复用同一个标记而不是再次重置。 + +#### Scenario: 用户重新启用 +- **WHEN** 已迁移用户在设置中重新启用沙盒,然后保存其他设置并再次启动 +- **THEN** 沙盒仍然开启,迁移不再写入关闭值 + +#### Scenario: 重复启动 +- **WHEN** 已迁移配置再次启动且用户未修改设置 +- **THEN** 本次迁移不重写配置 + +### Requirement: 迁移持久化一致性 + +系统 MUST 将迁移与现有配置变更串行化,并在持久化失败时报告失败而不记录成功;重试时 SHALL 重新读取磁盘配置。 + +#### Scenario: 写入失败后重试 +- **WHEN** 首次迁移因文件写入失败而无法完成 +- **THEN** 原配置保持完整,迁移不被视为成功,解除故障后可再次迁移 + +#### Scenario: 并发迁移 +- **WHEN** 多个进程或线程同时尝试执行本次迁移 +- **THEN** 只有首次成功者修改配置,后续调用识别已完成状态 diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/tasks.md b/openspec/changes/disable-sandbox-once-on-upgrade/tasks.md new file mode 100644 index 000000000..99dd63446 --- /dev/null +++ b/openspec/changes/disable-sandbox-once-on-upgrade/tasks.md @@ -0,0 +1,17 @@ +## 1. 配置迁移 + +- [x] 1.1 实现迁移标记读取与保存、串行化原子迁移及共享启动入口集成,审查确认标记与开关一起写入且环境变量不被写回。 +- [x] 1.2 添加迁移、重新启用后重复启动、其他设置保存、未知字段保留、并发及失败重试测试,运行相关 C++ 测试通过。 + +## 2. 文档与验证 + +- [x] 2.1 更新沙盒文档说明首次关闭及用户重新启用的持久行为,核对与实现一致。 +- [x] 2.2 运行配置与安全相关回归、OpenSpec 严格校验及 git diff --check,并记录验证范围。 + +## 验证记录 + +- Windows MSVC MinSizeRel:`cmake --build build --config MinSizeRel --target acecode_unit_tests --parallel 4` 通过。 +- `tests/config/` 全部测试套件及 `SecurityHandler`:57 个套件、336 项测试全部通过。覆盖真实 `load_config()` 启动入口及环境变量不落盘。 +- 旧测试 `RuntimeSkillAllowlistIsNeverPersisted` 在再次启动加载前关闭文件读取句柄,以允许 Windows 原子替换;原有业务断言保持不变。 +- `openspec validate disable-sandbox-once-on-upgrade --strict`、`git diff --check` 通过。 +- 本次完成源码与原生测试验证,未打包或发布安装包。 diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/.openspec.yaml b/openspec/changes/fix-native-select-dropdown-dismissal/.openspec.yaml new file mode 100644 index 000000000..2b596d13d --- /dev/null +++ b/openspec/changes/fix-native-select-dropdown-dismissal/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-12 diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/design.md b/openspec/changes/fix-native-select-dropdown-dismissal/design.md new file mode 100644 index 000000000..f153eec9b --- /dev/null +++ b/openspec/changes/fix-native-select-dropdown-dismissal/design.md @@ -0,0 +1,25 @@ +## Context + +See proposal.md. Language and feedback use ordinary HTML selects with independent value handlers. Windows WebView2 152 reproduced transient native popup dismissal in a standalone page. Rendering the same controls with `appearance: base-select` kept all three test pickers open under physical mouse input. The exact internal runtime dismissal trigger remains unproven; changing host position notifications did not reliably resolve it. + +## Goals / Non-Goals + +Keep selection and form ownership in existing controls. Avoid per-page replacement components or synthetic mouse/selection events. Native host changes and runtime upgrades are outside this fix. + +## Decisions + +- Enable shared picker styles only for Windows Desktop and only when both base-select appearance and the picker selector are supported. Other browsers/platforms and older engines retain their existing native controls. +- Apply the rules to single-choice dropdowns, excluding multiple and multirow selects. Use existing theme tokens, bounded scrollable menus, and readable wrapping for long feedback labels. +- Preserve the real select DOM. The browser owns opening, focus, selection, accessibility, and `change` events; React continues owning the value. +- 菜单使用内容固有宽度,并以触发控件宽度作为最小值;最小和最大宽度都受视口限制。“每次询问”“最小化到托盘”“退出应用”等常规选项保持单行,超长标题或路径仅在菜单达到视口上限后换行。沿用浏览器的锚点定位和边缘回退,所有选项保持常规字重。 +- Intercept propagation of Escape only while a browser-rendered select picker is open. Do not prevent its default action: the browser closes the picker, while the containing Modal stays open. + +## Risks / Trade-offs + +- Picker keyboard behavior follows the engine's customizable-select behavior. Validate mouse selection, keyboard selection, cancellation, and disabled options against actual controls. +- Unsupported older engines retain native rendering. This keeps them usable without introducing an untested JavaScript combobox fallback. +- Automated browser clicks did not consistently expose the native popup failure. Physical Windows mouse input is the regression acceptance check. + +## Migration Plan + +Ship through the normal Web asset build. Rollback consists of removing the shared opt-in and stylesheet; persisted configuration and select values require no migration. diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/proposal.md b/openspec/changes/fix-native-select-dropdown-dismissal/proposal.md new file mode 100644 index 000000000..b826028c8 --- /dev/null +++ b/openspec/changes/fix-native-select-dropdown-dismissal/proposal.md @@ -0,0 +1,25 @@ +## Why + +Windows Desktop single-choice dropdowns can open and immediately close on a physical mouse click, including language, document controls, and feedback sessions. A standalone WebView2 page reproduces the problem without React; browser-rendered select pickers remain open in the same runtime. + +## What Changes + +- Opt Windows Desktop single-choice selects into browser-rendered pickers when the engine supports them, through shared startup guards and styles. +- Keep existing select elements, labels, options, change handlers, and form values. +- Keep Escape inside an open picker so it does not dismiss its containing dialog. +- Retain platform rendering in other environments, unsupported engines, and list or multiple selects. +- 下拉菜单按选项内容自然展开,至少与触发控件等宽,并限制在视口内;修复“关闭窗口时”等紧凑控件把短选项挤成多行的问题。 + +## Capabilities + +### New Capabilities + +- `web-select-interaction`: Shared select opening, dismissal, keyboard, theme, and compatibility behavior. + +### Modified Capabilities + +None. + +## Impact + +Shared Web startup guards and select styling; no daemon protocol, C++ host, or dependency changes. Validation includes actual Windows mouse input, language and feedback surfaces, keyboard dismissal, and existing Web checks. diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/specs/web-select-interaction/spec.md b/openspec/changes/fix-native-select-dropdown-dismissal/specs/web-select-interaction/spec.md new file mode 100644 index 000000000..32ea80343 --- /dev/null +++ b/openspec/changes/fix-native-select-dropdown-dismissal/specs/web-select-interaction/spec.md @@ -0,0 +1,51 @@ +## Purpose + +Keep shared single-choice dropdowns usable across ACECode settings, documents, and feedback while preserving form values, keyboard control, and platform compatibility. + +## ADDED Requirements + +### Requirement: A single click opens a stable dropdown + +On supported Windows Desktop engines, enabled single-choice dropdowns SHALL stay open after a primary mouse click until the user chooses an option or dismisses the picker. + +#### Scenario: User opens language or feedback options +- **WHEN** the user clicks the interface-language or feedback-session dropdown once +- **THEN** the available options SHALL remain visible and selectable +- **AND** opening the dropdown SHALL NOT change its value or submit a form + +#### Scenario: User selects an option +- **WHEN** the user selects an enabled option with the mouse or keyboard +- **THEN** the dropdown SHALL close and deliver the selected value once to the existing change handler + +### Requirement: Picker dismissal respects its containing surface + +An open picker SHALL handle Escape without closing its containing settings or feedback dialog. + +#### Scenario: User cancels a selection +- **WHEN** the user presses Escape while the picker is open +- **THEN** the picker SHALL close without changing its value +- **AND** its containing dialog SHALL remain open + +### Requirement: Shared pickers respect compatibility and appearance + +Shared dropdown styling SHALL follow active theme colors and keep long menus within the viewport. Multiple selections, multirow lists, unsupported engines, and non-Windows-Desktop environments SHALL retain their existing control rendering. + +#### Scenario: Feedback labels are long +- **WHEN** feedback options contain long session titles or paths +- **THEN** the menu SHALL remain readable and scrollable without extending beyond the viewport + +#### Scenario: Enhanced rendering is unavailable +- **WHEN** the engine lacks the required picker rendering support or is not Windows Desktop +- **THEN** the original select rendering and keyboard event propagation SHALL remain available + +### Requirement: 菜单宽度适应选项内容 + +增强下拉菜单 SHALL 按选项内容展开,并在视口允许时至少与触发控件等宽;菜单及选项 SHALL 保留既有主题颜色、常规字重、选中标记和键盘交互。 + +#### Scenario: 紧凑控件包含较长选项 +- **WHEN** 用户展开“关闭窗口时”等紧凑下拉控件 +- **THEN** 菜单可宽于控件,“每次询问”“最小化到托盘”“退出应用”等普通选项保持单行 + +#### Scenario: 菜单靠近视口边缘或包含超长内容 +- **WHEN** 控件靠近视口边缘,或选项包含超长会话标题、路径 +- **THEN** 菜单不超出视口,长文本可换行、长列表可滚动,禁用选项不可选择 diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/tasks.md b/openspec/changes/fix-native-select-dropdown-dismissal/tasks.md new file mode 100644 index 000000000..8717b92a7 --- /dev/null +++ b/openspec/changes/fix-native-select-dropdown-dismissal/tasks.md @@ -0,0 +1,17 @@ +## 1. Shared select behavior + +- [x] 1.1 Add Windows Desktop feature-gated select styling and Escape handling; verify focused guard tests and physical mouse opening in WebView2. +- [x] 1.2 Verify real language and feedback controls, option selection, Escape, disabled/list controls, and long labels in light/dark themes with a native-host integration check. + +## 2. Validation + +- [x] 2.1 Run Web tests and build, strict OpenSpec validation, and scoped diff checks; record results and runtime limitations. + +## 3. 下拉菜单内容宽度 + +- [x] 3.1 修复共享下拉菜单被触发器宽度压窄的问题,保留视口限制、正常字重和长文本换行能力。 +- [x] 3.2 浏览器验证关闭行为、语言等实际控件及右侧边缘、明暗主题、中英文、大字号、长标签、禁用项和键盘操作;运行 Web 测试、构建与严格 OpenSpec 校验。 + +2026-09-19 验证:真实 SettingsPage 配合模拟桌面桥在 Chromium 复现旧宽度:89.08px 菜单内三个中文选项均为两行;新样式菜单自然展开到 120.25px,三项均为一行,触发器宽度不变。10 组场景包含修复前后对照、界面语言、暗色、中英文大字号、390px 窄屏、禁用托盘项、右下角翻转,以及超长中文标题和无空格路径。超长菜单限制在 374px 宽度并纵向滚动,选项无横向溢出、全部为 400 字重。鼠标选择与键盘选择各只写入一次模拟值,Escape 仅关闭菜单,禁用项被跳过,无浏览器异常。 + +`pnpm test` 通过(2,559 条 pass 记录),`pnpm build`、产物正则兼容检查、严格 OpenSpec 校验和 `git diff --check` 通过;select.css 设计扫描无发现。验证预览使用独立 Vite 缓存,避免共享缓存导致重复 React;临时页面、临时配置及预览进程已清理。截图与测量数据保存在本任务可视化目录的 `settings-select/`。本次未重新打包 Desktop 或重复原生 WebView2 物理鼠标验证。 diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/verification.md b/openspec/changes/fix-native-select-dropdown-dismissal/verification.md new file mode 100644 index 000000000..3ab2c6186 --- /dev/null +++ b/openspec/changes/fix-native-select-dropdown-dismissal/verification.md @@ -0,0 +1,22 @@ +## Results + +Validated on Windows with WebView2 152 using an isolated native window built from the repository's unchanged WebHost source and the production Web bundle. API reads were proxied to the current daemon; configuration changes were mocked, and all other writes were rejected. The user's desktop process was not restarted or replaced. + +- `pnpm test`: passed, including all 13 browser-default guard tests. +- `pnpm build`: passed; generated-bundle regex compatibility check passed. +- `openspec validate fix-native-select-dropdown-dismissal --strict`: passed. +- Scoped `git diff --check`: passed. +- Physical Windows mouse input opened actual Settings language, inline Settings feedback, and standalone feedback dropdowns. Each remained open for at least 600 ms without changing its value. +- Mouse selection committed language once, with one mocked API write. Mouse and keyboard selection worked in feedback without uploading or submitting the form. +- Escape dismissed the picker while preserving its dialog and feedback draft. A second Escape closed the standalone feedback dialog. Clicking explanatory text outside the picker dismissed it without changing the selection. +- Light/dark theme screenshots were inspected. Long options wrap inside a menu bounded to its control width and viewport height; scrolling to the final option worked. Closed controls retain one line and a visible arrow for long selected labels. +- Disabled controls did not open; keyboard navigation skipped disabled options. Multiple and multirow controls retained native appearance. Removing the opt-in restored native rendering. +- The running desktop daemon's served HTML contains the new select opt-in. Reloading its window loads the fix; a new desktop executable is not required for this local runtime. + +## Reproduction and scope + +The native-popup dismissal also reproduced with three ordinary selects (language, document, feedback) in a standalone WebView page with no React handlers. Exact internal WebView2 dismissal cause remains unproven. Automated browser clicks did not consistently reproduce it; physical mouse opening is the acceptance check. + +The user's specific document dropdown entry has not yet been identified. The shared styles cover single-choice selects in the main ACECode document on supported Windows Desktop engines; no claim is made for native file dialogs or third-party embedded documents. + +The isolated native check and screenshots are in the session's temporary `ace-select-host-probe` directory, outside the repository. Other browsers, platforms, and older engines retain their existing select behavior. diff --git a/openspec/changes/guard-unsaved-file-navigation/.openspec.yaml b/openspec/changes/guard-unsaved-file-navigation/.openspec.yaml new file mode 100644 index 000000000..eaa6b1cd4 --- /dev/null +++ b/openspec/changes/guard-unsaved-file-navigation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/guard-unsaved-file-navigation/design.md b/openspec/changes/guard-unsaved-file-navigation/design.md new file mode 100644 index 000000000..0e3bec790 --- /dev/null +++ b/openspec/changes/guard-unsaved-file-navigation/design.md @@ -0,0 +1,28 @@ +## Context + +`ChatView` 持有预览页签和文件 `edit` 状态,现有关闭动作已经使用 `Modal` 与 `saveEditableFileDraftBatch`。文件切换直接更新页签,`App` 更新导航历史,`Sidebar` 还存在直接激活 Desktop 工作区并重定向的入口,单独监听切换后的 sessionId 无法实现“取消不切换”。 + +## Goals / Non-Goals + +**Goals:** 在动作执行前统一决定,保存成功或丢弃后只执行一次原动作,失败与取消不离开当前编辑现场。 + +**Non-Goals:** 不把文件修改复制成多个会话草稿,不重构所有缓存归属,不修改聊天输入草稿,不接管浏览器或操作系统自身的窗口关闭/刷新行为。 + +## Decisions + +1. 使用一个可注入保存/丢弃回调的确认控制器,管理单个待处理请求、保存中状态与错误。干净路径同步放行,存在修改时返回等待用户决定的 Promise。关闭、页签切换、会话切换共用控制器和弹窗。 +2. 预览状态通过唯一提交函数同步更新 ref 和 React state,确认读取最新文件版本,避免保存完成后第二层导航再次看到旧 dirty 标志。保存复用现有 read_id 校验通路,失败保留修改,不新增文件写入接口。 +3. 页签切换检查当前活动文件;会话/工作区导航检查当前可见页签中的全部脏文件。每次请求捕获原归属和相关页签键,保存/丢弃只更新这些文件。再次点击当前文件不提示。 +4. `ChatView` 向 `App` 注册离开检查,`App` 导航提交与 Desktop resume/重定向前调用,`Sidebar` 在本地选择状态和原生工作区激活前也调用。相同导航身份的元数据更新保持放行。 +5. 自动浏览器页签前置也经过预览选择入口;原生页面选中与面板展开必须等确认。已有确认期间拒绝额外请求,卸载时取消等待,保存失败可重试。 + +## Risks / Trade-offs + +- [多层导航检查重复弹窗] → 确认成功前同步提交干净/丢弃状态,后续检查直接放行。 +- [保存期间异步回调串入其他会话] → 保存操作捕获原文件归属,未确认不能导航,卸载后不执行旧请求。 +- [部分文件保存成功后另一文件失败] → 保留全部页签,已成功文件更新基线,失败文件保留修改,再次保存仅处理仍未保存的文件。 +- [原生自动选页绕过确认] → 将 select 延后到确认后的回调,覆盖浏览器自动前置和手动新建入口。 + +## Migration Plan + +无需持久化迁移。按确认控制器、预览入口、会话/工作区入口、交互验证顺序实施,整体回退本变更即可恢复旧行为。 diff --git a/openspec/changes/guard-unsaved-file-navigation/proposal.md b/openspec/changes/guard-unsaved-file-navigation/proposal.md new file mode 100644 index 000000000..b8035e90f --- /dev/null +++ b/openspec/changes/guard-unsaved-file-navigation/proposal.md @@ -0,0 +1,24 @@ +## Why + +右侧文件编辑器目前只在关闭页签时提示未保存修改,切换文件、预览类型或会话时会直接离开编辑现场。用户要求把离开前的决定交给自己,取消时留在原处,选择保存或不保存后才继续原操作。 + +## What Changes + +- 复用关闭文件的确认与版本校验保存流程,在切换详情页签、打开另一文件、切换会话/工作区、返回首页及前进后退之前拦截未保存修改。 +- 统一提示用户存在未保存修改,按钮按“取消 / 不保存 / 保存”排列;取消不切换,不保存丢弃受影响修改后继续,保存成功后继续,失败保持原位置和修改。 +- 同一文件重复激活不提示;自动打开浏览器页签不能绕过正在编辑的文件或覆盖已有确认目标。 +- 保留聊天输入框已有的会话级草稿机制,本变更不迁移其他缓存。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `webui-side-panel`: 增加未保存文件修改的离开确认与取消导航约束。 + +## Impact + +涉及 `ChatView` 的预览操作与关闭弹窗、`App` 和 `Sidebar` 的导航入口,以及可测试的未保存确认控制器。保存继续走现有 `saveEditableFileDraftBatch` 与文件版本校验 API;无新增后端协议或依赖。 diff --git a/openspec/changes/guard-unsaved-file-navigation/specs/webui-side-panel/spec.md b/openspec/changes/guard-unsaved-file-navigation/specs/webui-side-panel/spec.md new file mode 100644 index 000000000..770b8533c --- /dev/null +++ b/openspec/changes/guard-unsaved-file-navigation/specs/webui-side-panel/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: 离开未保存文件前确认 + +系统 MUST 在用户离开含未保存修改的文件编辑现场前显示确认对话框,按钮按“取消”“不保存”“保存”排列。切换文件或详情页签时检查即将离开的文件,切换会话、工作区、返回首页及前进后退时检查当前预览区域的未保存文件。关闭文件 SHALL 复用相同决定流程。 + +#### Scenario: 取消切换 +- **WHEN** 用户编辑文件后切换详情页签或会话,并选择“取消”或按 Esc +- **THEN** 原文件、编辑内容、活动会话及导航历史保持不变 + +#### Scenario: 不保存后切换 +- **WHEN** 用户选择“不保存” +- **THEN** 系统丢弃即将离开范围内的未保存修改,再执行最初请求的切换,且不写入磁盘 + +#### Scenario: 保存后切换 +- **WHEN** 用户选择“保存”且所有相关文件保存成功 +- **THEN** 系统完成原切换,文件的未保存标记清除,保存过程中禁止重复提交 + +#### Scenario: 保存失败 +- **WHEN** 保存发生版本冲突、网络错误或其他失败 +- **THEN** 系统保留原位置与未成功保存的修改,在弹窗内显示错误并允许重新选择;不得继续切换 + +#### Scenario: 相同目标与干净文件 +- **WHEN** 用户重复激活当前文件,或离开范围内没有未保存修改 +- **THEN** 系统不弹出未保存确认 + +#### Scenario: 工作区导航尚未执行 +- **WHEN** 用户请求进入其他工作区且当前文件未保存 +- **THEN** 系统在激活目标工作区、重定向页面或修改会话导航状态之前等待决定,取消不触发这些动作 + +#### Scenario: 自动页签切换与重复导航 +- **WHEN** 原生浏览器产生新的自动激活请求或确认期间再次收到导航请求 +- **THEN** 未保存文件不能被直接切走,已有确认目标不被替换或同时执行 + +#### Scenario: 聊天输入草稿保持独立 +- **WHEN** 用户确认文件修改并切换会话 +- **THEN** 聊天输入框仍按照各自会话的草稿恢复,文件确认不会清空或覆盖聊天输入草稿 diff --git a/openspec/changes/guard-unsaved-file-navigation/tasks.md b/openspec/changes/guard-unsaved-file-navigation/tasks.md new file mode 100644 index 000000000..4491ffef7 --- /dev/null +++ b/openspec/changes/guard-unsaved-file-navigation/tasks.md @@ -0,0 +1,14 @@ +## 1. 统一未保存确认 + +- [x] 1.1 实现可测试的单请求确认控制器,覆盖取消、不保存、保存成功、保存失败重试、并发请求和卸载取消。 +- [x] 1.2 接入预览切换和现有关闭流程,复用文件版本校验保存,按钮统一为“取消 / 不保存 / 保存”;通过定向测试验证保存及丢弃只影响原文件。 + +## 2. 导航联动 + +- [x] 2.1 在会话、首页、前进后退及工作区导航执行前接入确认,取消不修改目标或导航历史;覆盖真实导航入口验证。 +- [x] 2.2 将浏览器自动前置、手动新建与隐藏详情接入相同检查,确认前保持原活动文件;通过定向验证检查取消与保存失败路径。 + +## 3. 验证与文案 + +- [x] 3.1 补齐英文覆盖并生成 i18n 目录,通过完整 `pnpm test`、`pnpm build`、OpenSpec 严格校验及 `git diff --check`。 +- [x] 3.2 在浏览器验证三按钮、文件/tab/会话切换、保存失败与关闭回归,检查中文/英文及 390px 展示,并记录实际覆盖范围。 diff --git a/openspec/changes/guard-unsaved-file-navigation/validation.md b/openspec/changes/guard-unsaved-file-navigation/validation.md new file mode 100644 index 000000000..850e9369c --- /dev/null +++ b/openspec/changes/guard-unsaved-file-navigation/validation.md @@ -0,0 +1,22 @@ +# 验证记录 + +2026-09-19,本地源码验证。 + +- `pnpm test`:完整前端测试通过,包含确认控制器的取消、丢弃、保存、失败重试、重复请求及卸载取消;已有关闭文件和 Modal 契约断言同步更新。 +- `pnpm build`:通过,生成单文件前端并通过旧 WebView 正则兼容性检查。 +- `pnpm i18n:catalog`:从英文覆盖生成语言目录,按钮为“取消 / 不保存 / 保存”和“Cancel / Don't save / Save”。 +- `openspec validate guard-unsaved-file-navigation --strict`、`git diff --check`:通过。 + +使用独立 Chromium 测试实例加载实际 React 应用,所有 API 和 Desktop bridge 均为测试桩,不操作用户会话和真实文件。已验证: + +- 文件树切换:取消后编辑内容、活动页签及树选中项保持;保存完成后切换;丢弃不写磁盘,再次打开恢复基线。 +- 会话切换与历史后退:取消不改变原会话和历史目的地;保存失败留在原处;目标会话恢复其独立聊天输入草稿。 +- 关闭文件:取消保留文件,丢弃后关闭。 +- 工作区与新建会话入口:确认前不调用原生工作区激活;取消保留原会话,丢弃后进入首页。 +- 隐藏详情或整个右侧栏:取消后仍显示编辑器。 +- 浏览器页签:手动新建和自动前置均等待确认;取消不调用原生选页;保存失败后重试成功才选页。 +- 中文、英文与 390×844、减少动态效果:三按钮完整可见,弹窗宽 358px,无内部横向溢出,页面无未捕获异常。 + +测试在会话导航后等待网络空闲再输入,并断言文字确实进入文件编辑器,避免现有聊天输入框切换后 80ms 自动聚焦影响测试目标。 + +未重新构建或验证已安装 Desktop 原生包。原生 bridge 的时序通过测试桩验证,真实磁盘保存继续使用既有 read_id 校验接口。 diff --git a/openspec/changes/present-shell-command-cards/.openspec.yaml b/openspec/changes/present-shell-command-cards/.openspec.yaml new file mode 100644 index 000000000..eaa6b1cd4 --- /dev/null +++ b/openspec/changes/present-shell-command-cards/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/present-shell-command-cards/design.md b/openspec/changes/present-shell-command-cards/design.md new file mode 100644 index 000000000..4afaa70cc --- /dev/null +++ b/openspec/changes/present-shell-command-cards/design.md @@ -0,0 +1,22 @@ +## Context + +`ToolBlock` 被主会话与子代理的 `TranscriptItems` 复用。当前仅 `tool === 'bash'` 时把 args.command 放入展开文本,历史结构化结果的 args 为空,配对阶段丢弃工具调用包装导致命令缺失。 + +## Goals / Non-Goals + +- 按参考图使用普通字重 Shell 标题、等宽命令与输出、主题色浅底和圆角边框。 +- 默认显示命令和短输出;较长内容在预览尾部渐隐,展开后读取完整的已保存内容。 +- 运行、完成、失败、实时增量与历史恢复都共用同一卡片;复制不丢失已记录命令。 +- 不修改命令执行行为,不取消用户刚配置的消息自动折叠策略,不重排其它工具样式。 + +## Decisions + +1. 在 ToolBlock 添加 Shell 专用分支,识别规范 bash 工具与历史 Ran 摘要;其它工具继续既有 ActivityLine 与专用内容框。 +2. 提取命令展示数据为纯函数,分开保存命令、输出与复制文本。旧调用/结果合成项额外保留结果原文,避免 Shell 输出重复包含调用包装。 +3. 配对时只为 Shell 调用补齐缺失的工具名和参数;按既有 call id 归属处理,并且不覆盖已有实时参数、不修改原始历史。 +4. 展开状态仍由 ToolBlock 唯一管理,键盘、标题点击与桌面右键菜单使用同一状态;审阅时沿用暂停滚动跟随回调。 +5. 采用主题 token,缩略区域按实际内容溢出显示渐隐;完整态仅内容区域滚动,适配窄屏与大字号。 + +## Risks / Trade-offs + +部分旧记录没有完整请求,只能展示现有摘要或明确缺失提示,不推测命令。默认预览增加少量卡片高度,但长内容有明确上限,仍接受外层消息折叠与历史窗口管理。 diff --git a/openspec/changes/present-shell-command-cards/proposal.md b/openspec/changes/present-shell-command-cards/proposal.md new file mode 100644 index 000000000..0d990253d --- /dev/null +++ b/openspec/changes/present-shell-command-cards/proposal.md @@ -0,0 +1,20 @@ +## Why + +执行命令目前以外部 `Ran` 行和独立输出框展示,命令容易被截断,部分历史记录展开后甚至只显示输出。用户要求统一为参考图中的 Shell 卡片。 + +## What Changes + +- 命令调用显示为单一 Shell 卡片,卡片内排列命令与输出,默认显示紧凑预览,长内容渐隐并可展开。 +- 复用现有复制、工具折叠状态、运行状态、失败信息与主会话/子代理共享入口。 +- 历史工具包装与结构化结果合并时恢复已记录的命令参数,避免只剩输出或把截断摘要当完整命令。 + +## Capabilities + +### New Capabilities +- `shell-command-card`: 实时与历史执行命令的统一卡片展示。 + +### Modified Capabilities + +## Impact + +仅修改 WebUI 共享工具展示与历史投影,无后端协议变更。其它工具、文件 diff、提问结果及消息自动折叠偏好保持既有职责。 diff --git a/openspec/changes/present-shell-command-cards/specs/shell-command-card/spec.md b/openspec/changes/present-shell-command-cards/specs/shell-command-card/spec.md new file mode 100644 index 000000000..691464dff --- /dev/null +++ b/openspec/changes/present-shell-command-cards/specs/shell-command-card/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: 统一 Shell 命令卡片 +执行命令调用 SHALL 在主会话与子代理中使用相同 Shell 卡片,卡片内显示命令及输出预览,不再使用卡片外的 Ran 行配独立输出框。 + +#### Scenario: 查看已完成命令 +- **WHEN** 显示已完成的 bash 调用或历史 Ran 记录 +- **THEN** 默认可见 Shell 标题、已记录的命令与短输出,长内容可展开,复制获得完整已保存命令及输出 + +#### Scenario: 实时运行与失败 +- **WHEN** 命令运行中、输出持续到达或执行失败 +- **THEN** 卡片保持相同结构,保留运行/耗时及失败信息,展开状态不被后续输出重置 + +#### Scenario: 历史工具配对 +- **WHEN** 结构化 Shell 结果同时有匹配的调用包装 +- **THEN** 优先恢复完整命令参数而非截断摘要,调用去重仍生效,多个并行调用不串命令 + +#### Scenario: 缺失请求和其它工具 +- **WHEN** 旧记录没有完整命令,或记录属于其它工具 +- **THEN** 旧命令仅展示已有信息,其它工具继续使用原有专用展示 + +### Requirement: 交互和主题一致性 +卡片 SHALL 使用现有主题与字体 token,支持键盘及桌面右键展开/收起,复制按钮 SHALL 不触发折叠。消息自动折叠的外层策略 SHALL 保持生效。 + +#### Scenario: 窄屏与主题 +- **WHEN** 在明暗主题、中英文界面或 390px 窄屏查看长命令和长输出 +- **THEN** Shell 卡片无页面横向溢出,命令/输出清晰分层,所有内容可通过展开与滚动查看 diff --git a/openspec/changes/present-shell-command-cards/tasks.md b/openspec/changes/present-shell-command-cards/tasks.md new file mode 100644 index 000000000..1575514f4 --- /dev/null +++ b/openspec/changes/present-shell-command-cards/tasks.md @@ -0,0 +1,16 @@ +## 1. 命令数据 +- [x] 1.1 提取 Shell 命令展示数据,补齐匹配历史调用的参数,覆盖实时/历史/旧包装/并行/缺失请求测试。 + +## 2. 卡片 +- [x] 2.1 接入共享 Shell 卡片与主题样式,保留工具展开、复制、运行/失败状态和外层消息折叠。 + +## 3. 验证 +- [x] 3.1 完成前端全量测试、构建、严格规范及差异检查;浏览器验证明暗主题、中英文、窄屏、运行/失败、复制、展开及主会话/子代理共享渲染。 + +## 验证记录 + +- `pnpm test`:2574 项通过;新增 8 项命令展示与历史配对测试。 +- `pnpm build`、`openspec validate present-shell-command-cards --strict`、`git diff --check` 通过;定向样式检测无问题。 +- 浏览器验证真实 ToolBlock、主会话与 SubagentPanel 渲染:完整命令恢复、流式更新保留展开状态、复制全文、键盘和桌面菜单事件、失败/空输出,以及原有消息自动折叠均通过。 +- 浅色/深色、中英文、390px 窄屏、大字号和减少动画状态通过,无页面横向溢出;截图等待主题过渡结束后保存。 +- 此次验证覆盖 Web 源码与构建产物,未重新打包或启动原生 Desktop 安装包。 diff --git a/openspec/changes/refine-settings-surface/.openspec.yaml b/openspec/changes/refine-settings-surface/.openspec.yaml new file mode 100644 index 000000000..7a8e2be6f --- /dev/null +++ b/openspec/changes/refine-settings-surface/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/refine-settings-surface/design.md b/openspec/changes/refine-settings-surface/design.md new file mode 100644 index 000000000..06c49a1e6 --- /dev/null +++ b/openspec/changes/refine-settings-surface/design.md @@ -0,0 +1,22 @@ +## Context + +See proposal.md for the visual defects. SettingsPage currently wraps both columns below a 40px title bar. Scoped CSS already flattens legacy cards but adds row borders without removing the old group separators. The shared stylesheet contains unrelated local edits. + +## Goals / Non-Goals + +Keep the change within the Settings shell and its existing style adapter. Do not alter configuration logic, modal dimensions, font sizing, or application-wide colors. + +## Decisions + +- Render navigation beside a full-height main column. Place existing window actions at the upper right of that column and reserve space above the scroll area; retain a visually hidden dialog name. +- Derive Settings surface tokens from the existing surface and foreground tokens. Use these for neutral navigation states and subtle dividers, and use the content surface for standard form fields. Avoid changing global theme tokens or hardcoding colors in components. +- Turn the legacy my-5 group rule into whitespace inside Settings. Retain row separators and inner form boundaries rather than restoring card frames or suppressing all borders. +- Preserve the current panel size and narrow-screen navigation behavior. Check the right column and actions at scaled desktop sizes, narrow widths, dark mode, and large fonts. +- 字重按语义层级维护:主标题保留当前标题字重,一级分组标题保留半粗体;其余字段、选项、卡片、导航和统计值统一为 400,使用 `text-fg` 与 `text-fg-mute` 区分主次。直接修正设置组件及其专属 CSS,不使用全局强制覆盖。设置打开的模型表单和主题导入中的下级标题遵守同一规则,独立弹窗主标题可保留加粗。 +- 保留字号、字体栈、颜色体系、布局和交互;同步仓库前端样式规范,避免后续新增设置恢复旧的加粗字段样式。浏览器验证使用模拟配置,覆盖明暗主题、中英文、窄屏和设置子弹窗。 + +## Risks / Trade-offs + +- Shared CSS may affect embedded settings sections: inspect general, appearance, config, and representative list/form sections. +- Overlay actions could cover scrolled content: reserve their space in the right column instead of floating them over its scrollport. +- Live configuration must remain unchanged during visual checks: use a temporary preview that renders the real component with mocked API responses. diff --git a/openspec/changes/refine-settings-surface/proposal.md b/openspec/changes/refine-settings-surface/proposal.md new file mode 100644 index 000000000..5ed2a167d --- /dev/null +++ b/openspec/changes/refine-settings-surface/proposal.md @@ -0,0 +1,25 @@ +## Why + +Settings still has a title bar across both columns and reuses the workspace background, unlike the supplied Claude references. The recent card flattening also leaves row underlines beside old group dividers, producing duplicate lines. + +## What Changes + +- Extend navigation and content to the top of the dialog; retain accessible expand and close controls independently in the upper right. +- Use a bright content surface, subtly contrasting navigation, neutral selection, and restrained separators in both themes. +- Replace legacy group dividers with whitespace so flattened rows do not produce double rules. +- Preserve configuration behavior, search, font preferences, independent scrolling, dialog dimensions, and expansion. +- 统一设置页字重:仅页面主标题和一级分组标题保留加粗;字段名、卡片名称、选项、导航和更深层标题使用常规字重,需要强调时使用现有前景色 token。 + +## Capabilities + +### New Capabilities + +- `settings-surface`: Continuous Settings columns with themed surfaces and single separators. + +### Modified Capabilities + +None. + +## Impact + +SettingsPage, scoped Settings CSS, and existing dialog architecture checks. Browser visual checks and the Web test/build pipeline verify the result. No API or dependency changes. diff --git a/openspec/changes/refine-settings-surface/specs/settings-surface/spec.md b/openspec/changes/refine-settings-surface/specs/settings-surface/spec.md new file mode 100644 index 000000000..59a1ab340 --- /dev/null +++ b/openspec/changes/refine-settings-surface/specs/settings-surface/spec.md @@ -0,0 +1,41 @@ +## Purpose + +Provide a quiet, readable Settings dialog whose continuous columns and restrained separators match the supplied visual references. + +## ADDED Requirements + +### Requirement: Continuous columns with accessible window actions + +The Settings dialog SHALL extend navigation and the content surface to its top edge without a visible shared Settings title bar. The dialog SHALL retain an accessible name, independent scrolling, and working expand, restore, and close actions. + +#### Scenario: Open and resize Settings +- **WHEN** the user opens Settings and expands or restores it +- **THEN** the navigation divider reaches both vertical edges of the dialog and window actions remain reachable without covering content + +### Requirement: Theme-aware surface hierarchy + +Settings SHALL display a bright content area in light mode, subtly contrasting navigation, neutral navigation selection, and fine separators. Dark mode and the user's accent and font preferences SHALL remain usable. + +#### Scenario: Change appearance +- **WHEN** the user changes between light and dark modes or the available accent themes +- **THEN** Settings keeps legible text, distinct control states, and consistent column surfaces + +### Requirement: Single separators between settings + +Adjacent configuration groups SHALL NOT display a flattened row underline together with a legacy group rule at the same boundary. Group spacing SHALL preserve readable hierarchy while inputs retain visible boundaries. + +#### Scenario: Flattened row followed by a group +- **WHEN** a configuration row precedes another settings group +- **THEN** the boundary contains at most one divider and whitespace separates the groups + +### Requirement: 仅两级标题使用加粗字重 + +设置界面 SHALL 仅对页面主标题和一级分组标题使用加粗字重。字段名、选项标题、卡片名称、列表项、导航文字和更深层标题 SHALL 使用 400 常规字重;强调 SHALL 通过现有前景色 token 表达。设置子弹窗 SHALL 遵守相同层级规则,保留其独立主标题的字重。 + +#### Scenario: 查看配置字段 +- **WHEN** 用户打开配置页 +- **THEN** “配置”“升级服务”“工作空间依赖项”和“默认终端”等主标题或一级标题保留加粗,而“升级服务 URL”“Python 工具”“Node.js 工具”“C# 工具”“终端类型”和“终端程序路径”使用常规字重 + +#### Scenario: 切换设置分区和主题 +- **WHEN** 用户查看常规、外观、技能、模型、工具、安全中心等设置分区,或打开设置子弹窗,并切换明暗主题、语言与字体大小 +- **THEN** 下级标题、选项和字段不因选中、默认状态或主题发生加粗,主次文字继续使用主题前景色区分,现有配置行为保持不变 diff --git a/openspec/changes/refine-settings-surface/tasks.md b/openspec/changes/refine-settings-surface/tasks.md new file mode 100644 index 000000000..fbc581f25 --- /dev/null +++ b/openspec/changes/refine-settings-surface/tasks.md @@ -0,0 +1,19 @@ +## 1. Settings surface + +- [x] 1.1 Remove the shared title bar, retain an accessible dialog name and independent window actions, and verify the existing dialog architecture checks. +- [x] 1.2 Apply scoped surface colors and single group boundaries, then inspect rendered light/dark, accent, scaled, and narrow layouts with input borders intact. + +## 2. Integration verification + +- [x] 2.1 Run Web tests/build, strict OpenSpec validation, the design detector, and browser checks for navigation, search, expansion, scrolling, and close behavior; remove the temporary preview page. + +Verification: The Web test runner passed (2,135 pass assertions), production build and regex compatibility checks passed, and strict OpenSpec validation passed. Chromium checked the real Settings component in ten states: General, Appearance, Config, restored dialog, light/dark with blue/orange accents, 1280x720 scaling, 600px width, and large English text. Column edges aligned, actions remained outside the scrollport, group rules were absent, input borders remained, and content had no horizontal overflow. Search, independent scrolling, exact 13px expansion, restore, close, and mask dismissal passed with no browser exceptions or API writes. The design detector found only pre-existing styles outside the changed Settings region. Temporary preview HTML was removed; screenshots and measurements are stored in the task visualization directory. + +## 3. 统一设置标题字重 + +- [x] 3.1 保留主标题和一级标题字重,统一字段、选项、卡片、导航及设置子弹窗内下级标题为常规字重,并同步前端样式规范。 +- [x] 3.2 运行 Web 测试、构建和严格 OpenSpec 校验;在浏览器检查各设置分区的实际字重,以及明暗主题、中英文、窄屏和减少动态效果状态。 + +2026-09-19 验证:`pnpm test` 通过(2,552 条 pass 记录),`pnpm build` 和产物正则兼容检查通过,严格 OpenSpec 校验及 `git diff --check` 通过。Chromium 在 24 个页面/状态检查实际字重,覆盖全部 15 个设置入口、模型编辑及拖动预览、安全中心各页签、主题导入、明暗主题、390px 中英文大字号与减少动态效果。所有字段、选项和下级标题为 400,保留加粗的文字均为主标题或一级分组标题;模型表单 label/legend/h4、拖动预览另有实际样式断言。验证使用模拟 API,无真实配置写入,无浏览器异常。设计检测器只报告 globals.css 其他区域的 12 项既有样式,不涉及本次修改。 + +验证边界:390px 英文大字号下,未修改的配置页“标题 + 重新检测”单行布局仍有 68px 横向溢出;本次未扩展为响应式布局修复。其余桌面状态和 390px 中文配置页无内容横向溢出。尚未重新打包或启动原生 Desktop 验证。临时页面已清理,截图、模拟数据和实际字重记录保存在任务可视化目录的 `settings-typography/`。 diff --git a/openspec/changes/retry-trailing-user-message/.openspec.yaml b/openspec/changes/retry-trailing-user-message/.openspec.yaml new file mode 100644 index 000000000..f2cbbe6a6 --- /dev/null +++ b/openspec/changes/retry-trailing-user-message/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-18 diff --git a/openspec/changes/retry-trailing-user-message/design.md b/openspec/changes/retry-trailing-user-message/design.md new file mode 100644 index 000000000..5129c572c --- /dev/null +++ b/openspec/changes/retry-trailing-user-message/design.md @@ -0,0 +1,25 @@ +## Context + +InputBar 与 ChatView 分别拒绝空输入;普通消息经 SessionClient 入队,由 AgentLoop 追加用户记录。前端存在完整 items、折叠投影、窗口化渲染和本地待发消息,后端模型历史与持久化 transcript 也并不完全相同。 + +## Goals / Non-Goals + +目标是对真实末尾用户消息或用户主动中断的最后一条用户消息重试,并保留模型角色序列和结构化内容。不提供任意历史消息重试,不更改忙碌排队、首条消息创建和内置命令路由。 + +## Decisions + +1. 普通重试从已加载完整 transcript 的最后一个 item 判断,绝不向前查找用户消息;排除空历史、非用户、缺少持久化身份、加载中、流式中、忙碌及只读状态。用户中断例外按决策 6 校验。点击时读取最新 store 再校验。输入含文字或附件/上下文时继续普通发送。 +2. 新增 `POST /api/sessions/:id/messages/retry`,仅接受 `expected_user_message_id`。模型输入由后端原始消息恢复,不从展示文本或富文本重新展开。 +3. AgentLoop 在队列锁下验证无活动和排队任务,检查模型历史末尾及完整持久化 transcript 的最后一条可见消息均为指定用户消息。跳过与前端一致的 is_meta、文件检查点、压缩检查点、回合计时、文件差异及隐藏目标上下文;不跳过可见 assistant、tool、system、error。事件观察器同时阻止尚未进入 JSONL 的错误、系统消息及部分输出。入队后在 worker 开始时再校验。 +4. 使用现有 Chat worker 任务承载重试身份,在正常回合生命周期中复用原始 user 消息,不重新持久化、发出 user 事件或展开 skill/附件。复用回合身份和文件检查点。重试准备阶段不执行会改写末尾历史的前置压缩;正常模型边界压缩规则保留。 +5. 在途提交继续只禁用发送动作,不禁用编辑器;回执须按 session 身份处理,不能影响切换后的会话或清除新草稿。 +6. 用户主动中断作为明确的例外:回合收尾时持久化 `transcript_only` 系统记录,携带 `user_aborted: true` 和 `retry_user_message_id`。前后端只有完整 transcript 末尾为该标记时才能查找其指定的最后一条用户消息;后来出现的任何可见记录使该资格失效。插话、普通错误、同文案系统提示不构成此例外。 +7. 中断标记只属于 transcript,不进入模型上下文。模型历史仍以原用户消息结尾时复用;已经有 assistant/tool 消息时复制原始结构化内容并分配新用户身份,复用正常持久化和检查点流程,不重复展开 skill。中断时已流出的正文以 `transcript_only` / `interrupted_output` assistant 消息持久化,避免重新加载时丢失输出,同时不把未完成的工具调用加入模型历史。 +8. 本地点击停止只显示提示并标记中断待确认;收到后端回合结束前空输入发送仍禁用。历史恢复通过持久化中断标记恢复资格,按钮与 Enter 继续共用入口。 + +## Risks / Trade-offs + +- 页面显示与后端状态竞态 → 前端提交时复核,后端队列门控及 worker 复核。 +- 展示消息不能完整恢复原始模型输入 → 后端复用 ChatMessage 的 content、content_parts、metadata。 +- 纯系统提示与错误出现在用户消息之后 → 严格拒绝,不从历史中挑选较早用户消息。 +- 原生程序需要重新构建才能包含新端点 → 本任务验证源码、前端构建及 C++ 测试,不声称已更新安装包。 diff --git a/openspec/changes/retry-trailing-user-message/proposal.md b/openspec/changes/retry-trailing-user-message/proposal.md new file mode 100644 index 000000000..a72e57096 --- /dev/null +++ b/openspec/changes/retry-trailing-user-message/proposal.md @@ -0,0 +1,24 @@ +## Why + +Desktop 会话停留在用户消息时,空输入目前无法再次请求模型。直接复制并追加用户消息会形成连续 user 记录,影响要求角色交错的模型。 + +## What Changes + +- 完整 transcript 末尾为已持久化用户消息、会话空闲且输入无新内容时,允许点击发送或按 Enter 重试该消息。 +- 用户主动中断完成后也允许空输入重发上一条用户消息,保留中断前已有输出。后端持久化明确的用户中断标记,不通过提示文案判断。 +- 重试复用原始模型输入、附件及上下文;末尾仍是该用户时保留原身份,主动中断且已有完整 assistant/tool 记录时以新身份追加,避免相邻 user。 +- 后端原子校验会话空闲状态和消息身份;除明确的用户中断外,非用户末尾、过期请求及忙碌状态均拒绝。 + +## Capabilities + +### New Capabilities + +- `trailing-user-message-retry`: 对 transcript 最后一条用户消息进行受校验保护的空输入重试。 + +### Modified Capabilities + +无。忙碌时空输入仍不能排队。 + +## Impact + +涉及共享 Desktop/Web composer、会话 API、SessionClient 和 AgentLoop。新增重试端点及前后端回归测试,无新依赖,不修改 TUI 交互。 diff --git a/openspec/changes/retry-trailing-user-message/specs/trailing-user-message-retry/spec.md b/openspec/changes/retry-trailing-user-message/specs/trailing-user-message-retry/spec.md new file mode 100644 index 000000000..f85e5b40d --- /dev/null +++ b/openspec/changes/retry-trailing-user-message/specs/trailing-user-message-retry/spec.md @@ -0,0 +1,69 @@ +## Purpose + +允许用户在已有会话的完整对话记录以用户消息或明确的用户中断标记结束时,通过空输入发送重新请求对应用户消息,保持结构化输入与模型角色顺序,并防止过期界面状态导致错误重发。 + +## ADDED Requirements + +### Requirement: 空输入仅允许重试完整记录末尾的用户消息 + +系统 SHALL 仅在会话已加载且空闲、输入没有文字或新增附件/上下文、完整 transcript 最后一个条目是已持久化用户消息或明确的用户主动中断标记时启用空输入发送。按钮和 Enter SHALL 使用相同规则。 + +#### Scenario: 末尾用户消息 +- **WHEN** 已加载空闲会话的完整 transcript 以用户消息结束,输入为空或只有空白 +- **THEN** 点击发送或按 Enter 重试该用户消息 + +#### Scenario: 非用户内容在用户消息之后 +- **WHEN** 用户消息之后存在 assistant、推理、tool、system、error 或其他可见条目,包括空 assistant 条目,且完整 transcript 末尾没有明确的用户主动中断标记 +- **THEN** 空输入发送保持禁用,不得跳过条目查找更早的用户消息 + +#### Scenario: 会话不可重试 +- **WHEN** 会话无历史、未加载、加载失败、只读、正在运行、提交中或缺少末尾消息身份 +- **THEN** 空输入不触发重试,也不进入排队或新建会话路径 + +#### Scenario: 输入含新内容 +- **WHEN** 用户输入文字或新增附件/上下文 +- **THEN** 使用正常发送或排队流程 + +### Requirement: 重试复用原始输入并保持角色序列 + +系统 MUST 使用原始用户消息重新请求模型,保留附件、上下文及展示信息。模型历史末尾仍为该用户时保留消息身份,不增加重复 user 记录;主动中断前已有 assistant/tool 记录时保留已有记录并以新身份追加原始用户内容。 + +#### Scenario: 富文本或附件消息重试 +- **WHEN** 末尾用户消息含已展开 skill、附件或上下文 +- **THEN** 模型收到原始结构化内容,transcript 保留原 user 记录且后续响应追加在其后 + +### Requirement: 后端拒绝过期或并发重试 + +系统 MUST 在接受重试时及执行时校验完整 transcript 末尾和指定用户身份符合对应重试规则,接受动作必须与任务队列状态校验串行化。 + +#### Scenario: 界面状态过期 +- **WHEN** 点击时后端已有后续消息,或请求携带的用户消息身份不再匹配 +- **THEN** 拒绝重试,不追加消息且不调用模型 + +#### Scenario: 重复点击或已有排队任务 +- **WHEN** 重试已接受或已有其他活动/排队任务 +- **THEN** 后续重试被拒绝,单次操作最多触发一个模型回合 + +#### Scenario: 请求期间切换会话或编辑草稿 +- **WHEN** 重试请求尚未完成,用户切换会话或输入下一条消息 +- **THEN** 原会话请求回执不改变新会话的忙碌状态,也不清除新草稿 + +### Requirement: 用户中断后可直接重发 + +系统 MUST 以持久化的结构化用户中断标记判断重发资格,并等待后端实际结束当前回合。 + +#### Scenario: 中断发生在输出之前 +- **WHEN** 用户停止回合,后端确认结束,模型历史仍以原用户消息结束 +- **THEN** 空输入重发复用原用户消息,不产生相邻 user + +#### Scenario: 中断发生在部分回复或工具结果之后 +- **WHEN** 用户停止回合,后端确认结束,已有部分回复或工具结果 +- **THEN** 保留已有输出并允许空输入重发;模型历史已有 assistant/tool 时追加原始结构化用户内容,否则复用原用户消息 + +#### Scenario: 中断确认前或中断之后又有新记录 +- **WHEN** 后端尚未确认中断,或中断标记后出现新消息、错误或活动任务 +- **THEN** 不允许根据旧中断标记重发 + +#### Scenario: 恢复中断历史 +- **WHEN** 重新加载的完整 transcript 以有效用户中断标记结束 +- **THEN** 恢复同样的重发资格;普通中断字样或插话标记不得获得该资格 diff --git a/openspec/changes/retry-trailing-user-message/tasks.md b/openspec/changes/retry-trailing-user-message/tasks.md new file mode 100644 index 000000000..fa3b203b6 --- /dev/null +++ b/openspec/changes/retry-trailing-user-message/tasks.md @@ -0,0 +1,37 @@ +## 1. 后端重试 + +- [x] 1.1 增加 AgentLoop 串行重试入口及原消息复用,C++ 定向测试验证角色末尾、消息身份、并发拒绝和结构化输入保留。 +- [x] 1.2 接入 SessionClient 和重试 API,更新 daemon-api 文档,编译并验证请求校验及返回状态。 + +## 2. 输入框接入 + +- [x] 2.1 完整 transcript 末尾判定及提交时复核接入共享发送入口,前端测试验证按钮/Enter 同规则、禁止跳过非用户条目及状态边界。 + +## 3. 集成验证 + +- [x] 3.1 完成前端 pnpm test、pnpm build、C++ 定向测试、OpenSpec 严格校验和 git diff --check,记录验证边界并检查无关改动保留。 + +## 4. 用户中断后重发 + +- [x] 4.1 后端持久化用户中断标记并校验重试身份,按模型真实末尾复用或追加原始输入,覆盖输出前、部分输出、工具后、中断历史恢复与过期资格。 +- [x] 4.2 前端识别明确中断标记,等待后端结束后启用空输入发送,覆盖完整历史、按钮/Enter 和状态边界。 +- [x] 4.3 更新 API 文档并完成前端测试/构建、C++ 定向测试、OpenSpec 严格校验和差异检查。 + +## 用户中断扩展验证记录 + +- `pnpm test`、`pnpm build` 通过,生产产物正则兼容性检查通过。 +- `cmake --build build --config Release --target acecode_unit_tests --parallel 6` 通过。 +- C++ 定向回归 34 项通过,覆盖重试、插话、目标中断、回合计时/差异、trajectory、skill、历史恢复及 HTTP 入口。 +- Playwright 挂载真实 InputBar 并使用实际 transcript reducer,24 项按钮、Enter、中断确认、历史恢复、禁止状态和 390px 中英文检查通过,无页面错误。 +- 中断后收到的剩余 token 与持久化部分回复不会产生重复回复或重复中断提示;旧中断标记回放不会重新启用已完成回合的重试。 +- `openspec validate retry-trailing-user-message --strict`、`git diff --check` 通过。未改动其他任务的功能,ChatView 的本任务改动仅涉及 `abortPending` 取值和重试判断。 +- 验证范围为源码、Web 构建、浏览器及本地 C++ 测试;没有重新打包或替换已安装 Desktop。 + +## 原始变更验证记录 + +- `pnpm test`、`pnpm build` 通过,生产产物正则兼容性检查通过。 +- `cmake --build build --config Release --target acecode_unit_tests --parallel 6` 通过。 +- `AgentLoopUserMessageRetry.*:AgentLoopTurnSteering.*:AgentLoopTrajectoryTest.*:WebServerHttp.RetryLastUserMessage*` 共 15 项通过。 +- Playwright 挂载真实 InputBar,13 项按钮、Enter、状态边界及 390px 中英文检查通过,无页面错误。 +- `openspec validate retry-trailing-user-message --strict`、`git diff --check` 通过。无关已修改文件哈希不变,daemon-api 原有改动保留。 +- 验证范围为源码、Web 构建、浏览器及本地 C++ 测试;没有重新打包或替换已安装 Desktop。 diff --git a/openspec/changes/unify-rounded-interface-icons/tasks.md b/openspec/changes/unify-rounded-interface-icons/tasks.md index 51090a04a..efb61abef 100644 --- a/openspec/changes/unify-rounded-interface-icons/tasks.md +++ b/openspec/changes/unify-rounded-interface-icons/tasks.md @@ -27,3 +27,15 @@ - [x] 5.1 两处列表开关使用同一三横线图标、16 px 尺寸及圆端点细线;列表展开时按钮显示激活状态。 - [x] 5.2 验证列表展开/收起切换、明暗主题、SVG 导出及前端测试与构建。 + +## 6. 新建任务图标简化 + +- [x] 6.1 使用用户确认的纸张与简笔轮廓,移除纸上文字和多余细节,平滑笔尖过渡,保持 18 px 显示尺寸和主题颜色继承。 +- [x] 6.2 重新生成 SVG,确认与批准稿路径一致,并通过前端测试、构建及变更校验。 +- [x] 6.3 按用户反馈将纸张方框圆角由 1.5 加大到 3 个视图单位,保持外框边界、平滑笔尖、尺寸及线宽不变。 +- [x] 6.4 重新导出图标,检查实际尺寸与放大预览,并通过前端测试和构建。 + +## 7. 扩展图标简化 + +- [x] 7.1 使用三个空心圆角正方形和右上角一个圆形,整齐排列为 2×2;将轮廓收紧至 3.5–16.5 坐标范围,沿用共享尺寸、线宽和主题颜色继承。 +- [x] 7.2 重新导出 SVG,检查菜单实际尺寸、边界和明暗预览,并通过前端测试、构建和变更校验。 diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp index 8252bcd41..c1fcd18fd 100644 --- a/src/agent_loop.cpp +++ b/src/agent_loop.cpp @@ -355,15 +355,20 @@ bool is_hidden_goal_context_message(const ChatMessage& msg) { msg.metadata.value("hidden_goal_context", false); } +bool is_transcript_bookkeeping(const ChatMessage& message) { + return message.is_meta || is_file_checkpoint_message(message) || + is_compact_checkpoint_message(message) || + is_turn_timing_message(message) || is_turn_net_diff_message(message) || + web::is_hidden_goal_context_message(message); +} + const ChatMessage* trailing_transcript_message( - const std::vector& messages) { + const std::vector& messages, bool user_only = false) { for (auto it = messages.rbegin(); it != messages.rend(); ++it) { // Match the transcript's invisible bookkeeping records. Never skip a - // visible assistant/tool/system/error, even when its content is empty. - if (it->is_meta || is_file_checkpoint_message(*it) || - is_compact_checkpoint_message(*it) || - is_turn_timing_message(*it) || is_turn_net_diff_message(*it) || - web::is_hidden_goal_context_message(*it)) continue; + // visible non-user unless an explicit user-abort marker allows retry. + if (is_transcript_bookkeeping(*it)) continue; + if (user_only && it->role != "user") continue; return &*it; } return nullptr; @@ -498,7 +503,11 @@ void AgentLoop::set_session_manager(SessionManager* sm) { const auto metadata = payload.value("metadata", nlohmann::json::object()); if (!payload.value("is_meta", false) && !(metadata.is_object() && metadata.value("hidden_goal_context", false))) { - live_transcript_tail_blocked_ = payload.value("role", std::string{}) != "user"; + const auto role = payload.value("role", std::string{}); + const bool user_abort = role == "system" && metadata.is_object() && + metadata.value("transcript_only", false) && + metadata.value("user_aborted", false); + live_transcript_tail_blocked_ = role != "user" && !user_abort; } break; } @@ -1022,7 +1031,7 @@ void AgentLoop::worker_main() { if (!task.retry_user_message_id.empty()) { const auto message = retryable_user_message(task.retry_user_message_id); if (!message) { - throw std::runtime_error("transcript no longer ends with the expected user message"); + throw std::runtime_error("user message is no longer eligible for retry"); } UserInput input; input.text = message->content; @@ -1279,15 +1288,24 @@ void AgentLoop::submit(const UserInput& input) { std::optional AgentLoop::retryable_user_message( const std::string& expected_user_message_id) const { if (expected_user_message_id.empty() || live_transcript_tail_blocked_.load()) return std::nullopt; - const auto* message = trailing_transcript_message(messages_); - if (!message || message->role != "user" || - message->uuid != expected_user_message_id) return std::nullopt; + const auto* model_tail = trailing_transcript_message(messages_); + if (!model_tail || (model_tail->role != "user" && + model_tail->role != "assistant" && model_tail->role != "tool")) return std::nullopt; + bool user_aborted = false; if (session_manager_) { const auto persisted = session_manager_->load_active_messages(); const auto* tail = trailing_transcript_message(persisted); + user_aborted = tail && tail->role == "system" && tail->metadata.is_object() && + tail->metadata.value("transcript_only", false) && + tail->metadata.value("user_aborted", false) && + tail->metadata.value("retry_user_message_id", std::string{}) == expected_user_message_id; + if (user_aborted) tail = trailing_transcript_message(persisted, true); if (!tail || tail->role != "user" || tail->uuid != expected_user_message_id) return std::nullopt; } + const auto* message = trailing_transcript_message(messages_, user_aborted); + if (!message || message->role != "user" || + message->uuid != expected_user_message_id) return std::nullopt; return *message; } @@ -1301,7 +1319,7 @@ bool AgentLoop::retry_last_user_message( return false; } if (!retryable_user_message(expected_user_message_id)) { - error = "transcript does not end with the expected user message"; + error = "user message is not eligible for retry"; return false; } WorkerTask task; @@ -2557,17 +2575,6 @@ void AgentLoop::begin_active_turn(const std::string& turn_id) { active_turn_accepting_ = !turn_id.empty(); } -std::string AgentLoop::abort_notice_text() const { - return turn_interrupt_requested_.load() ? "[Interjected]" : "[Interrupted]"; -} - -nlohmann::json AgentLoop::abort_notice_metadata() const { - if (!turn_interrupt_requested_.load()) return nlohmann::json{}; - return nlohmann::json{ - {"turn_interrupt", true}, - }; -} - void AgentLoop::append_interrupted_turn_context(const std::string& turn_id) { ChatMessage marker; marker.role = "user"; @@ -2772,6 +2779,13 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, if (!user_msg.metadata.is_object()) user_msg.metadata = nlohmann::json::object(); user_msg.metadata["hidden_goal_context"] = true; } + append_user_turn_message(info, hidden_goal_context); + start_user_turn(info); + return info; +} + +void AgentLoop::append_user_turn_message(UserTurnInfo& info, bool hidden_goal_context) { + auto& user_msg = info.user_msg; ensure_user_message_identity(user_msg); info.active_turn_id = user_msg.uuid; info.visible_timed_turn = @@ -2788,7 +2802,7 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, } if (!hidden_goal_context) { nlohmann::json msg_event = { - {"role", "user"}, {"content", model_user_message}, + {"role", "user"}, {"content", user_msg.content}, {"is_tool", false}, {"id", user_msg.uuid}, }; if (!user_msg.content_parts.is_null() && user_msg.content_parts.is_array() && @@ -2800,15 +2814,29 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, } events_.emit(SessionEventKind::Message, msg_event); } - - start_user_turn(info); - return info; } AgentLoop::UserTurnInfo AgentLoop::prepare_retry_user_turn(const ChatMessage& message) { UserTurnInfo info; info.user_msg = message; info.turn_started_at_ms = now_epoch_ms(); + const auto* tail = trailing_transcript_message(messages_); + if (tail && tail->role != "user") { + // An aborted turn may already contain assistant/tool output. Preserve + // it and append the original input with a fresh identity, without + // expanding skills or attachments for a second time. + info.user_msg.uuid.clear(); + info.user_msg.timestamp.clear(); + if (info.user_msg.metadata.is_object()) { + for (const auto* key : {"client_message_id", "turn_steer", "turn_id", + "turn_interrupt", "interrupted_turn_id"}) { + info.user_msg.metadata.erase(key); + } + } + append_user_turn_message(info, false); + start_user_turn(info); + return info; + } info.active_turn_id = message.uuid; info.visible_timed_turn = true; info.turn_user_uuid = message.uuid; @@ -3708,8 +3736,6 @@ AgentLoop::HandleErrorResult AgentLoop::run_pa_overflow_rescue( emit_pa_rescue_wait_progress( error, plan, attempt, max_attempts, true); if (!wait_for_pa_rescue_delay(plan.wait_ms)) { - dispatch_message("system", abort_notice_text(), false, - abort_notice_metadata()); return HandleErrorResult::Break; } emit_pa_rescue_wait_progress( @@ -5630,8 +5656,6 @@ void AgentLoop::run_agent_with_input(const UserInput& input, if (abort_requested_) { LOG_WARN("Abort requested, breaking loop"); - dispatch_message( - "system", abort_notice_text(), false, abort_notice_metadata()); break; } @@ -5692,8 +5716,22 @@ void AgentLoop::run_agent_with_input(const UserInput& input, TokenUsage step_usage = provider_result.accumulated.usage; if (abort_requested_) { - dispatch_message( - "system", abort_notice_text(), false, abort_notice_metadata()); + const auto& output = provider_result.accumulated; + if (!output.content.empty() || + (output.content_parts.is_array() && !output.content_parts.empty())) { + // Keep already displayed output across history reloads. An + // interrupted response (especially partial tool calls) is not + // a completed provider message, so it remains transcript-only. + ChatMessage partial; + partial.role = "assistant"; + partial.content = output.content; + partial.content_parts = output.content_parts; + partial.reasoning_content = output.reasoning_content; + partial.metadata = {{"transcript_only", true}, {"interrupted_output", true}}; + if (session_manager_) session_manager_->on_message(partial); + dispatch_message(partial.role, partial.content, false, + partial.metadata, partial.content_parts); + } record_model_response( current_model_step, provider_result, step_usage, "aborted"); emit_model_step_finish(current_model_step, "aborted", step_usage); @@ -5952,6 +5990,20 @@ void AgentLoop::run_agent_with_input(const UserInput& input, turn_timing_status); } + if (abort_requested_) { + if (interrupted_for_new_turn) { + dispatch_message("system", "[Interjected]", false, {{"turn_interrupt", true}}); + } else { + const auto* user = trailing_transcript_message(messages_, true); + // Persist the completed stop, including its exact retry target. + // This notice stays out of the provider's message history. + emit_transcript_system_message("[Interrupted]", { + {"user_aborted", true}, + {"retry_user_message_id", user ? user->uuid : std::string{}}, + }); + } + } + if (callbacks_.on_turn_finished) { callbacks_.on_turn_finished(turn_timing_status); } diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp index 5110b05f9..72383193b 100644 --- a/src/agent_loop.hpp +++ b/src/agent_loop.hpp @@ -206,8 +206,8 @@ class AgentLoop { // context parts. Existing text-only submit overloads delegate here. void submit(const UserInput& input); - // Retry only the exact trailing user message while the worker and queue - // are idle. Reuses its stored input instead of appending another user. + // Retry the trailing user or the last user of an explicitly aborted turn + // while idle. Reuses stored input without adding adjacent user messages. bool retry_last_user_message(const std::string& expected_user_message_id, std::string& error); @@ -551,11 +551,6 @@ class AgentLoop { // acceptance under the same lock, eliminating the final-response race. bool drain_active_turn_inputs(bool close_if_empty); void append_interrupted_turn_context(const std::string& turn_id); - // Visible abort notice: manual stop keeps [Interrupted]; interjection - // uses a dedicated [Interjected] system marker so the transcript does - // not look like a user stop. - std::string abort_notice_text() const; - nlohmann::json abort_notice_metadata() const; std::size_t close_active_turn_and_discard(); bool maybe_run_auto_compact(); // 摘要压缩失败后的兜底:改用不调用模型的机械修剪腾出空间。返回 true 表示 @@ -644,6 +639,7 @@ class AgentLoop { }; UserTurnInfo prepare_user_turn(const UserInput& input, bool hidden_goal_context); UserTurnInfo prepare_retry_user_turn(const ChatMessage& message); + void append_user_turn_message(UserTurnInfo& info, bool hidden_goal_context); void start_user_turn(const UserTurnInfo& info); // Phase 2: Build the full message list for the LLM provider. diff --git a/src/config/config.cpp b/src/config/config.cpp index 69a7f9bc6..1a710bb6e 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -2,6 +2,7 @@ #include "permissions.hpp" #include "config_recovery.hpp" +#include "config_mutation.hpp" #include "model_provider_registry.hpp" #include "request_headers.hpp" #include "../themes/theme_id.hpp" @@ -755,7 +756,17 @@ static void sanitize_disabled_model_providers(AppConfig& cfg) { AppConfig load_config() { const std::string config_path = path_to_utf8(path_from_utf8(get_acecode_dir()) / "config.json"); - return load_config_from_path(config_path, true); + AppConfig cfg = load_config_from_path(config_path, true); + if (!cfg.sandbox_disable_migration_completed) { + const auto migration = disable_sandbox_once(config_path); + if (!migration.ok) { + throw std::runtime_error( + "failed to persist one-time sandbox migration: " + migration.error); + } + // Reapply runtime-only environment overrides after the locked disk update. + cfg = load_config_from_path(config_path, true); + } + return cfg; } static AppConfig load_config_from_path_once( @@ -864,6 +875,12 @@ static AppConfig load_config_from_path_once( cfg.default_permission_mode = normalize_permission_mode_name( j["default_permission_mode"].get()); } + if (j.contains("migrations") && j["migrations"].is_object()) { + const auto& migrations = j["migrations"]; + cfg.sandbox_disable_migration_completed = + migrations.contains("disable_sandbox_once") && + migrations["disable_sandbox_once"] == true; + } // 沙盒段(openspec add-auto-mode-sandbox)。缺省 → enabled、不放行 // 网络、无额外可写根。非法条目静默跳过,不阻塞启动。 if (j.contains("sandbox") && j["sandbox"].is_object()) { @@ -1064,6 +1081,14 @@ static AppConfig load_config_from_path_once( LOG_WARN("[config] invalid 'web_ui.font_size', using 'medium'"); } } + if (uij.contains("message_auto_collapse")) { + if (uij["message_auto_collapse"].is_boolean()) { + cfg.web_ui.message_auto_collapse = + uij["message_auto_collapse"].get(); + } else { + LOG_WARN("[config] invalid 'web_ui.message_auto_collapse', using true"); + } + } if (uij.contains("sidebar_session_time")) { if (uij["sidebar_session_time"].is_boolean()) { cfg.web_ui.sidebar_session_time = @@ -2271,6 +2296,9 @@ nlohmann::json build_config_json(const AppConfig& cfg) { normalize_permission_mode_name(cfg.default_permission_mode); } + if (cfg.sandbox_disable_migration_completed) { + j["migrations"]["disable_sandbox_once"] = true; + } { SandboxConfig sandbox_d; nlohmann::json sbj = nlohmann::json::object(); @@ -2352,6 +2380,8 @@ nlohmann::json build_config_json(const AppConfig& cfg) { web_uij["font_size"] = cfg.web_ui.font_size; if (cfg.web_ui.sidebar_session_time != web_ui_d.sidebar_session_time) web_uij["sidebar_session_time"] = cfg.web_ui.sidebar_session_time; + if (cfg.web_ui.message_auto_collapse != web_ui_d.message_auto_collapse) + web_uij["message_auto_collapse"] = cfg.web_ui.message_auto_collapse; if (!web_uij.empty()) j["web_ui"] = std::move(web_uij); MemoryConfig mem_d; diff --git a/src/config/config.hpp b/src/config/config.hpp index a58cfb1bc..f41bf390e 100644 --- a/src/config/config.hpp +++ b/src/config/config.hpp @@ -184,6 +184,8 @@ struct WebUiPreferencesConfig { // Sidebar session rows show a relative timestamp. Product default is on; // turning it off leaves the time visible only in the row hover card. bool sidebar_session_time = true; + // Collapse conversation activity; false keeps only individual tools foldable. + bool message_auto_collapse = true; }; struct ModelsDevConfig { @@ -531,6 +533,8 @@ struct AppConfig { // Canonical values: default | auto | plan | yolo (accept-edits is read as auto). std::string default_permission_mode = "default"; SandboxConfig sandbox; // bash 沙盒(openspec add-auto-mode-sandbox) + // Fixed one-time startup migration; keep separate from editable sandbox settings. + bool sandbox_disable_migration_completed = false; std::map mcp_servers; // MCP stdio servers (optional) SkillsConfig skills; // skill system configuration (optional) MemoryConfig memory; // persistent user memory settings diff --git a/src/config/config_mutation.cpp b/src/config/config_mutation.cpp index 41319d7b4..cea395cce 100644 --- a/src/config/config_mutation.cpp +++ b/src/config/config_mutation.cpp @@ -1,9 +1,13 @@ #include "config_mutation.hpp" +#include "config_recovery.hpp" +#include "../utils/atomic_file.hpp" +#include "../utils/logger.hpp" #include "../utils/utf8_path.hpp" #include #include +#include #include #include #include @@ -185,4 +189,64 @@ ConfigMutationResult mutate_config( } } +ConfigMutationResult disable_sandbox_once(const std::string& explicit_path) { + ConfigMutationResult result; + try { + std::lock_guard process_lock(g_config_mutation_mutex); + const std::string config_path = resolved_config_path(explicit_path); + ConfigFileLock file_lock(config_path); + AppConfig latest = load_config_from_path(config_path, false); + if (!latest.sandbox_disable_migration_completed) { + // Patch only these two fields after validation. Do not serialize + // AppConfig here: that would drop unrecognized user config fields. + nlohmann::json document; + { + std::ifstream input(path_from_utf8(config_path), std::ios::binary); + if (!input.is_open()) { + throw std::runtime_error("failed to read config for sandbox migration"); + } + input >> document; + if (input.bad()) { + throw std::runtime_error("failed to read config for sandbox migration"); + } + } // Close the read handle before replacing the file on Windows. + if (!document.is_object()) { + throw std::runtime_error("config for sandbox migration is not an object"); + } + if (!document.contains("sandbox") || !document["sandbox"].is_object()) { + document["sandbox"] = nlohmann::json::object(); + } + if (!document.contains("migrations") || !document["migrations"].is_object()) { + document["migrations"] = nlohmann::json::object(); + } + document["sandbox"]["enabled"] = false; + document["migrations"]["disable_sandbox_once"] = true; + const std::string bytes = document.dump(2) + "\n"; + if (!atomic_write_file(config_path, bytes, true)) { + throw std::runtime_error("failed to write config for sandbox migration"); + } + latest.sandbox.enabled = false; + latest.sandbox_disable_migration_completed = true; + result.changed = true; + std::string snapshot_error; + if (!write_last_good_config(config_path, bytes, &snapshot_error)) { + Logger::instance().init_with_rotation_if_disabled( + get_logs_dir(), "config", /*mirror_stderr=*/false); + LOG_ERROR("[config_recovery] sandbox migration saved but failed to " + "advance last-good snapshot"); + } + } + result.ok = true; + result.config = std::move(latest); + } catch (const nlohmann::json::exception&) { + // JSON parser diagnostics may contain credential-bearing input bytes. + result.error_kind = ConfigMutationErrorKind::Persistence; + result.error = "failed to parse config for sandbox migration"; + } catch (const std::exception& e) { + result.error_kind = ConfigMutationErrorKind::Persistence; + result.error = e.what(); + } + return result; +} + } // namespace acecode diff --git a/src/config/config_mutation.hpp b/src/config/config_mutation.hpp index 8a25fe593..6ad5384d7 100644 --- a/src/config/config_mutation.hpp +++ b/src/config/config_mutation.hpp @@ -34,4 +34,8 @@ ConfigMutationResult mutate_config( const std::string& explicit_path = {}, const AppConfig* seed_if_missing = nullptr); +// Startup-only, fixed migration. Reuses the mutation locks and patches the raw +// JSON so unrelated/unknown fields survive. Later calls preserve user choices. +ConfigMutationResult disable_sandbox_once(const std::string& explicit_path = {}); + } // namespace acecode diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index fa63e4bab..321196bc4 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -2217,6 +2217,7 @@ int main(int argc, char** argv) { {"color_theme", desktop_cfg.web_ui.color_theme}, {"font_size", desktop_cfg.web_ui.font_size}, {"sidebar_session_time", desktop_cfg.web_ui.sidebar_session_time}, + {"message_auto_collapse", desktop_cfg.web_ui.message_auto_collapse}, }.dump(); const std::string startup_bootstrap = startup_timeline.snapshot_json(); host.init_script(acecode::desktop::locale_bootstrap_script( diff --git a/src/web/routes/routes_misc.cpp b/src/web/routes/routes_misc.cpp index ce898e5f8..67d275825 100644 --- a/src/web/routes/routes_misc.cpp +++ b/src/web/routes/routes_misc.cpp @@ -2287,6 +2287,13 @@ void WebServer::Impl::register_ui_preferences() { "font_size must be small, medium, or large"); } } + if (body.contains("message_auto_collapse")) { + has_supported_field = true; + if (!body["message_auto_collapse"].is_boolean()) { + return json_err(400, "BAD_REQUEST", + "message_auto_collapse must be a boolean"); + } + } if (body.contains("sidebar_session_time")) { has_supported_field = true; if (!body["sidebar_session_time"].is_boolean()) { @@ -2314,6 +2321,10 @@ void WebServer::Impl::register_ui_preferences() { } const auto before = deps.app_config->web_ui; deps.app_config->web_ui.show_acecode_avatar = false; + if (body.contains("message_auto_collapse")) { + deps.app_config->web_ui.message_auto_collapse = + body["message_auto_collapse"].get(); + } if (body.contains("theme")) { deps.app_config->web_ui.theme = body["theme"].get(); diff --git a/src/web/server_helpers.cpp b/src/web/server_helpers.cpp index 48a50e2b5..69336ec1c 100644 --- a/src/web/server_helpers.cpp +++ b/src/web/server_helpers.cpp @@ -79,6 +79,7 @@ json ui_preferences_to_json(const WebUiPreferencesConfig& cfg) { {"color_theme", cfg.color_theme}, {"font_size", cfg.font_size}, {"sidebar_session_time", cfg.sidebar_session_time}, + {"message_auto_collapse", cfg.message_auto_collapse}, }; } diff --git a/tests/agent_loop/agent_loop_turn_steering_test.cpp b/tests/agent_loop/agent_loop_turn_steering_test.cpp index ca153b6f2..ba0c77bae 100644 --- a/tests/agent_loop/agent_loop_turn_steering_test.cpp +++ b/tests/agent_loop/agent_loop_turn_steering_test.cpp @@ -289,6 +289,164 @@ TEST(AgentLoopUserMessageRetry, RejectsActiveOrQueuedControlWork) { EXPECT_EQ(h.provider().turn_count(), 0); } +TEST(AgentLoopUserMessageRetry, RetriesConfirmedUserAbortWithoutDuplicatingUser) { + TurnSteeringHarness h("retry-user-abort"); + h.provider().set_latency_ms(200); + h.provider().push_text("cancelled response"); + h.provider().push_text("retried response"); + h.loop().submit("original request"); + const auto id = h.wait_for_active_turn(); + ASSERT_TRUE(h.wait_for_provider_turns(1)); + h.loop().abort(); + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(1)); + + const auto stopped = h.session_manager().load_active_messages(); + ASSERT_FALSE(stopped.empty()); + EXPECT_EQ(stopped.back().metadata.value("retry_user_message_id", ""), id); + EXPECT_TRUE(stopped.back().metadata.value("user_aborted", false)); + EXPECT_TRUE(stopped.back().metadata.value("transcript_only", false)); + std::string error; + EXPECT_FALSE(h.loop().retry_last_user_message("stale-id", error)); + ASSERT_TRUE(h.loop().retry_last_user_message(id, error)) << error; + EXPECT_FALSE(h.loop().retry_last_user_message(id, error)); + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(2)); + EXPECT_EQ(request_user_texts(h.provider().messages_for_turn(1)), + 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"; + }), 1); + EXPECT_FALSE(h.loop().retry_last_user_message(id, error)); +} + +TEST(AgentLoopUserMessageRetry, RetriesAbortAfterPartialTextOrReasoning) { + for (const auto kind : {acecode::SessionEventKind::Token, + acecode::SessionEventKind::Reasoning}) { + TurnSteeringHarness h("retry-partial-abort"); + acecode::StreamEvent partial; + partial.type = kind == acecode::SessionEventKind::Token + ? acecode::StreamEventType::Delta : acecode::StreamEventType::ReasoningDelta; + partial.content = "partial output"; + h.provider().push_events({partial}); + h.provider().push_text("retried response"); + const auto sub = h.loop().events().subscribe([&](const auto& event) { + if (event.kind == kind) h.loop().abort(); + }); + h.loop().submit("original request"); + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(1)); + h.loop().events().unsubscribe(sub); + const auto stopped = h.session_manager().load_active_messages(); + ASSERT_FALSE(stopped.empty()); + if (kind == acecode::SessionEventKind::Token) { + const auto partial_message = std::find_if(stopped.begin(), stopped.end(), [](const auto& message) { + return message.role == "assistant" && message.content == "partial output"; + }); + ASSERT_NE(partial_message, stopped.end()); + EXPECT_TRUE(partial_message->metadata.value("transcript_only", false)); + EXPECT_TRUE(partial_message->metadata.value("interrupted_output", false)); + } + const auto id = stopped.back().metadata.value("retry_user_message_id", ""); + ASSERT_FALSE(id.empty()); + std::string error; + ASSERT_TRUE(h.loop().retry_last_user_message(id, error)) << error; + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(2)); + EXPECT_EQ(request_user_texts(h.provider().messages_for_turn(1)), + std::vector{"original request"}); + } +} + +TEST(AgentLoopUserMessageRetry, PreservesToolResultsAndAppendsOriginalStructuredInputAfterAbort) { + TurnSteeringHarness h("retry-tools-abort"); + // Even an unavailable tool produces an assistant call and paired result. + h.provider().push_tool_call("unavailable_tool", "{}", "retry-tool-call"); + h.provider().push_text("retried response"); + const auto sub = h.loop().events().subscribe([&](const auto& event) { + if (event.kind == acecode::SessionEventKind::ModelStepFinish) h.loop().abort(); + }); + acecode::UserInput input; + input.text = "original with context"; + input.display_text = "original visible text"; + input.content_parts = nlohmann::json::array({ + {{"type", "text"}, {"text", input.text}}, + {{"type", "image_url"}, {"image_url", {{"url", "data:image/png;base64,AAAA"}}}}, + }); + input.metadata = {{"client_message_id", "original-client"}}; + h.loop().submit(input); + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(1)); + h.loop().events().unsubscribe(sub); + const auto stopped = h.session_manager().load_active_messages(); + const auto id = stopped.back().metadata.value("retry_user_message_id", ""); + ASSERT_FALSE(id.empty()); + std::string error; + ASSERT_TRUE(h.loop().retry_last_user_message(id, error)) << error; + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(2)); + const auto request = h.provider().messages_for_turn(1); + ASSERT_GE(request.size(), 4u); + const auto& retried = request.back(); + EXPECT_EQ(retried.role, "user"); + EXPECT_NE(retried.uuid, id); + EXPECT_EQ(retried.content, input.text); + EXPECT_EQ(retried.content_parts, input.content_parts); + EXPECT_EQ(retried.metadata.value("display_text", ""), input.display_text); + EXPECT_FALSE(retried.metadata.contains("client_message_id")); + 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 auto persisted = h.session_manager().load_active_messages(); + EXPECT_EQ(std::count_if(persisted.begin(), persisted.end(), [](const auto& message) { + return message.role == "user"; + }), 2); +} + +TEST(AgentLoopUserMessageRetry, RestoresExplicitAbortButRejectsPlainNoticesAndSupersededStops) { + for (const auto& mode : {"restored", "plain", "interjected", "stale", "newer-message", "live-error", "invalid-model-tail"}) { + SCOPED_TRACE(mode); + TurnSteeringHarness h(std::string("retry-abort-") + mode); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "restored-user"; + user.content = "original"; + acecode::ChatMessage assistant; + assistant.role = std::string(mode) == "invalid-model-tail" ? "system" : "assistant"; + assistant.content = "existing partial reply"; + for (const auto& message : {user, assistant}) { + h.loop().push_message(message); + h.session_manager().on_message(message); + } + acecode::ChatMessage marker; + marker.role = "system"; + marker.content = "[Interrupted]"; + marker.metadata = {{"transcript_only", true}}; + const std::string variant = mode; + if (variant != "plain" && variant != "interjected") { + marker.metadata["user_aborted"] = true; + marker.metadata["retry_user_message_id"] = variant == "stale" ? "older-user" : user.uuid; + } + if (variant == "interjected") marker.metadata["turn_interrupt"] = true; + h.session_manager().on_message(marker); + if (variant == "newer-message") h.loop().emit_transcript_system_message("later notice"); + if (variant == "live-error") { + h.loop().events().emit(acecode::SessionEventKind::Error, {{"reason", "later error"}}); + } + h.provider().push_text("retried response"); + std::string error; + const bool accepted = h.loop().retry_last_user_message(user.uuid, error); + EXPECT_EQ(accepted, variant == "restored") << error; + if (accepted) { + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(1)); + const auto request = h.provider().messages_for_turn(0); + ASSERT_GE(request.size(), 3u); + EXPECT_EQ(request[request.size() - 2].content, assistant.content); + EXPECT_EQ(request.back().content, user.content); + EXPECT_NE(request.back().uuid, user.uuid); + } + } +} + TEST(AgentLoopTurnSteering, RejectsInvalidIdleAndMismatchedRequests) { TurnSteeringHarness h("reject"); diff --git a/tests/config/config_first_init_test.cpp b/tests/config/config_first_init_test.cpp index 719f7e003..d10a476de 100644 --- a/tests/config/config_first_init_test.cpp +++ b/tests/config/config_first_init_test.cpp @@ -110,6 +110,8 @@ TEST_F(ConfigFirstInitTest, LoadConfigTracksFreshAcecodeHomeCreationOnce) { EXPECT_TRUE(cfg.saved_models.empty()); EXPECT_TRUE(cfg.default_model_name.empty()); EXPECT_EQ(cfg.default_permission_mode, "default"); + EXPECT_FALSE(cfg.sandbox.enabled); + EXPECT_TRUE(cfg.sandbox_disable_migration_completed); std::ifstream ifs(temp_home / ".acecode" / "config.json"); ASSERT_TRUE(ifs.is_open()); @@ -225,6 +227,8 @@ TEST_F(ConfigFirstInitTest, RuntimeSkillAllowlistIsNeverPersisted) { EXPECT_FALSE(saved["skills"].contains("allowed")); EXPECT_FALSE(saved["skills"].contains("allowlist")); } + // Startup migrations atomically replace the config on Windows. + ifs.close(); const auto reloaded = acecode::load_config(); EXPECT_FALSE(reloaded.skills.allowed.has_value()); @@ -576,6 +580,31 @@ TEST_F(ConfigFirstInitTest, DefaultPermissionModeLoadsAndInvalidFallsBack) { EXPECT_EQ(cfg.default_permission_mode, "default"); } +TEST_F(ConfigFirstInitTest, StartupDisablesSandboxOnceAndPreservesReenabledChoice) { + const fs::path config_path = temp_home / ".acecode" / "config.json"; + fs::create_directories(config_path.parent_path()); + { + std::ofstream output(config_path); + output << R"({"saved_models":[],"sandbox":{"enabled":true}, + "openai":{"stream_timeout_ms":1234}})"; + } + set_env_value(kOpenAiStreamTimeoutEnvName, "9876"); + const auto initial = acecode::load_config(); + EXPECT_FALSE(initial.sandbox.enabled); + EXPECT_TRUE(initial.sandbox_disable_migration_completed); + EXPECT_EQ(initial.openai.stream_timeout_ms, 9876); + + auto disk = acecode::load_config_from_path(config_path.string()); + EXPECT_EQ(disk.openai.stream_timeout_ms, 1234); + disk.sandbox.enabled = true; + acecode::save_config(disk, config_path.string()); + for (int i = 0; i < 3; ++i) { + const auto restarted = acecode::load_config(); + EXPECT_TRUE(restarted.sandbox.enabled); + EXPECT_TRUE(restarted.sandbox_disable_migration_completed); + } +} + TEST_F(ConfigFirstInitTest, SavedModelsDefaultCodexFallsBackToEnabledModel) { fs::create_directories(temp_home / ".acecode"); { diff --git a/tests/config/config_sandbox_test.cpp b/tests/config/config_sandbox_test.cpp index 5e376bc2c..a3247467f 100644 --- a/tests/config/config_sandbox_test.cpp +++ b/tests/config/config_sandbox_test.cpp @@ -1,12 +1,25 @@ #include #include "config/config.hpp" +#include "config/config_mutation.hpp" +#include "config/config_recovery.hpp" #include "../sandbox/test_support.hpp" #include +#include #include #include using namespace acecode; +namespace { + +std::string read_sandbox_config_bytes(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + return std::string(std::istreambuf_iterator(input), + std::istreambuf_iterator()); +} + +} // namespace + // 场景:sandbox 段字段类型全错(字符串 / 数字 / null / 相对路径与空串)。 // 期望:逐字段静默回落默认值(enabled=true、不放行网络、无额外根),不阻塞启动。 TEST(ConfigSandbox, DefaultsAndInvalidFieldTypesRemainConservative) { @@ -78,3 +91,138 @@ TEST(ConfigSandbox, RoundTripsFilesystemEntriesAndWindowsBackend) { std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); EXPECT_EQ(text.find("\"sandbox\""), std::string::npos) << "默认值不落盘"; } + +TEST(ConfigSandboxMigration, DisablesLegacyValuesAndPreservesOtherJsonFields) { + for (const auto& sandbox_json : { + nlohmann::json(), nlohmann::json{{"enabled", true}}, + nlohmann::json{{"enabled", false}}}) { + sandbox::test::TempTree tree; + const auto path = tree.root / "config.json"; + nlohmann::json original = { + {"max_sessions", 37}, {"default_permission_mode", "auto"}, + {"future_setting", {{"keep", "unchanged"}}}, + {"migrations", {{"other_migration", true}}}}; + if (!sandbox_json.is_null()) { + original["sandbox"] = sandbox_json; + original["sandbox"]["network_access"] = true; + original["sandbox"]["filesystem"]["deny"] = {"~/.ssh", "**/.env"}; + original["sandbox"]["future_policy"] = "keep"; + } + tree.write(path, original.dump()); + + const auto migrated = disable_sandbox_once(path_to_utf8(path)); + ASSERT_TRUE(migrated.ok) << migrated.error; + EXPECT_TRUE(migrated.changed); + EXPECT_FALSE(migrated.config.sandbox.enabled); + EXPECT_TRUE(migrated.config.sandbox_disable_migration_completed); + original["sandbox"]["enabled"] = false; + original["migrations"]["disable_sandbox_once"] = true; + const auto bytes = read_sandbox_config_bytes(path); + EXPECT_EQ(nlohmann::json::parse(bytes), original); + EXPECT_EQ(read_last_good_config(path_to_utf8(path)), + std::optional(bytes)); + + const auto repeated = disable_sandbox_once(path_to_utf8(path)); + EXPECT_TRUE(repeated.ok) << repeated.error; + EXPECT_FALSE(repeated.changed); + EXPECT_EQ(read_sandbox_config_bytes(path), bytes); + } +} + +TEST(ConfigSandboxMigration, ReenabledSandboxSurvivesNormalSavesAndRepeatedMigration) { + sandbox::test::TempTree tree; + const auto path = tree.root / "config.json"; + tree.write(path, "{}"); + ASSERT_TRUE(disable_sandbox_once(path_to_utf8(path)).ok); + auto config = load_config_from_path(path_to_utf8(path)); + ASSERT_TRUE(config.sandbox_disable_migration_completed); + config.sandbox.enabled = true; + save_config(config, path_to_utf8(path)); + + // Saving an enabled sandbox omits its default-valued section. The separate + // migration marker must still prevent a later startup from disabling it. + EXPECT_FALSE(nlohmann::json::parse(read_sandbox_config_bytes(path)).contains("sandbox")); + const auto other_setting = mutate_config([](AppConfig& candidate, std::string&) { + candidate.max_sessions = 73; + return true; + }, path_to_utf8(path)); + ASSERT_TRUE(other_setting.ok) << other_setting.error; + const auto bytes = read_sandbox_config_bytes(path); + const auto repeated = disable_sandbox_once(path_to_utf8(path)); + ASSERT_TRUE(repeated.ok) << repeated.error; + EXPECT_FALSE(repeated.changed); + EXPECT_TRUE(repeated.config.sandbox.enabled); + EXPECT_EQ(repeated.config.max_sessions, 73); + EXPECT_EQ(read_sandbox_config_bytes(path), bytes); +} + +TEST(ConfigSandboxMigration, CompletedMarkerPreservesExplicitEnabledAndDisabledChoices) { + for (bool enabled : {true, false}) { + sandbox::test::TempTree tree; + const auto path = tree.root / "config.json"; + const auto bytes = nlohmann::json{ + {"sandbox", {{"enabled", enabled}}}, + {"migrations", {{"disable_sandbox_once", true}}}}.dump(); + tree.write(path, bytes); + const auto repeated = disable_sandbox_once(path_to_utf8(path)); + ASSERT_TRUE(repeated.ok) << repeated.error; + EXPECT_FALSE(repeated.changed); + EXPECT_EQ(repeated.config.sandbox.enabled, enabled); + EXPECT_EQ(read_sandbox_config_bytes(path), bytes); + } +} + +TEST(ConfigSandboxMigration, OnlyBooleanTrueCountsAsCompleted) { + for (const auto& marker : {nlohmann::json(false), nlohmann::json("true"), + nlohmann::json(1), nlohmann::json(nullptr)}) { + sandbox::test::TempTree tree; + const auto path = tree.root / "config.json"; + tree.write(path, nlohmann::json{ + {"sandbox", {{"enabled", true}}}, + {"migrations", {{"disable_sandbox_once", marker}}}}.dump()); + const auto migrated = disable_sandbox_once(path_to_utf8(path)); + ASSERT_TRUE(migrated.ok) << migrated.error; + EXPECT_TRUE(migrated.changed); + EXPECT_FALSE(migrated.config.sandbox.enabled); + } +} + +TEST(ConfigSandboxMigration, FailedWriteLeavesOriginalIntactAndCanRetry) { + sandbox::test::TempTree tree; + const auto path = tree.root / "config.json"; + const auto blocker = tree.dir("config.json.tmp"); + const std::string original = R"({"sandbox":{"enabled":true}})"; + tree.write(path, original); + const auto failed = disable_sandbox_once(path_to_utf8(path)); + EXPECT_FALSE(failed.ok); + EXPECT_FALSE(failed.changed); + EXPECT_EQ(failed.error_kind, ConfigMutationErrorKind::Persistence); + EXPECT_EQ(read_sandbox_config_bytes(path), original); + EXPECT_FALSE(load_config_from_path(path_to_utf8(path)).sandbox_disable_migration_completed); + + ASSERT_TRUE(std::filesystem::remove(blocker)); + const auto retried = disable_sandbox_once(path_to_utf8(path)); + ASSERT_TRUE(retried.ok) << retried.error; + EXPECT_TRUE(retried.changed); + EXPECT_FALSE(retried.config.sandbox.enabled); +} + +TEST(ConfigSandboxMigration, ConcurrentAttemptsApplyExactlyOnce) { + sandbox::test::TempTree tree; + const auto path = path_to_utf8(tree.root / "config.json"); + tree.write(path_from_utf8(path), "{}"); + std::vector> attempts; + for (int i = 0; i < 8; ++i) { + attempts.push_back(std::async(std::launch::async, [path] { + return disable_sandbox_once(path); + })); + } + int changed = 0; + for (auto& attempt : attempts) { + const auto result = attempt.get(); + EXPECT_TRUE(result.ok) << result.error; + EXPECT_FALSE(result.config.sandbox.enabled); + if (result.changed) ++changed; + } + EXPECT_EQ(changed, 1); +} diff --git a/tests/config/config_web_ui_preferences_test.cpp b/tests/config/config_web_ui_preferences_test.cpp index 1d62bc2c7..848ef6e29 100644 --- a/tests/config/config_web_ui_preferences_test.cpp +++ b/tests/config/config_web_ui_preferences_test.cpp @@ -258,6 +258,40 @@ TEST(ConfigWebUiPreferencesSave, NationalDayThemeRoundTripsForUpgradedProfiles) std::filesystem::remove(path, ec); } +// 消息自动折叠默认开启,旧配置和无效值兼容默认;关闭后稀疏落盘并可恢复。 +TEST(ConfigWebUiPreferencesMessageAutoCollapse, DefaultsAndLegacyConfigStayEnabled) { + EXPECT_TRUE(WebUiPreferencesConfig{}.message_auto_collapse); + const auto path = temp_config_path("message-collapse-default"); + for (const auto& value : {nlohmann::json::object(), + nlohmann::json{{"message_auto_collapse", "false"}}}) { + write_json(path, {{"web_ui", value}}); + EXPECT_TRUE(load_config_from_path(path.string()).web_ui.message_auto_collapse); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + +TEST(ConfigWebUiPreferencesMessageAutoCollapse, DisabledRoundTripsAndDefaultIsOmitted) { + const auto path = temp_config_path("message-collapse-roundtrip"); + AppConfig cfg; + cfg.web_ui.message_auto_collapse = false; + save_config(cfg, path.string()); + { + std::ifstream input(path); + const auto saved = nlohmann::json::parse(input); + EXPECT_EQ(saved["web_ui"]["message_auto_collapse"], false); + } + EXPECT_FALSE(load_config_from_path(path.string()).web_ui.message_auto_collapse); + cfg.web_ui.message_auto_collapse = true; + save_config(cfg, path.string()); + { + std::ifstream input(path); + EXPECT_FALSE(nlohmann::json::parse(input).contains("web_ui")); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + TEST(ConfigWebUiPreferencesSave, DownloadedThemePreservesOrdinaryDarkPreference) { const auto path = temp_config_path("eva-theme"); AppConfig cfg; diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index db37a4983..9e35b3fd9 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -7813,6 +7813,42 @@ TEST(WebServerHttp, UiPreferencesSidebarSessionTimeRejectsNonBoolean) { EXPECT_TRUE(fx.cfg.web_ui.sidebar_session_time); } +// 消息折叠偏好独立更新,后续外观修改必须保留关闭值;无效值整笔拒绝。 +TEST(WebServerHttp, UiPreferencesMessageAutoCollapseRoundTrips) { + WebServerFixture fx; + const auto url = cpr::Url{fx.url("/api/config/ui-preferences")}; + const cpr::Header headers{{"Content-Type", "application/json"}}; + EXPECT_EQ(json::parse(cpr::Get(url).text)["message_auto_collapse"], true); + auto disabled = cpr::Put(url, headers, cpr::Body{R"({"message_auto_collapse":false})"}); + ASSERT_EQ(disabled.status_code, 200) << disabled.text; + EXPECT_EQ(json::parse(disabled.text)["message_auto_collapse"], false); + EXPECT_FALSE(fx.cfg.web_ui.message_auto_collapse); + auto other = cpr::Put(url, headers, cpr::Body{R"({"font_size":"large"})"}); + ASSERT_EQ(other.status_code, 200) << other.text; + EXPECT_EQ(json::parse(other.text)["message_auto_collapse"], false); + const auto saved = acecode::load_config_from_path((fx.tmp_dir / "config.json").u8string()); + EXPECT_FALSE(saved.web_ui.message_auto_collapse); + EXPECT_EQ(saved.web_ui.font_size, "large"); + auto invalid = cpr::Put(url, headers, + cpr::Body{R"({"message_auto_collapse":"true","font_size":"small"})"}); + EXPECT_EQ(invalid.status_code, 400) << invalid.text; + EXPECT_FALSE(fx.cfg.web_ui.message_auto_collapse); + EXPECT_EQ(fx.cfg.web_ui.font_size, "large"); + auto enabled = cpr::Put(url, headers, cpr::Body{R"({"message_auto_collapse":true})"}); + ASSERT_EQ(enabled.status_code, 200) << enabled.text; + EXPECT_TRUE(fx.cfg.web_ui.message_auto_collapse); +} + +TEST(WebServerHttp, UiPreferencesMessageAutoCollapseRollsBackOnSaveFailure) { + WebServerFixture fx; + std::filesystem::create_directories(fx.tmp_dir / "config.json"); + auto response = cpr::Put(cpr::Url{fx.url("/api/config/ui-preferences")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{R"({"message_auto_collapse":false})"}); + EXPECT_EQ(response.status_code, 500) << response.text; + EXPECT_TRUE(fx.cfg.web_ui.message_auto_collapse); +} + TEST(WebServerHttp, PutUiPreferencesPartialUpdatePreservesOtherAppearanceFields) { WebServerFixture fx; fx.server->with_app_config_lock([&] { diff --git a/web/public/vs-icons/Extension.svg b/web/public/vs-icons/Extension.svg index f55dabbe0..90a24de7b 100644 --- a/web/public/vs-icons/Extension.svg +++ b/web/public/vs-icons/Extension.svg @@ -1 +1 @@ - + diff --git a/web/public/vs-icons/NewSession.svg b/web/public/vs-icons/NewSession.svg index 5bcca3d39..14d5da8a3 100644 --- a/web/public/vs-icons/NewSession.svg +++ b/web/public/vs-icons/NewSession.svg @@ -1 +1 @@ - + diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 60f048fa9..70377101e 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,15 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '展开命令详情': 'Expand command details', + '收起命令详情': 'Collapse command details', + '命令未记录': 'Command not recorded', + '执行失败': 'Execution failed', + '消息自动折叠': 'Automatically collapse messages', + '会话 conversation messages auto collapse fold expand tools': 'conversation messages auto collapse fold expand tools', + '关闭后展开所有消息,仅保留工具调用的折叠': 'When off, expand all messages and keep only tool calls collapsible', + '{{p0}} 个文件有未保存的修改。是否保存后继续?': '{{p0}} file(s) have unsaved changes. Save before continuing?', + '保存': 'Save', '调整 {{p0}} 的顺序': 'Reorder {{p0}}', '拖动排序,也可按上下方向键': 'Drag to reorder, or use the up and down arrow keys', '模型列表已发生变化,请刷新后重新排序': 'The model list has changed. Refresh it and reorder again.', @@ -28,6 +37,7 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '{{p0}} – {{p1}} 使用了 {{p2}} 个 Token': '{{p0}} – {{p1}}: {{p2}} tokens used', '{{p0}} – {{p1}} 累计使用了 {{p2}} 个 Token': '{{p0}} – {{p1}}: {{p2}} tokens used in total', '会话续接建议': 'Conversation continuation suggestion', + '{{p0}}秒后关闭': 'Closes in {{p0}} s', '后台任务建议': 'Suggested task', '任务建议': 'Task suggestions', '在新会话中继续': 'Continue in a new conversation', diff --git a/web/src/App.jsx b/web/src/App.jsx index e79f77707..348332a12 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -36,6 +36,7 @@ import { navigationHistoryFromHash, normalizeHistory, pushNavigation, + sameNavigationRef, stripNavigationHistoryHash, } from './lib/navigationHistory.js'; import { @@ -68,6 +69,7 @@ import { import { DEFAULT_UI_PREFS, effectiveFontSize, + effectiveMessageAutoCollapse, effectiveSidebarSessionTime, effectiveSidePanelListCollapsed, rightPanelHidden, @@ -123,6 +125,7 @@ import { validateLayoutWidths, } from './lib/singleLayout.js'; import { initInactiveSelection } from './lib/inactiveSelection.js'; +import { runAfterFileApproval } from './lib/unsavedFileGuard.js'; import { desktopOpenSessionUrl, openSessionTargetFromSearch, @@ -282,6 +285,7 @@ export function App() { ...DEFAULT_UI_PREFS, fontSize: initialAppearance.fontSize, sidebarSessionTime: initialAppearance.sidebarSessionTime, + messageAutoCollapse: initialAppearance.messageAutoCollapse, }), [initialAppearance]); const [uiPrefs, setUiPrefs] = usePreference( UI_PREFS_STORAGE_KEY, initialUiPrefs, validateUiPrefs); @@ -289,6 +293,11 @@ export function App() { useLayoutEffect(() => { setUiPrefs((prev) => prev.sidePanelCollapsed ? prev : { ...prev, sidePanelCollapsed: true }); }, [setUiPrefs]); + useLayoutEffect(() => { + if (bootstrapAppearance) { + setUiPrefs({ messageAutoCollapse: bootstrapAppearance.messageAutoCollapse }); + } + }, [bootstrapAppearance, setUiPrefs]); const [consoleDock, setConsoleDock] = usePreference( CONSOLE_DOCK_STORAGE_KEY, DEFAULT_CONSOLE_DOCK, validateConsoleDock); const [recentExpertIds, setRecentExpertIds] = usePreference( @@ -315,12 +324,14 @@ export function App() { const view = 'single'; const fontSize = effectiveFontSize(uiPrefs); const sidebarSessionTime = effectiveSidebarSessionTime(uiPrefs); + const messageAutoCollapse = effectiveMessageAutoCollapse(uiPrefs); const applyAppearance = useCallback((next) => { setTheme(next.theme); setColorTheme(next.colorTheme); setUiPrefs({ fontSize: next.fontSize, sidebarSessionTime: next.sidebarSessionTime, + messageAutoCollapse: next.messageAutoCollapse, }); }, [setColorTheme, setTheme, setUiPrefs]); const appearanceControllerRef = useRef(null); @@ -331,6 +342,7 @@ export function App() { colorTheme, fontSize, sidebarSessionTime, + messageAutoCollapse: bootstrapAppearance?.messageAutoCollapse ?? messageAutoCollapse, }, apply: applyAppearance, save: (payload) => api.setUiPreferences(payload), @@ -507,28 +519,46 @@ export function App() { setSidebarSessionLoadResetSequence((sequence) => sequence + 1); }, []); + const previewLeaveGuardRef = useRef(null); + const registerPreviewLeaveGuard = useCallback((guard) => { + previewLeaveGuardRef.current = guard; + return () => { + if (previewLeaveGuardRef.current === guard) previewLeaveGuardRef.current = null; + }; + }, []); + const requestPreviewLeave = useCallback((nextRef) => { + if (nextRef && sameNavigationRef(activeRefRef.current, nextRef)) return true; + return previewLeaveGuardRef.current?.() ?? true; + }, []); + const replaceActiveRef = useCallback((nextRefOrUpdater) => { - resetSidebarSessionLoading(); const current = activeRefRef.current; const next = typeof nextRefOrUpdater === 'function' ? nextRefOrUpdater(current) : nextRefOrUpdater; - activeRefRef.current = next; - setActiveRef(next); - }, [resetSidebarSessionLoading]); + return runAfterFileApproval(requestPreviewLeave(next), () => { + resetSidebarSessionLoading(); + activeRefRef.current = next; + setActiveRef(next); + return true; + }); + }, [requestPreviewLeave, resetSidebarSessionLoading]); const navigateToRef = useCallback((nextRefOrUpdater, options = {}) => { - if (!options.preserveSidebarSessionLoading) resetSidebarSessionLoading(); const current = activeRefRef.current; const next = typeof nextRefOrUpdater === 'function' ? nextRefOrUpdater(current) : nextRefOrUpdater; - const nextHistory = pushNavigation(navHistoryRef.current, current, next); - navHistoryRef.current = nextHistory; - activeRefRef.current = next; - setNavHistory(nextHistory); - setActiveRef(next); - }, [resetSidebarSessionLoading]); + return runAfterFileApproval(requestPreviewLeave(next), () => { + if (!options.preserveSidebarSessionLoading) resetSidebarSessionLoading(); + const nextHistory = pushNavigation(navHistoryRef.current, current, next); + navHistoryRef.current = nextHistory; + activeRefRef.current = next; + setNavHistory(nextHistory); + setActiveRef(next); + return true; + }); + }, [requestPreviewLeave, resetSidebarSessionLoading]); const syncActiveRemoteControlBound = useCallback((detail = {}) => { const sessionId = String(detail.sessionId || detail.session_id || '').trim(); @@ -565,13 +595,16 @@ export function App() { }, [syncActiveRemoteControlBound]); const replaceNavigationState = useCallback((nextRef, nextHistory) => { - resetSidebarSessionLoading(); - const normalized = normalizeHistory(nextHistory); - navHistoryRef.current = normalized; - activeRefRef.current = nextRef; - setNavHistory(normalized); - setActiveRef(nextRef); - }, [resetSidebarSessionLoading]); + return runAfterFileApproval(requestPreviewLeave(nextRef), () => { + resetSidebarSessionLoading(); + const normalized = normalizeHistory(nextHistory); + navHistoryRef.current = normalized; + activeRefRef.current = nextRef; + setNavHistory(normalized); + setActiveRef(nextRef); + return true; + }); + }, [requestPreviewLeave, resetSidebarSessionLoading]); const finishSessionNavigation = useCallback((navigationId) => { const timer = sessionNavigationTimersRef.current.get(navigationId); @@ -619,6 +652,7 @@ export function App() { const resumeAndOpenSession = useCallback(async (target, options = {}) => { const sessionId = sessionJumpId(target); if (!sessionId) return false; + if (!await requestPreviewLeave(target)) return false; resetSidebarSessionLoading(); const navigationId = beginSessionNavigation(); const navigationIsPending = () => @@ -717,14 +751,14 @@ export function App() { navigateToRef, replaceActiveRef, replaceNavigationState, + requestPreviewLeave, resetSidebarSessionLoading, ]); const openHistoryDestination = useCallback((result) => { if (!result?.activeRef) return Promise.resolve(false); if (!sessionJumpId(result.activeRef)) { - replaceNavigationState(result.activeRef, result.history); - return Promise.resolve(true); + return Promise.resolve(replaceNavigationState(result.activeRef, result.history)); } return resumeAndOpenSession(result.activeRef, { forceResume: true, @@ -1308,8 +1342,11 @@ export function App() { }, [probe]); const toggleSidePanel = useCallback(() => { - setUiPrefs((prev) => toggleRightPanel(prev, previewPanelVisible)); - }, [previewPanelVisible, setUiPrefs]); + const approval = rightPanelHidden(uiPrefs, previewPanelVisible) ? true : requestPreviewLeave(); + return runAfterFileApproval(approval, () => { + setUiPrefs((prev) => toggleRightPanel(prev, previewPanelVisible)); + }); + }, [previewPanelVisible, requestPreviewLeave, setUiPrefs, uiPrefs]); const toggleSidePanelList = useCallback(() => { setUiPrefs((prev) => ({ @@ -1707,6 +1744,7 @@ export function App() { }, [abortGuidedTour, guidedTourHasActiveSession, guidedTourPreparing, guidedTourRun]); const createDesktopTraySession = useCallback(async () => { + if (!await requestPreviewLeave()) return; try { const next = await createNewSessionForActiveWorkspace(api, activeRefRef.current, health); void refreshWorkspaceGitInfo(createApi(next), next).catch(() => {}); @@ -1728,7 +1766,7 @@ export function App() { } catch (e) { toast({ kind: 'err', text: '新建会话失败:' + (e.message || '') }); } - }, [health, navigateToRef]); + }, [health, navigateToRef, requestPreviewLeave]); const handleSubagentTasksChange = useCallback((info) => { const parentId = info?.parentId || ''; @@ -2062,6 +2100,7 @@ export function App() { activeId={activeId} activeRef={activeRef} onSelect={navigateToRef} + onBeforeNavigate={requestPreviewLeave} onActiveRemoteControlBoundChange={syncActiveRemoteControlBound} onSessionLoadStateChange={setSidebarSessionLoadState} sessionLoadResetSequence={sidebarSessionLoadResetSequence} @@ -2116,6 +2155,7 @@ export function App() { onHomeComposerDraftAccepted={acceptHomeComposerDraft} modelProfileRevision={modelProfileRevision} onSessionPromoted={navigateToRef} + onRegisterPreviewLeaveGuard={registerPreviewLeaveGuard} onSessionExpertChanged={replaceActiveSessionExpert} onHomeWorkspaceChange={replaceHomeWorkspace} onCommandWorkspaceChange={setCommandWorkspaceHash} @@ -2141,6 +2181,7 @@ export function App() { sidePanelMaximized={sidePanelMaximized} onToggleSidePanelMaximized={toggleSidePanelMaximized} showAceCodeAvatar={showAceCodeAvatar} + messageAutoCollapse={messageAutoCollapse} permissionRequests={visiblePermissionEntries} onPermissionDecision={handlePermissionDecision} questionRequest={visibleQuestionReq} @@ -2207,6 +2248,8 @@ export function App() { onFontSizeChange={(nextFontSize) => changeAppearance({ fontSize: nextFontSize })} sidebarSessionTime={sidebarSessionTime} onSidebarSessionTimeChange={(next) => changeAppearance({ sidebarSessionTime: next })} + messageAutoCollapse={messageAutoCollapse} + onMessageAutoCollapseChange={(next) => changeAppearance({ messageAutoCollapse: next })} /> )} themeDownloads.controller.dismissFailure()} /> diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index a6be45607..25903f203 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -207,6 +207,7 @@ import { closePreviewTabsToRight, closeVisiblePreviewTabs, defaultBrowserTabTitle, + discardFileTabDraft, openFileTab, openBrowserTab, openGitChangesTab, @@ -214,7 +215,6 @@ import { previewFileLocation, previewScopeKey, previewTabContext, - previewTabsWithUnsavedDrafts, refreshPreviewTab, reorderPreviewTab, sessionWorkingCwd, @@ -232,6 +232,7 @@ import { editableFileError, saveEditableFileDraftBatch, } from '../lib/editableFileDraft.js'; +import { createUnsavedFileGuard, runAfterFileApproval } from '../lib/unsavedFileGuard.js'; import { AGENT_BROWSER_STATE_EVENT, agentBrowserActivityFromItems, @@ -551,7 +552,7 @@ const EXPERT_SWITCH_CANONICAL_POLL_ATTEMPTS = 6; const EXPERT_SWITCH_CANONICAL_POLL_INTERVAL_MS = 160; const FORK_ACTION_KEY = 'fork-session'; -export function ChatView({ titleTarget, actionsTarget, children, sessionRef, sessionId, homeLogoEffectEnabled = true, homeComposerDrafts = {}, homeComposerAttentionRequest = 0, onHomeComposerDraftChange, onHomeComposerDraftAccepted, modelProfileRevision = 0, onSessionPromoted, onSessionExpertChanged, onHomeWorkspaceChange, onCommandWorkspaceChange, onConsoleCwdChange, onFindInConversation, onOpenModelSettings, health, autoFocusOnDesktopWindowFocus = false, onPermissionRequest, onQuestionRequest, permissionRequests = [], onPermissionDecision, questionRequest, onQuestionResolve, onPermissionModeChanged, onSubagentTasksChange, recentExpertIds = [], onRememberExpert, onInitialDraftConsumed, showSidePanel = false, sidePanelWidth = 280, onSidePanelResize, previewPanelWidth = 640, previewPanelAutoFit = false, onPreviewPanelResize, subagentPanelWidth = DEFAULT_SUBAGENT_PANEL_WIDTH, onSubagentPanelResize, onPreviewPanelVisibleChange, sidePanelCollapsed = false, sidePanelListCollapsed = false, onToggleSidePanel, onToggleSidePanelList, onRevealSidePanelList, sidePanelMaximized = false, onToggleSidePanelMaximized, showAceCodeAvatar = false, nativeSurfacesVisible = true }) { +export function ChatView({ titleTarget, actionsTarget, children, sessionRef, sessionId, homeLogoEffectEnabled = true, homeComposerDrafts = {}, homeComposerAttentionRequest = 0, onHomeComposerDraftChange, onHomeComposerDraftAccepted, modelProfileRevision = 0, onSessionPromoted, onSessionExpertChanged, onHomeWorkspaceChange, onCommandWorkspaceChange, onConsoleCwdChange, onFindInConversation, onOpenModelSettings, health, autoFocusOnDesktopWindowFocus = false, onPermissionRequest, onQuestionRequest, permissionRequests = [], onPermissionDecision, questionRequest, onQuestionResolve, onPermissionModeChanged, onSubagentTasksChange, recentExpertIds = [], onRememberExpert, onInitialDraftConsumed, showSidePanel = false, sidePanelWidth = 280, onSidePanelResize, previewPanelWidth = 640, previewPanelAutoFit = false, onPreviewPanelResize, subagentPanelWidth = DEFAULT_SUBAGENT_PANEL_WIDTH, onSubagentPanelResize, onPreviewPanelVisibleChange, onRegisterPreviewLeaveGuard, sidePanelCollapsed = false, sidePanelListCollapsed = false, onToggleSidePanel, onToggleSidePanelList, onRevealSidePanelList, sidePanelMaximized = false, onToggleSidePanelMaximized, showAceCodeAvatar = false, messageAutoCollapse = true, nativeSurfacesVisible = true }) { const ref = useMemo(() => normalizeSessionRef(sessionRef, sessionId), [sessionRef, sessionId]); const sid = ref?.sessionId || ref?.id || ''; const sessionRuntimeUnavailable = ref?.resumePending === true || ref?.resumeFailed === true; @@ -602,6 +603,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses status: transcriptStatus, loadState: transcriptLoadState, streamingId, + abortPending, trajectoryPartial, tokenUsage, goal, @@ -793,8 +795,18 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const [permissionSwitching, setPermissionSwitching] = useState(false); const [reviewRequest, setReviewRequest] = useState(0); const [fileLocateRequest, setFileLocateRequest] = useState({ path: '', token: 0 }); - const [previewTabState, setPreviewTabState] = useState({}); + const [previewTabState, setPreviewTabSnapshot] = useState({}); + const previewTabStateRef = useRef(previewTabState); + const setPreviewTabState = useCallback((producer) => { + const next = producer(previewTabStateRef.current); + previewTabStateRef.current = next; + setPreviewTabSnapshot(next); + }, []); const [previewCloseConfirm, setPreviewCloseConfirm] = useState(null); + const previewFileGuardRef = useRef(null); + if (!previewFileGuardRef.current) { + previewFileGuardRef.current = createUnsavedFileGuard(setPreviewCloseConfirm); + } const [previewPanelHidden, setPreviewPanelHidden] = useState(false); const [dismissedDockSignatures, setDismissedDockSignatures] = usePreference( CHANGE_DOCK_DISMISSALS_STORAGE_KEY, @@ -989,11 +1001,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const composerHistory = useMemo(() => composerHistoryEntries.map((entry) => entry.text), [composerHistoryEntries]); const renderedItems = useMemo( () => projectCollapsedTranscriptItems(rawItems, { + messageAutoCollapse, deferTrailingToolSummary: busy, ensureLiveActivity: busy, liveTurnId: currentTurnActivityId(rawItems, activeTurnId, sid), }), - [rawItems, busy, activeTurnId, sid], + [rawItems, busy, activeTurnId, sid, messageAutoCollapse], ); // 尾部窗口(渐进虚拟化,见 lib/transcriptWindow.js):大会话初始只渲染 // 最近的一段投影行,DOM 行数从数百降到 ≤ INITIAL_TAIL_ITEMS。窗口状态 @@ -1262,8 +1275,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses useEffect(() => { draftSessionKeyRef.current = draftSessionKey; }, [draftSessionKey]); useEffect(() => { setPreviewPanelHidden(false); - setPreviewCloseConfirm(null); + previewFileGuardRef.current.cancelPending(); }, [sid]); + useEffect(() => () => previewFileGuardRef.current.cancelPending(), []); const handleComposerChange = useCallback((next, content = null) => { const normalized = normalizeComposerContent(content); @@ -2885,7 +2899,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const retryUserMessageId = trailingUserMessageRetryId({ sessionId: sid, items, loadState: transcriptLoadState, busy, - status: transcriptStatus, streamingId, + status: transcriptStatus, streamingId, abortPending, disabled: readOnlyExternalSession || sessionRuntimeUnavailable, }); @@ -4569,12 +4583,66 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses () => activePreviewTab(previewTabState, previewContext), [previewContext, previewTabState], ); + const requestPreviewApproval = useCallback((getTabs, kind = 'switch') => ( + previewFileGuardRef.current.request({ + kind, + getTabs, + discard: (tabs) => { + setPreviewTabState((state) => tabs.reduce((next, tab) => discardFileTabDraft(next, { + scopeKey: previewScope, tabKey: tab.key, + }), state)); + }, + save: async (tabs) => { + const updateDraft = (tab, patch) => setPreviewTabState((state) => updateFileTabDraft(state, { + scopeKey: previewScope, tabKey: tab.key, patch, + })); + const result = await saveEditableFileDraftBatch(api, { + tabs, + fallbackCwd: sidePanelCwd, + onSaving: (tab) => updateDraft(tab, { saving: true, error: '' }), + onSaved: (tab, saved) => updateDraft(tab, saved.patch), + }); + if (!result.ok) { + const message = editableFileError(result.error, '保存失败'); + updateDraft(result.tab, { + saving: false, + externalChanged: editableFileConflict(result.error), + error: message, + }); + throw new Error(`${result.tab.title || result.tab.path}:${message}`); + } + }, + }) + ), [api, previewScope, setPreviewTabState, sidePanelCwd]); + const requestPreviewLeave = useCallback(() => requestPreviewApproval( + () => visiblePreviewTabs(previewTabStateRef.current, previewContext), + ), [previewContext, requestPreviewApproval]); + useLayoutEffect(() => onRegisterPreviewLeaveGuard?.(requestPreviewLeave), [ + onRegisterPreviewLeaveGuard, requestPreviewLeave, + ]); + const requestActiveFileLeave = useCallback(() => { + const key = activePreviewTab(previewTabStateRef.current, previewContext)?.key; + return requestPreviewApproval(() => visiblePreviewTabs(previewTabStateRef.current, previewContext) + .filter((tab) => tab.key === key)); + }, [previewContext, requestPreviewApproval]); + const selectPreview = useCallback((producer, afterSelect) => { + if (previewFileGuardRef.current.isPending()) return false; + const state = previewTabStateRef.current; + const current = activePreviewTab(state, previewContext); + const next = activePreviewTab(producer(state), previewContext); + const approval = current?.key === next?.key ? true : requestActiveFileLeave(); + return runAfterFileApproval(approval, () => { + setPreviewTabState(producer); + afterSelect?.(); + return true; + }); + }, [previewContext, requestActiveFileLeave, setPreviewTabState]); const previewTabsOpen = previewTabs.length > 0; // 总开关必须连最大化详情一起隐藏;恢复时仍保留最大化偏好与原页签。 const previewPanelVisible = previewTabsOpen && !sidePanelCollapsed && !previewPanelHidden; const previewPanelMaximized = sidePanelMaximized && previewPanelVisible; const previewCloseConfirmMessage = previewCloseConfirm - ? `${previewCloseConfirm.dirtyCount} 个文件含有未保存的修改。请选择保存后关闭或不保存直接关闭;取消会保留所有页签。` + ? `${previewCloseConfirm.dirtyCount} 个文件有未保存的修改。是否保存后继续?` : ''; const selectedChangeFile = activePreview?.type === PREVIEW_TAB_TYPES.SESSION_CHANGES ? activePreview.expandedFile || '' @@ -4630,27 +4698,22 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses onPreviewPanelVisibleChange?.(previewPanelVisible); }, [onPreviewPanelVisibleChange, previewPanelVisible]); - useEffect(() => { - if (previewCloseConfirm && previewTabs.length === 0) { - setPreviewCloseConfirm(null); - } - }, [previewCloseConfirm, previewTabs.length]); - // line 由聊天正文的文件链接(foo.cpp:42)带入,预览打开后滚动到该行并高亮; // 文件树等其它入口不带 line,走原「只打开」语义。 const openFilePreview = useCallback((path, line = null) => { const location = previewFileLocation({ cwd: sidePanelCwd, path }); if (!previewScope || !location.cwd || !location.path) return; // 侧栏折叠时开预览 tab 也不会显示(previewPanelVisible 依赖非折叠),先展开。 - if (sidePanelCollapsed) onToggleSidePanel?.(); - setPreviewPanelHidden(false); - setPreviewTabState((prev) => openFileTab(prev, { + return selectPreview((prev) => openFileTab(prev, { ...previewContext, cwd: location.cwd, path: location.path, line, - })); - }, [previewContext, previewScope, sidePanelCwd, sidePanelCollapsed, onToggleSidePanel]); + }), () => { + if (sidePanelCollapsed) onToggleSidePanel?.(); + setPreviewPanelHidden(false); + }); + }, [previewContext, previewScope, selectPreview, sidePanelCwd, sidePanelCollapsed, onToggleSidePanel]); // transcript 里本地文件链接的兜底入口。assistant 气泡在 Message.jsx 里自带 // 拦截,但同一片区域还有别的 markdown 渲染位置没有各自的拦截器 @@ -4683,19 +4746,22 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const showBrowserPage = useCallback((pageId, title, favicon) => { if (!sid || !pageId) return; - if (sidePanelCollapsed) onToggleSidePanel?.(); - setPreviewPanelHidden(false); - setPreviewTabState((prev) => openBrowserTab(prev, { + return selectPreview((prev) => openBrowserTab(prev, { scopeKey: previewScope, sessionId: sid, pageId, title, favicon, - })); - }, [onToggleSidePanel, previewScope, sid, sidePanelCollapsed]); + }), () => { + if (sidePanelCollapsed) onToggleSidePanel?.(); + setPreviewPanelHidden(false); + void selectAgentBrowserPage(pageId); + }); + }, [onToggleSidePanel, previewScope, selectPreview, sid, sidePanelCollapsed]); const openBrowserPreview = useCallback(async () => { if (!sid || !hasNativeAgentBrowser()) return; + if (!await requestActiveFileLeave()) return; if (sidePanelCollapsed) onToggleSidePanel?.(); const created = await createAgentBrowserPage(agentBrowserOwnerForSession(ref)); if (created?.ok === false || !created?.page_id) return; @@ -4704,7 +4770,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses created.title || defaultBrowserTabTitle(), created.favicon, ); - }, [onToggleSidePanel, ref, showBrowserPage, sid, sidePanelCollapsed]); + }, [onToggleSidePanel, ref, requestActiveFileLeave, showBrowserPage, sid, sidePanelCollapsed]); // 切会话时向 Desktop 对账一次 native 页面池;事件流已经在 App 级持续镜像。 useEffect(() => { @@ -4769,17 +4835,14 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses agentBrowserTargetRef.current = scoped; if (!sessionBrowserPages.some((page) => page.pageId === agentBrowserActivePageId)) return; showBrowserPage(agentBrowserActivePageId); - void selectAgentBrowserPage(agentBrowserActivePageId); }, [agentBrowserActivePageId, sessionBrowserPages, showBrowserPage, sid]); const openSessionChangePreview = useCallback((filePath, turnUserMessageId = '') => { if (!sid || !filePath) return; - if (sidePanelCollapsed) onToggleSidePanel?.(); - setPreviewPanelHidden(false); const turnChangeSet = turnUserMessageId ? turnChangeSets.find((set) => set.userMessageId === turnUserMessageId) : null; - setPreviewTabState((prev) => openSessionChangesTab(prev, { + return selectPreview((prev) => openSessionChangesTab(prev, { scopeKey: previewScope, sessionId: sid, expandedFile: filePath, @@ -4787,24 +4850,28 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses ? (turnChangeSet?.summary?.fileCount || 0) : changeSummary.fileCount, turnUserMessageId, - })); - }, [changeSummary.fileCount, onToggleSidePanel, previewScope, sid, sidePanelCollapsed, turnChangeSets]); + }), () => { + if (sidePanelCollapsed) onToggleSidePanel?.(); + setPreviewPanelHidden(false); + }); + }, [changeSummary.fileCount, onToggleSidePanel, previewScope, selectPreview, sid, sidePanelCollapsed, turnChangeSets]); // git 变更点击文件 → 在中间详情栏开/聚焦「变更」页签(复刻会话级变更旧行为)。 // gitBase 只有从 SidePanel 导航列表点击时才带;详情栏内点文件不带,由 // openGitChangesTab 保留页签原 base。 const openGitChangePreview = useCallback((filePath, gitBase, gitFileCount) => { if (!previewContext.sessionId || !filePath) return; - if (sidePanelCollapsed) onToggleSidePanel?.(); - setPreviewPanelHidden(false); - setPreviewTabState((prev) => openGitChangesTab(prev, { + return selectPreview((prev) => openGitChangesTab(prev, { ...previewContext, cwd: sidePanelCwd, base: gitBase, expandedFile: filePath, fileCount: gitFileCount, - })); - }, [onToggleSidePanel, previewContext, sidePanelCollapsed, sidePanelCwd]); + }), () => { + if (sidePanelCollapsed) onToggleSidePanel?.(); + setPreviewPanelHidden(false); + }); + }, [onToggleSidePanel, previewContext, selectPreview, sidePanelCollapsed, sidePanelCwd]); // SidePanel 切基线时,若「变更」页签已打开则同步其 base(详情栏跟着换比较对象)。 const updateGitChangeBase = useCallback((gitBase) => { @@ -4814,14 +4881,15 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const activatePreview = useCallback((tabKey) => { const tab = previewTabs.find((candidate) => candidate.key === tabKey); - if (tab?.type === PREVIEW_TAB_TYPES.BROWSER && tab.pageId) { - void selectAgentBrowserPage(tab.pageId); - } - setPreviewTabState((prev) => activatePreviewTab(prev, { + return selectPreview((prev) => activatePreviewTab(prev, { ...previewContext, tabKey, - })); - }, [previewContext, previewTabs]); + }), () => { + if (tab?.type === PREVIEW_TAB_TYPES.BROWSER && tab.pageId) { + void selectAgentBrowserPage(tab.pageId); + } + }); + }, [previewContext, previewTabs, selectPreview]); const refreshPreview = useCallback((tabKey) => { setPreviewTabState((prev) => refreshPreviewTab(prev, { @@ -4862,10 +4930,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const performPreviewClose = useCallback((kind, tabKey = '') => { const affected = previewTabsForCloseAction(kind, tabKey); - if (affected.length === 0) { - setPreviewCloseConfirm(null); - return; - } + if (affected.length === 0) return; affected.forEach((tab) => { if (tab.type === PREVIEW_TAB_TYPES.BROWSER && tab.pageId) { void closeAgentBrowserPage(tab.pageId); @@ -4878,7 +4943,6 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (kind === 'right') return closePreviewTabsToRight(prev, options); return closeVisiblePreviewTabs(prev, options); }); - setPreviewCloseConfirm(null); if (affected.length >= previewTabs.length && sidePanelMaximized) { onToggleSidePanelMaximized?.(); } @@ -4893,67 +4957,17 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const requestPreviewClose = useCallback((kind, tabKey = '') => { const affected = previewTabsForCloseAction(kind, tabKey); if (affected.length === 0) return; - const dirty = previewTabsWithUnsavedDrafts(affected); - if (dirty.length > 0) { - setPreviewCloseConfirm({ - kind, - tabKey, - affectedCount: affected.length, - dirtyCount: dirty.length, - saving: false, - error: '', - }); - return; - } - performPreviewClose(kind, tabKey); - }, [performPreviewClose, previewTabsForCloseAction]); - - const saveAndClosePreviews = useCallback(async () => { - const pending = previewCloseConfirm; - if (!pending || pending.saving) return; - const affected = previewTabsForCloseAction(pending.kind, pending.tabKey); - const dirtyTabs = previewTabsWithUnsavedDrafts(affected); - if (dirtyTabs.length === 0) { - performPreviewClose(pending.kind, pending.tabKey); - return; - } - - setPreviewCloseConfirm((current) => (current ? { - ...current, - saving: true, - error: '', - } : current)); - - const saveResult = await saveEditableFileDraftBatch(api, { - tabs: dirtyTabs, - fallbackCwd: sidePanelCwd, - onSaving: (tab) => updateFilePreviewDraft(tab.key, { saving: true, error: '' }), - onSaved: (tab, saved) => updateFilePreviewDraft(tab.key, saved.patch), - }); - if (!saveResult.ok) { - const message = editableFileError(saveResult.error, '保存失败'); - updateFilePreviewDraft(saveResult.tab.key, { - saving: false, - externalChanged: editableFileConflict(saveResult.error), - error: message, - }); - setPreviewCloseConfirm((current) => (current ? { - ...current, - saving: false, - error: `${saveResult.tab.title || saveResult.tab.path}:${message}`, - } : current)); - toast({ kind: 'err', text: message }); - return; - } - - performPreviewClose(pending.kind, pending.tabKey); + const keys = new Set(affected.map((tab) => tab.key)); + return runAfterFileApproval(requestPreviewApproval( + () => visiblePreviewTabs(previewTabStateRef.current, previewContext) + .filter((tab) => keys.has(tab.key)), + 'close', + ), () => performPreviewClose(kind, tabKey)); }, [ - api, performPreviewClose, - previewCloseConfirm, + previewContext, previewTabsForCloseAction, - sidePanelCwd, - updateFilePreviewDraft, + requestPreviewApproval, ]); const closePreview = useCallback((tabKey) => { @@ -4973,8 +4987,8 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }, [requestPreviewClose]); const hidePreviewPanel = useCallback(() => { - setPreviewPanelHidden(true); - }, []); + return runAfterFileApproval(requestActiveFileLeave(), () => setPreviewPanelHidden(true)); + }, [requestActiveFileLeave]); const reorderPreview = useCallback((sourceKey, targetKey, placement) => { setPreviewTabState((prev) => reorderPreviewTab(prev, { @@ -5343,6 +5357,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses )} )} { - if (!previewCloseConfirm.saving) setPreviewCloseConfirm(null); - }} + onClose={() => previewFileGuardRef.current.choose('cancel')} + dismissOnBackdrop={false} + dismissOnEscape={!previewCloseConfirm.saving} + labelledBy="unsaved-file-dialog-title" width={440} > {() => (
-
保存文件后关闭?
+
有未保存的修改
{previewCloseConfirmMessage}
@@ -5689,7 +5706,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses type="button" className="px-3 py-1.5 text-[12.5px] rounded-lg border border-border hover:bg-surface-hi transition-colors disabled:opacity-50" disabled={previewCloseConfirm.saving} - onClick={() => setPreviewCloseConfirm(null)} + onClick={() => previewFileGuardRef.current.choose('cancel')} > 取消 @@ -5697,10 +5714,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses type="button" className="px-3 py-1.5 text-[12.5px] rounded-lg border border-border hover:bg-surface-hi transition-colors disabled:opacity-50" disabled={previewCloseConfirm.saving} - onClick={() => performPreviewClose( - previewCloseConfirm.kind, - previewCloseConfirm.tabKey, - )} + onClick={() => previewFileGuardRef.current.choose('discard')} > 不保存 @@ -5709,9 +5723,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses data-ace-dialog-primary="true" className="px-3 py-1.5 text-[12.5px] rounded-lg bg-accent text-white hover:opacity-90 transition-opacity disabled:opacity-50" disabled={previewCloseConfirm.saving} - onClick={saveAndClosePreviews} + onClick={() => previewFileGuardRef.current.choose('save')} > - {previewCloseConfirm.saving ? '保存中...' : '保存并关闭'} + {previewCloseConfirm.saving ? '保存中...' : '保存'}
diff --git a/web/src/components/FeedbackForm.jsx b/web/src/components/FeedbackForm.jsx index 697cedec5..bd6540ec3 100644 --- a/web/src/components/FeedbackForm.jsx +++ b/web/src/components/FeedbackForm.jsx @@ -131,7 +131,8 @@ export function FeedbackForm({ onClose }) { {submitError &&

上传失败:{submitError}

}
diff --git a/web/src/components/ImageGenerationSettings.jsx b/web/src/components/ImageGenerationSettings.jsx index cf9dac348..0cc7685a1 100644 --- a/web/src/components/ImageGenerationSettings.jsx +++ b/web/src/components/ImageGenerationSettings.jsx @@ -73,7 +73,7 @@ export function ImageGenerationSettings({ onCheckUpdates }) {
-
图像生成
+
图像生成
根据描述生成图片,或编辑已有图片
buildCompactMessagePreview({ role, @@ -402,7 +404,7 @@ function SystemRow({ role, content, metadata }) { 'self-stretch bg-surface-alt border border-dashed border-border rounded-md text-fg-2 overflow-hidden', isToolCompact ? 'ace-tool-call-text' : 'text-[12px]', )}> - + : ( +
{label}
+ )} {expanded && (
; } - return ; + return ; }); diff --git a/web/src/components/SecurityCenterSettings.jsx b/web/src/components/SecurityCenterSettings.jsx index ed2c90735..4a52472a8 100644 --- a/web/src/components/SecurityCenterSettings.jsx +++ b/web/src/components/SecurityCenterSettings.jsx @@ -80,7 +80,7 @@ function RowCard({ title, desc, children, onClick, className }) { )} >
-
{title}
+
{title}
{desc &&
{desc}
}
{children} @@ -367,7 +367,7 @@ function OverviewTab({ sandbox, rules, audit, onNavigate }) { {platform && (
-
平台状态
+
平台状态
{platform.os} · {platform.backend}
@@ -396,7 +396,7 @@ function OverviewTab({ sandbox, rules, audit, onNavigate }) {

拦截 / 放行记录与日志导出。

-
{`日志(${counts.total} 条)`}
+
{`日志(${counts.total} 条)`}
@@ -413,7 +413,7 @@ function OverviewTab({ sandbox, rules, audit, onNavigate }) { function ListEditor({ label, desc, value, onChange, error, disabled, placeholder }) { return (
-
{label}
+
{label}
{desc}
, y", 5); + EXPECT_TRUE(got.preambles.empty()); + EXPECT_EQ(got.visible, "a < b, , y"); +} + +// 场景:空标签 ``、正文为空的标签、只有标点的标签。 +// 期望:不产生前言,后面的正文照常可见(空标签后的空白也吞掉)。 +TEST(ToolPreambleScanner, EmptyTagsAreSkipped) { + const Collected self_closing = scan_in_chunks("\nHi", 3); + EXPECT_TRUE(self_closing.preambles.empty()); + EXPECT_EQ(self_closing.visible, "Hi"); + const Collected empty_body = scan_in_chunks( + "Hi", 2); + EXPECT_TRUE(empty_body.preambles.empty()); + EXPECT_EQ(empty_body.visible, "Hi"); + const Collected blank_body = scan_in_chunks(" \t Hi", 64); + EXPECT_TRUE(blank_body.preambles.empty()); + EXPECT_EQ(blank_body.visible, "Hi"); +} + +// 场景:type 的各种写法。期望:引号 / 单引号 / 裸值都认,大小写不敏感;不是 +// read / write 的当没写;标签名大小写也不敏感。 +TEST(ToolPreambleScanner, KindParsingIsLenient) { + const auto kind_of = [](const std::string& text) { + const Collected got = scan_in_chunks(text, 64); + return got.preambles.empty() ? std::string("") : got.preambles[0].kind; }; - acecode::ToolDef bare; - bare.name = "ping"; - acecode::ToolDef own; - own.name = "custom"; - own.parameters = {{"type", "object"}, {"properties", {{"preamble", {{"type", "integer"}}}}}}; - - std::vector defs{normal, bare, own}; - EXPECT_EQ(inject_preamble_parameter(defs), 2u); - EXPECT_EQ(defs[0].parameters["properties"]["preamble"]["type"], "string"); - EXPECT_NE(defs[0].parameters["properties"]["preamble"]["description"].get().find("Reading registry sections"), - std::string::npos); - EXPECT_EQ(defs[0].parameters["required"], nlohmann::json::array({"file_path", "preamble"})); - EXPECT_EQ(defs[1].parameters["type"], "object"); - EXPECT_EQ(defs[1].parameters["properties"]["preamble"]["type"], "string"); - EXPECT_EQ(defs[1].parameters["required"], nlohmann::json::array({"preamble"})); - EXPECT_EQ(defs[2].parameters["properties"]["preamble"]["type"], "integer"); - EXPECT_FALSE(defs[2].parameters.contains("required")); - // 再注入一次是幂等的:required 里不会出现第二个 preamble。 - EXPECT_EQ(inject_preamble_parameter(defs), 0u); - EXPECT_EQ(defs[0].parameters["required"], nlohmann::json::array({"file_path", "preamble"})); -} - -// 场景:判断工具定义是否自带 `preamble` 参数。期望:properties 里有同名键才算; -// parameters 为 null / 没有 properties / properties 不是对象都不算。 -TEST(ToolPreambleParameter, DefinitionDeclaresPreambleOnlyWhenSchemaHasIt) { - acecode::ToolDef own; - own.parameters = {{"type", "object"}, {"properties", {{"preamble", {{"type", "string"}}}}}}; - EXPECT_TRUE(definition_declares_preamble(own)); - acecode::ToolDef plain; - plain.parameters = {{"type", "object"}, {"properties", {{"file_path", {{"type", "string"}}}}}}; - EXPECT_FALSE(definition_declares_preamble(plain)); - acecode::ToolDef bare; - EXPECT_FALSE(definition_declares_preamble(bare)); - acecode::ToolDef odd; - odd.parameters = {{"type", "object"}, {"properties", "not-an-object"}}; - EXPECT_FALSE(definition_declares_preamble(odd)); -} - -// 场景:参数 JSON 还在流式传输,前缀里 `preamble` 的值已经完整 / 尚未闭合 / -// 只是别的字符串值里恰好含这串。期望:完整时返回规整后的值(含转义解码), -// 未闭合返回空(等下一段),值里的假键不算,只认对象顶层的键。 -TEST(ToolPreambleParameter, ExtractsFromPartialArgumentsOnlyWhenComplete) { - EXPECT_EQ(extract_preamble_from_partial_arguments( - R"({"preamble":"Reading the loader","file_path":"regi)"), - "Reading the loader"); - EXPECT_EQ(extract_preamble_from_partial_arguments(R"({"preamble":"Reading the lo)"), ""); - EXPECT_EQ(extract_preamble_from_partial_arguments(R"({"preamble":)"), ""); - // 下面两条不用原始字符串:MSVC 传统预处理器会把宏参数里含 \" 的 R"(...)" 重新切分成 - // 普通字符串(报 C2017 / C3688),先存进变量再传给宏就绕开了。 - const std::string fake_key_inside_value = - "{\"query\":\"\\\"preamble\\\":\\\"fake\\\"\",\"preamble\":\"Real one\"}"; - EXPECT_EQ(extract_preamble_from_partial_arguments(fake_key_inside_value), "Real one"); - const std::string escaped_value = "{\"preamble\":\"Say \\\"hi\\\" \\u4e2d\"}"; - const std::string expected_decoded = std::string("Say \"hi\" ") + "\xE4\xB8\xAD"; - EXPECT_EQ(extract_preamble_from_partial_arguments(escaped_value), expected_decoded); - EXPECT_EQ(extract_preamble_from_partial_arguments(R"({"file_path":"a"})"), ""); -} - -// 场景:完整参数里剥掉 `preamble`。期望:返回规整后的前言,参数变成不含该键的 -// JSON(工具看不到它);没有该键 / 非法 JSON 时参数原样不动;值不是字符串时键 -// 照样剥掉但不出标题。 -TEST(ToolPreambleParameter, StripsParameterBeforeExecution) { - std::string args = R"({"preamble":"Reading the loader.","file_path":"a.txt"})"; - EXPECT_EQ(strip_preamble_parameter(args), "Reading the loader"); - EXPECT_EQ(nlohmann::json::parse(args), nlohmann::json({{"file_path", "a.txt"}})); - - std::string untouched = R"({"file_path":"a.txt"})"; - EXPECT_EQ(strip_preamble_parameter(untouched), ""); - EXPECT_EQ(untouched, R"({"file_path":"a.txt"})"); - - std::string broken = R"({"preamble":"x")"; - EXPECT_EQ(strip_preamble_parameter(broken), ""); - EXPECT_EQ(broken, R"({"preamble":"x")"); - - std::string non_string = R"({"preamble":42,"file_path":"a"})"; - EXPECT_EQ(strip_preamble_parameter(non_string), ""); - EXPECT_EQ(nlohmann::json::parse(non_string), nlohmann::json({{"file_path", "a"}})); -} - -// 场景:mode 校验。期望:三个规范名有效,其它(含大小写变体)无效。 -TEST(ToolPreambleMode, ValidatesCanonicalNames) { - EXPECT_TRUE(is_valid_mode("prompt")); - EXPECT_TRUE(is_valid_mode("reasoning")); - EXPECT_TRUE(is_valid_mode("sidecar")); - EXPECT_FALSE(is_valid_mode("Prompt")); - EXPECT_FALSE(is_valid_mode("")); - EXPECT_FALSE(is_valid_mode("auto")); + EXPECT_EQ(kind_of("x"), "write"); + EXPECT_EQ(kind_of("x"), "read"); + EXPECT_EQ(kind_of("x"), "read"); + EXPECT_EQ(kind_of("x"), ""); + EXPECT_EQ(kind_of("x"), ""); + EXPECT_EQ(kind_of("x"), "read"); +} + +// 场景:标签正文带多余空白 / 尾句号,以及超过 200 个 code point 的正文。 +// 期望:折叠空白、去尾标点;超长按 code point 截断并补 "…"。 +TEST(ToolPreambleScanner, BodyIsNormalizedAndCapped) { + const Collected got = scan_in_chunks( + " Reading the loader. ", 64); + ASSERT_EQ(got.preambles.size(), 1u); + EXPECT_EQ(got.preambles[0].title, "Reading the loader"); + + const std::string long_body(300, 'a'); + const Collected capped = scan_in_chunks( + "" + long_body + "", 64); + ASSERT_EQ(capped.preambles.size(), 1u); + EXPECT_EQ(capped.preambles[0].title, std::string(kTextPreambleMaxCodePoints, 'a') + "\xE2\x80\xA6"); +} + +// 场景:标签正文流了 1300 字节既没闭合也没换行(模型跑偏)。期望:到 +// kTextPreambleMaxBodyBytes 就当闭合(前言按 code point 截断),之后的字节回到 +// 可见正文,不会把整段回答都吞进 loading。 +TEST(ToolPreambleScanner, OversizedBodyWithoutNewlineIsCut) { + const std::string body(1300, 'b'); + const Collected got = scan_in_chunks("" + body, 100); + ASSERT_EQ(got.preambles.size(), 1u); + EXPECT_EQ(got.preambles[0].title, std::string(kTextPreambleMaxCodePoints, 'b') + "\xE2\x80\xA6"); + EXPECT_EQ(got.visible.size(), 1300u - kTextPreambleMaxBodyBytes); +} + +// 场景:流开头的空白与正文中间的空白。期望:只有第一个可见字符之前的空白被 +// 吞掉(否则界面会为一段 "\n" 建一条空气泡),之后的换行原样透传;reset 后扣住 +// 的半截标签被丢弃。 +TEST(ToolPreambleScanner, LeadingWhitespaceSwallowedOnceAndResetDropsHeldBytes) { + const Collected got = scan_in_chunks("\n\nHello\n\nWorld", 3); + EXPECT_TRUE(got.preambles.empty()); + EXPECT_EQ(got.visible, "Hello\n\nWorld"); + + TextPreambleScanner scanner; + (void)scanner.feed("partial"); + scanner.reset(); + Collected after; + absorb(after, scanner.feed("Hi")); + absorb(after, scanner.flush()); + EXPECT_TRUE(after.preambles.empty()); + EXPECT_EQ(after.visible, "Hi"); +} + +// 场景:渲染层整段剥标签(TUI 回放 / on_message 完整正文)。期望:与扫描器同款 +// 规则;没有标签的文本逐字节原样返回(含前导空白,不能误伤普通正文);多个标签 +// 各自剥掉;整段都是标签时返回空串。 +TEST(ToolPreambleStrip, StripsTagsAndLeavesPlainTextUntouched) { + EXPECT_EQ(strip_text_preamble_tags(" plain\ntext "), " plain\ntext "); + EXPECT_EQ(strip_text_preamble_tags(""), ""); + EXPECT_EQ(strip_text_preamble_tags( + "Reading A\n\nFirst.\n\n" + "Editing B\n\nSecond."), + "First.\n\nSecond."); + EXPECT_EQ(strip_text_preamble_tags( + "Reading A\n\n"), ""); + EXPECT_EQ(strip_text_preamble_tags("No close\nVisible"), "Visible"); } diff --git a/tests/web/tool_preamble_handler_test.cpp b/tests/web/tool_preamble_handler_test.cpp index 0e6350eea..1cef3e768 100644 --- a/tests/web/tool_preamble_handler_test.cpp +++ b/tests/web/tool_preamble_handler_test.cpp @@ -1,7 +1,7 @@ // 覆盖 src/web/handlers/tool_preamble_handler.cpp 的纯函数(openspec add-tool-preamble): // 1. tool_preamble_snapshot:GET 响应体的字段与候选列表 // 2. parse_tool_preamble_request:PUT 的 patch 语义、逐字段类型 / 取值校验、 -// 失败时 out 不变 +// 失败时 out 不变;已废弃的 sidecar 相关键被当作未知键忽略 #include @@ -16,88 +16,65 @@ using acecode::web::tool_preamble_snapshot; namespace { -const std::vector kModels{"main", "fast"}; - ToolPreambleConfig current_cfg() { ToolPreambleConfig cfg; cfg.enabled = true; cfg.mode = "reasoning"; - cfg.sidecar_model = "fast"; - cfg.sidecar_wait_ms = 800; return cfg; } } // namespace -// 场景:GET 快照。期望:四个配置字段原样,modes 固定三项且顺序为 -// prompt / reasoning / sidecar(设置页按此顺序渲染,默认选第一项), -// saved_models 透传给旁路模型下拉。 +// 场景:GET 快照。期望:两个配置字段原样,modes 固定两项且顺序为 +// prompt / reasoning(设置页按此顺序渲染,默认选第一项),没有旁路模型字段。 TEST(ToolPreambleHandler, SnapshotExposesConfigAndChoices) { - const auto snap = tool_preamble_snapshot(current_cfg(), kModels); + const auto snap = tool_preamble_snapshot(current_cfg()); EXPECT_TRUE(snap.at("enabled").get()); EXPECT_EQ(snap.at("mode").get(), "reasoning"); - EXPECT_EQ(snap.at("sidecar_model").get(), "fast"); - EXPECT_EQ(snap.at("sidecar_wait_ms").get(), 800); - ASSERT_EQ(snap.at("modes").size(), 3u); + ASSERT_EQ(snap.at("modes").size(), 2u); EXPECT_EQ(snap["modes"][0].get(), "prompt"); EXPECT_EQ(snap["modes"][1].get(), "reasoning"); - EXPECT_EQ(snap["modes"][2].get(), "sidecar"); - ASSERT_EQ(snap.at("saved_models").size(), 2u); - EXPECT_EQ(snap["saved_models"][1].get(), "fast"); + EXPECT_FALSE(snap.contains("sidecar_model")); + EXPECT_FALSE(snap.contains("saved_models")); } -// 场景:PUT 只带 {enabled:false}。期望:patch 语义 —— 其它字段沿用当前值。 +// 场景:PUT 只带 {enabled:false}。期望:patch 语义 —— mode 沿用当前值。 TEST(ToolPreambleHandler, PatchKeepsUnmentionedFields) { ToolPreambleConfig out; std::string error; - ASSERT_TRUE(parse_tool_preamble_request({{"enabled", false}}, current_cfg(), kModels, out, error)) + ASSERT_TRUE(parse_tool_preamble_request({{"enabled", false}}, current_cfg(), out, error)) << error; EXPECT_FALSE(out.enabled); EXPECT_EQ(out.mode, "reasoning"); - EXPECT_EQ(out.sidecar_model, "fast"); - EXPECT_EQ(out.sidecar_wait_ms, 800); } -// 场景:PUT 全字段合法 {mode:"sidecar", sidecar_model:"main", sidecar_wait_ms:0}; -// sidecar_model 传 null 表示清空(沿用会话模型)。期望:逐字段生效。 -TEST(ToolPreambleHandler, AcceptsAllFieldsAndNullClearsModel) { +// 场景:PUT {mode:"prompt"};旧前端可能还带 sidecar_model / sidecar_wait_ms。 +// 期望:mode 生效,多余的旧键不报错也不影响结果。 +TEST(ToolPreambleHandler, AcceptsModeAndIgnoresLegacySidecarKeys) { ToolPreambleConfig out; std::string error; ASSERT_TRUE(parse_tool_preamble_request( - {{"mode", "sidecar"}, {"sidecar_model", "main"}, {"sidecar_wait_ms", 0}}, - current_cfg(), kModels, out, error)) << error; - EXPECT_EQ(out.mode, "sidecar"); - EXPECT_EQ(out.sidecar_model, "main"); - EXPECT_EQ(out.sidecar_wait_ms, 0); - - ASSERT_TRUE(parse_tool_preamble_request( - {{"sidecar_model", nullptr}}, current_cfg(), kModels, out, error)) << error; - EXPECT_TRUE(out.sidecar_model.empty()); - ASSERT_TRUE(parse_tool_preamble_request( - {{"sidecar_model", ""}}, current_cfg(), kModels, out, error)) << error; - EXPECT_TRUE(out.sidecar_model.empty()); + {{"mode", "prompt"}, {"sidecar_model", "fast"}, {"sidecar_wait_ms", 500}}, + current_cfg(), out, error)) << error; + EXPECT_EQ(out.mode, "prompt"); + EXPECT_TRUE(out.enabled); } -// 场景:非法请求 —— body 不是对象 / mode 不在三选一里 / sidecar_model 不是 -// 已保存模型 / sidecar_wait_ms 越界或非整数 / enabled 非布尔。 -// 期望:全部返回 false 且 out 保持调用前的值(这里预置成 current 以便比对), -// error 非空。 +// 场景:非法请求 —— body 不是对象 / mode 不在二选一里(含已废弃的 sidecar)/ +// mode 非字符串 / enabled 非布尔。期望:全部返回 false 且 out 保持调用前的值 +// (这里预置成 current 以便比对),error 非空。 TEST(ToolPreambleHandler, RejectsInvalidRequestsWithoutTouchingOutput) { const auto cases = std::vector{ nlohmann::json::array(), {{"mode", "auto"}}, + {{"mode", "sidecar"}}, {{"mode", 3}}, - {{"sidecar_model", "missing"}}, - {{"sidecar_model", 1}}, - {{"sidecar_wait_ms", -1}}, - {{"sidecar_wait_ms", 15001}}, - {{"sidecar_wait_ms", "500"}}, {{"enabled", "yes"}}, }; for (const auto& body : cases) { ToolPreambleConfig out = current_cfg(); std::string error; - EXPECT_FALSE(parse_tool_preamble_request(body, current_cfg(), kModels, out, error)) + EXPECT_FALSE(parse_tool_preamble_request(body, current_cfg(), out, error)) << body.dump(); EXPECT_FALSE(error.empty()) << body.dump(); EXPECT_EQ(out, current_cfg()) << body.dump(); diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index a27e893d2..cf8586887 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -14,27 +14,20 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '工具前言': 'Tool preamble', '工具前言来源': 'Tool preamble source', '启用工具前言': 'Enable tool preamble', - '在每批工具调用上方显示一句「正在做什么」的标题,代替笼统的「正在处理」。默认关闭。': 'Show a one-line "what is happening" title above each batch of tool calls instead of a generic "Working". Off by default.', + '多步工具任务进行时,在活动行显示模型自己写的一句「正在做什么」,代替笼统的「正在处理」。只在等待期显示,不留在记录里。默认关闭。': 'During multi-step tool tasks, show a one-line "what is happening" written by the model on the activity row instead of a generic "Working". Shown only while waiting; it does not stay in the record. Off by default.', '当前:': 'Current: ', '未启用': 'Not enabled', - '沿用会话模型': 'Use the session model', '{{p0}}:查看说明': '{{p0}}: details', '加载工具前言配置失败': 'Could not load tool preamble settings', '保存工具前言配置失败,请重试': 'Could not save tool preamble settings. Please try again.', - '选择标题的来源。三种方式只能启用一种,保存后对新的工具调用立即生效。': 'Choose where the title comes from. Only one source can be active; saving applies to new tool calls immediately.', + '选择前言的来源。两种方式只能启用一种,保存后对新的回合立即生效。': 'Choose where the preamble comes from. Only one source can be active; saving applies to new turns immediately.', '提示驱动': 'Prompt-driven', - '给每个工具调用加一个 preamble 参数,由模型在调用时填一句前言。': 'Add a preamble argument to every tool call and let the model fill in a one-line preamble as it calls.', - '在每个工具的参数表里注入一个 preamble 字符串参数,并在系统提示里要求模型每次调用都填一句 8~12 个词的前言(例如「正在读取注册表段落」)。参数一流出来就作为这次调用的 loading 文案,工具行运行时显示它,执行前会被剥掉,工具本身看不到。不依赖模型服务的特殊能力、不额外发请求,每次调用只多十几个 token;模型偶尔漏填时该调用退回默认文案。': 'A preamble string argument is injected into every tool schema and the system prompt asks the model to fill it on every call with an 8-12 word line (for example "Reading registry sections"). As soon as the argument streams in it becomes the loading text for that call, the running tool row shows it, and it is stripped before the tool executes, so the tool never sees it. No special provider capability and no extra request; about a dozen extra tokens per call. When the model occasionally leaves it out, that call falls back to the default text.', + '让模型在多步工具任务里用 标签写一句「正在做什么」。': 'Ask the model to write a one-line "what is happening" in a tag during multi-step tool tasks.', + '在系统提示里要求模型在第一次工具调用前、以及阶段或计划变化时,用 … 标出一句话:一开始写下一步要做什么,之后写已核实的结果加下一步;最终回答不打标签。标签一闭合这句话就进入活动行的 loading 文案,直到下一句前言或普通正文出现;标签本身不显示为正文,完成后的记录里也看不到它。不依赖模型服务的特殊能力、不额外发请求,每个阶段只多一句话的 token。': 'The system prompt asks the model to mark one sentence with … before the first tool call and at major phase or plan changes: the next step at first, then the verified result plus the next step; final answers are never tagged. As soon as the tag closes, that sentence becomes the loading text on the activity row until the next preamble or ordinary text appears; the tag itself is never shown as prose and does not remain in the finished record. No special provider capability and no extra request; one extra sentence of tokens per phase.', '推理服务内置摘要': 'Reasoning summary', '从模型服务返回的推理摘要里取标题,不改提示词、不多发请求。': 'Take the title from the reasoning summary the provider returns; no prompt change, no extra request.', - '不改提示词、不多发请求。模型服务流式返回的推理摘要若以加粗标题开头(OpenAI Responses、Codex、Gemini 的摘要都是这种格式),就抠出那行标题;在推理进行中标题就会出现在活动行上。没有加粗标题时取推理的第一句话兜底。只返回原始思维链或散文摘要的模型(如 DeepSeek、Claude)标题质量会差一些;完全不返回推理内容的模型不会出标题,该批次退回默认汇总。': 'No prompt change and no extra request. When the streamed reasoning summary starts with a bold title (OpenAI Responses, Codex and Gemini summaries all do), that line becomes the title and appears on the activity row while the model is still reasoning. Without a bold title the first sentence of the reasoning is used. Models that only return raw chains of thought or prose summaries (such as DeepSeek or Claude) give weaker titles; models that return no reasoning at all produce no title and the batch falls back to the default summary.', - '旁路模型摘要': 'Sidecar model summary', - '另发一次小请求,让旁路模型为每批工具调用起 3~8 个词的标题。': 'Send a small extra request so a sidecar model titles each batch of tool calls in 3-8 words.', - '每批工具调用开始时,把用户请求、模型当前正文和即将执行的工具调用另发一次小请求给旁路模型(默认沿用会话模型,也可以指定更便宜的模型),让它给出 3~8 个词的标签。任何模型都能用、标题最贴切;代价是每个批次多一次小请求的费用,并且落盘前最多等待设定的毫秒数,超时才到的标题只在当前页面显示、不写入会话记录。': 'When a batch of tool calls starts, the user request, the current assistant text and the pending tool calls are sent in a small extra request to a sidecar model (the session model by default, or a cheaper one you pick), which returns a 3-8 word label. It works with any model and gives the most accurate titles; the cost is one small request per batch, and the daemon waits at most the configured milliseconds before saving, so a title that arrives later is shown on this page only and not written to the session record.', - '旁路模型': 'Sidecar model', - '最长等待': 'Max wait', - '毫秒,超时的标题只在当前页面显示': 'ms; titles that arrive later are shown on this page only', - 'tool preamble progress title status line reasoning summary sidecar 前言 标题': 'tool preamble progress title status line reasoning summary sidecar', + '不改提示词、不多发请求。模型服务流式返回的推理摘要若以加粗标题开头(OpenAI Responses、Codex、Gemini 的摘要都是这种格式),就抠出那行标题;在推理进行中标题就会出现在活动行上。没有加粗标题时取推理的第一句话兜底。只返回原始思维链或散文摘要的模型(如 DeepSeek、Claude)标题质量会差一些;完全不返回推理内容的模型不会出标题,活动行退回默认文案。': 'No prompt change and no extra request. When the streamed reasoning summary starts with a bold title (OpenAI Responses, Codex and Gemini summaries all do), that line becomes the title and appears on the activity row while the model is still reasoning. Without a bold title the first sentence of the reasoning is used. Models that only return raw chains of thought or prose summaries (such as DeepSeek or Claude) give weaker titles; models that return no reasoning at all produce no title and the activity row falls back to the default text.', + 'tool preamble progress status line text_preamble reasoning summary 前言 阶段': 'tool preamble progress status line text_preamble reasoning summary', '打开项目菜单,选择已有工作区或添加本地代码目录,让 ACECode 知道从哪里开始;也可以选择“不使用工作区”处理通用任务。': 'Open the project menu to pick an existing workspace or add a local code folder so ACECode knows where to start; choose "No workspace" for general tasks.', '左侧“新建任务”会回到新任务首页,默认使用“无工作区”。它不会创建工作区目录;可在首页的项目菜单中选择或添加工作区。': '"New task" on the left returns to the new-task home page with "No workspace" selected. It does not create a workspace folder; pick or add a workspace from the project menu on the home page.', '开发者模式': 'Developer mode', diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index 6622e4575..07fe00f00 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -216,7 +216,7 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio preamble = null, } = entry || {}; // 工具前言(add-tool-preamble):运行中的工具行以前言为标题(verb / object 退到 - // detail);落定后的行保持原样,前言只进悬浮提示,不给已完成的记录添噪音。 + // detail);落定后的行保持原样,前言不再出现(用户决定:完成后不显示)。 const preambleTitle = String(preamble?.title || '').trim(); const shouldExpandAskResult = isDone && success !== false @@ -456,7 +456,7 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio expandable expanded={expanded} onToggle={toggleExpanded} - title={joinTooltipParts(preambleTitle, buttonTooltip || (expanded ? '收起' : '展开'))} + title={buttonTooltip || (expanded ? '收起' : '展开')} ariaLabel={expanded ? '收起' : '展开'} /> {expanded && (shellDetails || createdFile || diffHtml || fullToolOutput) && ( diff --git a/web/src/components/ToolPreambleSettings.jsx b/web/src/components/ToolPreambleSettings.jsx index d26bf062a..a7c54ab2e 100644 --- a/web/src/components/ToolPreambleSettings.jsx +++ b/web/src/components/ToolPreambleSettings.jsx @@ -4,9 +4,6 @@ import { clsx } from '../lib/format.js'; import { DEFAULT_TOOL_PREAMBLE_STATE, TOOL_PREAMBLE_MODES, - TOOL_PREAMBLE_MODE_SIDECAR, - TOOL_PREAMBLE_SIDECAR_WAIT_MAX_MS, - TOOL_PREAMBLE_SIDECAR_WAIT_MIN_MS, buildToolPreambleUpdate, normalizeToolPreambleState, toolPreambleStatusText, @@ -75,13 +72,11 @@ function HelpTip({ label, text }) { function ToolPreambleConfigModal({ state, busy, onClose, onSave }) { const [mode, setMode] = useState(state.mode); - const [sidecarModel, setSidecarModel] = useState(state.sidecarModel); - const [sidecarWaitMs, setSidecarWaitMs] = useState(String(state.sidecarWaitMs)); const titleId = useId(); const submit = () => { if (busy) return; - onSave({ mode, sidecarModel, sidecarWaitMs }); + onSave({ mode }); }; const select = (nextMode) => setMode(nextMode); @@ -89,7 +84,7 @@ function ToolPreambleConfigModal({ state, busy, onClose, onSave }) {

工具前言

-

选择标题的来源。三种方式只能启用一种,保存后对新的工具调用立即生效。

+

选择前言的来源。两种方式只能启用一种,保存后对新的回合立即生效。

{TOOL_PREAMBLE_MODES.map((entry) => { @@ -124,40 +119,6 @@ function ToolPreambleConfigModal({ state, busy, onClose, onSave }) {
{entry.label}
{entry.summary}
- {selected && entry.id === TOOL_PREAMBLE_MODE_SIDECAR && ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - -
- )}
@@ -230,7 +191,7 @@ export function ToolPreambleSettings() { return (
工具前言
-

在每批工具调用上方显示一句「正在做什么」的标题,代替笼统的「正在处理」。默认关闭。

+

多步工具任务进行时,在活动行显示模型自己写的一句「正在做什么」,代替笼统的「正在处理」。只在等待期显示,不留在记录里。默认关闭。

启用工具前言
diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index cc7700dad..a767466ce 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -355,7 +355,6 @@ export const sourceCatalogs = { "s_25e5d2d94f923e99": "使用“将元素添加到聊天”在聊天提示中引用 UI 元素。", "s_25ec0421b03bc4f3": "拖动调整右侧栏宽度", "s_2623156dcd71bf2d": "星号", - "s_262562b47ca19d67": "旁路模型摘要", "s_2657124b1481b789": "项目提供", "s_267c4a7a86dd1849": "全部类型", "s_26ab0eee67e8c707": "已调用工具", @@ -389,7 +388,7 @@ export const sourceCatalogs = { "s_299b88328ba9699e": "Grok 模型服务返回错误", "s_29a48fe3b6f429d3": "保存摘要生成配置失败", "s_29ee7148fcb3b94d": "打开本地保存的探测结果", - "s_2a75a6b7b4fe8aad": "毫秒,超时的标题只在当前页面显示", + "s_29fe9607e330a580": "选择前言的来源。两种方式只能启用一种,保存后对新的回合立即生效。", "s_2a7e69d433c4bddc": "专家团至少需要两位专家", "s_2ad8405732859cf9": "暂无已配置的 MCP 服务器。", "s_2b00fbc472f4e202": "固定引用上下文", @@ -424,6 +423,7 @@ export const sourceCatalogs = { "s_2e659c0c617332d7": "读隔离生效", "s_2e6bfec616ad6945": "团队成员", "s_2e7e1c747a435c14": "(多选)", + "s_2e8cf74dfb3a4848": "让模型在多步工具任务里用 标签写一句「正在做什么」。", "s_2ec809a15744b733": "外观配置保存失败,主题未删除", "s_2ed9c6fabac2013a": "紫色", "s_2efad4f47ae7804b": "工具完成", @@ -529,6 +529,7 @@ export const sourceCatalogs = { "s_3badb01486cd5de8": "迁移会复制现有数据,完成后需重启 ACECode", "s_3bc19f21617f0a86": "根据描述生成图片,或编辑已有图片", "s_3bceaa431f3153d5": "正在读取状态", + "s_3bdbd82d365049c9": "多步工具任务进行时,在活动行显示模型自己写的一句「正在做什么」,代替笼统的「正在处理」。只在等待期显示,不留在记录里。默认关闭。", "s_3be2c9604a690f97": "旁路聊天连接超时,请重试。", "s_3c39e9bde120ddb5": "定时任务", "s_3c446e94a996b7aa": "旁路聊天连接失败,请重试。", @@ -545,7 +546,6 @@ export const sourceCatalogs = { "s_3d5c3c3dbaca6b32": "(可选)", "s_3d7d7c8a6917f5d5": "自营模型", "s_3d82a5ea3f4b5d82": "已读取本地探测结果;仅点击“重新探测”才会访问 Provider。", - "s_3d9b14aad33d7834": "给每个工具调用加一个 preamble 参数,由模型在调用时填一句前言。", "s_3db7b06b5f6de0e0": "工作目录", "s_3dcff5522afda275": "提示词缓存命中率", "s_3dd1245c155aba60": "模型配置不可用,已保留当前连接", @@ -804,7 +804,6 @@ export const sourceCatalogs = { "s_57e4fcb2dad50b5c": "远程 Web 模式已开启", "s_57eb46dacc97defb": "在任务列表每一行右侧显示最近活动时间,关闭后仍可在悬停卡片里查看", "s_5804b9cca84304ff": "清空侧边聊天", - "s_58251ec975738d9a": "另发一次小请求,让旁路模型为每批工具调用起 3~8 个词的标题。", "s_5835048333dad1d6": "选择自定义答案", "s_584f28c1077647a9": "正在取消...", "s_5868e5f7632072d9": "拒绝 {{p0}}", @@ -1025,7 +1024,6 @@ export const sourceCatalogs = { "s_71fa1b09e415447e": "把身份、擅长领域、开场白和运行时能力配置在同一个专家中。", "s_720182db8f88cf7d": "正在读取运行时能力…", "s_725831ec12394243": "取消归档失败:", - "s_72dad2026d457cd1": "最长等待", "s_72dde8dfef88d37d": "确认添加", "s_72ec61d7855c86a1": "请求超时(秒)", "s_73952aa91a9366b5": "已切换至 ", @@ -1058,7 +1056,6 @@ export const sourceCatalogs = { "s_75d889ac43a261ad": "已允许", "s_75dfaf262f476b6c": "取消中", "s_75e8c9e1324b05c4": "权限被拒", - "s_75ebc2db53d5d555": "每批工具调用开始时,把用户请求、模型当前正文和即将执行的工具调用另发一次小请求给旁路模型(默认沿用会话模型,也可以指定更便宜的模型),让它给出 3~8 个词的标签。任何模型都能用、标题最贴切;代价是每个批次多一次小请求的费用,并且落盘前最多等待设定的毫秒数,超时才到的标题只在当前页面显示、不写入会话记录。", "s_760f9739d4cbcc55": "打开任务完成通知", "s_7626053cb6469500": "等待 xAI 授权", "s_76420433f22d1c80": "导出", @@ -1110,7 +1107,6 @@ export const sourceCatalogs = { "s_7b187349d641590c": "导出会话失败:", "s_7b1c1c5b441d279b": "请检查网站地址和网络连接,然后重试。", "s_7b1e53cd6386dad4": "协助生成会话或者标题,记忆的摘要,建议使用本地模型或者轻量级模型。", - "s_7b48868d530b1e33": "在每个工具的参数表里注入一个 preamble 字符串参数,并在系统提示里要求模型每次调用都填一句 8~12 个词的前言(例如「正在读取注册表段落」)。参数一流出来就作为这次调用的 loading 文案,工具行运行时显示它,执行前会被剥掉,工具本身看不到。不依赖模型服务的特殊能力、不额外发请求,每次调用只多十几个 token;模型偶尔漏填时该调用退回默认文案。", "s_7b493bfc55770676": "待办事项 (", "s_7b4c4c37a5658899": "折叠目录", "s_7b89c50978dee6ed": "未注册", @@ -1166,7 +1162,6 @@ export const sourceCatalogs = { "s_8078f96cff000853": "已派遣{{p0}}:{{p1}}", "s_80891538e688b066": "正在处理请求", "s_80964c285e90e139": "单次 agent loop 的最大迭代数", - "s_809f109d52267acf": "不改提示词、不多发请求。模型服务流式返回的推理摘要若以加粗标题开头(OpenAI Responses、Codex、Gemini 的摘要都是这种格式),就抠出那行标题;在推理进行中标题就会出现在活动行上。没有加粗标题时取推理的第一句话兜底。只返回原始思维链或散文摘要的模型(如 DeepSeek、Claude)标题质量会差一些;完全不返回推理内容的模型不会出标题,该批次退回默认汇总。", "s_80abe59040677140": "审查当前改动\n帮我设计这项功能", "s_80e015bfff3865e5": "清空记录", "s_80f8fbcfa0117633": "放大", @@ -1267,7 +1262,7 @@ export const sourceCatalogs = { "s_8b36ad0259afb45c": "蜂群模式", "s_8b47380e110ab22e": "主题包尚未准备好,请重新导出", "s_8b4eb61f2c4876ba": "模型要执行一条危险命令", - "s_8b54eaececc9fee9": "tool preamble progress title status line reasoning summary sidecar 前言 标题", + "s_8b53ff6c5631bf5c": "tool preamble progress status line text_preamble reasoning summary 前言 阶段", "s_8b5f2a61b79721aa": "最低", "s_8b6ff498515bcc2f": "时间", "s_8b7cbd4cede975c1": "导入主题", @@ -1762,9 +1757,9 @@ export const sourceCatalogs = { "s_c56a5bb657de8f96": "更早", "s_c58129c7de6206bc": "拖动排序,也可按 Alt+上/下方向键", "s_c58140e6cf837851": "估算", + "s_c5b8490f479766cc": "不改提示词、不多发请求。模型服务流式返回的推理摘要若以加粗标题开头(OpenAI Responses、Codex、Gemini 的摘要都是这种格式),就抠出那行标题;在推理进行中标题就会出现在活动行上。没有加粗标题时取推理的第一句话兜底。只返回原始思维链或散文摘要的模型(如 DeepSeek、Claude)标题质量会差一些;完全不返回推理内容的模型不会出标题,活动行退回默认文案。", "s_c5c30a8c32f7755c": "选择代码工作区,让 ACECode 知道从哪里开始;也可以选择“不使用工作区”处理通用任务。", "s_c5c38d8eac297c25": "不附带会话", - "s_c5cdadb32c9f9562": "选择标题的来源。三种方式只能启用一种,保存后对新的工具调用立即生效。", "s_c5d0f14c188a62e7": "搜索目录模型", "s_c5f1c28c0d5f061d": "最近 30 天", "s_c5f21a7c119b8a56": "请在左侧 sidebar 把要在宫格中监控的会话置顶,", @@ -1828,6 +1823,7 @@ export const sourceCatalogs = { "s_cc0477362c195cf3": "暂无可复用的 OpenAI 兼容连接,请选择单独配置。", "s_cc66c3f576fd5a49": "完全访问权限", "s_cc91b14c8a02a155": "将 {{p0}} 设为默认模型", + "s_ccb09e86f30f07bd": "在系统提示里要求模型在第一次工具调用前、以及阶段或计划变化时,用 … 标出一句话:一开始写下一步要做什么,之后写已核实的结果加下一步;最终回答不打标签。标签一闭合这句话就进入活动行的 loading 文案,直到下一句前言或普通正文出现;标签本身不显示为正文,完成后的记录里也看不到它。不依赖模型服务的特殊能力、不额外发请求,每个阶段只多一句话的 token。", "s_cce894450069ba5b": "未知来源", "s_cd0832ff9b505231": "主题包已交给浏览器下载", "s_cd23a528a4cd4463": "清除中…", @@ -1916,7 +1912,6 @@ export const sourceCatalogs = { "s_d61b27abfe74268f": "正在探测模型…", "s_d61ba2462cc9803b": "模型池负载 {{p0}}%{{p1}}", "s_d61cdc9da87985e8": "已复制预览信息", - "s_d635d78dcb16afeb": "沿用会话模型", "s_d63a7a45c1c22d40": "旁路聊天未返回内容,请重试。", "s_d63c59a9df669b26": "沙箱:{{p0}}", "s_d63dfe4f7dad7694": "当前浏览器无法下载主题包", @@ -2222,9 +2217,7 @@ export const sourceCatalogs = { "s_f70f928df89406a7": "立即升级", "s_f72684a97677d48a": "对话问题轨道", "s_f733015aee32d198": "工作区菜单", - "s_f775c40a8081eeb6": "旁路模型", "s_f77e30ee1aed8819": "铅笔", - "s_f7a8c6e8f91d3c55": "在每批工具调用上方显示一句「正在做什么」的标题,代替笼统的「正在处理」。默认关闭。", "s_f7dd1b546141b550": "某些特殊审计场景,您可能需要重写工具,请确保您知道您在做什么才勾选此选项。", "s_f7e1c1a17a6c9e7e": "请先在已有会话中使用 /btw 或 /side", "s_f7e761e280e1d751": "深色主题", @@ -2674,7 +2667,6 @@ export const sourceCatalogs = { "s_25e5d2d94f923e99": "Use Add Element to Chat to reference UI elements in chat prompts.", "s_25ec0421b03bc4f3": "Drag to adjust the width of the right column", "s_2623156dcd71bf2d": "Asterisk", - "s_262562b47ca19d67": "Sidecar model summary", "s_2657124b1481b789": "Provided by project", "s_267c4a7a86dd1849": "All types", "s_26ab0eee67e8c707": "Tool called", @@ -2708,7 +2700,7 @@ export const sourceCatalogs = { "s_299b88328ba9699e": "The Grok model service returned an error", "s_29a48fe3b6f429d3": "Could not save summary generation settings", "s_29ee7148fcb3b94d": "Open the locally cached detection result", - "s_2a75a6b7b4fe8aad": "ms; titles that arrive later are shown on this page only", + "s_29fe9607e330a580": "Choose where the preamble comes from. Only one source can be active; saving applies to new turns immediately.", "s_2a7e69d433c4bddc": "An expert team needs at least two experts", "s_2ad8405732859cf9": "There are no configured MCP servers yet.", "s_2b00fbc472f4e202": "Fixed reference context", @@ -2743,6 +2735,7 @@ export const sourceCatalogs = { "s_2e659c0c617332d7": "read isolation active", "s_2e6bfec616ad6945": "Team members", "s_2e7e1c747a435c14": "(Multi)", + "s_2e8cf74dfb3a4848": "Ask the model to write a one-line \"what is happening\" in a tag during multi-step tool tasks.", "s_2ec809a15744b733": "Appearance preferences could not be saved. The theme was not deleted.", "s_2ed9c6fabac2013a": "Purple", "s_2efad4f47ae7804b": "Tool complete", @@ -2848,6 +2841,7 @@ export const sourceCatalogs = { "s_3badb01486cd5de8": "Migration copies your existing data. Restart ACECode when finished.", "s_3bc19f21617f0a86": "Generate images from descriptions or edit existing images", "s_3bceaa431f3153d5": "Loading status", + "s_3bdbd82d365049c9": "During multi-step tool tasks, show a one-line \"what is happening\" written by the model on the activity row instead of a generic \"Working\". Shown only while waiting; it does not stay in the record. Off by default.", "s_3be2c9604a690f97": "The side chat connection timed out. Try again.", "s_3c39e9bde120ddb5": "Scheduled tasks", "s_3c446e94a996b7aa": "Could not connect to side chat. Try again.", @@ -2864,7 +2858,6 @@ export const sourceCatalogs = { "s_3d5c3c3dbaca6b32": "(optional)", "s_3d7d7c8a6917f5d5": "First-party models", "s_3d82a5ea3f4b5d82": "The local detection result is loaded. The provider is contacted only when you click Detect again.", - "s_3d9b14aad33d7834": "Add a preamble argument to every tool call and let the model fill in a one-line preamble as it calls.", "s_3db7b06b5f6de0e0": "Working directory", "s_3dcff5522afda275": "Prompt cache hit rate", "s_3dd1245c155aba60": "The model configuration is unavailable; the current connection was retained", @@ -3123,7 +3116,6 @@ export const sourceCatalogs = { "s_57e4fcb2dad50b5c": "Remote Web mode enabled", "s_57eb46dacc97defb": "Show the last active time on the right of each task row; when off, it is still available in the hover card", "s_5804b9cca84304ff": "Clear side chat", - "s_58251ec975738d9a": "Send a small extra request so a sidecar model titles each batch of tool calls in 3-8 words.", "s_5835048333dad1d6": "Select custom answer", "s_584f28c1077647a9": "Cancelling...", "s_5868e5f7632072d9": "Denied {{p0}}", @@ -3344,7 +3336,6 @@ export const sourceCatalogs = { "s_71fa1b09e415447e": "Configure the identity, areas of expertise, opening remarks, and runtime capabilities in the same expert.", "s_720182db8f88cf7d": "Reading runtime capabilities...", "s_725831ec12394243": "Unarchiving failed:", - "s_72dad2026d457cd1": "Max wait", "s_72dde8dfef88d37d": "Confirm to add", "s_72ec61d7855c86a1": "Request timeout (seconds)", "s_73952aa91a9366b5": "Switched to", @@ -3377,7 +3368,6 @@ export const sourceCatalogs = { "s_75d889ac43a261ad": "allowed", "s_75dfaf262f476b6c": "Unarchiving…", "s_75e8c9e1324b05c4": "Permission denied", - "s_75ebc2db53d5d555": "When a batch of tool calls starts, the user request, the current assistant text and the pending tool calls are sent in a small extra request to a sidecar model (the session model by default, or a cheaper one you pick), which returns a 3-8 word label. It works with any model and gives the most accurate titles; the cost is one small request per batch, and the daemon waits at most the configured milliseconds before saving, so a title that arrives later is shown on this page only and not written to the session record.", "s_760f9739d4cbcc55": "Enable task completion notifications", "s_7626053cb6469500": "Waiting for xAI authorization", "s_76420433f22d1c80": "Export", @@ -3429,7 +3419,6 @@ export const sourceCatalogs = { "s_7b187349d641590c": "Export session failed:", "s_7b1c1c5b441d279b": "Check the site address and your network connection, then try again.", "s_7b1e53cd6386dad4": "Help generate conversation summaries, titles, and memory summaries. A local or lightweight model is recommended.", - "s_7b48868d530b1e33": "A preamble string argument is injected into every tool schema and the system prompt asks the model to fill it on every call with an 8-12 word line (for example \"Reading registry sections\"). As soon as the argument streams in it becomes the loading text for that call, the running tool row shows it, and it is stripped before the tool executes, so the tool never sees it. No special provider capability and no extra request; about a dozen extra tokens per call. When the model occasionally leaves it out, that call falls back to the default text.", "s_7b493bfc55770676": "To-do items (", "s_7b4c4c37a5658899": "Collapse table of contents", "s_7b89c50978dee6ed": "Not registered", @@ -3485,7 +3474,6 @@ export const sourceCatalogs = { "s_8078f96cff000853": "Dispatched {{p0}}:{{p1}}", "s_80891538e688b066": "Processing request", "s_80964c285e90e139": "Maximum iterations in one agent loop", - "s_809f109d52267acf": "No prompt change and no extra request. When the streamed reasoning summary starts with a bold title (OpenAI Responses, Codex and Gemini summaries all do), that line becomes the title and appears on the activity row while the model is still reasoning. Without a bold title the first sentence of the reasoning is used. Models that only return raw chains of thought or prose summaries (such as DeepSeek or Claude) give weaker titles; models that return no reasoning at all produce no title and the batch falls back to the default summary.", "s_80abe59040677140": "Review current changes\nHelp me design this feature", "s_80e015bfff3865e5": "Clear log", "s_80f8fbcfa0117633": "Zoom in", @@ -3586,7 +3574,7 @@ export const sourceCatalogs = { "s_8b36ad0259afb45c": "Swarm mode", "s_8b47380e110ab22e": "The theme package is not ready. Export it again.", "s_8b4eb61f2c4876ba": "The model wants to run a dangerous command", - "s_8b54eaececc9fee9": "tool preamble progress title status line reasoning summary sidecar", + "s_8b53ff6c5631bf5c": "tool preamble progress status line text_preamble reasoning summary", "s_8b5f2a61b79721aa": "Minimal", "s_8b6ff498515bcc2f": "Timing", "s_8b7cbd4cede975c1": "Import theme", @@ -4081,9 +4069,9 @@ export const sourceCatalogs = { "s_c56a5bb657de8f96": "earlier", "s_c58129c7de6206bc": "Drag to reorder, or press Alt+Up/Down", "s_c58140e6cf837851": "Estimate", + "s_c5b8490f479766cc": "No prompt change and no extra request. When the streamed reasoning summary starts with a bold title (OpenAI Responses, Codex and Gemini summaries all do), that line becomes the title and appears on the activity row while the model is still reasoning. Without a bold title the first sentence of the reasoning is used. Models that only return raw chains of thought or prose summaries (such as DeepSeek or Claude) give weaker titles; models that return no reasoning at all produce no title and the activity row falls back to the default text.", "s_c5c30a8c32f7755c": "Choose a code workspace so ACECode knows where to start, or choose \"No workspace\" for general tasks.", "s_c5c38d8eac297c25": "Does not come with session", - "s_c5cdadb32c9f9562": "Choose where the title comes from. Only one source can be active; saving applies to new tool calls immediately.", "s_c5d0f14c188a62e7": "Search catalog models", "s_c5f1c28c0d5f061d": "Last 30 days", "s_c5f21a7c119b8a56": "Please pin the session you want to monitor in the grid on the left sidebar.", @@ -4147,6 +4135,7 @@ export const sourceCatalogs = { "s_cc0477362c195cf3": "No reusable OpenAI-compatible connections. Choose Configure separately.", "s_cc66c3f576fd5a49": "Full access", "s_cc91b14c8a02a155": "Set {{p0}} as the default model", + "s_ccb09e86f30f07bd": "The system prompt asks the model to mark one sentence with … before the first tool call and at major phase or plan changes: the next step at first, then the verified result plus the next step; final answers are never tagged. As soon as the tag closes, that sentence becomes the loading text on the activity row until the next preamble or ordinary text appears; the tag itself is never shown as prose and does not remain in the finished record. No special provider capability and no extra request; one extra sentence of tokens per phase.", "s_cce894450069ba5b": "unknown source", "s_cd0832ff9b505231": "Theme package sent to browser downloads", "s_cd23a528a4cd4463": "Clearing…", @@ -4235,7 +4224,6 @@ export const sourceCatalogs = { "s_d61b27abfe74268f": "Detecting models…", "s_d61ba2462cc9803b": "Model pool load {{p0}}%{{p1}}", "s_d61cdc9da87985e8": "Preview information copied", - "s_d635d78dcb16afeb": "Use the session model", "s_d63a7a45c1c22d40": "Side chat returned no content. Try again.", "s_d63c59a9df669b26": "Sandbox: {{p0}}", "s_d63dfe4f7dad7694": "This browser cannot download the theme package", @@ -4541,9 +4529,7 @@ export const sourceCatalogs = { "s_f70f928df89406a7": "Upgrade now", "s_f72684a97677d48a": "Conversation Question Track", "s_f733015aee32d198": "Workspace menu", - "s_f775c40a8081eeb6": "Sidecar model", "s_f77e30ee1aed8819": "Pencil", - "s_f7a8c6e8f91d3c55": "Show a one-line \"what is happening\" title above each batch of tool calls instead of a generic \"Working\". Off by default.", "s_f7dd1b546141b550": "In some special audit scenarios you may need to rewrite tools. Only enable this option if you know what you are doing.", "s_f7e1c1a17a6c9e7e": "Please use /btw or /side in an existing session first", "s_f7e761e280e1d751": "Dark theme", diff --git a/web/src/lib/sessionTranscript.js b/web/src/lib/sessionTranscript.js index f2956ae21..25afba426 100644 --- a/web/src/lib/sessionTranscript.js +++ b/web/src/lib/sessionTranscript.js @@ -8,7 +8,7 @@ import { fallbackToolSummary } from './toolSummaryFallback.js'; import { normalizeToolInvocationItems } from './transcriptProjection.js'; import { createSingleWriterStore } from './singleWriterStore.js'; import { composerContentFromMessage } from './composerContent.js'; -import { normalizeToolPreambleEvent, toolPreambleFromMetadata } from './toolPreamble.js'; +import { preambleFromProgress, preambleFromToolStart, stripTextPreambleTags } from './toolPreamble.js'; function composerContentFields(message, fallback = null) { const content = composerContentFromMessage(message) || composerContentFromMessage(fallback); @@ -40,7 +40,6 @@ const STREAM_NEUTRAL_EVENT_TYPES = new Set([ 'goal_updated', 'goal_cleared', 'todo_updated', - 'tool_preamble', 'session_updated', 'permission_request', 'permission_closed', @@ -254,7 +253,6 @@ function cloneState(state) { todos: cloneTodos(state.todos), todoSummary: state.todoSummary && typeof state.todoSummary === 'object' ? { ...state.todoSummary } : null, activity: state.activity && typeof state.activity === 'object' ? { ...state.activity } : null, - pendingToolPreambles: { ...(state.pendingToolPreambles || {}) }, trajectoryPartial: state.trajectoryPartial && typeof state.trajectoryPartial === 'object' ? { ...state.trajectoryPartial, @@ -849,74 +847,26 @@ function historyItemsFromMessage(next, m, messageIndex) { return items; } -// 工具前言(add-tool-preamble)的历史还原:标题落盘在 assistant(tool_calls) 消息的 -// metadata.tool_preamble 上,而投影是按工具项分组的,所以这里把它按 tool_call_id -// 传播到同批次的结果项 —— 结构化结果(kind:tool)挂 tool.preamble,legacy 文本 -// 结果(kind:msg)挂 metadata.tool_preamble;assistant 正文与 tool_call 包装项的 -// metadata 补上 batch_id,让实时与历史两条路径的分组键一致。 -function tagHistoryToolPreambles(produced, message, preambleByCallId) { - if (message?.role === 'assistant') { - const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; - const ids = []; - for (let i = 0; i < toolCalls.length; i += 1) { - const call = normalizePersistedToolCall(toolCalls[i], i); - ids.push(call.toolCallId || `#${i}`); - } - const preamble = ids.length > 0 ? toolPreambleFromMetadata(message.metadata, ids[0]) : null; - if (!preamble) return produced; - // 批次标题给整批;参数模式的逐调用前言按 id(缺 id 用 "#")各取各的。 - ids.forEach((id, index) => { - const own = String(preamble.calls[id] ?? preamble.calls[`#${index}`] ?? '').trim(); - const title = own || preamble.title; - if (!title) return; - preambleByCallId.set(id, { title, source: preamble.source, batchId: preamble.title ? preamble.batchId : '' }); - }); - if (!preamble.title) return produced; - return produced.map((item) => { - if (item?.kind !== 'msg' || !item.metadata?.tool_preamble) return item; - return { - ...item, - metadata: { - ...item.metadata, - tool_preamble: { ...item.metadata.tool_preamble, batch_id: preamble.batchId }, - }, - }; - }); - } - if (message?.role !== 'tool') return produced; - const toolCallId = String((message.tool_call_id ?? message.toolCallId ?? '') || '').trim(); - const preamble = toolCallId ? preambleByCallId.get(toolCallId) : null; - if (!preamble) return produced; - return produced.map((item) => { - if (item?.kind === 'tool') { - return { ...item, tool: { ...item.tool, preamble } }; - } - if (item?.kind === 'msg') { - return { - ...item, - metadata: { - ...(item.metadata && typeof item.metadata === 'object' ? item.metadata : {}), - tool_preamble: { title: preamble.title, source: preamble.source, batch_id: preamble.batchId }, - }, - }; - } - return item; - }); +// 工具前言(add-tool-preamble):assistant 正文里的 标签只在实时 +// 期间进 loading,落盘正文保留原文(模型会模仿自己的历史输出),所以历史加载与 +// message 事件都要在这里剥掉;token 流由 daemon 剥过。 +function withVisibleAssistantContent(message) { + if (message?.role !== 'assistant' || typeof message.content !== 'string') return message; + const visible = stripTextPreambleTags(message.content); + return visible === message.content ? message : { ...message, content: visible }; } function historyItemsFromMessages(next, messages) { const items = []; const toolNamesByCallId = new Map(); - const preambleByCallId = new Map(); for (let i = 0; i < messages.length; i += 1) { const rawMessage = messages[i]; if (rawMessage?.role === 'user') { toolNamesByCallId.clear(); - preambleByCallId.clear(); } - const message = withPersistedToolName(rawMessage, toolNamesByCallId); - items.push(...tagHistoryToolPreambles( - historyItemsFromMessage(next, message, i), message, preambleByCallId)); + const message = withVisibleAssistantContent( + withPersistedToolName(rawMessage, toolNamesByCallId)); + items.push(...historyItemsFromMessage(next, message, i)); if (message?.role === 'tool') { const toolCallId = String((message.tool_call_id ?? message.toolCallId ?? '') || '').trim(); if (toolCallId) toolNamesByCallId.delete(toolCallId); @@ -1278,9 +1228,6 @@ export function createTranscriptState(overrides = {}) { todos: [], todoSummary: null, activity: null, - // 工具前言(add-tool-preamble):tool_preamble 事件先于对应的 tool_start 到达, - // 标题先按 tool_call_id 暂存在这里,tool_start 建项时取走挂到 tool.preamble。 - pendingToolPreambles: {}, // turnHadAssistantText / lastAssistantText 用于桌面通知:在 busy=true→false // 转换且本回合产生过 assistant 文本时,emit turn_completed effect。reducer 之外 // 的代码不应直接读 / 写它们。见 @@ -1359,6 +1306,9 @@ export function reduceTranscriptEvent(state, msg) { phase, label: label || phase, detail: p.detail || '', + // 工具前言(add-tool-preamble):当前阶段前言随进度帧透传,kind(read / + // write)给以后的读放大镜 / 写笔触效果留位。 + preamble: preambleFromProgress(p), tool: p.tool || '', toolCallId: p.tool_call_id || p.call_id || p.id || '', toolIndex: p.tool_index ?? null, @@ -1411,6 +1361,8 @@ export function reduceTranscriptEvent(state, msg) { break; } case 'message': { + // 工具前言:assistant 正文里的 标签不进 transcript。 + const p = withVisibleAssistantContent(msg?.payload || {}); const role = p.role || 'system'; if (role === 'assistant' && p.metadata?.transcript_only === true && p.metadata?.interrupted_output === true) { @@ -1641,24 +1593,10 @@ export function reduceTranscriptEvent(state, msg) { metadata: null, askUserQuestionResult: null, }; - // 工具前言:参数模式下 tool_start 直接带这次调用的前言;批次标题模式下 - // 由先到的 tool_preamble 事件暂存在 pending,这里取走(两者并存时后者带 batchId)。 - if (typeof p.preamble === 'string' && p.preamble.trim()) { - tool.preamble = { - title: p.preamble.trim(), - source: String(p.preamble_source || ''), - batchId: '', - }; - } - const pendingPreamble = tool.toolCallId - ? (next.pendingToolPreambles || {})[tool.toolCallId] - : null; - if (pendingPreamble) { - tool.preamble = pendingPreamble; - const pending = { ...next.pendingToolPreambles }; - delete pending[tool.toolCallId]; - next.pendingToolPreambles = pending; - } + // 工具前言(add-tool-preamble):tool_start 直接带这次调用沿用的阶段前言, + // 运行中的工具行与实时活动行用它当标题;落定后不再显示。 + const startPreamble = preambleFromToolStart(p); + if (startPreamble) tool.preamble = startPreamble; next.items = [...next.items, { kind: 'tool', id, tool, ts: eventTs(msg) }]; break; } @@ -1735,28 +1673,6 @@ export function reduceTranscriptEvent(state, msg) { next.goal = null; break; } - case 'tool_preamble': { - // 工具前言(add-tool-preamble):一个工具调用批次的标题。常规顺序是 - // tool_preamble → (assistant message) → tool_start…,所以还没出现的工具项 - // 先记进 pendingToolPreambles;sidecar 迟到(late=true)时工具项已经在, - // 原地打标即可。投影按 tool.preamble 把批次折成带标题的分组。 - const preamble = normalizeToolPreambleEvent(p); - if (!preamble) break; - markTranscriptRunning(next); - const tag = { title: preamble.title, source: preamble.source, batchId: preamble.batchId }; - const remaining = new Set(preamble.ids); - next.items = next.items.map((item) => { - if (item.kind !== 'tool') return item; - const callId = String(item.tool?.toolCallId || ''); - if (!callId || !remaining.has(callId)) return item; - remaining.delete(callId); - return { ...item, tool: { ...item.tool, preamble: tag } }; - }); - const pending = { ...(next.pendingToolPreambles || {}) }; - for (const id of remaining) pending[id] = tag; - next.pendingToolPreambles = pending; - break; - } case 'todo_updated': { const todos = normalizeTodos(p.todos); next.todos = todos; diff --git a/web/src/lib/sessionTranscriptToolPreamble.test.js b/web/src/lib/sessionTranscriptToolPreamble.test.js index f8db36aac..4380a0845 100644 --- a/web/src/lib/sessionTranscriptToolPreamble.test.js +++ b/web/src/lib/sessionTranscriptToolPreamble.test.js @@ -24,142 +24,95 @@ function toolItems(state) { return state.items.filter((item) => item.kind === 'tool'); } +function assistantItems(state) { + return state.items.filter((item) => item.kind === 'msg' && item.role === 'assistant'); +} + const LIVE = { deferTrailingToolSummary: true, ensureLiveActivity: true, liveTurnId: 't1' }; -// 触发场景:参数模式 —— daemon 从调用参数里剥出前言,随 tool_start 直接下发。 -// 期望行为:工具项建项时就带 tool.preamble(source=prompt、无 batchId);投影的 -// 实时活动行标题就是这句前言。 -run('tool_start 自带前言:建项即打标', () => { +// 触发场景:daemon 把本批次沿用的阶段前言随 tool_start 下发(preamble / preamble_source / +// preamble_kind)。期望行为:工具项建项时就带 tool.preamble = {title, source, kind}; +// 投影的实时活动行标题就是这句前言;kind 只透传,不影响标题。 +run('tool_start 自带前言:建项即打标,实时活动行用它当标题', () => { const state = reduceMany([ - { type: 'tool_start', payload: { tool: 'file_read', tool_call_id: 'c1', args: { file_path: 'a' }, preamble: 'Reading registry sections', preamble_source: 'prompt' }, seq: 1 }, + { type: 'tool_start', payload: { tool: 'file_read', tool_call_id: 'c1', args: { file_path: 'a' }, preamble: 'Reading registry sections', preamble_source: 'prompt', preamble_kind: 'read' }, seq: 1 }, ]); const tools = toolItems(state); assert.equal(tools.length, 1); - assert.deepEqual(tools[0].tool.preamble, { title: 'Reading registry sections', source: 'prompt', batchId: '' }); + assert.deepEqual(tools[0].tool.preamble, { title: 'Reading registry sections', source: 'prompt', kind: 'read' }); const projected = projectCollapsedTranscriptItems(state.items, LIVE); assert.deepEqual(projected.map((item) => item.kind), ['activity_summary']); assert.equal(projected[0].title, 'Reading registry sections'); - assert.equal(projected[0].live, true); + assert.deepEqual(projected[0].preamble, { title: 'Reading registry sections', source: 'prompt', kind: 'read' }); }); -// 触发场景:批次标题模式的常规顺序 —— tool_preamble 先于该批次的 tool_start 到达。 -// 期望行为:标题先暂存在 pendingToolPreambles,两个 tool_start 建项时各自取走并挂到 -// tool.preamble(带 batchId);取完 pending 清空。 -run('tool_preamble 先到:tool_start 建项时取走暂存标题', () => { +// 触发场景:没有前言的 tool_start(功能关闭 / 模型没打标签)。 +// 期望行为:工具项没有 preamble 字段,投影与改动前一致(模板文案)。 +run('没有前言的 tool_start 不打标', () => { const state = reduceMany([ - { type: 'tool_preamble', payload: { batch_id: 'c1', tool_call_ids: ['c1', 'c2'], title: 'Reading registry sections', source: 'reasoning', late: false }, seq: 1 }, - { type: 'tool_start', payload: { tool: 'file_read', tool_call_id: 'c1', args: { file_path: 'a' }, preamble: 'Reading registry sections', preamble_source: 'reasoning' }, seq: 2 }, - { type: 'tool_start', payload: { tool: 'file_read', tool_call_id: 'c2', args: { file_path: 'b' } }, seq: 3 }, + { type: 'tool_start', payload: { tool: 'file_read', tool_call_id: 'c1', args: { file_path: 'a' } }, seq: 1 }, ]); const tools = toolItems(state); - assert.equal(tools.length, 2); - for (const item of tools) { - assert.deepEqual(item.tool.preamble, { title: 'Reading registry sections', source: 'reasoning', batchId: 'c1' }); - } - assert.deepEqual(state.pendingToolPreambles, {}); + assert.equal(tools.length, 1); + assert.equal(tools[0].tool.preamble, undefined); }); -// 触发场景:sidecar 迟到 —— 工具已经跑完(tool_start / tool_end 都到了)标题才来, -// 事件带 late=true。 -// 期望行为:已存在的工具项原地打标;不残留 pending。 -run('late 标题原地打到已存在的工具项上', () => { +// 触发场景:标签一闭合 daemon 就发 agent_progress{phase:preamble, label, preamble:{…}}, +// 工具调用还没流出来。期望行为:activity.label 是前言,activity.preamble 带 kind, +// 投影的实时行(还没有工具项时靠 ensureLiveActivity)显示这句前言。 +run('agent_progress 的前言帧立刻换掉活动行文案', () => { const state = reduceMany([ - { type: 'tool_start', payload: { tool: 'bash', tool_call_id: 'c1', args: { cmd: 'ls' } }, seq: 1 }, - { type: 'tool_end', payload: { tool: 'bash', tool_call_id: 'c1', success: true, output: 'ok' }, seq: 2 }, - { type: 'tool_preamble', payload: { batch_id: 'c1', tool_call_ids: ['c1'], title: 'Listing files', source: 'sidecar', late: true }, seq: 3 }, + { type: 'busy_changed', payload: { busy: true }, seq: 1 }, + { type: 'agent_progress', payload: { phase: 'model_waiting', label: '正在等待模型响应' }, seq: 2 }, + { type: 'agent_progress', payload: { phase: 'preamble', label: 'Editing config', preamble: { title: 'Editing config', source: 'prompt', kind: 'write' } }, seq: 3 }, ]); - const tools = toolItems(state); - assert.equal(tools.length, 1); - assert.equal(tools[0].tool.preamble.title, 'Listing files'); - assert.equal(tools[0].tool.isDone, true); - assert.deepEqual(state.pendingToolPreambles, {}); + assert.equal(state.activity.label, 'Editing config'); + assert.deepEqual(state.activity.preamble, { title: 'Editing config', source: 'prompt', kind: 'write' }); + const plain = reduceMany([ + { type: 'agent_progress', payload: { phase: 'reasoning', label: '正在推理' }, seq: 1 }, + ]); + assert.equal(plain.activity.preamble, null); }); -// 触发场景:REST 历史 / resume,参数模式 —— 前言落在 assistant(tool_calls) 消息的 -// metadata.tool_preamble.calls 里(参数本身已被剥干净),结果在后面的 role:tool 消息。 -// 期望行为:结构化结果项按自己的 tool_call_id 拿到前言(无 batchId);同批里没 -// 填前言的调用没有 preamble;下一个 user 之后映射清空,不串到后面的回合。 -run('历史加载:逐调用前言按 id 传播到结果项', () => { +// 触发场景:落盘的 assistant 正文保留了 标签(有意为之,模型会模仿 +// 自己的历史),历史加载与实时 message 帧都可能带它。期望行为:两条路径的 assistant +// 正文都剥掉标签(标签后的空行一起去掉);整段都是标签的 assistant(tool_calls) +// 消息不产生正文气泡;metadata.tool_preamble 只是记录,不影响任何项。 +run('历史加载与 message 帧都剥掉 标签', () => { const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { messages: [ { id: 'u1', role: 'user', content: 'list files', ts: 1 }, { - id: 'a1', role: 'assistant', content: '', ts: 2, - tool_calls: [ - { id: 'c1', type: 'function', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }, - { id: 'c2', type: 'function', function: { name: 'bash', arguments: '{"cmd":"pwd"}' } }, - ], - metadata: { tool_preamble: { source: 'prompt', calls: { c1: 'Listing files' } } }, - }, - { - id: 't1', role: 'tool', tool_call_id: 'c1', content: 'ok', ts: 3, - metadata: { tool_success: true, tool_summary: { verb: 'ran', object: 'ls', icon: '', metrics: [] } }, - }, - { - id: 't2', role: 'tool', tool_call_id: 'c2', content: '/tmp', ts: 4, - metadata: { tool_success: true, tool_summary: { verb: 'ran', object: 'pwd', icon: '', metrics: [] } }, - }, - { id: 'u2', role: 'user', content: 'again', ts: 5 }, - { - id: 'a2', role: 'assistant', content: '', ts: 6, + id: 'a1', role: 'assistant', ts: 2, + content: 'Listing files\n\n', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }], + metadata: { tool_preamble: { source: 'prompt', title: 'Listing files', kind: 'read' } }, }, - { - id: 't3', role: 'tool', tool_call_id: 'c1', content: 'ok', ts: 7, - metadata: { tool_success: true, tool_summary: { verb: 'ran', object: 'ls', icon: '', metrics: [] } }, - }, + { id: 't1', role: 'tool', tool_call_id: 'c1', content: 'ok', ts: 3 }, + { id: 'a2', role: 'assistant', ts: 4, content: 'x\n\nAll done.' }, ], }).state; - const tools = toolItems(loaded); - assert.equal(tools.length, 3); - assert.deepEqual(tools[0].tool.preamble, { title: 'Listing files', source: 'prompt', batchId: '' }); - assert.equal(tools[1].tool.preamble, undefined, '没填前言的调用不该有 preamble'); - assert.equal(tools[2].tool.preamble, undefined, '下一回合复用同一 id 也不能继承上一回合的前言'); -}); + const assistants = assistantItems(loaded); + for (const item of assistants) { + assert.ok(!String(item.content || '').includes('text_preamble'), item.content); + } + assert.deepEqual(assistants.filter((item) => item.content.trim()).map((item) => item.content), ['All done.']); + for (const item of toolItems(loaded)) { + assert.equal(item.tool.preamble, undefined); + } -// 触发场景:REST 历史,批次标题模式 —— metadata.tool_preamble 只有 title。 -// 期望行为:整批结果项都挂批次标题(batchId = 第一个 tool_call_id);assistant -// 派生的 tool_call 包装项 metadata 补上 batch_id;投影出的分组仍是一条模板汇总。 -run('历史加载:批次标题传播到整批结果项', () => { - const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { - messages: [ - { id: 'u1', role: 'user', content: 'list files', ts: 1 }, - { - id: 'a1', role: 'assistant', content: '', ts: 2, - tool_calls: [{ id: 'c1', type: 'function', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }], - metadata: { tool_preamble: { title: 'Listing files', source: 'reasoning' } }, - }, - { - id: 't1', role: 'tool', tool_call_id: 'c1', content: 'ok', ts: 3, - metadata: { tool_success: true, tool_summary: { verb: 'ran', object: 'ls', icon: '', metrics: [] } }, - }, - { id: 'a2', role: 'assistant', content: 'Done.', ts: 4 }, - ], - }).state; - const result = loaded.items.find((item) => item.kind === 'tool' && item.tool.toolCallId === 'c1'); - assert.ok(result, '结构化结果项应存在'); - assert.deepEqual(result.tool.preamble, { title: 'Listing files', source: 'reasoning', batchId: 'c1' }); - const wrapper = loaded.items.find((item) => item.kind === 'msg' && item.role === 'tool_call' && item.toolCallId === 'c1'); - assert.ok(wrapper, 'tool_call 包装项应存在'); - assert.equal(wrapper.metadata.tool_preamble.batch_id, 'c1'); - const projected = projectCollapsedTranscriptItems(loaded.items, { deferTrailingToolSummary: false }); - assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary', 'msg']); - assert.equal(projected[1].mode, 'processed'); + const live = reduceMany([ + { type: 'message', payload: { role: 'assistant', id: 'm1', content: 'Editing\n\nDone editing.' }, seq: 1 }, + ]); + assert.deepEqual(assistantItems(live).map((item) => item.content), ['Done editing.']); }); -// 触发场景:tool_preamble 事件夹在流式 token 之间到达。 -// 期望行为:它是流中性事件 —— 不会把正在流式的 assistant 草稿切成两条;随后的 -// message 帧原位定稿。 -run('tool_preamble 不切断流式草稿', () => { +// 触发场景:旧 daemon 仍发 tool_preamble 事件(已从协议里删掉)。 +// 期望行为:当作未知事件忽略,不报错、不建项。 +run('已删除的 tool_preamble 事件被忽略', () => { const state = reduceMany([ - { type: 'token', payload: { text: 'Reading ' }, seq: 1 }, - { type: 'tool_preamble', payload: { batch_id: 'c1', tool_call_ids: ['c1'], title: 'Reading the loader', source: 'reasoning', late: false }, seq: 2 }, - { type: 'token', payload: { text: 'the loader' }, seq: 3 }, - { type: 'message', payload: { id: 'm1', role: 'assistant', content: 'Reading the loader' }, seq: 4 }, - { type: 'tool_start', payload: { tool: 'file_read', tool_call_id: 'c1', args: { file_path: 'a' } }, seq: 5 }, + { type: 'tool_preamble', payload: { batch_id: 'c1', tool_call_ids: ['c1'], title: 'Old', source: 'prompt', late: false }, seq: 1 }, ]); - const assistants = state.items.filter((item) => item.kind === 'msg' && item.role === 'assistant'); - assert.equal(assistants.length, 1); - assert.equal(assistants[0].content, 'Reading the loader'); - assert.equal(toolItems(state)[0].tool.preamble.title, 'Reading the loader'); + assert.equal(state.items.length, 0); + assert.equal(state.pendingToolPreambles, undefined); }); diff --git a/web/src/lib/settingsSearch.js b/web/src/lib/settingsSearch.js index 958c4a2c8..7c992ce3d 100644 --- a/web/src/lib/settingsSearch.js +++ b/web/src/lib/settingsSearch.js @@ -50,7 +50,7 @@ export function settingsSearchEntries(developerModeUnlocked = false) { ]; if (developerModeUnlocked) { fields.push(['developer', '允许多进程启动', 'developer multiple desktop instances processes']); - fields.push(['developer', '工具前言', 'tool preamble progress title status line reasoning summary sidecar 前言 标题']); + fields.push(['developer', '工具前言', 'tool preamble progress status line text_preamble reasoning summary 前言 阶段']); } const all = [ ...fields.map(([section, label, aliases], index) => ({ id: `setting-${index}`, section, label, aliases })), diff --git a/web/src/lib/toolPreamble.js b/web/src/lib/toolPreamble.js index 604aa4329..f7151105d 100644 --- a/web/src/lib/toolPreamble.js +++ b/web/src/lib/toolPreamble.js @@ -1,35 +1,24 @@ -// 工具前言(openspec add-tool-preamble)的纯逻辑:设置页的三选一定义与状态 -// 规整、REST 往返的字段映射、tool_preamble 事件 / 消息 metadata 的归一化。 -// 组件(ToolPreambleSettings.jsx)与 reducer / 投影只做映射,不在这里之外 -// 再解释一遍字段含义。 +// 工具前言(openspec add-tool-preamble)的纯逻辑:设置页的二选一定义与状态 +// 规整、REST 往返的字段映射、 标签的渲染层剥离、tool_start / +// agent_progress 里前言字段的归一化。组件(ToolPreambleSettings.jsx)与 reducer / +// 投影只做映射,不在这里之外再解释一遍字段含义。 export const TOOL_PREAMBLE_MODE_PROMPT = 'prompt'; export const TOOL_PREAMBLE_MODE_REASONING = 'reasoning'; -export const TOOL_PREAMBLE_MODE_SIDECAR = 'sidecar'; -export const TOOL_PREAMBLE_SIDECAR_WAIT_MIN_MS = 0; -export const TOOL_PREAMBLE_SIDECAR_WAIT_MAX_MS = 15000; -export const TOOL_PREAMBLE_SIDECAR_WAIT_DEFAULT_MS = 2000; - -// 三选一的顺序就是设置页的展示顺序,默认选第一项(提示驱动)。 +// 二选一的顺序就是设置页的展示顺序,默认选第一项(提示驱动)。 export const TOOL_PREAMBLE_MODES = Object.freeze([ Object.freeze({ id: TOOL_PREAMBLE_MODE_PROMPT, label: '提示驱动', - summary: '给每个工具调用加一个 preamble 参数,由模型在调用时填一句前言。', - help: '在每个工具的参数表里注入一个 preamble 字符串参数,并在系统提示里要求模型每次调用都填一句 8~12 个词的前言(例如「正在读取注册表段落」)。参数一流出来就作为这次调用的 loading 文案,工具行运行时显示它,执行前会被剥掉,工具本身看不到。不依赖模型服务的特殊能力、不额外发请求,每次调用只多十几个 token;模型偶尔漏填时该调用退回默认文案。', + summary: '让模型在多步工具任务里用 标签写一句「正在做什么」。', + help: '在系统提示里要求模型在第一次工具调用前、以及阶段或计划变化时,用 … 标出一句话:一开始写下一步要做什么,之后写已核实的结果加下一步;最终回答不打标签。标签一闭合这句话就进入活动行的 loading 文案,直到下一句前言或普通正文出现;标签本身不显示为正文,完成后的记录里也看不到它。不依赖模型服务的特殊能力、不额外发请求,每个阶段只多一句话的 token。', }), Object.freeze({ id: TOOL_PREAMBLE_MODE_REASONING, label: '推理服务内置摘要', summary: '从模型服务返回的推理摘要里取标题,不改提示词、不多发请求。', - help: '不改提示词、不多发请求。模型服务流式返回的推理摘要若以加粗标题开头(OpenAI Responses、Codex、Gemini 的摘要都是这种格式),就抠出那行标题;在推理进行中标题就会出现在活动行上。没有加粗标题时取推理的第一句话兜底。只返回原始思维链或散文摘要的模型(如 DeepSeek、Claude)标题质量会差一些;完全不返回推理内容的模型不会出标题,该批次退回默认汇总。', - }), - Object.freeze({ - id: TOOL_PREAMBLE_MODE_SIDECAR, - label: '旁路模型摘要', - summary: '另发一次小请求,让旁路模型为每批工具调用起 3~8 个词的标题。', - help: '每批工具调用开始时,把用户请求、模型当前正文和即将执行的工具调用另发一次小请求给旁路模型(默认沿用会话模型,也可以指定更便宜的模型),让它给出 3~8 个词的标签。任何模型都能用、标题最贴切;代价是每个批次多一次小请求的费用,并且落盘前最多等待设定的毫秒数,超时才到的标题只在当前页面显示、不写入会话记录。', + help: '不改提示词、不多发请求。模型服务流式返回的推理摘要若以加粗标题开头(OpenAI Responses、Codex、Gemini 的摘要都是这种格式),就抠出那行标题;在推理进行中标题就会出现在活动行上。没有加粗标题时取推理的第一句话兜底。只返回原始思维链或散文摘要的模型(如 DeepSeek、Claude)标题质量会差一些;完全不返回推理内容的模型不会出标题,活动行退回默认文案。', }), ]); @@ -42,38 +31,18 @@ export function toolPreambleModeLabel(mode) { return entry ? entry.label : ''; } -export function clampSidecarWaitMs(value) { - const n = Number(value); - if (!Number.isFinite(n)) return TOOL_PREAMBLE_SIDECAR_WAIT_DEFAULT_MS; - return Math.min(TOOL_PREAMBLE_SIDECAR_WAIT_MAX_MS, - Math.max(TOOL_PREAMBLE_SIDECAR_WAIT_MIN_MS, Math.round(n))); -} - export const DEFAULT_TOOL_PREAMBLE_STATE = Object.freeze({ enabled: false, mode: TOOL_PREAMBLE_MODE_PROMPT, - sidecarModel: '', - sidecarWaitMs: TOOL_PREAMBLE_SIDECAR_WAIT_DEFAULT_MS, - savedModels: Object.freeze([]), }); // GET /api/config/tool-preamble 的响应 → 组件状态。字段缺失 / 类型不对一律 -// 退回默认值,非法 mode 退回 prompt(与 daemon 的归一化口径一致)。 +// 退回默认值,非法 mode(含旧的 sidecar)退回 prompt(与 daemon 的归一化口径一致)。 export function normalizeToolPreambleState(payload) { const source = payload && typeof payload === 'object' ? payload : {}; - const mode = isToolPreambleMode(source.mode) ? source.mode : TOOL_PREAMBLE_MODE_PROMPT; - const savedModels = Array.isArray(source.saved_models) - ? source.saved_models.map((name) => String(name || '').trim()).filter(Boolean) - : []; - const sidecarModel = typeof source.sidecar_model === 'string' ? source.sidecar_model.trim() : ''; return { enabled: source.enabled === true, - mode, - sidecarModel: savedModels.includes(sidecarModel) ? sidecarModel : '', - sidecarWaitMs: clampSidecarWaitMs( - source.sidecar_wait_ms ?? TOOL_PREAMBLE_SIDECAR_WAIT_DEFAULT_MS, - ), - savedModels, + mode: isToolPreambleMode(source.mode) ? source.mode : TOOL_PREAMBLE_MODE_PROMPT, }; } @@ -84,62 +53,101 @@ export function buildToolPreambleUpdate(patch = {}) { if (Object.prototype.hasOwnProperty.call(patch, 'mode') && isToolPreambleMode(patch.mode)) { body.mode = patch.mode; } - if (Object.prototype.hasOwnProperty.call(patch, 'sidecarModel')) { - body.sidecar_model = String(patch.sidecarModel || '').trim(); - } - if (Object.prototype.hasOwnProperty.call(patch, 'sidecarWaitMs')) { - body.sidecar_wait_ms = clampSidecarWaitMs(patch.sidecarWaitMs); - } return body; } -// 行卡片右侧的状态文案:关闭 → 「未启用」;开启 → 当前模式名(旁路模式附模型)。 +// 行卡片右侧的状态文案:关闭 → 「未启用」;开启 → 当前模式名。 export function toolPreambleStatusText(state) { if (!state || state.enabled !== true) return '未启用'; - const label = toolPreambleModeLabel(state.mode) || state.mode; - if (state.mode === TOOL_PREAMBLE_MODE_SIDECAR) { - return `${label} · ${state.sidecarModel || '沿用会话模型'}`; - } - return label; + return toolPreambleModeLabel(state.mode) || state.mode; } -// tool_preamble 事件 payload → reducer 用的归一化结构;缺标题 / 缺 id 视为无效。 -export function normalizeToolPreambleEvent(payload) { - const source = payload && typeof payload === 'object' ? payload : {}; - const title = String(source.title || '').trim(); - const ids = Array.isArray(source.tool_call_ids) - ? source.tool_call_ids.map((id) => String(id || '').trim()).filter(Boolean) - : []; - if (!title || ids.length === 0) return null; - const batchId = String(source.batch_id || ids[0]).trim() || ids[0]; +// tool_start.preamble(+ preamble_source / preamble_kind)与 agent_progress.preamble +// 的归一化结构:{title, source, kind};没有标题返回 null。kind 只透传(read / +// write / ''),界面上的读放大镜 / 写笔触效果留给以后接。 +export function normalizeToolPreamble(title, source = '', kind = '') { + const text = String(title || '').trim(); + if (!text) return null; + const kindText = String(kind || '').trim().toLowerCase(); return { - title, - source: String(source.source || '').trim(), - batchId, - ids, - late: source.late === true, + title: text, + source: String(source || '').trim(), + kind: kindText === 'read' || kindText === 'write' ? kindText : '', }; } -// assistant 消息 metadata.tool_preamble → 历史还原用的结构:批次标题 `title` -// (reasoning / sidecar)与参数模式的逐调用 `calls`({tool_call_id: 前言}); -// 两者都没有时返回 null。 -export function toolPreambleFromMetadata(metadata, batchId = '') { - const entry = metadata && typeof metadata === 'object' ? metadata.tool_preamble : null; +export function preambleFromToolStart(payload) { + const source = payload && typeof payload === 'object' ? payload : {}; + return normalizeToolPreamble(source.preamble, source.preamble_source, source.preamble_kind); +} + +export function preambleFromProgress(payload) { + const entry = payload && typeof payload === 'object' ? payload.preamble : null; if (!entry || typeof entry !== 'object') return null; - const title = String(entry.title || '').trim(); - const calls = {}; - if (entry.calls && typeof entry.calls === 'object' && !Array.isArray(entry.calls)) { - for (const [id, value] of Object.entries(entry.calls)) { - const text = String(value || '').trim(); - if (id && text) calls[id] = text; + return normalizeToolPreamble(entry.title, entry.source, entry.kind); +} + +const OPEN_TAG_RE = //])[^>]*>/i; +const CLOSE_TAG_RE = /<\/text_preamble>/i; +const STRAY_CLOSE_RE = /^\s*<\/text_preamble>/i; + +// 渲染层剥标签(与 daemon 的 tool_preamble::strip_text_preamble_tags 同款规则): +// - … 整段去掉; +// - 没写 type 也认;闭合标签缺失时正文到行尾为止;`` 空标签 +// 直接跳过;标签名大小写不敏感;标签外孤立的 丢掉; +// - 文本开头与每个标签之后紧跟的空白(通常是 "\n\n")一并吞掉,免得为一段 +// 空白建一条空气泡。 +// 历史消息与 message 事件里的 assistant 正文都要过这一遍;token 流由 daemon +// 剥过,不必再剥。 +export function stripTextPreambleTags(text) { + const source = typeof text === 'string' ? text : (text == null ? '' : String(text)); + if (!source) return ''; + if (!/<\/?text_preamble/i.test(source)) return source; + let rest = source; + let out = ''; + let swallow = true; + const emit = (chunk) => { + if (!chunk) return; + if (swallow) { + const trimmed = chunk.replace(/^\s+/, ''); + if (!trimmed) return; + swallow = false; + out += trimmed; + return; } - } - if (!title && Object.keys(calls).length === 0) return null; - return { - title, - source: String(entry.source || '').trim(), - batchId: String(batchId || entry.batch_id || entry.batchId || '').trim(), - calls, + out += chunk; }; + while (rest) { + const stray = STRAY_CLOSE_RE.exec(rest); + if (stray) { + rest = rest.slice(stray[0].length); + swallow = true; + continue; + } + const open = OPEN_TAG_RE.exec(rest); + if (!open) { + emit(rest); + break; + } + emit(rest.slice(0, open.index)); + const body = rest.slice(open.index + open[0].length); + swallow = true; + if (open[0].endsWith('/>')) { + rest = body; + continue; + } + const close = CLOSE_TAG_RE.exec(body); + if (close) { + rest = body.slice(close.index + close[0].length); + continue; + } + const firstVisible = body.search(/\S/); + const newline = firstVisible >= 0 ? body.indexOf('\n', firstVisible) : -1; + if (newline >= 0) { + rest = body.slice(newline + 1); + continue; + } + rest = ''; + } + return out; } diff --git a/web/src/lib/toolPreamble.test.js b/web/src/lib/toolPreamble.test.js index 46880eaf3..7fbf075d8 100644 --- a/web/src/lib/toolPreamble.test.js +++ b/web/src/lib/toolPreamble.test.js @@ -3,10 +3,10 @@ import { DEFAULT_TOOL_PREAMBLE_STATE, TOOL_PREAMBLE_MODES, buildToolPreambleUpdate, - clampSidecarWaitMs, - normalizeToolPreambleEvent, normalizeToolPreambleState, - toolPreambleFromMetadata, + preambleFromProgress, + preambleFromToolStart, + stripTextPreambleTags, toolPreambleStatusText, } from './toolPreamble.js'; @@ -20,96 +20,81 @@ function run(name, fn) { } } -// 触发场景:设置页渲染三选一。 -// 期望行为:顺序固定为 提示驱动 / 推理服务内置摘要 / 旁路模型摘要(默认选第一项), -// 每项都带标签、一句摘要和圈圈问号的详细说明;默认状态是关闭 + prompt。 -run('三选一的顺序、文案与默认值', () => { - assert.deepEqual(TOOL_PREAMBLE_MODES.map((entry) => entry.id), ['prompt', 'reasoning', 'sidecar']); +// 触发场景:设置页渲染二选一。 +// 期望行为:顺序固定为 提示驱动 / 推理服务内置摘要(默认选第一项),每项都带标签、 +// 一句摘要和圈圈问号的详细说明;提示驱动的说明写的是 标签,不再 +// 提「参数」;默认状态是关闭 + prompt。旁路模型摘要已被砍掉。 +run('二选一的顺序、文案与默认值', () => { + assert.deepEqual(TOOL_PREAMBLE_MODES.map((entry) => entry.id), ['prompt', 'reasoning']); for (const entry of TOOL_PREAMBLE_MODES) { assert.ok(entry.label.length > 0, entry.id); assert.ok(entry.summary.length > 0, entry.id); - assert.ok(entry.help.length > 40, `${entry.id} 的说明要足够详细`); + assert.ok(entry.help.length > 40, entry.id); } - assert.equal(DEFAULT_TOOL_PREAMBLE_STATE.enabled, false); - assert.equal(DEFAULT_TOOL_PREAMBLE_STATE.mode, 'prompt'); + assert.ok(TOOL_PREAMBLE_MODES[0].help.includes('text_preamble')); + assert.ok(!TOOL_PREAMBLE_MODES[0].help.includes('参数')); + assert.deepEqual(DEFAULT_TOOL_PREAMBLE_STATE, { enabled: false, mode: 'prompt' }); }); -// 触发场景:GET /api/config/tool-preamble 返回完整 / 残缺 / 非法的响应。 -// 期望行为:完整响应逐字段映射;非法 mode 退回 prompt;sidecar_model 不在 -// saved_models 里时清空(下拉框不会出现选不到的值);sidecar_wait_ms 越界被 clamp; -// 空响应退回默认。 -run('normalizeToolPreambleState 映射与兜底', () => { - const full = normalizeToolPreambleState({ - enabled: true, mode: 'sidecar', sidecar_model: 'fast', sidecar_wait_ms: 800, - saved_models: ['main', 'fast'], - }); - assert.deepEqual(full, { - enabled: true, mode: 'sidecar', sidecarModel: 'fast', sidecarWaitMs: 800, savedModels: ['main', 'fast'], - }); - - const odd = normalizeToolPreambleState({ - enabled: 'true', mode: 'auto', sidecar_model: 'missing', sidecar_wait_ms: 99999, saved_models: ['main'], - }); - assert.deepEqual(odd, { - enabled: false, mode: 'prompt', sidecarModel: '', sidecarWaitMs: 15000, savedModels: ['main'], - }); - - assert.deepEqual(normalizeToolPreambleState(null), { - enabled: false, mode: 'prompt', sidecarModel: '', sidecarWaitMs: 2000, savedModels: [], - }); -}); - -// 触发场景:组件只改了一个字段就保存。 -// 期望行为:PUT body 只带出现的键(patch 语义);非法 mode 被丢弃而不是发给后端; -// sidecar 字段规整(去空白、clamp)。 -run('buildToolPreambleUpdate 只带出现的键', () => { - assert.deepEqual(buildToolPreambleUpdate({ enabled: true }), { enabled: true }); - assert.deepEqual(buildToolPreambleUpdate({ mode: 'bogus' }), {}); +// 触发场景:GET 响应字段缺失 / 类型不对 / 旧版本还带 sidecar 字段。 +// 期望行为:退回默认值;非法 mode(含已废弃的 sidecar)退回 prompt;多余字段丢弃。 +run('normalizeToolPreambleState:缺省、非法与旧字段', () => { + assert.deepEqual(normalizeToolPreambleState(null), { enabled: false, mode: 'prompt' }); + assert.deepEqual(normalizeToolPreambleState({ enabled: 'yes', mode: 'auto' }), { enabled: false, mode: 'prompt' }); assert.deepEqual( - buildToolPreambleUpdate({ mode: 'sidecar', sidecarModel: ' fast ', sidecarWaitMs: '99999' }), - { mode: 'sidecar', sidecar_model: 'fast', sidecar_wait_ms: 15000 }, + normalizeToolPreambleState({ enabled: true, mode: 'sidecar', sidecar_model: 'fast', sidecar_wait_ms: 500 }), + { enabled: true, mode: 'prompt' }, ); - assert.equal(clampSidecarWaitMs(-5), 0); - assert.equal(clampSidecarWaitMs('abc'), 2000); + assert.deepEqual(normalizeToolPreambleState({ enabled: true, mode: 'reasoning' }), { enabled: true, mode: 'reasoning' }); }); -// 触发场景:行卡片右侧的「当前:…」文案。 -// 期望行为:关闭 → 未启用;开启显示模式名;旁路模式附模型名或「沿用会话模型」。 -run('toolPreambleStatusText', () => { - assert.equal(toolPreambleStatusText({ enabled: false, mode: 'sidecar' }), '未启用'); +// 触发场景:组件只改了部分字段。期望行为:PUT body 只带出现的键(patch 语义), +// 非法 mode 不带出;行卡片状态文案关闭时是「未启用」,开启时是模式名。 +run('buildToolPreambleUpdate 与状态文案', () => { + assert.deepEqual(buildToolPreambleUpdate({ enabled: true }), { enabled: true }); + assert.deepEqual(buildToolPreambleUpdate({ mode: 'reasoning' }), { mode: 'reasoning' }); + assert.deepEqual(buildToolPreambleUpdate({ mode: 'sidecar' }), {}); + assert.equal(toolPreambleStatusText({ enabled: false, mode: 'prompt' }), '未启用'); + assert.equal(toolPreambleStatusText({ enabled: true, mode: 'prompt' }), '提示驱动'); assert.equal(toolPreambleStatusText({ enabled: true, mode: 'reasoning' }), '推理服务内置摘要'); - assert.equal(toolPreambleStatusText({ enabled: true, mode: 'sidecar', sidecarModel: 'fast' }), '旁路模型摘要 · fast'); - assert.equal(toolPreambleStatusText({ enabled: true, mode: 'sidecar', sidecarModel: '' }), '旁路模型摘要 · 沿用会话模型'); }); -// 触发场景:daemon 的 tool_preamble 事件 payload。 -// 期望行为:标题与 id 齐全时归一化(batch_id 缺省取第一个 id);缺标题或缺 id 视为无效。 -run('normalizeToolPreambleEvent', () => { +// 触发场景:tool_start / agent_progress 带前言字段。期望行为:归一化成 {title, source, +// kind};kind 只认 read / write(大小写不敏感),其它当空;没有标题返回 null。 +run('preambleFromToolStart / preambleFromProgress', () => { assert.deepEqual( - normalizeToolPreambleEvent({ title: ' Reading registry sections ', source: 'reasoning', tool_call_ids: ['c1', 'c2'], late: false }), - { title: 'Reading registry sections', source: 'reasoning', batchId: 'c1', ids: ['c1', 'c2'], late: false }, + preambleFromToolStart({ preamble: ' Reading the loader ', preamble_source: 'prompt', preamble_kind: 'READ' }), + { title: 'Reading the loader', source: 'prompt', kind: 'read' }, ); - assert.equal(normalizeToolPreambleEvent({ title: 'x', source: 'sidecar', batch_id: 'c9', tool_call_ids: ['c9'], late: true }).late, true); - assert.equal(normalizeToolPreambleEvent({ title: '', tool_call_ids: ['c1'] }), null); - assert.equal(normalizeToolPreambleEvent({ title: 'x', tool_call_ids: [] }), null); - assert.equal(normalizeToolPreambleEvent(null), null); -}); - -// 触发场景:REST 历史里的 assistant 消息 metadata。 -// 期望行为:批次标题 title 或逐调用 calls 任一存在就返回结构(calls 里的空值被剔除); -// batchId 优先取调用方传入的第一个 tool_call_id,否则取 metadata 里的 batch_id; -// 两者都没有则 null。 -run('toolPreambleFromMetadata', () => { assert.deepEqual( - toolPreambleFromMetadata({ tool_preamble: { title: 'Checking loader', source: 'reasoning' } }, 'c1'), - { title: 'Checking loader', source: 'reasoning', batchId: 'c1', calls: {} }, + preambleFromToolStart({ preamble: 'x', preamble_source: 'reasoning', preamble_kind: 'verify' }), + { title: 'x', source: 'reasoning', kind: '' }, ); + assert.equal(preambleFromToolStart({ preamble: ' ' }), null); + assert.equal(preambleFromToolStart({}), null); assert.deepEqual( - toolPreambleFromMetadata({ tool_preamble: { source: 'prompt', calls: { c1: ' Listing files ', c2: '' } } }), - { title: '', source: 'prompt', batchId: '', calls: { c1: 'Listing files' } }, + preambleFromProgress({ phase: 'preamble', label: 'x', preamble: { title: 'Editing', source: 'prompt', kind: 'write' } }), + { title: 'Editing', source: 'prompt', kind: 'write' }, + ); + assert.equal(preambleFromProgress({ phase: 'reasoning', label: '正在推理' }), null); +}); + +// 触发场景:历史消息 / message 事件里的 assistant 正文带 标签 +// (落盘正文保留原文,是有意的)。期望行为:标签整段去掉,标签后紧跟的空行也去掉; +// 没写 type、缺闭合(到行尾)、自闭合、孤立闭合标签、大小写都按 daemon 同款规则处理; +// 没有标签的文本逐字节原样返回(含前导空白)。 +run('stripTextPreambleTags 与 daemon 同款规则', () => { + assert.equal(stripTextPreambleTags(' plain\ntext '), ' plain\ntext '); + assert.equal(stripTextPreambleTags(''), ''); + assert.equal(stripTextPreambleTags(null), ''); + assert.equal( + stripTextPreambleTags('Reading A\n\nFirst.\n\nEditing B\n\nSecond.'), + 'First.\n\nSecond.', ); - assert.equal(toolPreambleFromMetadata({ tool_preamble: { title: 'x', batch_id: 'c7' } }).batchId, 'c7'); - assert.equal(toolPreambleFromMetadata({ tool_preamble: { title: '', calls: {} } }), null); - assert.equal(toolPreambleFromMetadata({}), null); - assert.equal(toolPreambleFromMetadata(null), null); + assert.equal(stripTextPreambleTags('Reading A\n\n'), ''); + assert.equal(stripTextPreambleTags('No close\nVisible'), 'Visible'); + assert.equal(stripTextPreambleTags('Reading A\n\n\nText'), 'Text'); + assert.equal(stripTextPreambleTags('\nHi'), 'Hi'); + assert.equal(stripTextPreambleTags('x\nHi'), 'Hi'); + assert.equal(stripTextPreambleTags('a < b, , y'), 'a < b, , y'); }); diff --git a/web/src/lib/transcriptProjection.js b/web/src/lib/transcriptProjection.js index 4b19a9943..cc0e52915 100644 --- a/web/src/lib/transcriptProjection.js +++ b/web/src/lib/transcriptProjection.js @@ -34,30 +34,17 @@ function isToolItem(item) { return item?.kind === 'tool'; } -// 工具前言(add-tool-preamble):一个工具调用的前言。实时路径挂在 -// tool.preamble(reducer 从 tool_start.preamble 或 tool_preamble 事件打标), -// 历史路径挂在 msg 的 metadata.tool_preamble:批次标题 `title`,或参数模式的 -// 逐调用 `calls[tool_call_id]`(tool_call 包装项 / legacy 结果按自己的 id 取)。 +// 工具前言(add-tool-preamble):一个工具调用沿用的阶段前言。reducer 从 +// tool_start.preamble 挂到 tool.preamble;历史里的工具项没有它(落定后不显示, +// 这是用户决定)。kind(read / write)只透传,给以后的效果留位。 function preambleOfItem(item) { - if (isToolItem(item)) { - const p = item.tool?.preamble; - if (!p || typeof p !== 'object') return null; - const title = String(p.title || '').trim(); - return title - ? { title, source: String(p.source || ''), batchId: String(p.batchId || '') } - : null; - } - if (item?.kind === 'msg') { - const p = item.metadata?.tool_preamble; - if (!p || typeof p !== 'object') return null; - const calls = p.calls && typeof p.calls === 'object' && !Array.isArray(p.calls) ? p.calls : null; - const ownTitle = calls ? String(calls[toolCallIdForItem(item)] || '').trim() : ''; - const title = ownTitle || String(p.title || '').trim(); - return title - ? { title, source: String(p.source || ''), batchId: String(p.batch_id || p.batchId || '') } - : null; - } - return null; + if (!isToolItem(item)) return null; + const p = item.tool?.preamble; + if (!p || typeof p !== 'object') return null; + const title = String(p.title || '').trim(); + return title + ? { title, source: String(p.source || ''), kind: String(p.kind || '') } + : null; } // 实时分组的前言 = 正在运行的最新一个工具的前言;工具都跑完(模型在想下一步) @@ -809,8 +796,6 @@ function makeLegacyInvocationItem(call, result, betweenItems) { const toolIndex = call?.toolIndex ?? call?.tool_index ?? result?.toolIndex ?? result?.tool_index ?? null; const ts = itemTimestamp(call) || itemTimestamp(result); - // 工具前言:legacy 包装归并后标题要跟着走,否则历史里的批次分组会丢标题。 - const legacyPreamble = preambleOfItem(call) || preambleOfItem(result); return { kind: 'tool', id: result?.id ?? call?.id ?? `legacy-tool-${toolCallId || ts || 'unknown'}`, @@ -841,7 +826,6 @@ function makeLegacyInvocationItem(call, result, betweenItems) { attachments: [], metadata, askUserQuestionResult: null, - ...(legacyPreamble ? { preamble: legacyPreamble } : {}), }, }; } diff --git a/web/src/lib/transcriptProjectionToolPreamble.test.js b/web/src/lib/transcriptProjectionToolPreamble.test.js index e2da0b11d..780a7aab1 100644 --- a/web/src/lib/transcriptProjectionToolPreamble.test.js +++ b/web/src/lib/transcriptProjectionToolPreamble.test.js @@ -15,10 +15,6 @@ function user(id, content = 'do work') { return { kind: 'msg', id, role: 'user', content, ts: id * 1000, messageId: `u-${id}` }; } -function assistant(id, content, extra = {}) { - return { kind: 'msg', id, role: 'assistant', content, ts: id * 1000, ...extra }; -} - function tool(id, { name = 'file_read', object = `file-${id}.txt`, isDone = true, preamble = null, toolCallId = `call-${id}` } = {}) { return { kind: 'tool', @@ -37,104 +33,67 @@ function tool(id, { name = 'file_read', object = `file-${id}.txt`, isDone = true }; } -function wrapper(id, role, content, extra = {}) { - return { kind: 'msg', id, role, content, ts: id * 1000, ...extra }; -} - const LIVE = { deferTrailingToolSummary: true, ensureLiveActivity: true, liveTurnId: 't1' }; -const A = { title: 'Reading registry sections', source: 'prompt', batchId: '' }; -const B = { title: 'Checking the expert loader', source: 'prompt', batchId: '' }; - -// 触发场景:实时回合里前一个工具(带前言 A)已跑完,当前工具(带前言 B)在跑。 -// 期望行为:仍然只有一条 activity_summary(不按前言拆成多行 —— 这正是用户 -// 反馈「一堆提示没折叠到一起」要修的),它的标题是正在运行那个工具的前言 B, -// 并带 preamble 字段供组件压过阶段文案 / 并行计数;id 与原来一致。 -run('实时:正在运行工具的前言成为活动行标题,不按批次拆行', () => { - const items = [user(1), tool(2, { preamble: A }), tool(3, { preamble: B, isDone: false })]; - const out = projectCollapsedTranscriptItems(items, LIVE); - assert.deepEqual(out.map((item) => item.kind), ['msg', 'activity_summary']); - assert.equal(out[1].live, true); - assert.equal(out[1].id, 'activity:turn:t1:0'); - assert.equal(out[1].title, B.title); - assert.deepEqual(out[1].preamble, B); - assert.deepEqual(out[1].coveredItemIds, [2, 3]); -}); - -// 触发场景:工具都跑完了,模型正在想下一步(没有正在运行的工具)。 -// 期望行为:活动行不再沿用上一步的前言(preamble 字段缺失,标题退回默认逻辑), -// 组件层因此会显示 agent_progress 的阶段文案而不是过时的前言。 -run('实时:没有正在运行的工具时不沿用旧前言', () => { - const items = [user(1), tool(2, { preamble: A })]; - const out = projectCollapsedTranscriptItems(items, LIVE); - assert.equal(out[1].live, true); - assert.equal(Object.prototype.hasOwnProperty.call(out[1], 'preamble'), false); - assert.notEqual(out[1].title, A.title); - assert.equal(__test__.liveToolPreamble([items[1]]), null); -}); +const SETTLED = { deferTrailingToolSummary: false }; -// 触发场景:回合结束(末尾是最终 assistant 正文),两个工具各有自己的前言。 -// 期望行为:外层「已处理 …」,展开后只有一条按工具统计的模板汇总,不按前言 -// 拆成多组,也没有 preamble 字段 —— 落定后的记录与功能关闭时形态一致。 -run('落定回合:已处理里只有一条模板汇总', () => { - const items = [user(1), tool(2, { preamble: A }), tool(3, { preamble: B }), assistant(4, 'Done.')]; - const out = projectCollapsedTranscriptItems(items, { deferTrailingToolSummary: false }); - assert.deepEqual(out.map((item) => item.kind), ['msg', 'activity_summary', 'msg']); - assert.equal(out[1].mode, 'processed'); - assert.ok(out[1].title.startsWith('已处理'), out[1].title); - assert.equal(out[1].detailItems.length, 1); - assert.equal(out[1].detailItems[0].title, __test__.summarizeToolItems([items[1], items[2]])); - assert.equal(Object.prototype.hasOwnProperty.call(out[1].detailItems[0], 'preamble'), false); +// 触发场景:实时回合里前一个工具(前言 A)已完成、当前工具(前言 B)在跑。 +// 期望行为:仍只有一行 activity_summary(不按前言拆组 —— 拆成一摞标题行是被用户 +// 否掉的那版),标题是正在运行的工具的前言 B,kind 随之透传。 +run('实时行标题 = 正在运行工具的前言,不拆组', () => { + const items = [ + user(1), + tool(2, { preamble: { title: 'Phase A', source: 'prompt', kind: 'read' } }), + tool(3, { isDone: false, preamble: { title: 'Phase B', source: 'prompt', kind: 'write' } }), + ]; + const projected = projectCollapsedTranscriptItems(items, LIVE); + assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary']); + assert.equal(projected[1].title, 'Phase B'); + assert.deepEqual(projected[1].preamble, { title: 'Phase B', source: 'prompt', kind: 'write' }); + assert.equal(projected[1].collapsedItems.length, 2); }); -// 触发场景:功能关闭 —— 没有任何条目带前言。 -// 期望行为:投影与改动前完全一致:单条 activity_summary、模板汇总标题、无 preamble。 -run('没有前言时投影保持原状', () => { - const items = [user(1), tool(2), tool(3, { isDone: false })]; - const out = projectCollapsedTranscriptItems(items, LIVE); - assert.deepEqual(out.map((item) => item.kind), ['msg', 'activity_summary']); - assert.equal(out[1].id, 'activity:turn:t1:0'); - assert.equal(Object.prototype.hasOwnProperty.call(out[1], 'preamble'), false); - assert.equal(out[1].live, true); +// 触发场景:工具都跑完了,模型在想下一步(实时行仍在)。 +// 期望行为:不沿用旧前言 —— 标题退回按工具统计的模板 / 阶段文案,preamble 字段不出现。 +run('工具都完成后不沿用旧前言', () => { + const items = [ + user(1), + tool(2, { preamble: { title: 'Phase A', source: 'prompt', kind: 'read' } }), + ]; + const projected = projectCollapsedTranscriptItems(items, LIVE); + assert.equal(projected[1].kind, 'activity_summary'); + assert.notEqual(projected[1].title, 'Phase A'); + assert.equal(projected[1].preamble, undefined); }); -// 触发场景:参数模式的历史 —— tool_call 包装消息带 metadata.tool_preamble.calls -// ({tool_call_id: 前言}),结果消息是 legacy 文本(没归并成结构化工具项)。 -// 期望行为:preambleOfItem 按包装项自己的 tool_call_id 从 calls 取前言;归并出的 -// legacy 工具项各带各的前言,不会串。 -run('历史:tool_call 包装按自己的 id 取逐调用前言', () => { - const calls = { c1: 'Listing files', c2: 'Reading config' }; - const items = [ +// 触发场景:回合落定后(非实时)投影带前言的工具项。 +// 期望行为:与没有前言时逐项一致 —— 落定后的记录不显示前言(用户决定)。 +run('落定后的投影与无前言时一致', () => { + const withPreamble = [ user(1), - wrapper(2, 'tool_call', '[Tool: bash] {"cmd":"ls"}', { - toolCallId: 'c1', - metadata: { tool_call_id: 'c1', tool_preamble: { source: 'prompt', calls } }, - }), - wrapper(3, 'tool_call', '[Tool: file_read] {"file_path":"cfg"}', { - toolCallId: 'c2', - metadata: { tool_call_id: 'c2', tool_preamble: { source: 'prompt', calls } }, - }), - wrapper(4, 'tool', 'ok', { toolCallId: 'c1', metadata: { tool_call_id: 'c1' } }), - wrapper(5, 'tool', 'cfg', { toolCallId: 'c2', metadata: { tool_call_id: 'c2' } }), + tool(2, { preamble: { title: 'Phase A', source: 'prompt', kind: 'read' } }), + tool(3, { preamble: { title: 'Phase A', source: 'prompt', kind: 'read' } }), ]; - assert.equal(__test__.preambleOfItem(items[1]).title, 'Listing files'); - assert.equal(__test__.preambleOfItem(items[2]).title, 'Reading config'); - const legacy = __test__.normalizeToolInvocationItems(items).filter((item) => item.kind === 'tool'); - assert.equal(legacy.length, 2); - assert.deepEqual(legacy.map((item) => __test__.preambleOfItem(item)?.title), ['Listing files', 'Reading config']); + const without = [user(1), tool(2), tool(3)]; + const a = projectCollapsedTranscriptItems(withPreamble, SETTLED); + const b = projectCollapsedTranscriptItems(without, SETTLED); + assert.deepEqual(a.map((item) => [item.kind, item.title]), b.map((item) => [item.kind, item.title])); + for (const item of a) assert.equal(item.preamble, undefined); }); -// 触发场景:批次标题模式(reasoning / sidecar)的老会话历史,包装 metadata 只有 title。 -// 期望行为:归并后的 legacy 工具项沿用批次标题。 -run('历史:批次标题模式的 legacy 包装沿用标题', () => { +// 触发场景:preambleOfItem / liveToolPreamble 的边界 —— 非工具项、空标题、 +// 没有 kind。期望行为:非工具项与空标题返回 null;kind 缺省为空串;实时前言取 +// 最后一个正在运行且带前言的工具。 +run('preambleOfItem / liveToolPreamble 边界', () => { + const { preambleOfItem, liveToolPreamble } = __test__; + assert.equal(preambleOfItem(user(1)), null); + assert.equal(preambleOfItem(tool(2, { preamble: { title: ' ' } })), null); + assert.deepEqual(preambleOfItem(tool(2, { preamble: { title: 'X', source: 'reasoning' } })), + { title: 'X', source: 'reasoning', kind: '' }); const items = [ - user(1), - wrapper(2, 'tool_call', '[Tool: bash] {"cmd":"ls"}', { - toolCallId: 'c1', - metadata: { tool_call_id: 'c1', tool_preamble: { title: 'Listing files', source: 'reasoning', batch_id: 'c1' } }, - }), - wrapper(3, 'tool', 'ok', { toolCallId: 'c1', metadata: { tool_call_id: 'c1' } }), + tool(1, { isDone: false, preamble: { title: 'first', source: 'prompt', kind: 'read' } }), + tool(2, { isDone: false }), + tool(3, { isDone: true, preamble: { title: 'done one', source: 'prompt', kind: 'read' } }), ]; - const legacy = __test__.normalizeToolInvocationItems(items).find((item) => item.kind === 'tool'); - assert.ok(legacy); - assert.deepEqual(__test__.preambleOfItem(legacy), { title: 'Listing files', source: 'reasoning', batchId: 'c1' }); + assert.equal(liveToolPreamble(items).title, 'first'); + assert.equal(liveToolPreamble([tool(4)]), null); }); From d607cc8be5b80355aaf2c090f0a4e228c2df53ad Mon Sep 17 00:00:00 2001 From: Trae User Date: Thu, 24 Sep 2026 16:54:39 +0800 Subject: [PATCH 113/126] fix(question): prevent AskUserQuestion from resetting answers on re-render The picker's reset effect depended on the `questions` array, which is rebuilt whenever the request object reference changes. App recreated the visible question object on every render (inline spread, not memoized), so any unrelated re-render while the question was pending reset the answers to their initial state and jumped back to the first question. - Gate the reset on requestId identity via a ref (reset only on new request) - Memoize visibleQuestionReq in App to avoid recreating the object - Add a regression test for same-request re-renders - Update the inline-permission architecture assertion to verify the permission-over-question intent instead of the old syntax --- web/src/App.jsx | 21 +++++++------- web/src/components/QuestionPicker.jsx | 6 ++++ .../lib/inlinePermissionArchitecture.test.js | 5 +++- web/src/lib/questionPickerInteraction.test.js | 28 +++++++++++++++++++ 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/web/src/App.jsx b/web/src/App.jsx index c08f01f21..6938fe37a 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -2072,17 +2072,16 @@ export function App() { const sidebarCollapsed = view !== 'single' || (projectSidebarCollapsed && !guidedTourPreparing && !guidedTourRun); - const visibleQuestionReq = !visiblePermissionUnresolved - ? (() => { - const request = visibleQuestionRequest(questionReqs, activeId, permissionOwnership); - return request - ? { - ...request, - origin_label: questionOriginLabel(request, permissionOwnership), - } - : null; - })() - : null; + const visibleQuestionReq = useMemo(() => { + if (visiblePermissionUnresolved) return null; + const request = visibleQuestionRequest(questionReqs, activeId, permissionOwnership); + return request + ? { + ...request, + origin_label: questionOriginLabel(request, permissionOwnership), + } + : null; + }, [visiblePermissionUnresolved, questionReqs, activeId, permissionOwnership]); const nativeSurfacesVisible = !showSettings && !showFeedback && !searchOpen diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index f1981da8c..53fc0045c 100644 --- a/web/src/components/QuestionPicker.jsx +++ b/web/src/components/QuestionPicker.jsx @@ -70,8 +70,14 @@ export function QuestionPicker({ request, onResolve, originLabel = '', className const copiedTimerRef = useRef(null); // 记录 Esc「取消选中」与「拒绝作答」之间的连按窗口。 const escTimerRef = useRef(null); + // 记录上一次执行重置的请求 id:只有切换到新请求才重置; + // 同一请求因重渲染产生新的 questions 引用时,不清空答案、不跳回第一题。 + const lastResetRequestIdRef = useRef(null); useEffect(() => { + // requestId 未变化(首次挂载除外)说明是同一请求的重渲染,保留已答内容。 + if (lastResetRequestIdRef.current === normalized.requestId) return; + lastResetRequestIdRef.current = normalized.requestId; setAnswers(makeInitialAnswers(questions)); setCurrentIndex(0); setFocusIndex(-1); diff --git a/web/src/lib/inlinePermissionArchitecture.test.js b/web/src/lib/inlinePermissionArchitecture.test.js index 0f828bf95..656fb59e9 100644 --- a/web/src/lib/inlinePermissionArchitecture.test.js +++ b/web/src/lib/inlinePermissionArchitecture.test.js @@ -88,7 +88,10 @@ run('App reconciles server close events and sends decisions to the request sessi run('permission is conversation-scoped and is not a global focus/search/tour blocker', () => { const app = source('App.jsx'); assert.match(app, /visiblePermissionRequests\(permReqs, activeId, permissionOwnership\)/); - assert.match(app, /const visibleQuestionReq = !visiblePermissionUnresolved/); + assert.match(app, /visibleQuestionRequest\(questionReqs, activeId, permissionOwnership\)/); + // 权限未解决时问题必须让位(permission 优先于 question);memoize 后以提前 return 表达。 + assert.match(app, /const visibleQuestionReq = useMemo\(/); + assert.match(app, /if \(visiblePermissionUnresolved\) return null/); assert.match(app, /permissionOpen: false/); const tourBlock = between(app, 'const guidedTourBlocked', 'useEffect(() => initInactiveSelection'); diff --git a/web/src/lib/questionPickerInteraction.test.js b/web/src/lib/questionPickerInteraction.test.js index 5a5c65f47..7caa6ccf5 100644 --- a/web/src/lib/questionPickerInteraction.test.js +++ b/web/src/lib/questionPickerInteraction.test.js @@ -219,3 +219,31 @@ run('empty custom input Enter skips locally and only the last question submits', key(picker, 'Enter', { ctrlKey: true }); assert.equal(picker.sent[0].answers[0].not_answered, true); }); + +run('same request re-rendered with a new object reference keeps answers and position', () => { + const picker = harness([q1, q2]); + // 双击第一题第一个选项:选中并本地推进到第二题(daemon first-wins,中途不发送)。 + rows(picker.render())[0].props.onMouseDown(event({ detail: 2 })); + const tabular = (tree) => nodes(tree).find( + (n) => /tabular-nums/.test(n.props?.className || '') && typeof n.props.children === 'string'); + assert.equal(tabular(picker.render()).props.children, '2 / 2'); + // 模拟 App 重渲染:request_id 不变,但传入内容相同、引用全新的 request 对象。 + picker.render({ request_id: 'r1', session_id: 's1', questions: [q1, q2] }); + // 修复前:重置 effect 误把引用变化当作新请求 -> 跳回第一题('1 / 2')并清空答案。 + assert.equal(tabular(picker.render()).props.children, '2 / 2'); + // 回到第一题,断言已选答案内容仍保留(不只是位置没跳)。 + button(picker.render(), '上一题').props.onClick(); + assert.match(rows(picker.render())[0].props.className, /text-accent/); +}); + +run('switching to a new request resets answers and position', () => { + const picker = harness([q1, q2]); + rows(picker.render())[0].props.onMouseDown(event({ detail: 2 })); + const tabular = (tree) => nodes(tree).find( + (n) => /tabular-nums/.test(n.props?.className || '') && typeof n.props.children === 'string'); + assert.equal(tabular(picker.render()).props.children, '2 / 2'); + // 切换到新的提问请求(request_id 变化):必须重置到第一题并清空答案。 + picker.render({ request_id: 'r2', session_id: 's1', questions: [q1, q2] }); + assert.equal(tabular(picker.render()).props.children, '1 / 2'); + assert.doesNotMatch(rows(picker.render())[0].props.className, /text-accent/); +}); From 4111ad0f79ff5a94a5ef31acd7c325f4adb55374 Mon Sep 17 00:00:00 2001 From: Trae User Date: Fri, 25 Sep 2026 10:12:24 +0800 Subject: [PATCH 114/126] test(web): expect 404 when resuming a missing workspace session Commit 38b6af5c added workspace-membership checks to the workspaces resume route, so a missing session short-circuits with 404 before reaching SessionClient. The escaped-exception test still expected 500 for that path and failed CI. Assert 404 for the workspace route; the plain /api/sessions/:id/resume route keeps covering the SESSION_RESUME_FAILED 500 path. --- tests/web/web_server_smoke_test.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 7c0eb9e55..5f9fcabd0 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -3885,8 +3885,8 @@ TEST(WebServerHttp, SessionRoutesReportEscapedExceptionsAsJson500) { const std::vector cases = { {"/api/workspaces/" + hash + "/sessions", "SESSION_CREATE_FAILED", "boom: create"}, {"/api/sessions", "SESSION_CREATE_FAILED", "boom: create"}, - {"/api/workspaces/" + hash + "/sessions/20260911-000000-dead/resume", - "SESSION_RESUME_FAILED", "boom: resume"}, + // 无 workspace 归属前置检查的 resume 直达 SessionClient,ThrowingSessionClient + // 抛异常应被转成带原因的 JSON 500。 {"/api/sessions/20260911-000000-dead/resume", "SESSION_RESUME_FAILED", "boom: resume"}, }; @@ -3902,6 +3902,18 @@ TEST(WebServerHttp, SessionRoutesReportEscapedExceptionsAsJson500) { << c.path << ": " << r.text; EXPECT_EQ(body.value("cwd", ""), fx.cwd) << c.path; } + + // 带 workspace 的 resume 对磁盘上不存在的 session 先做归属校验,直接 404 + // (引入自“无工作区会话恢复”改动),不会到达 SessionClient,因此不抛 500。 + auto missing_resume = cpr::Post( + cpr::Url{fx.url("/api/workspaces/" + hash + "/sessions/20260911-000000-dead/resume")}, + json_header, cpr::Body{R"({})"}); + EXPECT_EQ(missing_resume.status_code, 404) << missing_resume.text; + EXPECT_NE(missing_resume.header["Content-Type"].find("application/json"), std::string::npos) + << missing_resume.header["Content-Type"]; + json missing_body; + ASSERT_NO_THROW(missing_body = json::parse(missing_resume.text)) << missing_resume.text; + EXPECT_EQ(missing_body.value("error", ""), "session not found") << missing_resume.text; } // 场景:没有路由级 try/catch 的 handler(DELETE /api/sessions/:id 直接调 From 40216e0c3b8bcdaf13499901b95fc754e3147afa Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 25 Sep 2026 11:36:49 +0800 Subject: [PATCH 115/126] =?UTF-8?q?fix(tool-preamble):=20=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E5=8F=A5=E7=A4=BA=E4=BE=8B=E6=94=B9=E6=88=90=E5=B8=A6?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=BD=A2=E6=80=81,=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E8=A3=B8=E6=96=87=E6=9C=AC=E7=A4=BA=E4=BE=8B=E5=8E=8B=E8=BF=87?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grok-4.7 在用户会话 20260925-031619-1f93 里 20 步全部照着「Sharing progress updates」 一节的裸文本示例("Checking the test results.")写进度句、零标签。开启 prompt 模式时 该节的 Good 示例改成 …,并明说裸文本 进度句是错误;关闭时逐字节不变。 --- src/prompt/system_prompt.cpp | 37 +++++++++++++------ .../system_prompt_tool_preamble_test.cpp | 21 +++++++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/prompt/system_prompt.cpp b/src/prompt/system_prompt.cpp index 5dcbada96..772e00e26 100644 --- a/src/prompt/system_prompt.cpp +++ b/src/prompt/system_prompt.cpp @@ -354,23 +354,38 @@ std::string build_system_prompt(const ToolExecutor& tools, const std::string& cw << "- Be concise and direct.\n" << "- Do not use emojis unless the user explicitly requests them.\n\n"; + // 工具前言 · 提示驱动(add-tool-preamble)开启时,进度句的示例必须是带标签的 + // 形态。曾经的做法是本节照旧给裸文本示例("Checking the test results.")、只在 + // 下一节追加标签要求,实测 grok-4.7(用户会话 20260925-031619-1f93)20 步全部 + // 照着示例写裸文本进度句、零标签 —— 带具体示例的一节压过了只讲规则的一节。 oss << "# Sharing progress updates\n\n" << "Do not narrate every tool call. During multi-step work, prefer silent " << "batches of tool calls over alternating short text and one tool call. " << "Only emit a progress update when it helps the user understand a " << "long-running transition, a meaningful phase change, or why you are " << "about to perform a non-obvious action. Keep progress updates " - << "**extremely short** - 10 words or fewer:\n\n"; + << "**extremely short** - 10 words or fewer"; + if (prompt_tool_preamble) { + oss << " - and always wrap them in the tag described in the " + << "next section; a progress sentence written as plain text outside the " + << "tag is a mistake"; + } + oss << ":\n\n"; if (file_read_allowed) { oss << " Good: emit several independent `" << file_read_name << "` and available search calls together with no preceding text.\n"; } - oss - << " Good: \"Checking the test results.\"\n" - << " Good: \"Found the issue, fixing now.\"\n"; + if (prompt_tool_preamble) { + oss << " Good: Checking the test results.\n" + << " Good: Found the issue, fixing now.\n"; + } else { + oss << " Good: \"Checking the test results.\"\n" + << " Good: \"Found the issue, fixing now.\"\n"; + } if (file_read_allowed) { - oss << " Bad: \"Let me read this file.\" followed by one `" - << file_read_name + oss << " Bad: \"Let me read this file.\"" + << (prompt_tool_preamble ? " as plain text" : "") + << " followed by one `" << file_read_name << "`, then another progress sentence before the next read.\n"; } oss @@ -382,9 +397,8 @@ std::string build_system_prompt(const ToolExecutor& tools, const std::string& cw << "is complete.\n\n"; if (prompt_tool_preamble) { - // 工具前言 · 提示驱动(add-tool-preamble):模型用 标签 - // 标出「正在做什么」,daemon 流式识别后只进 loading,不进正文 —— 上面 - // 「不要叙述工具调用」的口径原样保留,标签不是叙述。 + // 模型用 标签标出「正在做什么」,daemon 流式识别后只进 + // loading,不进正文 —— 上面「不要叙述工具调用」的口径原样保留,标签不是叙述。 oss << "# Progress preamble\n\n" << "For multi-step tool tasks, emit exactly one short sentence in " << "... (use type=\"write\" for " @@ -393,8 +407,9 @@ std::string build_system_prompt(const ToolExecutor& tools, const std::string& cw << "never tag final answers. The tag is consumed by the UI as a status line " << "and is not shown as prose, so write it in the language of the user's " << "latest message, keep it to one line with no markdown, and put nothing " - << "else in it. It does not replace the rules above: still batch independent " - << "calls in one message and do not narrate tool calls outside the tag.\n\n"; + << "else in it. Every progress update goes inside this tag - never write " + << "one as plain text before a tool call. It does not replace the rules " + << "above: still batch independent calls in one message.\n\n"; } oss << "# Presenting your work and final message\n\n" diff --git a/tests/prompt/system_prompt_tool_preamble_test.cpp b/tests/prompt/system_prompt_tool_preamble_test.cpp index 8ef07a670..9bad25e16 100644 --- a/tests/prompt/system_prompt_tool_preamble_test.cpp +++ b/tests/prompt/system_prompt_tool_preamble_test.cpp @@ -100,6 +100,27 @@ TEST_F(SystemPromptToolPreambleTest, EnabledAddsTextPreambleGuidanceAndKeepsLega EXPECT_EQ(on.find("# Tool call preamble"), std::string::npos); } +// 场景:开关打开时「Sharing progress updates」一节的示例。期望:Good 示例是带标签的 +// 进度句,裸文本进度句的示例不再出现,并明说裸文本进度句是错误;关闭时示例仍是 +// 裸文本(与旧提示逐字节一致)。回归:grok-4.7 在用户会话 20260925-031619-1f93 里 +// 20 步全部照着旧的裸文本示例写进度句、零标签 —— 带示例的一节压过了只讲规则的一节。 +TEST_F(SystemPromptToolPreambleTest, EnabledProgressExamplesAreTagged) { + const std::string on = build(true); + EXPECT_NE(on.find("Good: Checking the test results."), + std::string::npos); + EXPECT_NE(on.find("Good: Found the issue, fixing now."), + std::string::npos); + EXPECT_EQ(on.find("Good: \"Checking the test results.\""), std::string::npos); + EXPECT_NE(on.find("a progress sentence written as plain text outside the tag is a mistake"), + std::string::npos); + EXPECT_NE(on.find("never write one as plain text before a tool call"), std::string::npos); + + const std::string off = build(false); + EXPECT_NE(off.find("Good: \"Checking the test results.\""), std::string::npos); + EXPECT_EQ(off.find("written as plain text outside the tag"), std::string::npos); + EXPECT_EQ(off.find(" Date: Fri, 25 Sep 2026 12:59:42 +0800 Subject: [PATCH 116/126] =?UTF-8?q?fix(tool-preamble):=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=A8=A1=E5=BC=8F=E4=B8=8B=E5=8E=BB=E6=8E=89=E6=97=A7?= =?UTF-8?q?=E7=9A=84=E8=A3=B8=E6=96=87=E6=9C=AC=E8=BF=9B=E5=BA=A6=E5=8F=A5?= =?UTF-8?q?=E4=B8=80=E8=8A=82,Good=20=E7=A4=BA=E4=BE=8B=E6=8C=89=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=A7=84=E5=88=99=E6=94=B9=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「Sharing progress updates」是 2026-06-14(5cc31231)加入的旧节,教模型在工具调用之间写 10 词以内的裸文本进度句。标签模式开启时整节不出,前言段自带按用户规则写的示例 (第一次调用前写下一步 / 阶段变化时写已验证结果 + 下一步,read / write 各一条)与 裸文本 Bad 示例;「批量调用」「别把结论塞进中途消息」保留;关闭时逐字节不变。 --- CLAUDE.md | 2 +- openspec/changes/add-tool-preamble/design.md | 2 +- .../specs/tool-preamble/spec.md | 2 +- openspec/changes/add-tool-preamble/tasks.md | 2 +- src/prompt/system_prompt.cpp | 94 +++++++++---------- .../system_prompt_tool_preamble_test.cpp | 37 +++++--- 6 files changed, 73 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c8fdc9034..6d0ccf58a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,7 +185,7 @@ MCP 工具调用同样响应 abort:`McpManager::invoke` 把阻塞的 JSON-RPC Codex 截图里红框那行 "Reading registry sections" 是 OpenAI 推理摘要的首行加粗,由客户端抠出当状态行;Codex 提示词另有 `Preamble messages`;Gemini CLI 同款抠 `**subject**`。ACECode 做成 设置 > 开发者模式 > 「工具前言」的二选一(默认**关闭**,`config.agent_loop.tool_preamble = {enabled, mode: prompt|reasoning}`),design 与调研表在 openspec/changes/add-tool-preamble。**形态是用户拍板的**:前言只在等待期显示(Web 活动行 / 运行中的工具行 / TUI 等待短语与写工具进度头),落定后不显示;标签留在落盘正文里;未加标签的正文出现即清除;`type=read|write` 只解析透传,界面效果留空。前两版都被否掉:v1「先说一句话再调工具」(文本先变气泡、按批次拆成一摞标题行),v2「每次调用必填 `preamble` 参数」(强迫模型每次填一句,体验差;grok 对可选参数几乎不填、进 required 才填)。 -- **prompt 模式 = `` 标签**。系统提示追加「# Progress preamble」段(用户原话:For multi-step tool tasks, emit exactly one short sentence in `…` (use type="write" for state-changing actions) before the first call and at major phase/plan changes: next step initially, verified result + next step thereafter; never tag final answers),「Do not narrate every tool call」口径原样保留,关闭时逐字节不变(`system_prompt_tool_preamble_test`)。流回调里 `tool_preamble::TextPreambleScanner`([src/tool_preamble/tool_preamble.cpp](src/tool_preamble/tool_preamble.cpp),纯逻辑)把正文增量切成可见文本与前言:标签可能切在任意字节处(尾部是 `` 跳过、大小写不敏感、超过 `kTextPreambleMaxBodyBytes` 未闭合按到此为止、流开头与每个闭合标签后紧跟的空白吞掉(否则界面为 "\n\n" 建空气泡)。**标签总是从可见正文里剥掉**(关闭功能时模型也可能沿习惯打标签),只有开启 prompt 模式才发布成前言。 +- **prompt 模式 = `` 标签**。系统提示追加「# Progress preamble」段(用户原话:For multi-step tool tasks, emit exactly one short sentence in `…` (use type="write" for state-changing actions) before the first call and at major phase/plan changes: next step initially, verified result + next step thereafter; never tag final answers),Good 示例按同一规则示范(第一次调用前写下一步 / 阶段变化时写已验证结果 + 下一步),Bad 示例是裸文本进度句。开启时 2026-06-14(5cc31231)加入的「# Sharing progress updates」整节**不出** —— 它教模型在工具调用之间写 10 词以内的裸文本进度句、Good 示例本身就是裸文本,实测 grok-4.7(用户会话 20260925-031619-1f93)20 步全部照着它写裸文本、零标签,带示例的一节压过了只讲规则的标签要求;「别把结论塞进中途消息」那句与标签无关,挪进前言段保留。关闭时逐字节不变(`system_prompt_tool_preamble_test`)。流回调里 `tool_preamble::TextPreambleScanner`([src/tool_preamble/tool_preamble.cpp](src/tool_preamble/tool_preamble.cpp),纯逻辑)把正文增量切成可见文本与前言:标签可能切在任意字节处(尾部是 `` 跳过、大小写不敏感、超过 `kTextPreambleMaxBodyBytes` 未闭合按到此为止、流开头与每个闭合标签后紧跟的空白吞掉(否则界面为 "\n\n" 建空气泡)。**标签总是从可见正文里剥掉**(关闭功能时模型也可能沿习惯打标签),只有开启 prompt 模式才发布成前言。 - **阶段前言是 AgentLoop 的一条状态**(`phase_preamble_`,受 `tool_preamble_mu_` 保护,工具流式进度可能在工具线程上读):`publish_phase_preamble` 记下它、回调 `on_thinking_title`(TUI 等待短语)、发一条 `agent_progress{phase:"preamble", label}`(独立 phase 键 + force 绕开 750ms 节流,活动行立刻换文案);prompt 模式下非空白可见正文一出现就 `clear_phase_preamble()`;回合结束清空。reasoning 模式的加粗标题 / 首句兜底(`resolve_tool_preamble_for_step`)走同一条状态,但不被正文清除。每条 `agent_progress` 帧都带 `preamble:{title,source,kind}`;每个批次的 `tool_start` 带 `preamble` / `preamble_source` / `preamble_kind`,`model_waiting` / `reasoning` / `tool_planning` / `tool_running` 的 label = 前言(通用文案 / 工具名退到 detail;`model_waiting` 那条在 `emit_agent_progress` 里集中换,否则批次之间 Web 实时行会在「前言 → 正在等待模型响应 → 前言」之间闪动,实测会话 20260925-013722-4299);权限 / 提问 / 压缩 / 重试这些必须被看见的状态不换。 - **落盘与三端剥离**:assistant 正文保留标签原文(模型会模仿自己的历史输出),`metadata.tool_preamble = {source,title,kind}` 只为记录。剥离点:daemon 的 token 流(扫描器)、工具步与文本回合的 assistant Message 帧(`strip_text_preamble_tags`,可见正文为空就不发帧)、TUI `on_message` 与 `session_replay`(整段都是标签不建行)、Web `sessionTranscript.js` 的历史加载与 `message` 事件(`stripTextPreambleTags`,与 C++ 同款规则)。`ToolCallDelta` 不再带参数前缀(参数版遗留已撤)。 - **Web 不按批次拆组**:一段活动一条 `activity_summary`,前言只影响实时行(`liveToolPreamble` = 正在运行的最新工具的前言)与运行中的工具行(`ToolBlock` 的 label);落定后与关闭态同形,工具行悬浮提示也不带前言。`tool_preamble` 事件与 `pendingToolPreambles` 已删,reducer 只认 `tool_start.preamble*` 与 `agent_progress.preamble`。 diff --git a/openspec/changes/add-tool-preamble/design.md b/openspec/changes/add-tool-preamble/design.md index 8cdb76f5f..55743ed33 100644 --- a/openspec/changes/add-tool-preamble/design.md +++ b/openspec/changes/add-tool-preamble/design.md @@ -20,7 +20,7 @@ | v2 | 每个工具定义注入必填 `preamble` 参数,模型在调用参数里填 | 时序问题没了,但等于强迫模型**每次调用**都写一句;grok 对可选参数几乎不填,进 required 才填 —— 用户实测后否掉整套 | | v3(现行) | 正文里的 `…` 标签,只在阶段变化时写 | 模型按阶段而不是按调用写,一次多步任务通常两三句;标签闭合的那一刻就换 loading 文案;不显示在落定的记录里 | -v3 的提示词是用户给的原话:For multi-step tool tasks, emit exactly one short sentence in `...` (use `type="write"` for state-changing actions) before the first call and at major phase/plan changes: next step initially, verified result + next step thereafter; never tag final answers。系统提示只**追加**「# Progress preamble」段,「Do not narrate every tool call / prefer silent batches」原样保留 —— 前言替代的是叙述文本,不是批处理;关闭态逐字节不变(`system_prompt_tool_preamble_test.cpp::DisabledIsByteIdenticalToLegacyPrompt`)。 +v3 的提示词是用户给的原话:For multi-step tool tasks, emit exactly one short sentence in `...` (use `type="write"` for state-changing actions) before the first call and at major phase/plan changes: next step initially, verified result + next step thereafter; never tag final answers。开启时「# Progress preamble」段**替换**掉 2026-06-14(5cc31231)加入的「# Sharing progress updates」整节:那一节教模型在工具调用之间写 10 词以内的裸文本进度句,Good 示例本身就是裸文本,实测 grok-4.7(用户会话 20260925-031619-1f93)20 步全部照着它写裸文本、零标签 —— 带具体示例的一节压过了只讲规则的标签要求。前言段自带按用户规则写的 Good 示例(第一次调用前写下一步 / 阶段变化时写已验证结果 + 下一步)与裸文本 Bad 示例,「批量调用」「别把结论塞进中途消息」两条保留 —— 前言替代的是叙述文本,不是批处理;关闭态逐字节不变(`system_prompt_tool_preamble_test.cpp::DisabledIsByteIdenticalToLegacyPrompt`)。 ## 数据流 diff --git a/openspec/changes/add-tool-preamble/specs/tool-preamble/spec.md b/openspec/changes/add-tool-preamble/specs/tool-preamble/spec.md index 20400a576..49ba0594e 100644 --- a/openspec/changes/add-tool-preamble/specs/tool-preamble/spec.md +++ b/openspec/changes/add-tool-preamble/specs/tool-preamble/spec.md @@ -15,7 +15,7 @@ ### Requirement: 阶段前言的来源 开启后 AgentLoop SHALL 维护一条**阶段前言**状态 `{title, source, kind}`,按 `mode` 建立: -- `prompt`:系统提示 MUST 追加「# Progress preamble」段(要求多步工具任务在第一次调用前与阶段 / 计划变化时用 `…` 写恰好一句,最终回答不打标签;「Do not narrate every tool call」的既有口径保留)。daemon MUST 流式扫描 assistant 正文:标签闭合的那一刻标签正文(规整为单行、截到 200 个 code point)成为阶段前言,`kind` 取 `type` 属性(只认 read / write,其它为空);标签正文 MUST NOT 出现在 `token` 帧 / `message` 帧 / TUI 行 / Web 渲染里。 +- `prompt`:系统提示 MUST 追加「# Progress preamble」段(要求多步工具任务在第一次调用前与阶段 / 计划变化时用 `…` 写恰好一句,最终回答不打标签;Good 示例按同一规则示范,Bad 示例是裸文本进度句;旧的「Sharing progress updates」裸文本进度句一节在开启时 MUST NOT 出现,「批量调用」与「别把结论塞进中途消息」的既有口径保留)。daemon MUST 流式扫描 assistant 正文:标签闭合的那一刻标签正文(规整为单行、截到 200 个 code point)成为阶段前言,`kind` 取 `type` 属性(只认 read / write,其它为空);标签正文 MUST NOT 出现在 `token` 帧 / `message` 帧 / TUI 行 / Web 渲染里。 - `reasoning`:推理内容里第一对闭合 `**…**` 的内文;没有时取首行首句(去掉 Okay, / 好的, 等口头填充),截到 60 个 code point;不足 2 个 code point 视为无标题。 标签识别 MUST 容错:标签切在任意字节处、没写 `type`、缺闭合标签(到行尾为止)、``(跳过)、大小写不敏感、超过 1200 字节未闭合(到此为止)、孤立闭合标签(丢弃);非标签的相似文本(`