diff --git a/assets/seed/MANIFEST.json b/assets/seed/MANIFEST.json index 27da9b1b7..d722dde71 100644 --- a/assets/seed/MANIFEST.json +++ b/assets/seed/MANIFEST.json @@ -1,5 +1,5 @@ { - "bundle_version": "2026-09-28.1", + "bundle_version": "2026-10-04.1", "description": "Versioned default ACECode global Skill, expert, and managed hook seed bundle reconciled at startup.", "skills": [ { @@ -46,9 +46,9 @@ }, { "name": "acecode-desktop-usage", - "source_id": "acecode:acecode-desktop-usage@2026-09-09", + "source_id": "acecode:acecode-desktop-usage@2026-10-04", "relative_path": "acecode/acecode-desktop-usage", - "skill_md_sha256": "5ace6b52f76f9fa95ccc2ec3c72231f9a4b159a5380d10aa2f638bca5a2b4d59" + "skill_md_sha256": "e67f13bd466c274840be46ab48bcffb8b4c06e61012010ab6cb2ae5670a64485" }, { "name": "vision-image-reader", @@ -61,6 +61,12 @@ "source_id": "acecode:ai-theme@2026-09-15.3", "relative_path": "acecode/ai-theme", "skill_md_sha256": "db18caf8b4d9586139cc6eb9e8907cd5a263406acab5cd890a762992762d9014" + }, + { + "name": "scheduled-task", + "source_id": "acecode:scheduled-task@2026-10-04", + "relative_path": "acecode/scheduled-task", + "skill_md_sha256": "89354de4dc007479bf5a39aba0179928f6f53b39ead451fddcdbb5c9a65b691e" } ], "experts": [ diff --git a/assets/seed/seed.version b/assets/seed/seed.version index c20d31c6d..2dc1bd9e9 100644 --- a/assets/seed/seed.version +++ b/assets/seed/seed.version @@ -1 +1 @@ -2026-09-28.1 +2026-10-04.1 diff --git a/assets/seed/skills/acecode/acecode-desktop-usage/SKILL.md b/assets/seed/skills/acecode/acecode-desktop-usage/SKILL.md index ba2b52a93..f85c1b9b5 100644 --- a/assets/seed/skills/acecode/acecode-desktop-usage/SKILL.md +++ b/assets/seed/skills/acecode/acecode-desktop-usage/SKILL.md @@ -225,7 +225,7 @@ Desktop 内置命令菜单只提供一组聚焦的命令: - 界面提供的有效时间范围。 Desktop 定时任务的权限选择少于普通任务,通常只提供默认审批或完全访问。保存后可以启用、 -停用循环,并查看运行记录。除非调度设置明确要求立即运行,否则不要声称保存循环后会马上执行。 +停用定时任务,并查看运行记录。除非调度设置明确要求立即运行,否则不要声称保存定时任务后会马上执行。 ## 使用通知、托盘和更新 diff --git a/assets/seed/skills/acecode/scheduled-task/SKILL.md b/assets/seed/skills/acecode/scheduled-task/SKILL.md new file mode 100644 index 000000000..f488de6a2 --- /dev/null +++ b/assets/seed/skills/acecode/scheduled-task/SKILL.md @@ -0,0 +1,37 @@ +--- +name: scheduled-task +description: 通过自然语言创建 ACECode 内置定时任务。用于定时提醒、每天或每周执行、每隔一段时间检查、重复若干天等请求,以及“智能添加定时任务”入口。只讨论计划或立即执行普通任务时不创建定时任务。 +--- + +# 创建定时任务 + +使用按需开放的内置工具 `create_scheduled_task` 创建任务,结果与页面“手动添加”相同。工具成功返回即完成创建;向用户说明任务、执行时间、时区及下次运行时间后结束本次回复,无需盯着、轮询或等待它执行。 + +## 整理用户意图 + +- 提取任务内容和执行时间。`X点`、`X天` 都是待用户填写的占位符,不能自行猜测或照字面创建。用简短问题补齐,必要时合并询问具体时刻、重复天数和会议内容。 +- 用执行环境当前时间解析“明天”等相对日期;默认沿用 daemon 所在时区,远程连接或用户另指定时区时明确核对。不要硬编码今天、UTC 偏移或猜测 epoch 毫秒值;可用系统时间工具或本地日期计算命令计算。 +- 名称保持简短;`prompt` 是到点要做的事。提醒任务应明确要求提醒用户,而不是让执行任务再次创建定时任务。不要把整段创建对话复制为执行提示词。 +- 未指定模型时省略 `model_name`,沿用手动表单默认模型。纯提醒省略工作区;项目任务使用现有工作区的 `workspace_hash` 与 `workspace_cwd`,可用工作区查询工具核对。`use_worktree` 默认为 false,只在用户要求隔离时开启。 + +## 时间规则 + +`schedule` 复用现有手动表单: + +- 一次性:`kind: "once"`,`once_at_ms` 为执行时刻的 epoch 毫秒。 +- 每天/工作日/每周:`kind: "period"`,`period: "daily" | "workdays" | "weekly"`,提供 `hour`、`minute`;每周另提供 `weekdays`(0 为周日,1 为周一)。 +- 固定间隔:`kind: "interval"`,`interval_value` 为正整数,`interval_unit: "minutes" | "hours" | "days"`;可提供 `anchor_ms`,省略时由工具取当前时间。 +- 可选 `timezone_offset_minutes` 使用固定 UTC 偏移,省略时用 daemon 当前偏移。当前能力不承诺夏令时自动换算。 +- 可选 `valid_from_ms` / `valid_until_ms` 指定有效期,边界包含在内。例如“从明天 9 点起,重复 3 天”使用每天 09:00,开始为明天 09:00,结束为第三天 09:00,确保恰有三次执行机会。留空字段应省略,不传 null。 + +## 创建与权限 + +调用 `create_scheduled_task` 时传 `name`、`prompt`、`schedule` 和必要的可选参数。工具会在保存前询问用户执行权限,默认推荐完全访问权限(YOLO),也可选默认权限。直接使用这次工具内询问,不提前重复询问,也不通过模型参数声称用户已确认。取消、插话或未作答时不会保存,按用户最新意图继续。 + +只有工具返回 `created: true` 才能告知“定时任务已创建”。失败时解释缺失参数、模型/工作区失效或时间冲突;调整后再创建。未提供工具的独立运行环境应说明当前入口不支持,不能伪称已创建,也不修改数据库或改用操作系统计划任务。 + +## 后续执行 + +任务在现有“定时任务”页面查看、修改、停用和删除;到期运行方式由调度器负责,不承诺每次新建或复用聊天。daemon 需运行,离线错过不补跑;同工作区已有定时任务运行时跳过该次。可使用截止时间或手动停用,停止正在执行的任务需打开对应执行会话。 + +普通任务沿用现有完成提示。监控类任务的执行提示词要保留用户的通知意图:无变化时保持安静,只在有实质变化、完成、失败或需要用户处理时报告;不要额外要求每次输出状态。 diff --git a/docs/composer-tag-selection-testing.md b/docs/composer-tag-selection-testing.md index 4cc481c57..0c5b678c9 100644 --- a/docs/composer-tag-selection-testing.md +++ b/docs/composer-tag-selection-testing.md @@ -82,3 +82,11 @@ node scripts/test-composer-completion.mjs - `materialize_context_items` 对 25 MiB 以内的栅格图带回字节;超限或读取失败退回路径引用,SVG、普通文件与文件夹不变。`composerFileIntake` 把带字节的图片条目还原成带来源路径标记的 File 走上传,同一手势里的其它条目照旧插 `@路径`。 - 对话记录中用户消息的图片附件改由气泡上方的缩略图条渲染,不再作为正文里的文件名按钮;只有图片的消息不画空气泡。 - 验证:`pnpm test`、`pnpm build` 通过;`DesktopContextItems.*` 8 项通过;在运行中的 Desktop 页面上确认旧会话的图片消息显示缩略图,并以桩桥接确认截图粘贴与「图片 + 普通文件」混合粘贴的输入框表现。`test-composer-file-drop.mjs` 需要 Playwright,本次未运行。 + +## 2026-10-04:tag 排版与普通方向键 + +此处覆盖前文历史行为:tag 为无背景的主题色文本,与正文使用相同字体、字号、行高和基线,仅稍加粗。选中时蓝底白字。普通左右键不再进入 tag 选择状态,而是一次越过一个 tag;Shift 和鼠标仍可选中,选中后复制、剪切、替换、删除和撤销保持原有行为。 + +- 生产 RichComposer 浏览器回归 89 项、InputBar 候选回归 16 项全部通过,包括连续 tag、空格、段落边界、RTL 和混合选择;定向模型测试、构建、OpenSpec 严格检查与差异检查通过。 +- 本轮全量 `pnpm test` 被当前工作树其它改动的三个未登记静态文案阻断,详见 `openspec/changes/fix-composer-tag-selection/validation.md`。未改动无关文案或翻译目录。 +- 验证平台为 Windows Chromium 生产组件 fixture;未重建或安装 Desktop/WebView2,未在真实输入法候选窗口人工验证。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 2288005a3..b916a3287 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -309,7 +309,7 @@ update their transcript presentation. | PUT | `/api/sessions/:id/permissions` | set session permission mode | | GET | `/api/sessions/:id/model` | read session model state | | POST | `/api/sessions/:id/model` | switch session model | -| POST | `/api/sessions/:id/reasoning` | set or reset the idle session's reasoning effort | +| POST | `/api/sessions/:id/reasoning` | set or reset session reasoning effort for the next model request | | POST | `/api/sessions/:id/model/reload` | force reload the selected saved-model profile for one active session | | POST | `/api/sessions/:id/fork` | fork a transcript prefix | | POST | `/api/sessions/:id/file-checkpoints/:message_id/restore` | restore files to checkpoint | @@ -1494,7 +1494,7 @@ records do not count as transcript messages; assistant (including empty 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 +`metadata.user_aborted: true`, `metadata.turn_id`, 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 @@ -1556,7 +1556,7 @@ returns `409` with the reason; so does posting directly to a mesh sub-agent emits `session_updated {"swarm_mode"}`. See `docs/subagents.md` §9. `client_message_id` is an optional non-empty string (maximum 256 bytes) used by -Desktop queued-input handoff. When accepted, it is preserved as +Desktop ordinary and queued-input handoff. When accepted, it is preserved as `metadata.client_message_id` on the canonical user message so an optimistic local item can reconcile with persistence and WebSocket replay. It does not deduplicate backend execution; callers that omit it retain the existing behavior. @@ -3206,8 +3206,13 @@ are unaffected. The override is persisted, restored on resume and inherited by a fork. An explicit effort takes precedence over the saved reasoning token budget; resetting the override restores that budget as well as the default. -Mutation runs under the worker's idle gate. An active turn or queued input -returns `409 SESSION_BUSY` without changing the selection. Malformed bodies, +Active turns and queued input accept changes. Each model request captures its +own provider snapshot: an in-flight request keeps its existing effort and is +not interrupted; the next request that captures its provider after the update +uses the new effort, including subsequent requests within the same turn. +Multiple successful changes before that boundary use the latest selection. +Failed persistence restores the prior provider and effort before another +request can capture them. Malformed bodies, non-null selections for unsupported models and undeclared efforts return `400 INVALID_REASONING_EFFORT`; unknown sessions return `404 SESSION_NOT_FOUND`, unavailable model profiles @@ -4947,11 +4952,22 @@ All client frames are JSON: | `user_input` | `{session_id,text}` | queues plain user input | | `decision` | `{session_id,request_id,choice}` | responds to permission request; `choice` is `allow`, `deny`, `allow_session`, `allow_scoped`, or `allow_remember` | | `question_answer` | `{session_id,request_id,cancelled,answers}` | responds to AskUserQuestion | -| `abort` | `{session_id}` | aborts current turn | +| `abort` | `{session_id}` | requests cancellation of the current turn; completion is confirmed by terminal events | | `ping` | `{}` | replies `{"type":"pong"}` | `decision` uses `choice`, not `decision`, in the payload. +Sending `abort` does not mean the turn has already ended. Clients keep the +turn busy while showing a stopping state, then settle it on the matching +`busy_changed(false)` or `done` event. Terminal `turn_id` values must not end +a different active turn. The persisted `user_aborted` message confirms the +same termination notice even when tool messages arrive in between; older +records without `turn_id` are matched only within their user-turn boundary. +Queued input remains visible during cancellation. Previously queued messages +pause on a user stop; an explicit send or resume during stopping is honored +after completion. A client that cannot send the stop request must report the +failure and allow retry rather than displaying a completed stop. + For `bash`, `permission_request.args` retains the original tool arguments and adds a server-generated `permission` object: @@ -5179,13 +5195,22 @@ Resize uses `POST /api/pty/:id/resize`, not the WebSocket. --- -## 14. LOOP scheduling +## 14. Scheduled tasks -LOOP (Chinese UI: “循环”) is a daemon-owned scheduler. It persists to +Scheduled tasks (Chinese UI: “定时任务”, internal identifier: LOOP) is a daemon-owned scheduler. It persists to `/scheduled-loops.sqlite3` and continues running without an open browser. Clients configure friendly period/interval/once fields; the compiled schedule expression is internal and is never returned by the API. +The daemon also registers the deferred built-in `create_scheduled_task` tool. +Loading the `scheduled-task` skill exposes it only in that conversation's model +requests. It shares request validation and persistence with the manual form. +The tool asks for execution permission before saving (YOLO is the recommended +choice; `default` is also available). Cancelled, unanswered, or automatically +timed-out choices do not create a task. Success returns `created`, `task`, and +`schedule_summary`; the task includes `id` and `next_run_at_ms`. The creating +conversation can finish immediately; execution follows the scheduler's policy. + Routes (all use the normal daemon auth and CORS rules): | Method | Route | Purpose | diff --git a/docs/help-source/captures.json b/docs/help-source/captures.json index ce52a7e6a..1089e4941 100644 --- a/docs/help-source/captures.json +++ b/docs/help-source/captures.json @@ -457,7 +457,7 @@ }, { "file": "assets/screenshots/schedules-list.jpg", - "description": "循环列表的模板、频率、模型、启用入口与最近运行失败状态;现有循环保持停用", + "description": "定时任务列表的模板、频率、模型、启用入口与最近运行失败状态;现有定时任务保持停用", "app": "process:N:\\Users\\shao\\acecode\\build\\Release\\acecode-desktop.exe", "windowTitle": "ACECode", "version": "0.9.11", @@ -469,7 +469,7 @@ }, { "file": "assets/screenshots/schedule-run-history.jpg", - "description": "循环真实运行记录:所选模型不可用导致停用,以及 daemon 未运行产生的已错过记录", + "description": "定时任务真实运行记录:所选模型不可用导致停用,以及 daemon 未运行产生的已错过记录", "app": "process:N:\\Users\\shao\\acecode\\build\\Release\\acecode-desktop.exe", "windowTitle": "ACECode", "version": "0.9.11", @@ -481,7 +481,7 @@ }, { "file": "assets/screenshots/schedule-create-form.jpg", - "description": "添加循环表单:名称、工作空间、提示词、模型、执行频率和生效日期。此示例尚未保存。", + "description": "添加定时任务表单:名称、工作空间、提示词、模型、执行频率和生效日期。此示例尚未保存。", "app": "process:N:\\Users\\shao\\acecode\\build\\Release\\acecode-desktop.exe", "windowTitle": "ACECode", "version": "0.9.11", diff --git a/docs/help-source/group5.py b/docs/help-source/group5.py index 54cd6732d..d0a0405db 100644 --- a/docs/help-source/group5.py +++ b/docs/help-source/group5.py @@ -30,17 +30,17 @@ '''

全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。

''') ], ["web/src/components/InputBar.jsx", "web/src/components/ComposerSessionControls.jsx", "web/src/components/SubagentPanel.jsx", "src/apps/tui/commands/builtin_commands.cpp", "src/apps/tui/commands/swarm_mode_command.cpp", "src/host/session_host/mesh/mesh_agent_service.cpp", "docs/subagents.md"]), -"schedules": page("把清晰且可重复的任务保存为循环,按周期、间隔或指定时间执行,并通过记录检查每次结果。", [ +"schedules": page("把清晰且可重复的任务保存为定时任务,按周期、间隔或指定时间执行,并通过记录检查每次结果。", [ section("create", "创建与设置执行时间", - '''
  1. 打开侧边栏定时任务,进入“循环”页面,点击添加或选择模板。
  2. 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。
  3. 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。
  4. 选择执行频率,按需设置生效和结束时间。
  5. 保存后核对下一次执行时间和启用状态。
''', + '''

点击智能添加定时任务会打开新建任务,并带上创建定时任务的 skill 和预置文本“我希望在明天X点提醒我参加会议,重复X天”。补齐具体时间和天数后发送;AI 会询问执行权限,默认推荐完全访问权限(YOLO)。创建成功后即可结束聊天,任务在此页面统一管理。

  1. 打开侧边栏定时任务,进入“定时任务”页面,点击手动添加或选择模板。
  2. 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。
  3. 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。
  4. 选择执行频率,按需设置生效和结束时间。
  5. 保存后核对下一次执行时间和启用状态。
''', table(["频率", "设置方式", "适合的例子"], [["周期", "每天、工作日或每周指定日期与时间。", "工作日早上检查仓库待处理事项。"], ["按间隔", "每隔指定分钟、小时或天执行。", "定期检查本地生成结果。"], ["单次", "指定一个日期与时间。", "稍后执行一次已准备好的检查。"]]), - note("保存前检查权限", "循环表单提供默认权限与完全访问权限。当前新建默认可能为完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。"), + note("保存前检查权限", "定时任务表单提供默认权限与完全访问权限。新建默认使用完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。"), '''

提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。

''', - figure("AD-05", "循环创建与执行时间", "展示名称、工作空间、工作树、模型、权限和频率设置,使用一个无敏感数据的只读检查任务。"), - code("检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。", "循环提示词示例")), + figure("AD-05", "定时任务创建与执行时间", "展示名称、工作空间、工作树、模型、权限和频率设置,使用一个无敏感数据的只读检查任务。"), + code("检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。", "定时任务提示词示例")), section("manage", "查看记录、暂停与管理", - '''

列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看循环仍为启用状态。

停用用于暂停后续计划,重新启用后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除循环也会移除其运行记录,保留需要的信息后再删除。

daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。

远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。

''', - figure("AD-06", "循环列表与运行记录", "展示下一次执行、启用或停用按钮和一条真实运行记录,标出状态、原因及查看任务入口。")) + '''

列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。

停用用于暂停后续计划,重新启用后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。

daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。

远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。

''', + figure("AD-06", "定时任务列表与运行记录", "展示下一次执行、启用或停用按钮和一条真实运行记录,标出状态、原因及查看任务入口。")) ], ["web/src/components/LoopPage.jsx", "web/src/lib/loops.js", "src/host/loop/loop_types.hpp", "src/host/loop/loop_scheduler.cpp", "src/host/loop/loop_store.cpp"]), "agent-browser": page("在桌面端把需要处理的网页共享给智能体,让浏览、定位和操作发生在你能看到的页面中。", [ diff --git a/docs/help-source/image-plan.md b/docs/help-source/image-plan.md index c328ba656..58699c8ee 100644 --- a/docs/help-source/image-plan.md +++ b/docs/help-source/image-plan.md @@ -46,8 +46,8 @@ | AD-02 | [专家与专家团](../help/experts.html) | 已实拍 | 高级模式与专家团成员设置 | 高级模式可填写专家定位与能力介绍,并设置 Skill、MCP 和本地工具范围。专家团草稿中可选择成员并指定主理人;这些表单在截图后均已取消。 | | | AD-03 | [蜂群模式与子代理](../help/swarm.html) | 已实拍 | 添加蜂群模式与编写分工草稿 | 从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。 | | | AD-04 | [蜂群模式与子代理](../help/swarm.html) | 占位 | 后台任务与来自子任务的请求 | 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 | 已拍摄蜂群模式入口和草稿,没有为截图启动后台子任务,因此未取得运行分组及来自子任务的操作确认。 | -| AD-05 | [定时与循环任务](../help/schedules.html) | 已实拍 | 循环任务创建表单 | 添加循环时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的循环。 | | -| AD-06 | [定时与循环任务](../help/schedules.html) | 已实拍 | 循环列表与真实运行记录 | 循环列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,循环已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 | | +| AD-05 | [定时任务](../help/schedules.html) | 已实拍 | 定时任务创建表单 | 添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。 | | +| AD-06 | [定时任务](../help/schedules.html) | 已实拍 | 定时任务列表与真实运行记录 | 定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 | | | AD-07 | [Agent 浏览器](../help/agent-browser.html) | 已实拍 | 打开浏览器页签与访问示例页面 | 在详情区域的打开工具菜单选择浏览器,可通过地址栏打开网页。下图为 ACECode Agent 浏览器实际加载的 example.com 页面。 | | | AD-08 | [Agent 浏览器](../help/agent-browser.html) | 占位 | 页面共享与可见操作 | 展示单个页签的共享开关、共享状态,以及智能体操作时的边框或光标反馈,遮挡任何账号信息。 | 已实际使用 Agent 浏览器打开公开页面,但当前运行界面中未取得页签共享开关及智能体操作反馈画面。 | | AD-09 | [消息渠道与远程控制](../help/channels.html) | 占位 | 渠道绑定与远程消息 | 展示 /rc 返回的已连接状态和会话绑定标记,以及一条测试消息的往返;必须遮挡 Token、Webhook 密钥与私人聊天。 | 未进行消息渠道绑定或向外部聊天发送消息,没有可公开的往返记录。 | diff --git a/docs/help-source/images.json b/docs/help-source/images.json index 0e85a4905..be88fbe58 100644 --- a/docs/help-source/images.json +++ b/docs/help-source/images.json @@ -1106,16 +1106,16 @@ } }, { - "article": "定时与循环任务", + "article": "定时任务", "file": "schedules.html", "id": "AD-05", - "title": "循环任务创建表单", - "description": "添加循环时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的循环。", + "title": "定时任务创建表单", + "description": "添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。", "status": "captured", "captures": [ { "file": "assets/screenshots/schedule-create-form.jpg", - "description": "添加循环表单:名称、工作空间、提示词、模型、执行频率和生效日期。此示例尚未保存。", + "description": "添加定时任务表单:名称、工作空间、提示词、模型、执行频率和生效日期。此示例尚未保存。", "app": "process:N:\\Users\\shao\\acecode\\build\\Release\\acecode-desktop.exe", "windowTitle": "ACECode", "version": "0.9.11", @@ -1129,16 +1129,16 @@ ] }, { - "article": "定时与循环任务", + "article": "定时任务", "file": "schedules.html", "id": "AD-06", - "title": "循环列表与真实运行记录", - "description": "循环列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,循环已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。", + "title": "定时任务列表与真实运行记录", + "description": "定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。", "status": "captured", "captures": [ { "file": "assets/screenshots/schedules-list.jpg", - "description": "循环列表的模板、频率、模型、启用入口与最近运行失败状态;现有循环保持停用", + "description": "定时任务列表的模板、频率、模型、启用入口与最近运行失败状态;现有定时任务保持停用", "app": "process:N:\\Users\\shao\\acecode\\build\\Release\\acecode-desktop.exe", "windowTitle": "ACECode", "version": "0.9.11", @@ -1151,7 +1151,7 @@ }, { "file": "assets/screenshots/schedule-run-history.jpg", - "description": "循环真实运行记录:所选模型不可用导致停用,以及 daemon 未运行产生的已错过记录", + "description": "定时任务真实运行记录:所选模型不可用导致停用,以及 daemon 未运行产生的已错过记录", "app": "process:N:\\Users\\shao\\acecode\\build\\Release\\acecode-desktop.exe", "windowTitle": "ACECode", "version": "0.9.11", diff --git a/docs/help-source/screenshot-map.json b/docs/help-source/screenshot-map.json index 7baf8aa22..6f38b834a 100644 --- a/docs/help-source/screenshot-map.json +++ b/docs/help-source/screenshot-map.json @@ -174,13 +174,13 @@ }, "AD-05": { "images": ["schedule-create-form.jpg"], - "title": "循环任务创建表单", - "caption": "添加循环时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的循环。" + "title": "定时任务创建表单", + "caption": "添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。" }, "AD-06": { "images": ["schedules-list.jpg", "schedule-run-history.jpg"], - "title": "循环列表与真实运行记录", - "caption": "循环列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,循环已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。" + "title": "定时任务列表与真实运行记录", + "caption": "定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。" }, "AD-07": { "images": ["desktop-tools-menu.jpg", "agent-browser-page.jpg"], diff --git a/docs/help-source/sources.json b/docs/help-source/sources.json index dce1e0ee6..1ef715afc 100644 --- a/docs/help-source/sources.json +++ b/docs/help-source/sources.json @@ -327,7 +327,7 @@ ] }, { - "article": "定时与循环任务", + "article": "定时任务", "file": "schedules.html", "sources": [ "web/src/components/LoopPage.jsx", diff --git a/docs/help-source/tree.json b/docs/help-source/tree.json index 54bf2b73d..7525b8c48 100644 --- a/docs/help-source/tree.json +++ b/docs/help-source/tree.json @@ -39,7 +39,7 @@ {"title":"进阶使用","pages":[ {"slug":"experts","title":"专家与专家团","sections":["选择与派遣专家","创建专家与组建专家团"]}, {"slug":"swarm","title":"蜂群模式与子代理","sections":["任务分工与并行执行","查看进度与处理操作确认"]}, - {"slug":"schedules","title":"定时与循环任务","sections":["创建与设置执行时间","查看记录、暂停与管理"]}, + {"slug":"schedules","title":"定时任务","sections":["创建与设置执行时间","查看记录、暂停与管理"]}, {"slug":"agent-browser","title":"Agent 浏览器"}, {"slug":"channels","title":"消息渠道与远程控制"}, {"slug":"examples","title":"实战示例","sections":["新项目实战","排查问题与修复缺陷","实现功能与重构代码","审查代码变更"]} diff --git a/docs/help/acemodel.html b/docs/help/acemodel.html index 14bdcbb2c..0d8a091a0 100644 --- a/docs/help/acemodel.html +++ b/docs/help/acemodel.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/agent-browser.html b/docs/help/agent-browser.html index c35302a05..41db2a455 100644 --- a/docs/help/agent-browser.html +++ b/docs/help/agent-browser.html @@ -26,7 +26,7 @@ - + @@ -44,7 +44,7 @@
展示单个页签的共享开关、共享状态,以及智能体操作时的边框或光标反馈,遮挡任何账号信息。

隐藏面板会保留页面状态;关闭页签会释放该页面。遇到操作失败时先检查它是否仍存在、是否共享和是否加载完成,再让智能体继续。

引用页面元素与控制台信息

元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。添加控制台信息时,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。

提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。

让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在设置 > 工具检查。

- + diff --git a/docs/help/api.html b/docs/help/api.html index b35fd8f40..ac6a5150f 100644 --- a/docs/help/api.html +++ b/docs/help/api.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/appearance.html b/docs/help/appearance.html index 96303a6b0..7d3e3c244 100644 --- a/docs/help/appearance.html +++ b/docs/help/appearance.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/architecture.html b/docs/help/architecture.html index 1127438da..5b9e11068 100644 --- a/docs/help/architecture.html +++ b/docs/help/architecture.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index d163e715f..9f99bdb4b 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -1009,28 +1009,28 @@ window.ACECODE_HELP_INDEX = [ "text": "查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。 子任务始终跟随主会话:已结束的任务一直留在面板里,随时可以打开查看,不能单独归档或删除。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" }, { - "page": "定时与循环任务", + "page": "定时任务", "group": "进阶使用", - "title": "定时与循环任务", + "title": "定时任务", "url": "schedules.html", - "description": "把清晰且可重复的任务保存为循环,按周期、间隔或指定时间执行,并通过记录检查每次结果。", - "text": "创建与设置执行时间 打开侧边栏 定时任务 ,进入“循环”页面,点击 添加 或选择模板。 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。 选择执行频率,按需设置生效和结束时间。 保存后核对下一次执行时间和启用状态。 频率 设置方式 适合的例子 周期 每天、工作日或每周指定日期与时间。 工作日早上检查仓库待处理事项。 按间隔 每隔指定分钟、小时或天执行。 定期检查本地生成结果。 单次 指定一个日期与时间。 稍后执行一次已准备好的检查。 保存前检查权限 循环表单提供默认权限与完全访问权限。当前新建默认可能为完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。 提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。 添加循环时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的循环。 Windows 实际界面 · 点击图片查看原图 循环提示词示例 复制 检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。 查看记录、暂停与管理 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看循环仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除循环也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 循环列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,循环已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" + "description": "把清晰且可重复的任务保存为定时任务,按周期、间隔或指定时间执行,并通过记录检查每次结果。", + "text": "创建与设置执行时间 点击 智能添加定时任务 会打开新建任务,并带上创建定时任务的 skill 和预置文本“我希望在明天X点提醒我参加会议,重复X天”。补齐具体时间和天数后发送;AI 会询问执行权限,默认推荐完全访问权限(YOLO)。创建成功后即可结束聊天,任务在此页面统一管理。 打开侧边栏 定时任务 ,进入“定时任务”页面,点击 手动添加 或选择模板。 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。 选择执行频率,按需设置生效和结束时间。 保存后核对下一次执行时间和启用状态。 频率 设置方式 适合的例子 周期 每天、工作日或每周指定日期与时间。 工作日早上检查仓库待处理事项。 按间隔 每隔指定分钟、小时或天执行。 定期检查本地生成结果。 单次 指定一个日期与时间。 稍后执行一次已准备好的检查。 保存前检查权限 定时任务表单提供默认权限与完全访问权限。新建默认使用完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。 提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。 添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。 Windows 实际界面 · 点击图片查看原图 定时任务提示词示例 复制 检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。 查看记录、暂停与管理 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" }, { - "page": "定时与循环任务", + "page": "定时任务", "group": "进阶使用", "title": "创建与设置执行时间", "url": "schedules.html#create", - "description": "创建与设置执行时间 打开侧边栏 定时任务 ,进入“循环”页面,点击 添加 或选择模板。 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。 选择执行频率,按需设置生效", - "text": "创建与设置执行时间 打开侧边栏 定时任务 ,进入“循环”页面,点击 添加 或选择模板。 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。 选择执行频率,按需设置生效和结束时间。 保存后核对下一次执行时间和启用状态。 频率 设置方式 适合的例子 周期 每天、工作日或每周指定日期与时间。 工作日早上检查仓库待处理事项。 按间隔 每隔指定分钟、小时或天执行。 定期检查本地生成结果。 单次 指定一个日期与时间。 稍后执行一次已准备好的检查。 保存前检查权限 循环表单提供默认权限与完全访问权限。当前新建默认可能为完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。 提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。 添加循环时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的循环。 Windows 实际界面 · 点击图片查看原图 循环提示词示例 复制 检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。" + "description": "创建与设置执行时间 点击 智能添加定时任务 会打开新建任务,并带上创建定时任务的 skill 和预置文本“我希望在明天X点提醒我参加会议,重复X天”。补齐具体时间和天数后发送;AI 会询问执行权限,默认推荐完全访问权限(YOLO)。创建成功后即可结束", + "text": "创建与设置执行时间 点击 智能添加定时任务 会打开新建任务,并带上创建定时任务的 skill 和预置文本“我希望在明天X点提醒我参加会议,重复X天”。补齐具体时间和天数后发送;AI 会询问执行权限,默认推荐完全访问权限(YOLO)。创建成功后即可结束聊天,任务在此页面统一管理。 打开侧边栏 定时任务 ,进入“定时任务”页面,点击 手动添加 或选择模板。 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。 选择执行频率,按需设置生效和结束时间。 保存后核对下一次执行时间和启用状态。 频率 设置方式 适合的例子 周期 每天、工作日或每周指定日期与时间。 工作日早上检查仓库待处理事项。 按间隔 每隔指定分钟、小时或天执行。 定期检查本地生成结果。 单次 指定一个日期与时间。 稍后执行一次已准备好的检查。 保存前检查权限 定时任务表单提供默认权限与完全访问权限。新建默认使用完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。 提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。 添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。 Windows 实际界面 · 点击图片查看原图 定时任务提示词示例 复制 检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。" }, { - "page": "定时与循环任务", + "page": "定时任务", "group": "进阶使用", "title": "查看记录、暂停与管理", "url": "schedules.html#manage", - "description": "查看记录、暂停与管理 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看循环仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时", - "text": "查看记录、暂停与管理 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看循环仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除循环也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 循环列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,循环已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" + "description": "查看记录、暂停与管理 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执", + "text": "查看记录、暂停与管理 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" }, { "page": "Agent 浏览器", diff --git a/docs/help/build.html b/docs/help/build.html index 81968fbbb..ede5cb9cc 100644 --- a/docs/help/build.html +++ b/docs/help/build.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/channels.html b/docs/help/channels.html index 59d8c6f2b..684e99965 100644 --- a/docs/help/channels.html +++ b/docs/help/channels.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/cli.html b/docs/help/cli.html index f95051914..a0949983f 100644 --- a/docs/help/cli.html +++ b/docs/help/cli.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/configuration.html b/docs/help/configuration.html index 78ee81dac..3c5879978 100644 --- a/docs/help/configuration.html +++ b/docs/help/configuration.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/connectors.html b/docs/help/connectors.html index ceff0c6a2..ae4210668 100644 --- a/docs/help/connectors.html +++ b/docs/help/connectors.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/context-usage.html b/docs/help/context-usage.html index e38d44a8b..c0c8c898d 100644 --- a/docs/help/context-usage.html +++ b/docs/help/context-usage.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/contributing.html b/docs/help/contributing.html index 823c085be..54dcaecf7 100644 --- a/docs/help/contributing.html +++ b/docs/help/contributing.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/conversation.html b/docs/help/conversation.html index f57e1a39b..e202b8add 100644 --- a/docs/help/conversation.html +++ b/docs/help/conversation.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/daemon.html b/docs/help/daemon.html index 38af14e15..cf0a4f144 100644 --- a/docs/help/daemon.html +++ b/docs/help/daemon.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/desktop.html b/docs/help/desktop.html index 251043e2d..cd19f7acd 100644 --- a/docs/help/desktop.html +++ b/docs/help/desktop.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/examples.html b/docs/help/examples.html index d7bbed75a..5d42a33b8 100644 --- a/docs/help/examples.html +++ b/docs/help/examples.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/experts.html b/docs/help/experts.html index a566e713e..e33a8c952 100644 --- a/docs/help/experts.html +++ b/docs/help/experts.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/extension-development.html b/docs/help/extension-development.html index c6b56b5fe..0835ef890 100644 --- a/docs/help/extension-development.html +++ b/docs/help/extension-development.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/faq.html b/docs/help/faq.html index f3ee609a9..78f29640f 100644 --- a/docs/help/faq.html +++ b/docs/help/faq.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/feedback.html b/docs/help/feedback.html index 387193f17..fd2f5dce3 100644 --- a/docs/help/feedback.html +++ b/docs/help/feedback.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/git.html b/docs/help/git.html index d268a3a4b..34c982dce 100644 --- a/docs/help/git.html +++ b/docs/help/git.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/hooks.html b/docs/help/hooks.html index 1e2d11911..0e7ae1563 100644 --- a/docs/help/hooks.html +++ b/docs/help/hooks.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/index.html b/docs/help/index.html index 1ca6f1dee..6bb02733a 100644 --- a/docs/help/index.html +++ b/docs/help/index.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/installation.html b/docs/help/installation.html index d1dc2c278..cc9cdc067 100644 --- a/docs/help/installation.html +++ b/docs/help/installation.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/lsp.html b/docs/help/lsp.html index 10dc07730..e2cce3467 100644 --- a/docs/help/lsp.html +++ b/docs/help/lsp.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/manage-models.html b/docs/help/manage-models.html index a534f5b4f..e94dba0bc 100644 --- a/docs/help/manage-models.html +++ b/docs/help/manage-models.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/mcp.html b/docs/help/mcp.html index 348548150..e53aecf81 100644 --- a/docs/help/mcp.html +++ b/docs/help/mcp.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/memory.html b/docs/help/memory.html index 3ac5fead2..90e55a91d 100644 --- a/docs/help/memory.html +++ b/docs/help/memory.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/model-basics.html b/docs/help/model-basics.html index c046b4775..2e63a0c70 100644 --- a/docs/help/model-basics.html +++ b/docs/help/model-basics.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/model-capabilities.html b/docs/help/model-capabilities.html index 928821c83..8104a2c5b 100644 --- a/docs/help/model-capabilities.html +++ b/docs/help/model-capabilities.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/network.html b/docs/help/network.html index d848ff9e5..ab766c74e 100644 --- a/docs/help/network.html +++ b/docs/help/network.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/permissions.html b/docs/help/permissions.html index 29401df68..c057ee9a6 100644 --- a/docs/help/permissions.html +++ b/docs/help/permissions.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/project-rules.html b/docs/help/project-rules.html index ac61e0875..4cf76cdbf 100644 --- a/docs/help/project-rules.html +++ b/docs/help/project-rules.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/providers.html b/docs/help/providers.html index 0e13ff37e..886a4e69a 100644 --- a/docs/help/providers.html +++ b/docs/help/providers.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/quick-start.html b/docs/help/quick-start.html index dce11ab2e..a0024650e 100644 --- a/docs/help/quick-start.html +++ b/docs/help/quick-start.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/reference.html b/docs/help/reference.html index c06ad0130..4d3dafeb6 100644 --- a/docs/help/reference.html +++ b/docs/help/reference.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/schedules.html b/docs/help/schedules.html index 6da518be3..d5c9ed806 100644 --- a/docs/help/schedules.html +++ b/docs/help/schedules.html @@ -4,8 +4,8 @@ - - 定时与循环任务 · ACECode 文档 + + 定时任务 · ACECode 文档 @@ -26,7 +26,7 @@ - + @@ -34,18 +34,18 @@
-

定时与循环任务

把清晰且可重复的任务保存为循环,按周期、间隔或指定时间执行,并通过记录检查每次结果。

+

定时任务

把清晰且可重复的任务保存为定时任务,按周期、间隔或指定时间执行,并通过记录检查每次结果。

本页内容
-

创建与设置执行时间

  1. 打开侧边栏定时任务,进入“循环”页面,点击添加或选择模板。
  2. 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。
  3. 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。
  4. 选择执行频率,按需设置生效和结束时间。
  5. 保存后核对下一次执行时间和启用状态。
+

创建与设置执行时间

点击智能添加定时任务会打开新建任务,并带上创建定时任务的 skill 和预置文本“我希望在明天X点提醒我参加会议,重复X天”。补齐具体时间和天数后发送;AI 会询问执行权限,默认推荐完全访问权限(YOLO)。创建成功后即可结束聊天,任务在此页面统一管理。

  1. 打开侧边栏定时任务,进入“定时任务”页面,点击手动添加或选择模板。
  2. 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。
  3. 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。
  4. 选择执行频率,按需设置生效和结束时间。
  5. 保存后核对下一次执行时间和启用状态。
频率设置方式适合的例子
周期每天、工作日或每周指定日期与时间。工作日早上检查仓库待处理事项。
按间隔每隔指定分钟、小时或天执行。定期检查本地生成结果。
单次指定一个日期与时间。稍后执行一次已准备好的检查。
- +

提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。

-
添加循环表单:名称、工作空间、提示词、模型、执行频率和生效日期。此示例尚未保存。
添加循环时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的循环。Windows 实际界面 · 点击图片查看原图
-
循环提示词示例
检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。
-

查看记录、暂停与管理

列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看循环仍为启用状态。

停用用于暂停后续计划,重新启用后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除循环也会移除其运行记录,保留需要的信息后再删除。

daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。

远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。

-
循环列表的模板、频率、模型、启用入口与最近运行失败状态;现有循环保持停用 -循环真实运行记录:所选模型不可用导致停用,以及 daemon 未运行产生的已错过记录
循环列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,循环已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。Windows 实际界面 · 点击图片查看原图
+
添加定时任务表单:名称、工作空间、提示词、模型、执行频率和生效日期。此示例尚未保存。
添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。Windows 实际界面 · 点击图片查看原图
+
定时任务提示词示例
检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。
+

查看记录、暂停与管理

列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。

停用用于暂停后续计划,重新启用后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。

daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。

远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。

+
定时任务列表的模板、频率、模型、启用入口与最近运行失败状态;现有定时任务保持停用 +定时任务真实运行记录:所选模型不可用导致停用,以及 daemon 未运行产生的已错过记录
定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。Windows 实际界面 · 点击图片查看原图
diff --git a/docs/help/skills.html b/docs/help/skills.html index 0d763c87d..6bec48f22 100644 --- a/docs/help/skills.html +++ b/docs/help/skills.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/swarm.html b/docs/help/swarm.html index 83ae94918..3ac374aaa 100644 --- a/docs/help/swarm.html +++ b/docs/help/swarm.html @@ -26,7 +26,7 @@ - + @@ -50,7 +50,7 @@ /tasks abort 子任务ID
展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。

全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。

- + diff --git a/docs/help/tools.html b/docs/help/tools.html index 98e779c5e..cce52509b 100644 --- a/docs/help/tools.html +++ b/docs/help/tools.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/troubleshoot-context.html b/docs/help/troubleshoot-context.html index 4d1e286d2..46bc40562 100644 --- a/docs/help/troubleshoot-context.html +++ b/docs/help/troubleshoot-context.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/troubleshoot-desktop.html b/docs/help/troubleshoot-desktop.html index 96b35c277..9c2800102 100644 --- a/docs/help/troubleshoot-desktop.html +++ b/docs/help/troubleshoot-desktop.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/troubleshoot-install.html b/docs/help/troubleshoot-install.html index bf491a3c4..942dd0c50 100644 --- a/docs/help/troubleshoot-install.html +++ b/docs/help/troubleshoot-install.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/troubleshoot-models.html b/docs/help/troubleshoot-models.html index 3485a1915..c6660ecc3 100644 --- a/docs/help/troubleshoot-models.html +++ b/docs/help/troubleshoot-models.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/troubleshoot-tools.html b/docs/help/troubleshoot-tools.html index d0b5af148..4e7401fd1 100644 --- a/docs/help/troubleshoot-tools.html +++ b/docs/help/troubleshoot-tools.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/tui.html b/docs/help/tui.html index 215e90929..622f3d047 100644 --- a/docs/help/tui.html +++ b/docs/help/tui.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/update-uninstall.html b/docs/help/update-uninstall.html index c68618777..8717dc790 100644 --- a/docs/help/update-uninstall.html +++ b/docs/help/update-uninstall.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/web.html b/docs/help/web.html index f1dd0da8a..f99f1e759 100644 --- a/docs/help/web.html +++ b/docs/help/web.html @@ -26,7 +26,7 @@ - + diff --git a/docs/help/workspaces.html b/docs/help/workspaces.html index b3e96451e..e0861499a 100644 --- a/docs/help/workspaces.html +++ b/docs/help/workspaces.html @@ -26,7 +26,7 @@ - + diff --git a/openspec/changes/fix-composer-tag-selection/design.md b/openspec/changes/fix-composer-tag-selection/design.md index 8218f75f0..d29a49cbd 100644 --- a/openspec/changes/fix-composer-tag-selection/design.md +++ b/openspec/changes/fix-composer-tag-selection/design.md @@ -23,6 +23,12 @@ - 2026-09-20 用户确认的视觉增补:矩形属于选区,tag 本体保留原有圆角、底色、边框及图标颜色。选区背景独立绘制在 tag 后方,与周围文字连通,取消选择仅撤掉这一层;不得再用选中态覆盖 tag 本体颜色或圆角。 - 前置图标随文字字号缩放,修正左右不对称的外间距及内边距。这些调整仅作用于输入框,已发送消息使用的共用徽标样式保持不变。 +### 2026-10-04:最新 tag 外观与光标约定 + +本节覆盖上面的历史视觉和普通方向键约定。输入框 tag 不再使用徽标底色、边框或内边距;字体、字号、行高、基线与正文一致,只保留主题色和 600 字重。选中时文字和图标使用与正文相同的系统选择色,不播放颜色过渡,已发送消息的徽标不变。 + +无 Shift/Ctrl/Alt/Meta 的左右键从折叠光标越过相邻 tag 时,一次移动直接落在 tag 另一侧的可编辑位置,不进入 void,也不触发原子选择。每次只越过一个 tag,不跳过相邻空格或第二个 tag。已有选区的折叠、Shift 选择、鼠标选择、单词/行级快捷键及 IME 继续交给现有处理。使用实际 Slate point/path(而非纯文本长度)定位附件,左右方向跟随 Slate 段落的 RTL 标记。 + ### 拖入文件后恢复系统键盘焦点 Windows 进入拖放区域时的前置只是一次尝试。若该尝试没有成功,文件物化后 `restorePathCaret` 只能恢复 Slate/DOM 光标;WebView2 甚至仍报告 `document.hasFocus()` 为真,但系统按键继续投递给来源窗口。隔离窗口通过拒绝第一次激活请求、保留真实 OLE 文件拖放及系统按键,稳定复现此状态。 diff --git a/openspec/changes/fix-composer-tag-selection/proposal.md b/openspec/changes/fix-composer-tag-selection/proposal.md index 2a9e082d9..0a26afe07 100644 --- a/openspec/changes/fix-composer-tag-selection/proposal.md +++ b/openspec/changes/fix-composer-tag-selection/proposal.md @@ -5,6 +5,8 @@ Windows 聊天输入框的 tag 与文字共用 Slate 选区,但附件鼠标事 ## What Changes - 统一命令、技能、路径、会话和附件 tag 的原子选择行为,覆盖单击、鼠标双向拖选、Shift 加方向键及整段选择。 +- 普通左右键把 tag 当作单个字符直接越过,不先选中;保留 Shift 和鼠标的主动选择。 +- 最新视觉稿覆盖历史徽标样式:输入框 tag 使用无背景主题色文字,正文排版和稍加粗字重,选中时采用原生选择颜色。 - 保证混合选区的复制、剪切、替换、删除、撤销和重做保持文字与引用顺序。 - 修复自动命令 tag 转换破坏撤销历史的问题。 - 增加 Windows Chromium 浏览器回归脚本与探索性验证记录,保留现有界面样式和输入法保护。 diff --git a/openspec/changes/fix-composer-tag-selection/specs/composer-tag-selection/spec.md b/openspec/changes/fix-composer-tag-selection/specs/composer-tag-selection/spec.md index ece6ef9a5..8859008e3 100644 --- a/openspec/changes/fix-composer-tag-selection/specs/composer-tag-selection/spec.md +++ b/openspec/changes/fix-composer-tag-selection/specs/composer-tag-selection/spec.md @@ -59,17 +59,33 @@ - **WHEN** 用户从文字或 tag 开始向前或向后拖选,或使用 Shift 加方向键扩展选区跨越多个 tag - **THEN** 系统保留选区方向及文字边界,跨过的 tag 整体纳入选区 -### Requirement: 矩形选区独立于 tag 本体 +### Requirement: 普通左右键按单个字符越过 tag -输入框 SHALL 在选中 tag 时新增与文字选区连续的矩形背景,保留 tag 原有圆角、底色、边框和图标颜色。前置图标 SHALL 与文字具有相称的可见尺寸,tag 左右间距 SHALL 对称。 +输入框 SHALL 在折叠光标处使用无修饰左右键时直接越过相邻 tag,不产生 tag 选区;每次只越过一个原子项,不额外跳过文字、空格或相邻 tag。方向 SHALL 与段落的文字方向一致。 + +#### Scenario: 从 tag 两侧移动并继续输入 +- **WHEN** 光标紧邻任意类型 tag,用户按朝向 tag 的普通左右键 +- **THEN** 光标直接落在 tag 另一侧,tag 不高亮,随后输入插在该边界而不替换 tag + +#### Scenario: 连续 tag 与段落边界 +- **WHEN** 输入框包含无空格相邻 tag、行首或行尾 tag +- **THEN** 每次按键只越过一个 tag,可在两个 tag 之间停留,不跳过换行或移出文档 + +#### Scenario: 选择与组合键不被普通移动接管 +- **WHEN** 已有选区,或用户按 Shift/Ctrl/Alt/Meta 加方向键,或输入法正在组合 +- **THEN** 原有选区折叠、原子扩选、单词与行级导航及输入法行为保持不变 + +### Requirement: tag 使用正文排版与原生选择颜色 + +输入框 SHALL 将 tag 渲染为无背景和边框的主题色文本,与正文共享字体、字号、行高和基线,仅稍加粗。选中时 SHALL 使用与正文一致的矩形背景与前景色,且不触发颜色过渡或布局变化。 #### Scenario: 拖选与键盘混选的视觉一致 - **WHEN** 用户拖选或通过 Shift 加方向键选择文字、空格和多个 tag -- **THEN** 这些内容背后的矩形选区连续且上下对齐,tag 本体保持原样,换行时各行选区仍然完整 +- **THEN** 这些内容背后的矩形选区连续且上下对齐,tag 文字和图标使用选择前景色,换行时各行选区仍然完整 #### Scenario: 取消选区 - **WHEN** 用户将选区折叠成光标或输入框失去焦点 -- **THEN** tag 后方的新增选区背景消失,tag 本体不发生外观变化或布局跳动 +- **THEN** tag 选区背景消失,恢复主题色,不发生布局跳动 ### Requirement: 混合选区编辑保持引用与历史 diff --git a/openspec/changes/fix-composer-tag-selection/tasks.md b/openspec/changes/fix-composer-tag-selection/tasks.md index 5a92e7a64..420278404 100644 --- a/openspec/changes/fix-composer-tag-selection/tasks.md +++ b/openspec/changes/fix-composer-tag-selection/tasks.md @@ -30,3 +30,14 @@ - [x] 6.2 使用同一 Slate 插入事务处理选区替换、一次撤销、异步编辑及任务切换;文件粘贴与文本格式互斥,保留结构化复制粘贴。 - [x] 6.3 成对验证拖入与粘贴的外观、完整路径、顺序、混合选择、撤销与连续输入,覆盖截图、目录、纯 Web 和异步失败;完成前端与原生定向检查。 - [x] 6.4 复核改动范围,提交并推送远端,确认远端包含提交。 + +## 7. 最新输入框 tag 视觉(覆盖第 4 节历史稿) + +- [x] 7.1 改为无背景主题色文字,与正文统一字体、字号、行高和基线,仅增加字重;选中时蓝底白字。 +- [x] 7.2 完成 81 项浏览器回归、前端测试与构建,保留已发送消息样式。 + +## 8. 普通左右键将 tag 当作单个字符 + +- [x] 8.1 补充左右键直接越过 tag 的模型和浏览器回归,记录旧行为失败。 +- [x] 8.2 普通左右键一步越过一个 tag,保留空格、段落边界、RTL、Shift 选择、鼠标选择和 IME 保护。 +- [x] 8.3 完成键鼠与编辑浏览器回归、前端测试与构建、OpenSpec 严格检查和差异检查;全量测试发现无关文案目录缺失,已如实记录,未修改并行工作。 diff --git a/openspec/changes/fix-composer-tag-selection/validation.md b/openspec/changes/fix-composer-tag-selection/validation.md index 0ba665e85..be433c587 100644 --- a/openspec/changes/fix-composer-tag-selection/validation.md +++ b/openspec/changes/fix-composer-tag-selection/validation.md @@ -45,3 +45,12 @@ - 配对截图在临时目录 `acecode-file-intake-shots/drop.png`、`paste.png`,逐项样式与结构比较通过。macOS/Linux 尚未实机验证;浏览器中的平台分支使用桥接 fixture。 - Windows Release 原生支持库与 Desktop 编译、链接通过,使用当前工作树的 Desktop 启动器重新运行;HTTP 返回的开发前端 SHA-256 与 `web/dist/index.html` 一致。相邻 daemon 的 API 无变更,复用现有可运行产物。 - 功能提交 `9f1a847c`,合入远端 macOS 窗口按钮调整后,以 `b5aee5ba` 推送到 `origin/master`,远端提交哈希回读一致。 + +## 2026-10-04:普通左右键直接越过 tag + +- 同一工作树的变更前定向浏览器检查 8 项失败,命令、技能、路径、会话、附件均会先形成选区;真实 Slate 模型测试同样复现。修改后直接定位相邻 tag 另一侧的可编辑 point,不经过 void 内部选区,不使用附件为零的纯文本长度。 +- Windows Chromium 生产 RichComposer 回归 89/89 通过,包含双向移动后输入、连续无空格 tag、段落首尾、RTL、Shift 原子选中、鼠标拖选、剪贴板、撤销和排版。候选菜单生产 InputBar 回归 16/16 通过。浏览器异常及 console errors 为零。 +- `richComposerKeyboard.test.js`、`composerSelection.test.js`、`composerDropdownKeyboard.test.js`、`slateComposerArchitecture.test.js` 定向通过。IME 的十种组合状态均新增左右键保护检查。 +- `corepack pnpm build` 通过(3132 模块、4500 个正则兼容检查);OpenSpec 严格校验与本任务差异检查通过。 +- `corepack pnpm test` 已执行,但被当前工作树并行改动中的三个未登记文案阻断:`ChatView.jsx` 的“停止请求发送失败,连接恢复后请重试”、`goalControl.js` 的“正在等待当前任务停止”、`sessionTranscript.js` 的“正在停止本轮任务…”。失败来自 `sourceCatalog.test.js` 的目录覆盖断言,本任务未修改这些文件或绕过检查,不宣称全量通过。 +- 测试原始结果在本会话临时目录的 `composer-arrows-baseline/results.json` 和 `composer-arrows-final/results.json`。未重新构建或安装 Desktop/WebView2,未触碰真实草稿;系统输入法候选窗未人工验证。 diff --git a/scripts/channel_lab/README.md b/scripts/channel_lab/README.md new file mode 100644 index 000000000..86e89dc2b --- /dev/null +++ b/scripts/channel_lab/README.md @@ -0,0 +1,66 @@ +# 本机 IM / Channel 测试 + +一个只依赖 Python 3.10+ 标准库的临时文本 IM。左侧“自己”会话由服务端原样回送;“ACECode”会话使用真实 Channel v1 插件,经 `/rc/send` 入站、webhook 出站。无需 IM 账号。 + +## 启动 + +从仓库根目录运行(Windows,复用本检出的已有原生构建): + +```powershell +python scripts/channel_lab/server.py --acecode build/Release/acecode.exe --open +``` + +也可以双击 `scripts/channel_lab/start.bat`。首次自动选择空闲 IM 端口,控制台打印地址并打开浏览器。 + +重复启动同一运行目录时,会直接显示已运行实例的完整访问 URL;使用该地址即可,不会重复创建服务。 + +需要固定端口或选择已保存模型时: + +```powershell +python scripts/channel_lab/server.py --acecode build/Release/acecode.exe --port 39123 --model-name "你的已保存模型名" --open +``` + +`--model-name` 仅影响新建测试会话;已有会话继续使用其持久化模型。省略 `--acecode` 可仅运行自聊服务。通用协议见 `docs/channel-plugin-protocol.md`。完整自动接入启动目前支持 Windows。 + +## 使用 + +- **帮助**:在任一会话输入 `/help` 或点击右上角“帮助”,直接列出会话导航、问题回答等指令和示例。帮助由 IM 本地提供,Channel 未连接也可查看,记录随历史保存。 +- **自己**:发送中文、多行文本;服务端保存并原样回送。Enter 发送,Shift + Enter 换行,中文输入法选字不触发发送。 +- **ACECode**:直接聊天,或使用“会话列表”“全部会话”“待回答问题”“取消提问”。输入 `/session <编号>` 切换测试会话,`/session search <关键词>` 搜索,`/aq <回答>` 回答问题。 +- **断开连接 / 连接 ACECode**:执行测试 daemon 的 `/rc off` / `/rc` 生命周期。 +- **历史**:刷新、重开窗口和服务重启后保留;多个浏览器窗口每秒同步,单页最近 200 条,可加载更早消息或导出全部 JSON。 +- **发送状态**:已发送表示服务端/Channel 接受,不代表模型已完成回答。HTTP 拒绝标记失败;超时等未知结果不会自动重发,可以把文本放回输入框后自行判断。模型服务不可用时仍可测试 `/session` 和 `/aq --status`。 + +精简范围为单人、本机、文字会话;不提供第三方账号、附件、音视频。快捷按钮只使用通用 Channel 实际支持的命令,不能将 WhatsApp 专属的 `/stop`、`/approve` 等当成通用协议命令。 + +## 停止及运行数据 + +点击页面左下角“停止测试服务”,或运行: + +```powershell +python scripts/channel_lab/server.py --stop +``` + +运行数据默认在 `%TEMP%/acecode-channel-lab/`,可通过 `--runtime-dir <目录>` 隔离多套测试。停止命令要传入相同的 `--runtime-dir`。 + +- `history.sqlite3`:IM 历史,不记录凭据。 +- `profile/.acecode/`:子进程专用测试配置和测试会话;仅复制模型相关设置,不继承生产 MCP、Channel 绑定和会话。 +- `plugin-runtime.json`、`server.json`:含本地管理凭据,不能分享或提交。 +- `channel-plugin.json`:通用插件 manifest;短进程 `plugin.py` 连接当前 IM 服务。 +- `daemon.log`:原生测试进程日志,排障时注意其中可能含本地路径和模型请求信息。 + +工具仅监听 `127.0.0.1`。它不会覆盖正常用户配置或停止现有 Desktop/daemon。退出时仅停止它自己启动的测试 daemon,历史保留。全新测试可选择一个新的 `--runtime-dir`。 + +## 验证 + +```powershell +python -m unittest discover -s scripts/channel_lab -p test_server.py -v +``` + +可选浏览器验收需要已有 Python Playwright 和 Edge;对运行中的测试实例执行(会添加测试消息并切换测试会话): + +```powershell +python scripts/channel_lab/smoke_browser.py --url http://127.0.0.1:39123 --output "$env:TEMP/channel-lab-screenshots" +``` + +静态页面位于本工具的 `web/` 子目录,直接由 Python 提供,不依赖正式 WebUI 的打包产物。 diff --git a/scripts/channel_lab/plugin.py b/scripts/channel_lab/plugin.py new file mode 100644 index 000000000..405e37035 --- /dev/null +++ b/scripts/channel_lab/plugin.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Short-lived Channel v1 stdio process. Never print credentials or diagnostics.""" +import json +from pathlib import Path +import sys + +from server import request_json + + +def main(): + try: + descriptor = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8')) + request = json.loads(sys.stdin.buffer.readline(64 * 1024).decode('utf-8')) + result = request_json(descriptor['url'] + '/admin/lifecycle', request, + {'X-Lab-Token': descriptor['token']}, timeout=8) + except Exception: + result = {'type': 'channel.status', 'state': 'failed', + 'message': 'Local IM is unavailable; start the channel lab server.'} + sys.stdout.buffer.write((json.dumps(result, ensure_ascii=False) + '\n').encode('utf-8')) + sys.stdout.buffer.flush() + return 0 if result.get('state') == 'connected' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/channel_lab/server.py b/scripts/channel_lab/server.py new file mode 100644 index 000000000..51c5696d2 --- /dev/null +++ b/scripts/channel_lab/server.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +"""Local, text-only IM and Channel v1 test adapter (Python standard library).""" +from __future__ import annotations + +import argparse +import hmac +import json +import os +from pathlib import Path +import secrets +import socket +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urlsplit +from urllib.request import Request, build_opener, ProxyHandler +import webbrowser + +ROOT = Path(__file__).resolve().parents[2] +ASSETS = Path(__file__).with_name('web') +MAX_TEXT = 16 * 1024 +HTTP = build_opener(ProxyHandler({})) +HELP_TEXT = """可用指令 + +/help 显示这份指令帮助 + +会话导航(在 ACECode 会话使用) +/session 查看最近会话 +/session all 查看全部会话(也可用 /session more) +/session 2 切换到上次列表中的第 2 个会话 +/session search 关键词 搜索会话 +/sessions、/resume 是 /session 的别名,用法相同。 + +回答问题(在 ACECode 会话使用) +/aq --status 查看待回答问题和回答进度 +/aq 1 选择第 1 个选项,也可输入选项标签 +/aq 1,3 多选第 1、3 个选项 +/aq 我的回答 提交自定义答案 +/aq --repeat 重新显示当前问题 +/aq --back 返回上一题 +/aq --cancel 取消当前这批提问 + +使用提示 +“自己”会话原样回送普通文字;除 /help 外,不执行以上指令。 +Channel 指令需要先连接 ACECode;/help 断开时也可查看。 +右上角可连接或断开 ACECode、导出记录。 +Enter 发送,Shift + Enter 换行。""" + + +class ApiError(Exception): + def __init__(self, message, status=400): + super().__init__(message) + self.status = status + + +def request_json(url, data=None, headers=None, method=None, timeout=12): + body = None if data is None else json.dumps(data, ensure_ascii=False).encode('utf-8') + req = Request(url, data=body, method=method, + headers={'Content-Type': 'application/json', **(headers or {})}) + with HTTP.open(req, timeout=timeout) as response: + return json.loads(response.read().decode('utf-8')) + + +def write_json(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + + +def free_port(): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + + +def require_text(value, name='text', limit=MAX_TEXT): + if not isinstance(value, str) or not value.strip(): + raise ApiError(f'{name} 不能为空') + if len(value.encode('utf-8')) > limit: + raise ApiError(f'{name} 超过 {limit} 字节限制') + return value + + +class Lab: + def __init__(self, runtime): + self.runtime = runtime + runtime.mkdir(parents=True, exist_ok=True) + self.lock = threading.RLock() + self.action_lock = threading.Lock() + self.db = sqlite3.connect(runtime / 'history.sqlite3', check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.execute('''CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, room TEXT NOT NULL, + sender TEXT NOT NULL, text TEXT NOT NULL, created REAL NOT NULL, + status TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'text', + session_id TEXT NOT NULL DEFAULT '', receipt TEXT UNIQUE)''') + self.db.execute("UPDATE messages SET status='unknown' WHERE status='sending'") + self.db.commit() + self.binding = None + self.admin_token = secrets.token_urlsafe(32) + self.url = '' + self.daemon_url = '' + self.daemon_process = None + self.daemon_env = None + self.session_id = '' + self.session_titles = {} + self.startup_error = '' + self.server = None + + def add(self, room, sender, text, status='received', kind='text', session_id='', receipt=None): + with self.lock, self.db: + cursor = self.db.execute('''INSERT OR IGNORE INTO messages + (room,sender,text,created,status,kind,session_id,receipt) VALUES (?,?,?,?,?,?,?,?)''', + (room, sender, text, time.time(), status, kind, session_id, receipt)) + return cursor.lastrowid + + def history(self, room, before=0, limit=200): + if room not in ('self', 'acecode'): + raise ApiError('未知会话') + with self.lock: + rows = self.db.execute('''SELECT id,room,sender,text,created,status,kind,session_id + FROM messages WHERE room=? AND (?=0 OR id 都是普通文本。' + page.locator('#message-input').fill(text) + page.locator('#send').click() + expect(page.locator('.bubble').filter(has_text=text)).to_have_count(2) + second = context.new_page() + second.goto(args.url) + expect(second.locator('.bubble').filter(has_text=text)).to_have_count(2) + page.reload() + expect(page.locator('.bubble').filter(has_text=text)).to_have_count(2) + with page.expect_download() as downloaded: + page.locator('#export').click() + data = json.loads(Path(downloaded.value.path()).read_text(encoding='utf-8')) + assert len([m for m in data['messages'] if m['text'] == text]) == 2 + help_messages = page.locator('.message').filter(has=page.locator('.message-meta').filter(has_text='IM 帮助')) + help_count = help_messages.count() + page.locator('[data-command="/help"]').click() + expect(help_messages).to_have_count(help_count + 1) + expect(help_messages.last).to_contain_text('/session search') + page.reload() + expect(help_messages).to_have_count(help_count + 1) + # Enter during CJK composition must not submit. + page.locator('#message-input').fill('拼音输入中') + page.locator('#message-input').dispatch_event('keydown', {'key': 'Enter', 'isComposing': True}) + expect(page.locator('#message-input')).to_have_value('拼音输入中') + page.locator('#message-input').fill('') + # A delayed send completion must not overwrite a newer draft after changing rooms. + page.evaluate('''() => { + window.originalFetch = window.fetch; + window.fetch = async (...args) => { + const response = await window.originalFetch(...args); + if (String(args[0]).endsWith('/api/messages')) { + await new Promise(resolve => { window.releaseSend = resolve; }); + } + return response; + }; + }''') + page.locator('#message-input').fill('延迟发送回执测试') + page.locator('#send').click() + page.wait_for_function('() => typeof window.releaseSend === "function"') + page.locator('#message-input').fill('这是下一条草稿,不能丢失') + page.locator('[data-room="acecode"]').click() + page.evaluate('() => { window.fetch = window.originalFetch; window.releaseSend(); }') + expect(page.locator('#send')).to_be_enabled() + page.locator('[data-room="self"]').click() + expect(page.locator('#message-input')).to_have_value('这是下一条草稿,不能丢失') + page.locator('#message-input').fill('') + if not args.no_screenshots: + page.screenshot(path=str(args.output / 'im-self-desktop.png'), full_page=True) + page.locator('[data-room="acecode"]').click() + expect(page.locator('#binding-state')).to_have_text('Channel 已连接', timeout=20000) + initial_session = page.locator('#session-info').get_attribute('title') + current_state = page.request.get(args.url + '/api/state?room=acecode').json() + other_title = 'IM 会话切换测试' if current_state['session_title'] == 'IM Channel 测试' else 'IM Channel 测试' + catalog_count = page.locator('.bubble').filter(has_text='Use /sessions').count() + page.locator('[data-command="/session"]').click() + expect(page.locator('.bubble').filter(has_text='Use /sessions')).to_have_count(catalog_count + 1, timeout=20000) + catalog = page.locator('.bubble').filter(has_text='Use /sessions').last.inner_text() + match = re.search(r'^(\d+)\. ' + re.escape(other_title) + r' \|', catalog, re.M) + assert match, catalog + page.locator('#message-input').fill('/session ' + match.group(1)) + page.locator('#send').click() + page.wait_for_function('(original) => document.querySelector("#session-info").title !== original', arg=initial_session, timeout=20000) + page.locator('#connection').click() + expect(page.locator('#binding-state')).to_have_text('Channel 未连接', timeout=20000) + page.locator('#message-input').fill('断线时保留输入') + page.locator('#send').click() + expect(page.locator('#error')).to_contain_text('Channel 尚未连接') + expect(page.locator('#message-input')).to_have_value('断线时保留输入') + help_count = help_messages.count() + page.locator('#message-input').fill('/help') + page.locator('#send').click() + expect(help_messages).to_have_count(help_count + 1) + expect(help_messages.last).to_contain_text('/aq --cancel') + expect(page.locator('#error')).to_be_hidden() + page.locator('#message-input').fill('') + page.locator('#connection').click() + expect(page.locator('#binding-state')).to_have_text('Channel 已连接', timeout=20000) + page.locator('[data-command="/aq --status"]').click() + page.wait_for_timeout(1800) + if not args.no_screenshots: + page.screenshot(path=str(args.output / 'im-channel-desktop.png'), full_page=True) + page.set_viewport_size({'width': 390, 'height': 844}) + if not args.no_screenshots: + page.screenshot(path=str(args.output / 'im-channel-mobile.png'), full_page=True) + assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth') + assert not errors, errors + print(json.dumps({'self_echo': True, 'multi_window': True, 'history_export': True, + 'ime': True, 'draft_preserved': True, 'local_help': True, + 'disconnected_help': True, 'session_switch': True, 'disconnect_reconnect': True, + 'mobile_overflow': False, 'page_errors': errors}, ensure_ascii=False)) + browser.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/channel_lab/start.bat b/scripts/channel_lab/start.bat new file mode 100644 index 000000000..25e12eb97 --- /dev/null +++ b/scripts/channel_lab/start.bat @@ -0,0 +1,6 @@ +@echo off +setlocal +pushd "%~dp0..\.." +python -X utf8 scripts\channel_lab\server.py --acecode build\Release\acecode.exe --open %* +popd +endlocal diff --git a/scripts/channel_lab/test_server.py b/scripts/channel_lab/test_server.py new file mode 100644 index 000000000..1e827917d --- /dev/null +++ b/scripts/channel_lab/test_server.py @@ -0,0 +1,186 @@ +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch +from urllib.error import HTTPError, URLError + +from server import Lab, ApiError, make_server, request_json, write_json + + +class LabTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.lab = Lab(Path(self.temp.name)) + self.server = make_server(self.lab) + self.worker = threading.Thread(target=self.server.serve_forever) + self.worker.start() + + def tearDown(self): + self.server.shutdown() + self.worker.join() + self.server.server_close() + self.lab.db.close() + self.temp.cleanup() + + def activate(self, sid='s1', token='private-inbound'): + return self.lab.lifecycle({'type': 'channel.activate', 'protocol_version': 1, + 'session_id': sid, 'inbound': { + 'url': 'http://127.0.0.1:28999/rc/send', 'token': token}}) + + def test_self_chat_persistence_dedup_and_collision(self): + payload = {'room': 'self', 'text': '你好\n第二行 + + +
+ +
+
+

自己

自己的消息,也可以有回音。

+
+
+ + +
+ +
+
我

和自己说句话

发一条消息,服务端会原样回送。
切换到 ACECode,开始测试 Channel。

+
+ +
+ +
+ + +
Enter 发送 · Shift + Enter 换行
+
+
+
+
+ + diff --git a/scripts/channel_lab/web/style.css b/scripts/channel_lab/web/style.css new file mode 100644 index 000000000..9fcb85c09 --- /dev/null +++ b/scripts/channel_lab/web/style.css @@ -0,0 +1,9 @@ +:root{font-family:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;color:#242b36;background:#edf0f4;font-synthesis:none;--muted:#657082;--line:#e4e8ef;--blue:#2863ce;--soft:#edf3ff} +*{box-sizing:border-box}body{margin:0}button,textarea{font:inherit}button{cursor:pointer;border:1px solid #dbe1ea;background:#fff;color:#3d495d;border-radius:7px;padding:8px 12px;font-size:13px;transition:background .15s}button:hover{background:#f1f4f9}button:disabled{opacity:.55;cursor:wait}button:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--blue);outline-offset:3px}[hidden]{display:none!important}::selection{background:#cfdefb}::-webkit-scrollbar{width:7px}::-webkit-scrollbar-thumb{background:#c5cdd9;border-radius:8px} +.app{display:grid;grid-template-columns:264px minmax(0,1fr);height:100dvh;max-width:1500px;margin:auto;background:#fff}.sidebar{display:flex;flex-direction:column;background:#f6f7f9;border-right:1px solid var(--line);padding:28px 16px 20px}.brand{display:flex;align-items:center;gap:11px;margin:0 10px 42px}.brand-mark{background:#263a56;color:#fff;font-weight:650;border-radius:11px;font-size:17px;width:40px;height:40px;display:grid;place-items:center}.brand h1{font-size:17px;font-weight:650;margin:0 0 3px}.brand p{color:var(--muted);font-size:12px;margin:0}.section-label{font-size:12px;color:var(--muted);padding:0 12px;margin-bottom:10px}nav{display:flex;flex-direction:column;gap:6px}.conversation{display:flex;text-align:left;align-items:center;gap:12px;width:100%;border:0;padding:14px 12px;background:transparent;border-radius:8px}.conversation.selected{background:#e7edf7}.avatar{width:38px;height:38px;flex-shrink:0;display:grid;place-items:center;border-radius:10px;font-size:16px;font-weight:600}.self-avatar{color:#405e8b;background:#dee7f3}.ace-avatar{background:#dfe9e4;color:#3e6350}.conversation-copy{display:flex;min-width:0;flex-direction:column;gap:5px}.conversation strong{font-size:14px;font-weight:600;color:#263448}.conversation-copy>span{font-size:11px;color:#657082;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-footer{margin-top:auto;padding:20px 12px 0}.service{display:flex;align-items:center;gap:7px;font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:#949ba8}.dot.online{background:#43825d}.sidebar-footer p{font-size:11px;line-height:1.85;color:var(--muted);margin:12px 0}.text-button{background:transparent;border:0;padding:4px 0;color:var(--muted);font-size:12px}.text-button:hover{background:transparent;color:var(--blue)} +main{min-width:0;min-height:0;display:flex;flex-direction:column;position:relative}.chat-header{padding:26px 34px 22px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--line)}h2{font-size:19px;font-weight:650;margin:0 0 6px}.chat-header p{font-size:12px;color:var(--muted);margin:0}.header-actions{display:flex;gap:8px;flex-shrink:0}.channel-info{display:flex;gap:18px;padding:10px 34px;background:#f7f9fc;border-bottom:1px solid var(--line);font-size:12px;color:var(--muted);overflow-wrap:anywhere}.channel-info.connected #binding-state{color:#326846}#session-info{font-variant-numeric:tabular-nums}.error{color:#923a32;background:#fff0ed;padding:12px 34px;font-size:13px;line-height:1.6}.messages{flex:1;min-height:0;overflow-y:auto;padding:24px 34px;scrollbar-gutter:stable}.older{display:block;margin:0 auto 20px}.empty{height:100%;min-height:240px;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;padding:30px 10px}.empty-mark{display:grid;place-items:center;width:56px;height:56px;border-radius:17px;background:#eef2f8;color:#5c7292;font-size:24px;margin-bottom:18px}.empty h3{font-size:18px;font-weight:550;margin:0 0 10px}.empty p{font-size:13px;color:var(--muted);line-height:1.9;margin:0 0 22px}.empty button{color:var(--blue)} +.message{display:flex;flex-direction:column;align-items:flex-start;margin:0 0 24px}.message.mine{align-items:flex-end}.message-meta{font-size:11px;color:var(--muted);margin-bottom:7px;display:flex;gap:10px;align-items:center;font-variant-numeric:tabular-nums}.bubble{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.7;font-size:14px;padding:11px 15px;background:#f1f3f6;border-radius:3px 12px 12px 12px;max-width:min(88%,740px)}.mine .bubble{background:var(--soft);color:#254975;border-radius:12px 3px 12px 12px}.message.tool .bubble{font-size:12px;color:#576273;background:#f6f7f9}.delivery{display:flex;align-items:center;gap:12px;font-size:10px;color:var(--muted);margin-top:5px}.delivery.failed,.delivery.unknown{color:#a14e33}.copy{opacity:0;font-size:10px;padding:0;border:0;background:none;color:var(--muted)}.message:hover .copy,.copy:focus-visible{opacity:1}.jump{position:absolute;bottom:205px;left:50%;transform:translateX(-50%);background:white;box-shadow:0 4px 20px #22314a16;z-index:2} +.composer{margin:0 34px 24px}.commands{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-bottom:12px}.commands button{font-size:11px;padding:5px 9px;background:#f7f8fa;border-color:transparent}.commands>span{font-size:10px;color:var(--muted);margin-left:4px}form{border:1px solid #dce2ec;border-radius:11px;background:#fff;transition:border-color .15s}form:focus-within{border-color:#8caee7}textarea{display:block;resize:none;border:0;background:none;width:100%;min-height:90px;max-height:210px;padding:15px 16px 8px;color:#263448;line-height:1.6;font-size:14px;caret-color:var(--blue)}textarea:focus-visible{outline:none}textarea::placeholder{color:#717e91}.composer-bottom{display:flex;justify-content:space-between;align-items:center;padding:6px 12px 11px;gap:10px}.composer-bottom>span{font-size:11px;color:var(--muted);padding-left:4px}.primary{display:flex;align-items:center;gap:14px;color:#fff;background:var(--blue);border:0;padding:8px 15px}.primary:hover{background:#2057bd}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +@media(min-width:1500px){.app{border-inline:1px solid var(--line)}} +@media(max-width:720px){.app{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr)}.sidebar{padding:12px 16px;border-right:0;border-bottom:1px solid var(--line)}.brand{margin:0 0 12px;gap:9px}.brand-mark{width:30px;height:30px;font-size:13px;border-radius:8px}.brand h1{font-size:14px;margin:0}.brand p{display:none}.section-label,.sidebar-footer{display:none}nav{flex-direction:row;gap:10px}.conversation{padding:9px 10px;gap:8px}.avatar{width:28px;height:28px;font-size:13px;border-radius:8px}.conversation-copy>span{display:none}.conversation strong{font-size:13px}.chat-header{padding:16px;gap:8px}.chat-header h2{font-size:16px}.chat-header p{font-size:11px;max-width:200px;line-height:1.5}.header-actions{gap:5px}.header-actions button{padding:7px;font-size:11px}.channel-info{padding:8px 16px;font-size:10px;gap:10px}.messages{padding:18px 16px}.bubble{max-width:94%;font-size:13px}.composer{margin:0 12px 12px}.composer-bottom>span{font-size:10px}.commands{gap:5px;margin-bottom:8px}.commands>span{display:none}.commands button{font-size:10px;padding:5px 7px}textarea{min-height:72px}.error{padding:10px 16px;font-size:12px}.copy{opacity:1}.jump{bottom:184px}} +@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}} diff --git a/src/adapters/provider/anthropic_provider.cpp b/src/adapters/provider/anthropic_provider.cpp index 1d80a2d9e..2f968251d 100644 --- a/src/adapters/provider/anthropic_provider.cpp +++ b/src/adapters/provider/anthropic_provider.cpp @@ -25,21 +25,6 @@ namespace acecode { -AnthropicProvider::AnthropicProvider(const std::string& base_url, - const std::string& api_key, - const std::string& model, - int stream_timeout_ms, - std::map request_headers, - ProviderRequestOptions request_options) - : base_url_(normalize_base_url(base_url)), - api_key_(api_key), - model_(model), - request_headers_(std::move(request_headers)), - request_options_(std::move(request_options)), - stream_timeout_ms_(stream_timeout_ms > 0 - ? stream_timeout_ms - : OpenAiConfig::kDefaultStreamTimeoutMs) {} - namespace { constexpr int kStreamConnectTimeoutCapMs = 15000; @@ -738,10 +723,15 @@ ChatResponse AnthropicProvider::parse_response(const nlohmann::json& j) { return resp; } -ChatResponse AnthropicProvider::chat( - const std::vector& messages, - const std::vector& tools -) { +ChatResponse AnthropicProvider::chat_cancellable( + const std::vector& messages, const std::vector& tools, + const std::atomic* abort_flag) { + const auto interrupted = [&] { + return make_chat_error_response(make_provider_error( + ProviderErrorKind::UserCancelled, 0, name(), model_, {}, {}, + "[Interrupted]", false)); + }; + if (abort_flag && abort_flag->load()) return interrupted(); if (api_key_.empty()) { auto info = make_provider_error( ProviderErrorKind::Unknown, @@ -788,9 +778,15 @@ ChatResponse AnthropicProvider::chat( network::build_ssl_options(proxy_opts), proxy_opts.proxies, proxy_opts.auth, - cpr::Timeout{stream_timeout_ms_} + cpr::Timeout{stream_timeout_ms_}, + cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !abort_flag || !abort_flag->load(); + }} ); + if (abort_flag && abort_flag->load()) return interrupted(); + if (r.status_code == 0) { const ProviderErrorKind kind = classify_cpr_error(r.error); return make_chat_error_response(make_provider_error( diff --git a/src/adapters/provider/anthropic_provider.hpp b/src/adapters/provider/anthropic_provider.hpp index 12f7bbd60..d423ae91e 100644 --- a/src/adapters/provider/anthropic_provider.hpp +++ b/src/adapters/provider/anthropic_provider.hpp @@ -27,7 +27,10 @@ class AnthropicProvider : public LlmProvider { ChatResponse chat( const std::vector& messages, const std::vector& tools - ) override; + ) override { return chat_cancellable(messages, tools, nullptr); } + + ChatResponse chat_cancellable(const std::vector& messages, + const std::vector& tools, const std::atomic* abort_flag) override; void chat_stream( const std::vector& messages, diff --git a/src/adapters/provider/copilot_provider.cpp b/src/adapters/provider/copilot_provider.cpp index 4bf1f0608..cadfb322f 100644 --- a/src/adapters/provider/copilot_provider.cpp +++ b/src/adapters/provider/copilot_provider.cpp @@ -159,6 +159,18 @@ ChatResponse CopilotProvider::chat( const std::vector& messages, const std::vector& tools ) { + return chat_cancellable(messages, tools, nullptr); +} + +ChatResponse CopilotProvider::chat_cancellable( + const std::vector& messages, const std::vector& tools, + const std::atomic* abort_flag) { + const auto cancelled = [&] { return abort_flag && abort_flag->load(); }; + const auto interrupted = [&] { + return make_copilot_error(ProviderErrorKind::UserCancelled, 0, model_, + "[Interrupted]", {}, false); + }; + if (cancelled()) return interrupted(); const auto token = copilot_token_snapshot(); if (token.empty()) { return make_copilot_error( @@ -189,9 +201,15 @@ ChatResponse CopilotProvider::chat( network::build_ssl_options(proxy_opts), proxy_opts.proxies, proxy_opts.auth, - cpr::Timeout{stream_timeout_ms_} + cpr::Timeout{stream_timeout_ms_}, + cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !abort_flag || !abort_flag->load(); + }} ); + if (cancelled()) return interrupted(); + if (r.status_code == 401) { // Token expired, try refresh once const auto refreshed_token = copilot_token_snapshot(token); @@ -205,11 +223,16 @@ ChatResponse CopilotProvider::chat( network::build_ssl_options(proxy_opts2), proxy_opts2.proxies, proxy_opts2.auth, - cpr::Timeout{stream_timeout_ms_} + cpr::Timeout{stream_timeout_ms_}, + cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !abort_flag || !abort_flag->load(); + }} ); } } + if (cancelled()) return interrupted(); if (r.status_code == 0) { const ProviderErrorKind kind = classify_cpr_error(r.error); return make_copilot_error( diff --git a/src/adapters/provider/copilot_provider.hpp b/src/adapters/provider/copilot_provider.hpp index 457075c9f..e7199bd6a 100644 --- a/src/adapters/provider/copilot_provider.hpp +++ b/src/adapters/provider/copilot_provider.hpp @@ -18,6 +18,9 @@ class CopilotProvider : public OpenAiCompatProvider { const std::vector& tools ) override; + ChatResponse chat_cancellable(const std::vector& messages, + const std::vector& tools, const std::atomic* abort_flag) override; + void chat_stream( const std::vector& messages, const std::vector& tools, diff --git a/src/adapters/provider/grok_provider.cpp b/src/adapters/provider/grok_provider.cpp index 478761760..185771445 100644 --- a/src/adapters/provider/grok_provider.cpp +++ b/src/adapters/provider/grok_provider.cpp @@ -207,6 +207,18 @@ bool GrokProvider::authenticate() { ChatResponse GrokProvider::chat( const std::vector& messages, const std::vector& tools) { + return chat_cancellable(messages, tools, nullptr); +} + +ChatResponse GrokProvider::chat_cancellable( + const std::vector& messages, + const std::vector& tools, const std::atomic* abort_flag) { + const auto cancelled = [&] { return abort_flag && abort_flag->load(); }; + const auto interrupted = [&] { + return grok_error_response(grok_error_info( + ProviderErrorKind::UserCancelled, 0, model_, "", "[Interrupted]", "", false)); + }; + if (cancelled()) return interrupted(); std::string conversion_error; nlohmann::json body = build_grok_responses_request( build_request_body(messages, tools, false), &messages, @@ -238,10 +250,15 @@ ChatResponse GrokProvider::chat( network::build_ssl_options(proxy), proxy.proxies, proxy.auth, - cpr::Timeout{stream_timeout_ms_}); + cpr::Timeout{stream_timeout_ms_}, + cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !abort_flag || !abort_flag->load(); + }}); }; cpr::Response upstream = make_request(access.tokens); + if (cancelled()) return interrupted(); if (upstream.status_code == 401) { GrokAccessTokenResult refreshed = ensure_grok_access_token( true, access.tokens.access_token, auth_config_); @@ -256,6 +273,7 @@ ChatResponse GrokProvider::chat( upstream = make_request(access.tokens); } + if (cancelled()) return interrupted(); if (upstream.status_code == 0) { const ProviderErrorKind kind = classify_cpr_error(upstream.error); return grok_error_response(grok_error_info( diff --git a/src/adapters/provider/grok_provider.hpp b/src/adapters/provider/grok_provider.hpp index bfff0118b..7229f24ab 100644 --- a/src/adapters/provider/grok_provider.hpp +++ b/src/adapters/provider/grok_provider.hpp @@ -19,6 +19,9 @@ class GrokProvider : public OpenAiCompatProvider { const std::vector& messages, const std::vector& tools) override; + ChatResponse chat_cancellable(const std::vector& messages, + const std::vector& tools, const std::atomic* abort_flag) override; + void chat_stream( const std::vector& messages, const std::vector& tools, diff --git a/src/adapters/provider/openai_provider.cpp b/src/adapters/provider/openai_provider.cpp index 0027342ee..e07d36cd6 100644 --- a/src/adapters/provider/openai_provider.cpp +++ b/src/adapters/provider/openai_provider.cpp @@ -28,22 +28,6 @@ #include #include namespace acecode { - -OpenAiCompatProvider::OpenAiCompatProvider(const std::string& base_url, - const std::string& api_key, - const std::string& model, - int stream_timeout_ms, - std::map request_headers, - ProviderRequestOptions request_options) - : base_url_(normalize_endpoint(base_url, request_options.endpoint_mode)), - api_key_(api_key), - model_(model), - request_headers_(std::move(request_headers)), - request_options_(std::move(request_options)), - stream_timeout_ms_(stream_timeout_ms > 0 - ? stream_timeout_ms - : OpenAiConfig::kDefaultStreamTimeoutMs) {} - namespace { constexpr int kStreamConnectTimeoutCapMs = 15000; @@ -1237,10 +1221,16 @@ ChatResponse OpenAiCompatProvider::parse_response(const nlohmann::json& j) { return resp; } -ChatResponse OpenAiCompatProvider::chat( +ChatResponse OpenAiCompatProvider::chat_cancellable( const std::vector& messages, - const std::vector& tools + const std::vector& tools, + const std::atomic* abort_flag ) { + if (abort_flag && abort_flag->load()) { + return make_chat_error_response(make_provider_error( + ProviderErrorKind::UserCancelled, 0, name(), model_, {}, {}, + "[Interrupted]", false)); + } nlohmann::json body = build_request_body(messages, tools, false); std::string url = request_url(); @@ -1276,9 +1266,19 @@ ChatResponse OpenAiCompatProvider::chat( network::build_ssl_options(proxy_opts), proxy_opts.proxies, proxy_opts.auth, - cpr::Timeout{stream_timeout_ms_} + cpr::Timeout{stream_timeout_ms_}, + cpr::ProgressCallback{[abort_flag](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !abort_flag || !abort_flag->load(); + }} ); + if (abort_flag && abort_flag->load()) { + return make_chat_error_response(make_provider_error( + ProviderErrorKind::UserCancelled, 0, name(), model_, {}, {}, + "[Interrupted]", false)); + } + if (r.status_code == 0) { const ProviderErrorKind kind = classify_cpr_error(r.error); return make_chat_error_response(make_provider_error( diff --git a/src/adapters/provider/openai_provider.hpp b/src/adapters/provider/openai_provider.hpp index 719bdcd76..efbf5c256 100644 --- a/src/adapters/provider/openai_provider.hpp +++ b/src/adapters/provider/openai_provider.hpp @@ -24,7 +24,12 @@ class OpenAiCompatProvider : public LlmProvider { ChatResponse chat( const std::vector& messages, const std::vector& tools - ) override; + ) override { return OpenAiCompatProvider::chat_cancellable(messages, tools, nullptr); } + + ChatResponse chat_cancellable( + const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) override; void chat_stream( const std::vector& messages, diff --git a/src/adapters/provider/provider_constructors.cpp b/src/adapters/provider/provider_constructors.cpp new file mode 100644 index 000000000..3d99ef3c4 --- /dev/null +++ b/src/adapters/provider/provider_constructors.cpp @@ -0,0 +1,38 @@ +#include "anthropic_provider.hpp" +#include "openai_provider.hpp" + +#include + +namespace acecode { + +OpenAiCompatProvider::OpenAiCompatProvider(const std::string& base_url, + const std::string& api_key, + const std::string& model, + int stream_timeout_ms, + std::map request_headers, + ProviderRequestOptions request_options) + : base_url_(normalize_endpoint(base_url, request_options.endpoint_mode)), + api_key_(api_key), + model_(model), + request_headers_(std::move(request_headers)), + request_options_(std::move(request_options)), + stream_timeout_ms_(stream_timeout_ms > 0 + ? stream_timeout_ms + : OpenAiConfig::kDefaultStreamTimeoutMs) {} + +AnthropicProvider::AnthropicProvider(const std::string& base_url, + const std::string& api_key, + const std::string& model, + int stream_timeout_ms, + std::map request_headers, + ProviderRequestOptions request_options) + : base_url_(normalize_base_url(base_url)), + api_key_(api_key), + model_(model), + request_headers_(std::move(request_headers)), + request_options_(std::move(request_options)), + stream_timeout_ms_(stream_timeout_ms > 0 + ? stream_timeout_ms + : OpenAiConfig::kDefaultStreamTimeoutMs) {} + +} // namespace acecode diff --git a/src/adapters/tool/skill_view_tool.cpp b/src/adapters/tool/skill_view_tool.cpp index f99d787bc..7f478d047 100644 --- a/src/adapters/tool/skill_view_tool.cpp +++ b/src/adapters/tool/skill_view_tool.cpp @@ -2,6 +2,7 @@ #include "config/config.hpp" #include "skills/skill_init.hpp" +#include "skills/skill_activation.hpp" #include "skills/skill_registry.hpp" #include "llm/tool_icons.hpp" #include "utils/logger.hpp" @@ -163,6 +164,9 @@ ToolImpl create_skill_view_tool(SkillRegistry& registry, oss << ifs.rdbuf(); body = oss.str(); } + if (body.empty()) { + return ToolResult{"[Error] Could not read the skill instructions. Reload the skill catalog and try again.", false}; + } auto supporting = active_registry.list_supporting_files(meta->name); nlohmann::json out; @@ -179,6 +183,7 @@ ToolImpl create_skill_view_tool(SkillRegistry& registry, std::to_string(supporting.size()) + " supporting files)"); ToolResult result{out.dump(), true}; result.summary = make_loaded_summary(meta->name); + result.metadata[kLoadedSkillsMetadata] = nlohmann::json::array({meta->name}); return result; }; diff --git a/src/adapters/tool/tool_executor.cpp b/src/adapters/tool/tool_executor.cpp index d250f2ebd..ee113f494 100644 --- a/src/adapters/tool/tool_executor.cpp +++ b/src/adapters/tool/tool_executor.cpp @@ -320,8 +320,10 @@ std::vector ToolExecutor::get_tool_definitions_by_source( // 返回空表 —— 空表意味着模型在零工具状态下静默运行,症状极难定位;原生名 // 永远可被 resolve_model_tool_name_to_native 精确命中,功能不受影响。 std::vector ToolExecutor::get_model_tool_definitions( - const ToolCapabilityPolicy* policy) const { + const ToolCapabilityPolicy* policy, + const std::unordered_set& loaded_skills) const { auto native = get_tool_definitions(policy); + filter_deferred_tools(native, loaded_skills); std::vector definitions; std::string error; if (!translate_tool_definitions_for_model(native, definitions, &error)) { @@ -334,8 +336,10 @@ std::vector ToolExecutor::get_model_tool_definitions( std::vector ToolExecutor::get_model_tool_definitions_by_source( ToolSource source, - const ToolCapabilityPolicy* policy) const { + const ToolCapabilityPolicy* policy, + const std::unordered_set& loaded_skills) const { auto native = get_tool_definitions_by_source(source, policy); + filter_deferred_tools(native, loaded_skills); std::vector definitions; std::string error; if (!translate_tool_definitions_for_model(native, definitions, &error)) { @@ -346,6 +350,18 @@ std::vector ToolExecutor::get_model_tool_definitions_by_source( return definitions; } +void ToolExecutor::filter_deferred_tools( + std::vector& definitions, + const std::unordered_set& loaded_skills) const { + std::lock_guard lock(tools_mu_); + definitions.erase(std::remove_if(definitions.begin(), definitions.end(), + [&](const ToolDef& def) { + const auto it = tools_.find(def.name); + return it != tools_.end() && !it->second.activation_skill.empty() && + loaded_skills.count(it->second.activation_skill) == 0; + }), definitions.end()); +} + std::string ToolExecutor::resolve_model_tool_name_to_native( const std::string& model_name) const { std::lock_guard lk(tools_mu_); @@ -419,6 +435,12 @@ ToolResult ToolExecutor::execute(const std::string& tool_name, const std::string ToolResult ToolExecutor::execute(const std::string& tool_name, const std::string& arguments_json, const ToolContext& ctx) const { + if (ctx.abort_flag && ctx.abort_flag->load(std::memory_order_acquire)) { + ToolResult result{"[Interrupted]", false}; + result.metadata = {{"cancelled", true}}; + ensure_tool_summary(tool_name, arguments_json, result); + return result; + } ToolImpl impl; { std::lock_guard lk(tools_mu_); @@ -442,6 +464,14 @@ ToolResult ToolExecutor::execute(const std::string& tool_name, const std::string } ToolContext effective_ctx = ctx; effective_ctx.tool_executor = const_cast(this); + // Admission can wait on registration/policy locks; recheck before invoking + // a handler. Already completed writes keep their real result after abort. + if (ctx.abort_flag && ctx.abort_flag->load(std::memory_order_acquire)) { + ToolResult result{"[Interrupted]", false}; + result.metadata = {{"cancelled", true}}; + ensure_tool_summary(tool_name, arguments_json, result); + return result; + } auto result = impl.execute(arguments_json, effective_ctx); ensure_tool_summary(tool_name, arguments_json, result); return result; diff --git a/src/adapters/tool/tool_executor.hpp b/src/adapters/tool/tool_executor.hpp index 4e3fbaf4f..ae1c10d77 100644 --- a/src/adapters/tool/tool_executor.hpp +++ b/src/adapters/tool/tool_executor.hpp @@ -221,6 +221,9 @@ struct ToolImpl { // Observation tools can be read-only for permissions yet depend on the // order of a stateful desktop session. Keep scheduling separate from it. bool requires_serial_execution = false; + // Model schemas are deferred until this skill has actually loaded in the + // requesting session. Registration and execution permissions stay separate. + std::string activation_skill; }; struct RegisteredToolInfo { @@ -258,11 +261,13 @@ class ToolExecutor { // Get tool definitions translated to the public names exposed to models. // Internal callers should continue using the native definition methods. std::vector get_model_tool_definitions( - const ToolCapabilityPolicy* policy = nullptr) const; + const ToolCapabilityPolicy* policy = nullptr, + const std::unordered_set& loaded_skills = {}) const; std::vector get_model_tool_definitions_by_source( ToolSource source, - const ToolCapabilityPolicy* policy = nullptr) const; + const ToolCapabilityPolicy* policy = nullptr, + const std::unordered_set& loaded_skills = {}) const; // Accept an exact registered native name first, then resolve a compatible // public alias only when its native handler is registered. 两步都没命中时 @@ -324,6 +329,8 @@ class ToolExecutor { const std::string& arguments_json); private: + void filter_deferred_tools(std::vector& definitions, + const std::unordered_set& loaded_skills) const; std::map tools_; mutable std::mutex tools_mu_; LifetimeToken lifetime_; diff --git a/src/adapters/tool/vision_subagent_tool.cpp b/src/adapters/tool/vision_subagent_tool.cpp index e46fdeecf..5660ad3eb 100644 --- a/src/adapters/tool/vision_subagent_tool.cpp +++ b/src/adapters/tool/vision_subagent_tool.cpp @@ -1,4 +1,5 @@ #include "vision_subagent_tool.hpp" +#include "utils/abandonable_call.hpp" #include "config/model_provider_registry.hpp" #include "provider/copilot_provider.hpp" @@ -320,6 +321,9 @@ ToolResult execute_vision_analyze( const ToolContext& ctx, const AppConfig* config, VisionSubagentToolOptions options) { + if (ctx.abort_flag && ctx.abort_flag->load()) { + return ToolResult{"[Interrupted]", false}; + } if (!config) { return error_result("CONFIG_UNAVAILABLE", "configuration unavailable"); } @@ -359,11 +363,6 @@ ToolResult execute_vision_analyze( auto factory = options.provider_factory ? options.provider_factory : VisionSubagentToolOptions::ProviderFactory(default_provider_factory); - auto provider = factory(*profile); - if (!provider) { - return error_result("PROVIDER_UNAVAILABLE", - "failed to create provider for saved model '" + profile->name + "'"); - } ChatMessage user; user.role = "user"; @@ -375,11 +374,30 @@ ToolResult execute_vision_analyze( ChatResponse response; try { - response = provider->chat({user}, {}); + // Only the independent provider and request snapshot can outlive this + // call. ToolContext, attachment/session services and callbacks cannot. + auto result = run_cancellable>( + [factory = std::move(factory), profile = *profile, user = std::move(user)] + (const std::atomic* cancellation) -> std::optional { + auto provider = factory(profile); + if (!provider) return std::nullopt; + return provider->chat_cancellable({user}, {}, cancellation); + }, ctx.abort_flag); + if (!result) return ToolResult{"[Interrupted]", false}; + if (!*result) { + return error_result("PROVIDER_UNAVAILABLE", + "failed to create provider for saved model '" + profile->name + "'"); + } + response = std::move(**result); } catch (const std::exception& e) { return error_result("PROVIDER_ERROR", e.what()); } + if (response.finish_reason == "error" || response.provider_error.has_error()) { + return error_result("PROVIDER_ERROR", response.provider_error.display_message.empty() + ? response.content : response.provider_error.display_message); + } + nlohmann::json out = { {"ok", true}, {"model_name", profile->name}, diff --git a/src/adapters/tool/vision_subagent_tool.hpp b/src/adapters/tool/vision_subagent_tool.hpp index 362ec9f81..17af624ce 100644 --- a/src/adapters/tool/vision_subagent_tool.hpp +++ b/src/adapters/tool/vision_subagent_tool.hpp @@ -15,6 +15,8 @@ struct VisionSubagentToolOptions { std::function(const ModelProfile&)>; using IndexChooser = std::function; + // With cancellation enabled, factory and its captures may outlive the + // invocation. Injected factories must own all captured dependencies. ProviderFactory provider_factory; IndexChooser choose_index; }; diff --git a/src/adapters/tool/web_search/backend_router.cpp b/src/adapters/tool/web_search/backend_router.cpp index a70874c85..394b81474 100644 --- a/src/adapters/tool/web_search/backend_router.cpp +++ b/src/adapters/tool/web_search/backend_router.cpp @@ -3,6 +3,7 @@ #include "duckduckgo_backend.hpp" #include "rss_search_backend.hpp" #include "utils/logger.hpp" +#include "utils/abandonable_call.hpp" #include #include @@ -17,6 +18,21 @@ namespace acecode::web_search { namespace { +using SearchOutcome = std::variant; + +SearchOutcome search_cancellable(std::shared_ptr backend, + std::string query, int limit, + const std::atomic* abort) { + const auto name = backend->name(); + auto result = run_cancellable( + [backend = std::move(backend), query = std::move(query), limit] + (const std::atomic* cancellation) { + return backend->search(query, limit, cancellation); + }, abort); + if (result) return std::move(*result); + return SearchError{SearchError::Kind::Network, "aborted", name}; +} + bool is_known_backend_name(const std::string& name) { return name == "parallel" || name == "rss" || name == "duckduckgo" || name == "bing_cn" || @@ -172,7 +188,7 @@ BackendRouter::search_parallel(std::string_view query, int limit, [backend = std::move(backend), backend_name, query = std::string(query), limit, abort]() -> Outcome { try { - return backend->search(query, limit, abort); + return search_cancellable(backend, query, limit, abort); } catch (const std::exception& e) { return SearchError{SearchError::Kind::Network, "search threw an exception: " + @@ -194,6 +210,10 @@ BackendRouter::search_parallel(std::string_view query, int limit, if (launched[i]) outcomes[i] = futures[i].get(); } + if (abort && abort->load()) { + return SearchError{SearchError::Kind::Network, "aborted", "parallel"}; + } + SearchResponse combined; combined.backend_name = "parallel"; std::array successful{}; @@ -278,7 +298,8 @@ BackendRouter::search_with_fallback(std::string_view query, int limit, ""}; } - auto first = primary_be->search(query, limit, abort); + auto first = search_cancellable(primary_be, std::string(query), limit, abort); + if (abort && abort->load()) return first; const bool rss_primary = primary == "rss"; std::string rss_fallback_reason; if (std::holds_alternative(first)) { @@ -308,7 +329,8 @@ BackendRouter::search_with_fallback(std::string_view query, int limit, return first; } - auto second = fallback_be->search(query, limit, abort); + auto second = search_cancellable(fallback_be, std::string(query), limit, abort); + if (abort && abort->load()) return second; if (std::holds_alternative(second)) { if (notify) { notify("RSS search had no usable result; used " + fallback_name + diff --git a/src/apps/daemon/worker.cpp b/src/apps/daemon/worker.cpp index add3ba9a5..2cb74de6e 100644 --- a/src/apps/daemon/worker.cpp +++ b/src/apps/daemon/worker.cpp @@ -39,6 +39,7 @@ #include "platform/process/process_runner.hpp" #include "loop/loop_scheduler.hpp" #include "loop/loop_store.hpp" +#include "loop/scheduled_task_tool.hpp" #include "session_host/local_session_client.hpp" #include "session/global_session_catalog.hpp" #include "session_host/session_registry.hpp" @@ -586,6 +587,13 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { if (loop_store_ready && !loop_scheduler.start(&loop_error)) { LOG_ERROR("[loop] scheduler failed to start: " + loop_error.message); } + // Shared ownership: this composition root owns the service; tool calls + // temporarily lock its weak reference. Sessions stop before dependencies. + auto scheduled_task_service = std::make_shared( + loop_store, cfg_mut, app_config_mu, workspace_registry, projects_dir, &loop_scheduler); + if (loop_store_ready && loop_scheduler.running()) { + tools.register_tool(acecode::loop::create_scheduled_task_tool(scheduled_task_service)); + } const std::string config_path = acecode::path_to_utf8(acecode::path_from_utf8(acecode::get_acecode_dir()) / "config.json"); diff --git a/src/apps/web/routes/routes_loops.cpp b/src/apps/web/routes/routes_loops.cpp index c3acccd95..33dfa96b7 100644 --- a/src/apps/web/routes/routes_loops.cpp +++ b/src/apps/web/routes/routes_loops.cpp @@ -2,6 +2,7 @@ #include "web/server_impl.hpp" #include "loop/loop_schedule.hpp" +#include "loop/loop_request.hpp" #include #include @@ -63,35 +64,28 @@ void WebServer::Impl::register_loops() { return json_response(req, 400, {{"error", "BAD_JSON"}, {"message", "invalid JSON body"}}); } + std::vector model_names; + { + std::shared_lock lock(app_config_mu); + if (deps.app_config) { + for (const auto& model : deps.app_config->saved_models) model_names.push_back(model.name); + } + } acecode::loop::ValidationError validation; - if (!acecode::loop::loop_from_json(body, value, &validation)) { + std::optional workspace_cwd; + if (body.is_object() && body.contains("workspace_hash") && body["workspace_hash"].is_string()) { + if (const auto workspace = resolve_workspace(body["workspace_hash"].get())) { + workspace_cwd = workspace->cwd; + } + } + const auto resolve = [workspace_cwd](const std::string&) { + return workspace_cwd; + }; + if (!acecode::loop::parse_loop_request(body, loop_now_ms(), model_names, resolve, value, validation)) { return json_response(req, 400, {{"error", validation.code}, {"field", validation.field}, {"message", validation.message}}); } - if (body.contains("schedule") && body["schedule"].is_object() && - !body["schedule"].contains("timezone_offset_minutes")) { - value.schedule.timezone_offset_minutes = - acecode::loop::current_timezone_offset_minutes(loop_now_ms()); - } - if (!value.workspace_hash.empty()) { - auto workspace = resolve_workspace(value.workspace_hash); - if (!workspace || workspace->cwd != value.workspace_cwd) { - return json_response(req, 400, - {{"error", "INVALID_WORKSPACE"}, - {"message", "workspace is not registered or its path changed"}}); - } - } - { - std::shared_lock lock(app_config_mu); - const bool exists = deps.app_config && std::any_of( - deps.app_config->saved_models.begin(), deps.app_config->saved_models.end(), - [&](const ModelProfile& model) { return model.name == value.model_name; }); - if (!exists) { - return json_response(req, 400, - {{"error", "INVALID_MODEL"}, {"message", "selected model is unavailable"}}); - } - } return std::nullopt; }; diff --git a/src/apps/web/routes/routes_models.cpp b/src/apps/web/routes/routes_models.cpp index 5d7a6cc99..280e36f49 100644 --- a/src/apps/web/routes/routes_models.cpp +++ b/src/apps/web/routes/routes_models.cpp @@ -571,7 +571,7 @@ void WebServer::Impl::register_models() { return with_cors(req, std::move(r)); }); - // Session reasoning is validated and applied behind the worker's idle gate. + // Session reasoning updates the provider snapshot for the next request. CROW_ROUTE(app, "/api/sessions//reasoning").methods(crow::HTTPMethod::POST) ([this](const crow::request& req, const std::string& sid) { if (auto rej = require_auth(req)) return std::move(*rej); @@ -598,8 +598,6 @@ void WebServer::Impl::register_models() { switch (result.status) { case SessionReasoningStatus::UnknownSession: return failure(404, "SESSION_NOT_FOUND", result.error); - case SessionReasoningStatus::Busy: - return failure(409, "SESSION_BUSY", result.error); case SessionReasoningStatus::InvalidEffort: return failure(400, "INVALID_REASONING_EFFORT", result.error); case SessionReasoningStatus::Unavailable: diff --git a/src/base/platform/process/process_runner.cpp b/src/base/platform/process/process_runner.cpp index 3a9a3315f..7f5649bb7 100644 --- a/src/base/platform/process/process_runner.cpp +++ b/src/base/platform/process/process_runner.cpp @@ -301,6 +301,10 @@ static HookProcessResult run_hook_process_impl( return result; }; + if (options.abort_flag && options.abort_flag->load()) { + result.aborted = true; + return finish(); + } std::vector argv = make_argv(command); if (argv.empty() || argv[0].empty()) { result.error = "hook command is empty"; @@ -340,6 +344,12 @@ static HookProcessResult run_hook_process_impl( cleanup(); return finish(); } + DWORD input_mode = PIPE_NOWAIT; + if (!SetNamedPipeHandleState(child_stdin_write, &input_mode, nullptr, nullptr)) { + result.error = "SetNamedPipeHandleState(stdin) failed: " + windows_error_message(GetLastError()); + cleanup(); + return finish(); + } if (!CreatePipe(&child_stdout_read, &child_stdout_write, &sa, 0)) { result.error = "CreatePipe(stdout) failed: " + windows_error_message(GetLastError()); cleanup(); @@ -480,19 +490,22 @@ static HookProcessResult run_hook_process_impl( } CloseHandle(pi.hThread); - if (!stdin_text.empty()) { - const char* ptr = stdin_text.data(); - std::size_t remaining = stdin_text.size(); - while (remaining > 0) { + std::size_t stdin_offset = 0; + auto feed_windows_stdin = [&]() { + if (!child_stdin_write) return; + if (stdin_offset < stdin_text.size()) { DWORD written = 0; - DWORD chunk = static_cast(std::min(remaining, 64 * 1024)); - if (!WriteFile(child_stdin_write, ptr, chunk, &written, nullptr)) break; - if (written == 0) break; - ptr += written; - remaining -= written; + const DWORD chunk = static_cast(std::min(stdin_text.size() - stdin_offset, 4096)); + if (!WriteFile(child_stdin_write, stdin_text.data() + stdin_offset, chunk, &written, nullptr)) { + close_handle(child_stdin_write); + return; + } + // A full nonblocking byte pipe reports zero bytes. Retry only + // after output draining and cancellation/timeout checks. + stdin_offset += written; } - } - close_handle(child_stdin_write); + if (stdin_offset == stdin_text.size()) close_handle(child_stdin_write); + }; CaptureState stdout_capture; CaptureState stderr_capture; @@ -503,7 +516,10 @@ static HookProcessResult run_hook_process_impl( std::size_t max_lines, bool stop_when_truncated, CaptureState& capture) { - for (;;) { + // Bound each pump so a continuously writing child cannot starve the + // outer cancellation checks. Also drain buffered output on completion. + std::size_t drained = 0; + while (drained < 1024 * 1024) { if (stop_when_truncated && capture.truncated) break; DWORD avail = 0; if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &avail, nullptr)) break; @@ -515,6 +531,7 @@ static HookProcessResult run_hook_process_impl( break; } if (bytes_read == 0) break; + drained += bytes_read; append_capped(out, buffer, bytes_read, @@ -570,8 +587,10 @@ static HookProcessResult run_hook_process_impl( terminate_child(); break; } + feed_windows_stdin(); Sleep(10); } + close_handle(child_stdin_write); drain_output(); DWORD exit_code = 1; @@ -659,17 +678,25 @@ static HookProcessResult run_hook_process_impl( flags = fcntl(stderr_pipe[0], F_GETFL, 0); if (flags >= 0) fcntl(stderr_pipe[0], F_SETFL, flags | O_NONBLOCK); - if (!stdin_text.empty()) { - const char* ptr = stdin_text.data(); - std::size_t remaining = stdin_text.size(); - while (remaining > 0) { - ssize_t written = write(stdin_pipe[1], ptr, remaining); - if (written <= 0) break; - ptr += written; - remaining -= static_cast(written); - } + flags = fcntl(stdin_pipe[1], F_GETFL, 0); + const bool nonblocking_stdin = flags >= 0 && fcntl(stdin_pipe[1], F_SETFL, flags | O_NONBLOCK) == 0; + if (!nonblocking_stdin) { + result.error = std::string("fcntl(stdin) failed: ") + std::strerror(errno); + close_fd(stdin_pipe[1]); } - close_fd(stdin_pipe[1]); + std::size_t stdin_offset = 0; + auto feed_stdin = [&]() { + if (stdin_pipe[1] < 0) return; + if (stdin_offset < stdin_text.size()) { + const ssize_t written = write(stdin_pipe[1], stdin_text.data() + stdin_offset, + std::min(stdin_text.size() - stdin_offset, 4096)); + if (written > 0) stdin_offset += static_cast(written); + else if (written < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + close_fd(stdin_pipe[1]); + } + } + if (stdin_offset == stdin_text.size()) close_fd(stdin_pipe[1]); + }; CaptureState stdout_capture; CaptureState stderr_capture; @@ -680,10 +707,12 @@ static HookProcessResult run_hook_process_impl( std::size_t max_lines, bool stop_when_truncated, CaptureState& capture) { - for (;;) { + std::size_t drained = 0; + while (drained < 1024 * 1024) { if (stop_when_truncated && capture.truncated) break; ssize_t n = read(fd, buffer, sizeof(buffer)); if (n > 0) { + drained += static_cast(n); append_capped(out, buffer, static_cast(n), @@ -746,6 +775,7 @@ static HookProcessResult run_hook_process_impl( terminate_and_reap(); break; } + if (!nonblocking_stdin) { terminate_and_reap(); break; } pid_t done = waitpid(pid, &status, WNOHANG); if (done == pid) { status_valid = true; @@ -762,8 +792,10 @@ static HookProcessResult run_hook_process_impl( terminate_and_reap(); break; } + feed_stdin(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } + close_fd(stdin_pipe[1]); drain_output(); if (status_valid && WIFEXITED(status)) result.exit_code = WEXITSTATUS(status); @@ -797,12 +829,25 @@ HookProcessResult run_hook_shell_command(const std::string& command, int timeout_ms, const std::string& cwd, const HookEnvironment& environment) { + HookProcessOptions options; + options.timeout_ms = timeout_ms; + return run_hook_shell_command(command, stdin_text, cwd, options, environment); +} + +HookProcessResult run_hook_shell_command(const std::string& command, + const std::string& stdin_text, + const std::string& cwd, + const HookProcessOptions& options, + const HookEnvironment& environment) { + if (options.abort_flag && options.abort_flag->load()) { + HookProcessResult result; + result.aborted = true; + return result; + } platform::ProcessSpec spec; #ifdef _WIN32 const char* comspec = std::getenv("COMSPEC"); spec.command = (comspec && *comspec) ? std::string(comspec) : std::string("cmd.exe"); - HookProcessOptions options; - options.timeout_ms = timeout_ms; return run_hook_process_impl( spec, stdin_text, cwd, options, environment, &command); #else @@ -821,7 +866,7 @@ HookProcessResult run_hook_shell_command(const std::string& command, spec.args.push_back("-c"); spec.args.push_back(command); } - return run_hook_process(spec, stdin_text, timeout_ms, cwd); + return run_hook_process(spec, stdin_text, cwd, options); #endif } diff --git a/src/base/platform/process/process_runner.hpp b/src/base/platform/process/process_runner.hpp index e13952b81..0536d9a02 100644 --- a/src/base/platform/process/process_runner.hpp +++ b/src/base/platform/process/process_runner.hpp @@ -81,4 +81,10 @@ HookProcessResult run_hook_shell_command(const std::string& command, const std::string& cwd, const HookEnvironment& environment = {}); +HookProcessResult run_hook_shell_command(const std::string& command, + const std::string& stdin_text, + const std::string& cwd, + const HookProcessOptions& options, + const HookEnvironment& environment = {}); + } // namespace acecode diff --git a/src/base/utils/abandonable_call.hpp b/src/base/utils/abandonable_call.hpp index bfbc8cd4a..841812016 100644 --- a/src/base/utils/abandonable_call.hpp +++ b/src/base/utils/abandonable_call.hpp @@ -156,4 +156,33 @@ auto run_abandonable(Fn&& fn, const Abort& abort, std::forward(fn), abort, poll); } +// A cancellable observation owns a fresh cancellation flag for its entire +// lifetime. The caller's flag is borrowed only while waiting; a late worker +// never observes a reset flag from a subsequent turn or a destroyed caller. +// Fn must own everything it uses and must not publish results/callbacks into +// its caller. Use cooperative, joined execution for mutations instead. +template +AbandonableResult run_cancellable(Fn&& fn, const std::atomic* abort) { + if (!abort) { + if constexpr (std::is_void_v) { + std::invoke(std::forward(fn), nullptr); + return true; + } else { + return std::invoke(std::forward(fn), nullptr); + } + } + if (abort->load(std::memory_order_acquire)) return {}; + // Shared only by this waiter and its owned worker; no link to the host. + auto cancellation = std::make_shared>(false); + auto result = run_abandonable( + [task = std::decay_t(std::forward(fn)), cancellation]() mutable -> R { + return std::invoke(task, cancellation.get()); + }, abort); + if (!result || abort->load(std::memory_order_acquire)) { + cancellation->store(true, std::memory_order_release); + return {}; + } + return result; +} + } // namespace acecode diff --git a/src/domain/hooks/hook_manager.cpp b/src/domain/hooks/hook_manager.cpp index 46ad7b0b2..1711ad5a3 100644 --- a/src/domain/hooks/hook_manager.cpp +++ b/src/domain/hooks/hook_manager.cpp @@ -12,22 +12,6 @@ namespace acecode { namespace { -HookProcessResult default_runner(const HookCommandSpec& command, - const std::string& stdin_text, - int timeout_ms, - const std::string& cwd) { - return run_hook_process(command, stdin_text, timeout_ms, cwd); -} - -HookProcessResult default_shell_runner(const std::string& command, - const std::string& stdin_text, - int timeout_ms, - const std::string& cwd, - const HookEnvironment& environment) { - return run_hook_shell_command( - command, stdin_text, timeout_ms, cwd, environment); -} - std::string mode_name(HookMode mode) { return mode == HookMode::Async ? "async" : "sync"; } @@ -76,8 +60,7 @@ HookManager::HookManager() HookManager::HookManager(HookConfig config, HookProcessRunner runner) : config_(std::move(config)), - runner_(runner ? std::move(runner) : HookProcessRunner(default_runner)), - shell_runner_(HookShellEnvironmentRunner(default_shell_runner)), + runner_(std::move(runner)), async_state_(std::make_shared()) {} HookManager::HookManager(HookRegistrySnapshot registry, @@ -85,7 +68,7 @@ HookManager::HookManager(HookRegistrySnapshot registry, HookShellRunner shell_runner) : config_{}, registry_(std::move(registry)), - runner_(legacy_runner ? std::move(legacy_runner) : HookProcessRunner(default_runner)), + runner_(std::move(legacy_runner)), shell_runner_(shell_runner ? HookShellEnvironmentRunner( [runner = std::move(shell_runner)]( @@ -96,7 +79,7 @@ HookManager::HookManager(HookRegistrySnapshot registry, const HookEnvironment&) { return runner(command, stdin_text, timeout_ms, cwd); }) - : HookShellEnvironmentRunner(default_shell_runner)), + : HookShellEnvironmentRunner{}), async_state_(std::make_shared()) {} HookManager::HookManager(HookRegistrySnapshot registry, @@ -104,11 +87,8 @@ HookManager::HookManager(HookRegistrySnapshot registry, HookShellEnvironmentRunner shell_runner) : config_{}, registry_(std::move(registry)), - runner_(legacy_runner ? std::move(legacy_runner) - : HookProcessRunner(default_runner)), - shell_runner_(shell_runner ? std::move(shell_runner) - : HookShellEnvironmentRunner( - default_shell_runner)), + runner_(std::move(legacy_runner)), + shell_runner_(std::move(shell_runner)), async_state_(std::make_shared()) {} std::size_t dispatch_startup_before_model_load_hooks(const std::string& cwd, @@ -125,7 +105,8 @@ HookManager::~HookManager() { std::size_t HookManager::dispatch(const std::string& event, const nlohmann::json& payload, - const std::string& cwd) { + const std::string& cwd, + const std::atomic* abort_flag) { if (!config_.enabled) return 0; auto it = config_.events.find(event); if (it == config_.events.end()) return 0; @@ -133,6 +114,7 @@ std::size_t HookManager::dispatch(const std::string& event, std::size_t count = 0; HookPlatform current = current_hook_platform(); for (const auto& hook : it->second) { + if (abort_flag && abort_flag->load()) break; if (!hook_platform_matches(hook, current)) { LOG_DEBUG("[hooks] skip id=" + hook.id + " event=" + event + " platform=" + hook_platform_name(current)); @@ -155,7 +137,7 @@ std::size_t HookManager::dispatch(const std::string& event, if (hook.mode == HookMode::Async) { enqueue_async(std::move(invocation)); } else { - run_invocation(invocation); + run_invocation(invocation, abort_flag); } } return count; @@ -174,6 +156,7 @@ HookAggregateOutcome HookManager::dispatch_codex(const HookDispatchRequest& requ std::optional payload_text; for (const auto& hook : registry.hooks) { + if (request.abort_flag && request.abort_flag->load()) break; if (!hook_matcher_matches(hook, request.event_name, request.matcher_value, @@ -219,10 +202,19 @@ HookAggregateOutcome HookManager::dispatch_codex(const HookDispatchRequest& requ environment["ACECODE_HOOK_SESSION_TITLE"] = request.payload.value("title", std::string{}); } - return shell_runner_(command, *payload_text, timeout_ms, - request.cwd, environment); + if (shell_runner_) { + return shell_runner_(command, *payload_text, timeout_ms, request.cwd, environment); + } + HookProcessOptions options; + options.timeout_ms = timeout_ms; + options.abort_flag = request.abort_flag; + options.terminate_process_tree = true; + return run_hook_shell_command(command, *payload_text, request.cwd, options, environment); }); + // Cancelled hooks cannot rewrite a result or block/continue a turn. + if (result.aborted || (request.abort_flag && request.abort_flag->load())) break; + const std::string status = result.timed_out ? "timeout" : (result.started && result.exit_code == 0 ? "ok" : (result.exit_code == 2 ? "blocked" : "error")); @@ -310,8 +302,8 @@ void HookManager::enqueue_async(Invocation invocation) { async_state_->cv.notify_one(); } -void HookManager::run_invocation(const Invocation& invocation) const { - run_invocation_with_runner(invocation, runner_); +void HookManager::run_invocation(const Invocation& invocation, const std::atomic* abort_flag) const { + run_invocation_with_runner(invocation, runner_, abort_flag); } void HookManager::worker_loop(std::shared_ptr state, @@ -351,7 +343,8 @@ void HookManager::worker_loop(std::shared_ptr state, } void HookManager::run_invocation_with_runner(const Invocation& invocation, - const HookProcessRunner& runner) { + const HookProcessRunner& runner, + const std::atomic* abort_flag) { LOG_INFO("[hooks] start id=" + invocation.hook.id + " event=" + invocation.hook.event + " mode=" + mode_name(invocation.hook.mode) + @@ -361,8 +354,13 @@ void HookManager::run_invocation_with_runner(const Invocation& invocation, HookProcessResult result = invoke_hook_safely([&] { const std::string payload_text = invocation.payload.dump( -1, ' ', false, nlohmann::json::error_handler_t::replace); - return runner(invocation.command, payload_text, - invocation.hook.timeout_ms, invocation.cwd); + if (runner) return runner(invocation.command, payload_text, + invocation.hook.timeout_ms, invocation.cwd); + HookProcessOptions options; + options.timeout_ms = invocation.hook.timeout_ms; + options.abort_flag = abort_flag; + options.terminate_process_tree = abort_flag != nullptr; + return run_hook_process(invocation.command, payload_text, invocation.cwd, options); }); std::string status = result.timed_out ? "timeout" : diff --git a/src/domain/hooks/hook_manager.hpp b/src/domain/hooks/hook_manager.hpp index 78e8146e0..d3bc5e1f6 100644 --- a/src/domain/hooks/hook_manager.hpp +++ b/src/domain/hooks/hook_manager.hpp @@ -54,7 +54,8 @@ class HookManager { std::size_t dispatch(const std::string& event, const nlohmann::json& payload, - const std::string& cwd); + const std::string& cwd, + const std::atomic* abort_flag = nullptr); HookAggregateOutcome dispatch_codex(const HookDispatchRequest& request); HookRegistrySnapshot registry_snapshot() const; @@ -84,12 +85,13 @@ class HookManager { void start_worker_locked(); void enqueue_async(Invocation invocation); - void run_invocation(const Invocation& invocation) const; + void run_invocation(const Invocation& invocation, const std::atomic* abort_flag) const; static void worker_loop(std::shared_ptr state, HookProcessRunner runner); static void run_invocation_with_runner(const Invocation& invocation, - const HookProcessRunner& runner); + const HookProcessRunner& runner, + const std::atomic* abort_flag = nullptr); HookConfig config_; HookRegistrySnapshot registry_; diff --git a/src/domain/hooks/hook_runtime.hpp b/src/domain/hooks/hook_runtime.hpp index b6b191b4e..132c17c46 100644 --- a/src/domain/hooks/hook_runtime.hpp +++ b/src/domain/hooks/hook_runtime.hpp @@ -37,6 +37,8 @@ struct HookDispatchRequest { std::string matcher_value; std::string cwd; nlohmann::json payload = nlohmann::json::object(); + // Nullable borrowed flag, used only during synchronous dispatch. + const std::atomic* abort_flag = nullptr; }; struct HookParsedOutput { diff --git a/src/domain/llm/llm_provider.hpp b/src/domain/llm/llm_provider.hpp index f87e9d0d2..02d2949db 100644 --- a/src/domain/llm/llm_provider.hpp +++ b/src/domain/llm/llm_provider.hpp @@ -229,6 +229,22 @@ class LlmProvider { const std::vector& tools ) = 0; + // Synchronous borrowed cancellation channel. Providers may override to + // cancel transport; the owned-call boundary bounds legacy provider waits. + virtual ChatResponse chat_cancellable( + const std::vector& messages, + const std::vector& tools, + const std::atomic* abort_flag) { + if (abort_flag && abort_flag->load()) { + ChatResponse response; + response.finish_reason = "error"; + response.provider_error.kind = ProviderErrorKind::UserCancelled; + response.provider_error.display_message = "[Interrupted]"; + return response; + } + return chat(messages, tools); + } + // Streaming chat: invokes callback for each event. abort_flag can cancel the request. virtual void chat_stream( const std::vector& messages, diff --git a/src/domain/skills/default_skill_seeder.cpp b/src/domain/skills/default_skill_seeder.cpp index 5736aab02..e9c4665d5 100644 --- a/src/domain/skills/default_skill_seeder.cpp +++ b/src/domain/skills/default_skill_seeder.cpp @@ -1262,7 +1262,7 @@ const std::vector& default_skill_seeds() { "acecode:acecode-tui-usage@2026-07-20", fs::path("acecode") / "acecode-tui-usage"}, {"acecode-desktop-usage", - "acecode:acecode-desktop-usage@2026-09-09", + "acecode:acecode-desktop-usage@2026-10-04", fs::path("acecode") / "acecode-desktop-usage"}, {"vision-image-reader", "acecode:vision-image-reader@2026-05-28", @@ -1270,6 +1270,9 @@ const std::vector& default_skill_seeds() { {"ai-theme", "acecode:ai-theme@2026-09-15.3", fs::path("acecode") / "ai-theme"}, + {"scheduled-task", + "acecode:scheduled-task@2026-10-04", + fs::path("acecode") / "scheduled-task"}, }; return seeds; } diff --git a/src/domain/skills/skill_activation.hpp b/src/domain/skills/skill_activation.hpp index 2718cffe0..2b24d095c 100644 --- a/src/domain/skills/skill_activation.hpp +++ b/src/domain/skills/skill_activation.hpp @@ -9,6 +9,9 @@ namespace acecode { class SkillRegistry; +// Trusted load records emitted after reading a skill, persisted with messages. +inline constexpr const char* kLoadedSkillsMetadata = "loaded_skills"; + // Result of applying Codex-compatible explicit Skill selection to one user // turn. `prompt` is model-visible; `injected_skill_names` is ordered by the // registry and contains each successfully-read Skill at most once. diff --git a/src/engine/agent/agent_loop.cpp b/src/engine/agent/agent_loop.cpp index 22c492ed1..53dd9d742 100644 --- a/src/engine/agent/agent_loop.cpp +++ b/src/engine/agent/agent_loop.cpp @@ -80,7 +80,7 @@ AgentLoop::AgentLoop(AgentLoopServices services, AgentLoopOptions options) , boundary_(std::make_unique(options.cwd, permissions_)) , exec_security_(std::make_unique(*boundary_, permissions_, busy_, runtime_)) , hooks_(std::make_unique(*boundary_, permissions_, - provider_accessor_, *transcript_, *history_)) + provider_accessor_, *transcript_, *history_, abort_signal_)) , tool_hooks_(std::make_unique(*hooks_)) , goal_(std::make_unique(*task_queue_, *history_, *transcript_, events_, callbacks_, permissions_, busy_, abort_signal_)) diff --git a/src/engine/agent/hook_bridge/agent_hook_bridge.cpp b/src/engine/agent/hook_bridge/agent_hook_bridge.cpp index 6d810732d..da0b5c8e9 100644 --- a/src/engine/agent/hook_bridge/agent_hook_bridge.cpp +++ b/src/engine/agent/hook_bridge/agent_hook_bridge.cpp @@ -9,6 +9,7 @@ #include "session/session_rewind.hpp" #include "session/system_notice.hpp" #include "utils/logger.hpp" +#include "utils/abort_signal.hpp" #include #include @@ -48,7 +49,7 @@ void AgentHookBridge::assistant_completed(HookManager* manager, SessionManager* provider_name, model_name, assistant_msg); - manager->dispatch(kHookEventAssistantMessageCompleted, payload, context_.cwd()); + manager->dispatch(kHookEventAssistantMessageCompleted, payload, context_.cwd(), &abort_.raw()); } void AgentHookBridge::apply(const HookAggregateOutcome& outcome, @@ -97,6 +98,11 @@ HookAggregateOutcome AgentHookBridge::dispatch(HookManager* manager, request.matcher_value = matcher_value; request.cwd = context_.cwd(); request.payload = payload.is_object() ? payload : nlohmann::json::object(); + // Session lifecycle hooks are outside a foreground turn. All synchronous + // turn hooks borrow the same cancellation channel as model/tool execution. + if (event_name != kCodexHookEventSessionStart && event_name != kCodexHookEventSessionTitleChanged) { + request.abort_flag = &abort_.raw(); + } return manager->dispatch_codex(request); } diff --git a/src/engine/agent/hook_bridge/agent_hook_bridge.hpp b/src/engine/agent/hook_bridge/agent_hook_bridge.hpp index eeac20244..caffa1dc8 100644 --- a/src/engine/agent/hook_bridge/agent_hook_bridge.hpp +++ b/src/engine/agent/hook_bridge/agent_hook_bridge.hpp @@ -8,7 +8,7 @@ #include #include -namespace acecode { class HookManager; struct ChatMessage; } +namespace acecode { class HookManager; class AbortSignal; struct ChatMessage; } namespace acecode::agent { class TranscriptWriter; @@ -18,8 +18,8 @@ class AgentHookBridge { public: AgentHookBridge(WorkspaceBoundary& boundary, PermissionManager& permissions, HookContextProvider::ProviderAccessor provider, - TranscriptWriter& transcript, ConversationHistory& history) - : context_(boundary, permissions, std::move(provider)), transcript_(transcript), history_(history) {} + TranscriptWriter& transcript, ConversationHistory& history, AbortSignal& abort) + : context_(boundary, permissions, std::move(provider)), transcript_(transcript), history_(history), abort_(abort) {} HookCommonPayloadFields common_fields(const std::string& event, SessionManager* session) const; HookAggregateOutcome dispatch(HookManager* manager, const std::string& event, const std::string& matcher, const nlohmann::json& payload); @@ -38,6 +38,7 @@ class AgentHookBridge { HookContextProvider context_; TranscriptWriter& transcript_; ConversationHistory& history_; + AbortSignal& abort_; std::mutex context_mu_; // Leaf: append or swap only, no observers/store calls. std::vector request_context_; bool stop_active_ = false; // Worker-only, intentionally survives turn boundaries. diff --git a/src/engine/agent/request/api_request_builder.cpp b/src/engine/agent/request/api_request_builder.cpp index 54b5320aa..c5252bedf 100644 --- a/src/engine/agent/request/api_request_builder.cpp +++ b/src/engine/agent/request/api_request_builder.cpp @@ -7,6 +7,7 @@ #include "prompt/memory_prompt.hpp" #include "prompt/mesh_swarm_prompts.hpp" #include "skills/skill_registry.hpp" +#include "skills/skill_activation.hpp" #include "skills/skill_usage_store.hpp" #include "utils/logger.hpp" #include @@ -159,10 +160,22 @@ RequestBuildInputs ApiRequestBuilder::capture( inputs.emergency_profile = emergency_profile; inputs.system_prompt = static_system_prompt(options); LOG_DEBUG("System prompt length: " + std::to_string(inputs.system_prompt.size())); + std::unordered_set loaded_skills; + for (const auto& message : history) { + if ((message.role != "user" && message.role != "tool") || + !message.metadata.is_object()) continue; + if (message.role == "tool" && + (!message.metadata.contains("tool_success") || message.metadata["tool_success"] != true)) continue; + const auto names = message.metadata.find(kLoadedSkillsMetadata); + if (names == message.metadata.end() || !names->is_array()) continue; + for (const auto& name : *names) { + if (name.is_string()) loaded_skills.insert(name.get()); + } + } auto builtin_tool_defs = tools_.get_model_tool_definitions_by_source( - ToolSource::Builtin, &options.tool_policy); + ToolSource::Builtin, &options.tool_policy, loaded_skills); auto mcp_tool_defs = tools_.get_model_tool_definitions_by_source( - ToolSource::Mcp, &options.tool_policy); + ToolSource::Mcp, &options.tool_policy, loaded_skills); if (emergency_profile) { // 这里拿到的已是模型侧定义,核心工具名必须经映射取,不能写死 read/write: // 「工具重写」关闭时它们叫 file_read / file_write,写死会把核心工具整个滤掉。 @@ -195,7 +208,7 @@ RequestBuildInputs ApiRequestBuilder::capture( // keeps builtin and MCP tools in registry order, which is also part of // prompt-cache stability and expert-switch behavior. inputs.tool_defs = - tools_.get_model_tool_definitions(&options.tool_policy); + tools_.get_model_tool_definitions(&options.tool_policy, loaded_skills); } // GPT / Codex 系模型只看到 apply_patch,其它模型只看到 file_edit / file_write // (openspec add-gpt-apply-patch-adaptation)。三个工具始终注册,这里只裁 diff --git a/src/engine/agent/turn/turn_finalizer.cpp b/src/engine/agent/turn/turn_finalizer.cpp index 93eb9e272..aae4c5ed8 100644 --- a/src/engine/agent/turn/turn_finalizer.cpp +++ b/src/engine/agent/turn/turn_finalizer.cpp @@ -243,6 +243,7 @@ void TurnFinalizer::prepare_normal(Frame& frame) { // This notice stays out of the provider's message history. transcript_.emit_transcript_system_message(session_manager_, "[Interrupted]", make_system_notice_metadata("turn_interrupted", {}, { {"user_aborted", true}, + {"turn_id", turn_info.active_turn_id}, {"retry_user_message_id", user ? user->uuid : std::string{}}, })); } diff --git a/src/engine/agent/turn/turn_lifecycle.cpp b/src/engine/agent/turn/turn_lifecycle.cpp index 3003f13f2..e85830203 100644 --- a/src/engine/agent/turn/turn_lifecycle.cpp +++ b/src/engine/agent/turn/turn_lifecycle.cpp @@ -106,6 +106,13 @@ UserTurnInfo TurnLifecycle::prepare_user_turn(const UserInput& input, if (input.metadata.is_object() && !input.metadata.empty()) { user_msg.metadata = input.metadata; } + // Only successful expansion may activate deferred tools; caller-provided + // metadata must not claim a skill was loaded. + if (user_msg.metadata.is_object()) user_msg.metadata.erase(kLoadedSkillsMetadata); + if (!skill_expansion.injected_skill_names.empty()) { + if (!user_msg.metadata.is_object()) user_msg.metadata = nlohmann::json::object(); + user_msg.metadata[kLoadedSkillsMetadata] = skill_expansion.injected_skill_names; + } if (!display_text.empty() && display_text != user_message) { // 让 UI 渲染 display_text(原文),LLM 看到的仍是 user_message(展开后的)。 // session_serializer 会把 metadata 全字段持久化,resume 后恢复。 diff --git a/src/host/loop/loop_request.cpp b/src/host/loop/loop_request.cpp new file mode 100644 index 000000000..8eda52b94 --- /dev/null +++ b/src/host/loop/loop_request.cpp @@ -0,0 +1,33 @@ +#include "loop_request.hpp" +#include "loop_schedule.hpp" + +#include + +namespace acecode::loop { + +bool parse_loop_request(const nlohmann::json& body, + std::int64_t now_ms, + const std::vector& model_names, + const std::function(const std::string&)>& workspace_cwd, + LoopDefinition& out, + ValidationError& error) { + if (!loop_from_json(body, out, &error)) return false; + if (!body["schedule"].contains("timezone_offset_minutes")) { + out.schedule.timezone_offset_minutes = current_timezone_offset_minutes(now_ms); + } + if (!out.workspace_hash.empty()) { + const auto cwd = workspace_cwd(out.workspace_hash); + if (!cwd || *cwd != out.workspace_cwd) { + error = {"INVALID_WORKSPACE", "workspace_cwd", + "workspace is not registered or its path changed"}; + return false; + } + } + if (std::find(model_names.begin(), model_names.end(), out.model_name) == model_names.end()) { + error = {"INVALID_MODEL", "model_name", "selected model is unavailable"}; + return false; + } + return true; +} + +} // namespace acecode::loop diff --git a/src/host/loop/loop_request.hpp b/src/host/loop/loop_request.hpp new file mode 100644 index 000000000..708d83cc6 --- /dev/null +++ b/src/host/loop/loop_request.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "loop_types.hpp" + +#include +#include + +namespace acecode::loop { + +// The resolver is borrowed synchronously; this function never retains it. +bool parse_loop_request(const nlohmann::json& body, + std::int64_t now_ms, + const std::vector& model_names, + const std::function(const std::string&)>& workspace_cwd, + LoopDefinition& out, + ValidationError& error); + +} // namespace acecode::loop diff --git a/src/host/loop/scheduled_task_tool.cpp b/src/host/loop/scheduled_task_tool.cpp new file mode 100644 index 000000000..57257bcbd --- /dev/null +++ b/src/host/loop/scheduled_task_tool.cpp @@ -0,0 +1,181 @@ +#include "scheduled_task_tool.hpp" + +#include "loop_request.hpp" +#include "loop_scheduler.hpp" +#include "config/config.hpp" +#include "workspace/workspace_registry.hpp" + +#include +#include + +namespace acecode::loop { +namespace { +using nlohmann::json; + +ToolResult failure(const std::string& code, const std::string& message) { + return ToolResult{json{{"error", code}, {"message", message}}.dump(), false}; +} + +json property(const char* type, const char* description) { + return {{"type", type}, {"description", description}}; +} +} + +ScheduledTaskService::ScheduledTaskService( + LoopStore& store, const AppConfig& config, std::shared_mutex& config_mutex, + desktop::WorkspaceRegistry& workspaces, std::string projects_dir, + LoopScheduler* scheduler, Clock clock) + : store_(store), config_(config), config_mutex_(config_mutex), + workspaces_(workspaces), projects_dir_(std::move(projects_dir)), + scheduler_(scheduler), clock_(std::move(clock)) { + if (!clock_) clock_ = [] { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + }; +} + +std::optional ScheduledTaskService::prepare(json body, ValidationError& error) const { + if (!body.is_object()) { + error = {"BAD_JSON", "", "scheduled task arguments must be an object"}; + return std::nullopt; + } + std::vector models; + { + std::shared_lock lock(config_mutex_); + for (const auto& model : config_.saved_models) models.push_back(model.name); + if (!body.contains("model_name")) { + body["model_name"] = !config_.default_model_name.empty() + ? config_.default_model_name : (models.empty() ? "" : models.front()); + } + } + const auto now = clock_(); + if (body.contains("schedule") && body["schedule"].is_object() && + body["schedule"].contains("kind") && body["schedule"]["kind"] == "interval" && + !body["schedule"].contains("anchor_ms")) body["schedule"]["anchor_ms"] = now; + LoopDefinition definition; + const auto resolve = [workspaces = workspaces_.list(), projects_dir = projects_dir_]( + const std::string& hash) -> std::optional { + for (const auto& workspace : workspaces) { + if (workspace.hash == hash) return workspace.cwd; + } + if (hash.size() != 16 || !std::all_of(hash.begin(), hash.end(), [](unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + })) return std::nullopt; + if (const auto workspace = desktop::load_workspace_metadata(projects_dir, hash)) { + if (desktop::workspace_hash_matches_cwd(hash, workspace->cwd)) return workspace->cwd; + } + return std::nullopt; + }; + if (!parse_loop_request(body, now, models, resolve, definition, error)) return std::nullopt; + ScheduleCompilation compiled; + if (!compile_schedule(definition.schedule, now, compiled, &error)) return std::nullopt; + return definition; +} + +ToolResult ScheduledTaskService::create(const LoopDefinition& definition) { + ValidationError validation; + auto checked = prepare(loop_to_json(definition), validation); + if (!checked) return failure(validation.code, validation.message); + StoreError error; + const auto created = store_.create_loop(std::move(*checked), clock_(), &error); + if (!created) { + json result{{"error", error.code}, {"message", error.message}}; + if (error.conflict) result["conflict"] = { + {"loop_id", error.conflict->loop_id}, {"loop_name", error.conflict->loop_name}, + {"first_conflict_at_ms", error.conflict->first_conflict_at_ms}}; + return ToolResult{result.dump(), false}; + } + if (scheduler_) scheduler_->notify_changed(); + return ToolResult{json{{"created", true}, {"task", loop_to_json(*created)}, + {"schedule_summary", schedule_summary(created->schedule)}, + {"message", "定时任务已创建。可在定时任务页面管理,无需等待执行。"}}.dump(), true}; +} + +ToolImpl create_scheduled_task_tool(std::weak_ptr service) { + ToolImpl tool; + tool.definition.name = "create_scheduled_task"; + tool.definition.description = + "Create an ACECode scheduled task using the same store and scheduler as manual creation. " + "Load the scheduled-task skill first. This tool asks the user to choose execution permissions " + "(YOLO recommended) before saving. On success, report the task and next run; do not wait for execution."; + auto schedule = property("object", "Same schedule fields as the manual scheduled task form. Epoch values are milliseconds."); + schedule["properties"] = { + {"kind", {{"type", "string"}, {"enum", {"period", "interval", "once"}}}}, + {"period", {{"type", "string"}, {"enum", {"daily", "workdays", "weekly"}}}}, + {"weekdays", {{"type", "array"}, {"items", {{"type", "integer"}, {"minimum", 0}, {"maximum", 6}}}}}, + {"hour", {{"type", "integer"}, {"minimum", 0}, {"maximum", 23}}}, + {"minute", {{"type", "integer"}, {"minimum", 0}, {"maximum", 59}}}, + {"interval_value", property("integer", "Positive interval length.")}, + {"interval_unit", {{"type", "string"}, {"enum", {"minutes", "hours", "days"}}}}, + {"anchor_ms", property("integer", "Interval anchor; defaults to now.")}, + {"once_at_ms", property("integer", "Required for a one-time task.")}, + {"timezone_offset_minutes", property("integer", "Defaults to daemon local UTC offset.")}, + {"valid_from_ms", property("integer", "Optional inclusive start of validity.")}, + {"valid_until_ms", property("integer", "Optional inclusive end of validity.")}}; + schedule["required"] = json::array({"kind"}); + schedule["additionalProperties"] = false; + tool.definition.parameters = {{"type", "object"}, {"properties", { + {"name", property("string", "Short user-visible task name.")}, + {"prompt", property("string", "Instructions to execute when the task is due.")}, + {"schedule", schedule}, + {"model_name", property("string", "Saved model name; omitted uses the manual form default.")}, + {"workspace_hash", property("string", "Optional registered workspace hash, paired with workspace_cwd.")}, + {"workspace_cwd", property("string", "Optional registered workspace path; omit both for reminders without a project.")}, + {"use_worktree", property("boolean", "Use an isolated worktree; default false, like manual creation.")}}}, + {"required", json::array({"name", "prompt", "schedule"})}, {"additionalProperties", false}}; + tool.activation_skill = "scheduled-task"; + tool.requires_serial_execution = true; + tool.execute = [service](const std::string& arguments, const ToolContext& ctx) { + auto body = json::parse(arguments, nullptr, false); + if (!body.is_object()) return failure("BAD_JSON", "arguments must be an object"); + // Permission comes only from the actual user response, never model args. + body.erase("permission_mode"); + body.erase("id"); + body["enabled"] = true; + ValidationError error; + std::optional definition; + { + const auto owner = service.lock(); + if (!owner) return failure("LOOP_UNAVAILABLE", "定时任务服务不可用。"); + definition = owner->prepare(std::move(body), error); + } + if (!definition) return failure(error.code, error.message); + if (!ctx.ask_user_questions) return failure("PERMISSION_REQUIRED", "请在支持权限提问的聊天中创建定时任务。"); + if (ctx.abort_flag && ctx.abort_flag->load()) return failure("CANCELLED", "已取消创建定时任务。"); + const std::string question = "定时任务“" + definition->name + "”使用哪种执行权限?"; + const auto answer = ctx.ask_user_questions(json::array({{ + {"id", "scheduled-task-permission"}, {"text", question}, {"header", "执行权限"}, + {"multiSelect", false}, {"options", json::array({ + {{"label", "完全访问权限(YOLO,推荐)"}, {"value", "yolo"}, {"recommended", true}, + {"description", "到点自动执行,工具操作无需逐次批准。"}}, + {{"label", "默认权限"}, {"value", "default"}, + {"description", "需要授权时等待你确认,可能暂停执行。"}} + })} + }})); + if (!answer.is_object() || answer.value("cancelled", false) || + answer.value("interjected", false) || answer.value("timed_out", false) || + (ctx.abort_flag && ctx.abort_flag->load())) { + return failure("CANCELLED", "尚未确认权限,未创建定时任务。"); + } + std::string permission; + if (answer.contains("answers") && answer["answers"].is_array()) { + for (const auto& item : answer["answers"]) { + if (!item.is_object() || item.value("question_id", "") != "scheduled-task-permission" || + item.value("auto_selected", false) || item.value("not_answered", false) || + !item.contains("selected") || !item["selected"].is_array() || item["selected"].size() != 1 || + !item["selected"][0].is_string() || !item.value("custom_text", std::string{}).empty()) continue; + permission = item["selected"][0].get(); + } + } + if (permission != "yolo" && permission != "default") { + return failure("PERMISSION_REQUIRED", "未选择执行权限,未创建定时任务。请重新选择权限后再试。"); + } + definition->permission_mode = permission; + const auto owner = service.lock(); + if (!owner) return failure("LOOP_UNAVAILABLE", "定时任务服务已关闭。"); + return owner->create(*definition); + }; + return tool; +} + +} // namespace acecode::loop diff --git a/src/host/loop/scheduled_task_tool.hpp b/src/host/loop/scheduled_task_tool.hpp new file mode 100644 index 000000000..90116b142 --- /dev/null +++ b/src/host/loop/scheduled_task_tool.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "loop_store.hpp" +#include "tool/tool_executor.hpp" + +#include +#include + +namespace acecode { +struct AppConfig; +namespace desktop { class WorkspaceRegistry; } +} + +namespace acecode::loop { + +class LoopScheduler; + +// Owned by the daemon; borrowed dependencies outlive registry shutdown. Tool +// closures only hold a weak_ptr, and lock it for each synchronous operation. +class ScheduledTaskService { +public: + using Clock = std::function; + ScheduledTaskService(LoopStore& store, const AppConfig& config, + std::shared_mutex& config_mutex, + desktop::WorkspaceRegistry& workspaces, + std::string projects_dir, + LoopScheduler* scheduler = nullptr, // nullable, borrowed + Clock clock = {}); + + std::optional prepare(nlohmann::json body, ValidationError& error) const; + ToolResult create(const LoopDefinition& definition); + +private: + LoopStore& store_; + const AppConfig& config_; + std::shared_mutex& config_mutex_; + desktop::WorkspaceRegistry& workspaces_; + std::string projects_dir_; + LoopScheduler* scheduler_; // nullable, borrowed + Clock clock_; +}; + +ToolImpl create_scheduled_task_tool(std::weak_ptr service); + +} // namespace acecode::loop diff --git a/src/host/session_host/session_registry.cpp b/src/host/session_host/session_registry.cpp index c2d5da210..10baa9308 100644 --- a/src/host/session_host/session_registry.cpp +++ b/src/host/session_host/session_registry.cpp @@ -1052,10 +1052,14 @@ SessionRegistry::make_entry_locked(const std::string& id, registry.handle_auto_title_turn_finished(id, status); }); }; - auto binding = entry->model_binding; - AgentLoop::ProviderAccessor provider_accessor = [binding]() { - return binding ? binding->provider_snapshot() - : std::shared_ptr{}; + AgentLoop::ProviderAccessor provider_accessor = [weak = std::weak_ptr(entry)]() { + auto active = weak.lock(); + if (!active) return std::shared_ptr{}; + // A new request must not observe a provider whose reasoning metadata + // is still being persisted (and may need to be rolled back). + std::lock_guard model_lock(active->model_control_mu); + return active->model_binding ? active->model_binding->provider_snapshot() + : std::shared_ptr{}; }; AgentLoopServices loop_services{*deps_.tools, *entry->perm}; loop_services.provider = std::move(provider_accessor); @@ -2017,54 +2021,48 @@ SessionReasoningResult SessionRegistry::set_reasoning_effort( result.error = "session model unavailable"; return result; } - // The queue gate covers pending submissions as well as running turns. - // Lock order: queue gate -> model control -> binding -> session metadata. - const bool ran = entry->loop->try_run_idle_control([&] { - std::lock_guard model_lock(entry->model_control_mu); - result.state = entry->model_binding->state_snapshot(); - SessionModelResolver resolver = [this, effort](const std::string& name) { - return resolve_target_for_name(deps_, name, effort, true); - }; - SessionModelResolvedTarget target; - try { - target = resolver(result.state.name); - } catch (const SessionReasoningValidationError& ex) { - result.status = SessionReasoningStatus::InvalidEffort; - result.error = ex.what(); - return; - } - if (!target.profile || !target.config) { - result.status = SessionReasoningStatus::Unavailable; - result.error = "session model unavailable"; - return; - } - auto previous = entry->model_binding->runtime_snapshot(); - const auto installed = entry->model_binding->install_explicit( - std::move(target), resolver); - result.state = installed.state; - if (!installed.ok) { - result.error = installed.error; - return; - } - // An empty session must retain an explicit selection across restart. - if (!entry->sm->set_active_model_state( - result.state.provider, result.state.model, result.state.name, - result.state.reasoning_effort, true)) { - entry->model_binding->install_cloned_snapshot(std::move(previous)); - result.state = entry->model_binding->state_snapshot(); - result.error = "session reasoning metadata could not be persisted"; - return; - } - if (result.state.context_window > 0) { - entry->loop->set_context_window(result.state.context_window); - } - result.status = SessionReasoningStatus::Updated; - }); - if (!ran) { - result.status = SessionReasoningStatus::Busy; + // Active requests retain their provider lease. Publish the new provider + // for the next request, including later steps in the same busy turn. + // Lock order: model control -> binding -> session metadata; no queue gate. + std::lock_guard model_lock(entry->model_control_mu); + result.state = entry->model_binding->state_snapshot(); + SessionModelResolver resolver = [this, effort](const std::string& name) { + return resolve_target_for_name(deps_, name, effort, true); + }; + SessionModelResolvedTarget target; + try { + target = resolver(result.state.name); + } catch (const SessionReasoningValidationError& ex) { + result.status = SessionReasoningStatus::InvalidEffort; + result.error = ex.what(); + return result; + } + if (!target.profile || !target.config) { + result.status = SessionReasoningStatus::Unavailable; + result.error = "session model unavailable"; + return result; + } + auto previous = entry->model_binding->runtime_snapshot(); + const auto installed = entry->model_binding->install_explicit( + std::move(target), resolver); + result.state = installed.state; + if (!installed.ok) { + result.error = installed.error; + return result; + } + // An empty session must retain an explicit selection across restart. + if (!entry->sm->set_active_model_state( + result.state.provider, result.state.model, result.state.name, + result.state.reasoning_effort, true)) { + entry->model_binding->install_cloned_snapshot(std::move(previous)); result.state = entry->model_binding->state_snapshot(); - result.error = "session is busy"; + result.error = "session reasoning metadata could not be persisted"; + return result; + } + if (result.state.context_window > 0) { + entry->loop->set_context_window(result.state.context_window); } + result.status = SessionReasoningStatus::Updated; return result; } diff --git a/src/host/session_host/session_registry.hpp b/src/host/session_host/session_registry.hpp index a7d289634..dfe06a3ba 100644 --- a/src/host/session_host/session_registry.hpp +++ b/src/host/session_host/session_registry.hpp @@ -92,7 +92,8 @@ struct SessionEntry { // 只在父会话的工具线程上读写。 std::string workspace_watch_cwd; std::vector workspace_watch_baseline; - // Serializes profile selection, reload and session reasoning overrides. + // Serializes profile selection, reload, reasoning overrides and request + // snapshot acquisition, including persistence failure rollback. // Never acquire the AgentLoop queue gate while holding this mutex. std::mutex model_control_mu; std::shared_ptr model_binding; @@ -193,7 +194,7 @@ class SessionReasoningValidationError : public std::invalid_argument { : std::invalid_argument("reasoning effort is not supported by the enabled model") {} }; -enum class SessionReasoningStatus { Updated, UnknownSession, Busy, InvalidEffort, Unavailable, Failed }; +enum class SessionReasoningStatus { Updated, UnknownSession, InvalidEffort, Unavailable, Failed }; struct SessionReasoningResult { SessionReasoningStatus status = SessionReasoningStatus::Failed; diff --git a/tests/agent/agent_loop_turn_steering_test.cpp b/tests/agent/agent_loop_turn_steering_test.cpp index 598ca8a0d..e9359e4ad 100644 --- a/tests/agent/agent_loop_turn_steering_test.cpp +++ b/tests/agent/agent_loop_turn_steering_test.cpp @@ -9,6 +9,9 @@ #include "session/turn_timing.hpp" #include "test_support/agent/stub_provider.hpp" #include "tool/tool_executor.hpp" +#include "utils/abandonable_call.hpp" +#include "utils/scope_exit.hpp" +#include "test_support/utils/concurrency_gate.hpp" #include #include @@ -76,6 +79,7 @@ class TurnSteeringHarness { acecode::AgentLoop& loop() { return *loop_; } acecode_test::StubLlmProvider& provider() { return *provider_; } acecode::SessionManager& session_manager() { return *sm_; } + acecode::ToolExecutor& tools() { return tools_; } std::string wait_for_active_turn(std::chrono::milliseconds timeout = 5s) { const auto deadline = std::chrono::steady_clock::now() + timeout; @@ -360,6 +364,68 @@ TEST(AgentLoopUserMessageRetry, RetriesAbortAfterPartialTextOrReasoning) { } } +TEST(AgentLoopCancellation, ParallelWaitsEndBeforeLateResultsAndQueuedTurnStartsCleanly) { + auto first = std::make_shared(); + auto second = std::make_shared(); + auto release = std::make_shared(); + acecode::ScopeExit unblock([release] { release->open(); }); + std::atomic writes{0}; + TurnSteeringHarness h("cancel-parallel-tools"); + for (const auto& name : {"slow_one", "slow_two"}) { + acecode::ToolImpl tool; + tool.definition.name = name; + tool.definition.parameters = {{"type", "object"}}; + tool.is_read_only = true; + tool.execute = [entered = std::string(name) == "slow_one" ? first : second, release] + (const std::string&, const acecode::ToolContext& context) { + auto result = acecode::run_cancellable( + [entered, release](auto) { + entered->open(); + release->wait(5s); + return acecode::ToolResult{"must not reach next turn", true}; + }, context.abort_flag); + return result ? std::move(*result) : acecode::ToolResult{"[Interrupted]", false}; + }; + ASSERT_TRUE(h.tools().register_tool(std::move(tool))); + } + acecode::ToolImpl write; + write.definition.name = "cancelled_write"; + write.definition.parameters = {{"type", "object"}}; + write.execute = [&writes](const std::string&, const acecode::ToolContext&) { + ++writes; + return acecode::ToolResult{"written", true}; + }; + ASSERT_TRUE(h.tools().register_tool(std::move(write))); + acecode_test::ScriptedResponse batch; + batch.tool_calls = {{"slow-1", "slow_one", "{}"}, {"slow-2", "slow_two", "{}"}, + {"write-1", "cancelled_write", "{}"}}; + h.provider().push_response(std::move(batch)); + h.provider().push_text("next turn completed"); + h.loop().submit("first turn"); + ASSERT_TRUE(first->wait()); + ASSERT_TRUE(second->wait()); + const auto stopped_turn = h.loop().active_turn_id(); + ASSERT_FALSE(stopped_turn.empty()); + h.loop().abort(); + h.loop().submit("second turn"); + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(2, 2s)); + EXPECT_EQ(writes, 0); + const auto request = h.provider().messages_for_turn(1); + for (const auto& id : {"slow-1", "slow-2", "write-1"}) { + EXPECT_EQ(std::count_if(request.begin(), request.end(), [&](const auto& message) { + return message.role == "tool" && message.tool_call_id == id; + }), 1); + } + for (const auto& message : request) EXPECT_NE(message.content, "must not reach next turn"); + const auto messages = h.session_manager().load_active_messages(); + EXPECT_EQ(std::count_if(messages.begin(), messages.end(), [&](const auto& message) { + return message.metadata.is_object() && message.metadata.value("user_aborted", false) && + message.metadata.value("turn_id", "") == stopped_turn; + }), 1); + release->open(); + EXPECT_TRUE(acecode::wait_for_abandoned_work(2s)); +} + TEST(AgentLoopUserMessageRetry, PreservesToolResultsAndAppendsOriginalStructuredInputAfterAbort) { TurnSteeringHarness h("retry-tools-abort"); // Even an unavailable tool produces an assistant call and paired result. diff --git a/tests/agent/hook_bridge/hook_bridge_test.cpp b/tests/agent/hook_bridge/hook_bridge_test.cpp index 94040094a..89f3ad6f2 100644 --- a/tests/agent/hook_bridge/hook_bridge_test.cpp +++ b/tests/agent/hook_bridge/hook_bridge_test.cpp @@ -8,6 +8,7 @@ #include "agent/transcript/transcript_writer.hpp" #include "agent/turn/turn_outcome.hpp" #include "hooks/hook_manager.hpp" +#include "utils/abort_signal.hpp" #include "permissions/permissions.hpp" #include "session/event_dispatcher.hpp" #include "utils/joining_thread.hpp" @@ -24,6 +25,7 @@ namespace { struct HookBridgeHarness { std::atomic busy{false}; + acecode::AbortSignal abort; acecode::AgentCallbacks callbacks; acecode::CallbacksSlot callback_slot; acecode::PermissionManager permissions; @@ -32,7 +34,7 @@ struct HookBridgeHarness { acecode::agent::TurnOutcomeRecord outcome; acecode::agent::TranscriptWriter transcript{history, events, callback_slot, outcome}; acecode::agent::WorkspaceBoundary boundary{".", permissions}; - acecode::agent::AgentHookBridge hooks{boundary, permissions, {}, transcript, history}; + acecode::agent::AgentHookBridge hooks{boundary, permissions, {}, transcript, history, abort}; acecode::agent::ToolHookBridge tools{hooks}; }; diff --git a/tests/agent/request/api_request_builder_test.cpp b/tests/agent/request/api_request_builder_test.cpp index 2673c5b71..2857c8ae3 100644 --- a/tests/agent/request/api_request_builder_test.cpp +++ b/tests/agent/request/api_request_builder_test.cpp @@ -1,6 +1,7 @@ #include #include "agent/request/api_request_builder.hpp" #include "agent/request/prompt_context_cache.hpp" +#include "skills/skill_activation.hpp" TEST(PromptContextCache, CwdChangePreservesSkillAndSessionPins) { // cwd 失效只针对 Git;相同内容键下重采的动态文本仍钉住原字节。 @@ -41,3 +42,39 @@ TEST(ApiRequestBuilder, MainAndCompactionUseIdenticalStaticPrefixAndCapturedInpu EXPECT_NE(request.messages_with_system.front().content.find("fixed loop rule"), std::string::npos); EXPECT_EQ(request.messages_with_system.front().content.find("changed after capture"), std::string::npos); } + +TEST(ApiRequestBuilder, DeferredSkillToolsStaySessionLocalAndRespectPolicyAfterReload) { + acecode::ToolExecutor tools; + acecode::ToolImpl tool; + tool.definition.name = "create_scheduled_task"; + tool.activation_skill = "scheduled-task"; + ASSERT_TRUE(tools.register_tool(tool)); + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + acecode::agent::RequestContextOptions options; + options.cwd = testing::TempDir(); + options.git_config.emplace(); + options.git_config->enabled = false; + options.project_config.emplace(); + options.project_config->enabled = false; + EXPECT_TRUE(builder.capture(options, {}, false).tool_defs.empty()); + EXPECT_EQ(tools.get_registered_tools().size(), 1u); + + acecode::ToolResult loaded{"skill instructions", true}; + loaded.metadata[acecode::kLoadedSkillsMetadata] = {"scheduled-task"}; + auto message = acecode::ToolExecutor::format_tool_result("load-skill", loaded); + EXPECT_EQ(builder.capture(options, {message}, false).tool_defs.size(), 1u); + EXPECT_EQ(builder.capture(options, {message}, false).builtin_tool_defs.size(), 1u); + // A shared registry does not activate another session or a compacted history. + EXPECT_TRUE(builder.capture(options, {}, false).tool_defs.empty()); + EXPECT_TRUE(tools.get_model_tool_definitions().empty()); + EXPECT_EQ(builder.capture(options, {message}, false).tool_defs.size(), 1u); + + message.metadata["tool_success"] = false; + EXPECT_TRUE(builder.capture(options, {message}, false).tool_defs.empty()); + // Explicit Skill expansion uses a user-message load record. + message.role = "user"; + EXPECT_EQ(builder.capture(options, {message}, false).tool_defs.size(), 1u); + options.tool_policy.builtin_tools = std::unordered_set{}; + EXPECT_TRUE(builder.capture(options, {message}, false).tool_defs.empty()); +} diff --git a/tests/agent/turn/turn_recovery_test.cpp b/tests/agent/turn/turn_recovery_test.cpp index 8eeb1dff8..c614ce06e 100644 --- a/tests/agent/turn/turn_recovery_test.cpp +++ b/tests/agent/turn/turn_recovery_test.cpp @@ -39,7 +39,7 @@ class TurnRecoveryTest : public testing::Test { AgentTaskQueue queue{busy}; ActiveTurnGate gate{busy, abort, interrupt}; WorkspaceBoundary boundary{".", permissions}; - AgentHookBridge hooks{boundary, permissions, {}, transcript, history}; + AgentHookBridge hooks{boundary, permissions, {}, transcript, history, abort}; GoalRuntime goal{queue, history, transcript, events, callback_slot, permissions, busy, abort}; ActivityNarrator activity{callback_slot}; TurnFinalizer finalizer{{history, transcript, outcome, gate, goal, hooks, activity, diff --git a/tests/hooks/hook_runtime_test.cpp b/tests/hooks/hook_runtime_test.cpp index 45d20554c..d4ce3c8fa 100644 --- a/tests/hooks/hook_runtime_test.cpp +++ b/tests/hooks/hook_runtime_test.cpp @@ -3,6 +3,12 @@ #include "hooks/hook_manager.hpp" #include "hooks/hook_runtime.hpp" +#include "utils/joining_thread.hpp" +#include "utils/uuid.hpp" + +#include +#include +#include #include #include @@ -37,6 +43,69 @@ acecode::HookProcessResult ok_json_result(const std::string& stdout_text) { } // namespace +TEST(HookRuntime, CancelledDispatchSkipsAllSynchronousHooks) { + acecode::HookRegistrySnapshot registry; + registry.feature_enabled = true; + registry.hooks = {make_hook("first", acecode::kCodexHookEventPreToolUse, "*")}; + int calls = 0; + acecode::HookManager manager(registry, {}, acecode::HookShellRunner{ + [&calls](const auto&, const auto&, int, const auto&) { + ++calls; + return ok_json_result("{}"); + }}); + std::atomic abort{true}; + acecode::HookDispatchRequest request; + request.event_name = acecode::kCodexHookEventPreToolUse; + request.matcher_value = "bash"; + request.abort_flag = &abort; + const auto outcome = manager.dispatch_codex(request); + EXPECT_EQ(calls, 0); + EXPECT_EQ(outcome.invoked_count, 0); +} + +TEST(HookRuntime, NativeSynchronousHookCancelsProcessTreeAndSkipsNextHook) { + using namespace std::chrono_literals; + namespace fs = std::filesystem; + const auto marker = fs::temp_directory_path() / ("hook-cancel-" + acecode::generate_uuid()); + acecode::HookRegistrySnapshot registry; + registry.feature_enabled = true; + auto hook = make_hook("blocking", acecode::kCodexHookEventPreToolUse, "*"); + hook.command.timeout_seconds = 10; +#ifdef _WIN32 + hook.command.command_windows = "echo ready > \"" + marker.string() + "\" & ping -n 20 127.0.0.1 > nul"; +#else + hook.command.command = "printf ready > '" + marker.string() + "'; sleep 20"; +#endif + registry.hooks = {hook, make_hook("must-not-run", acecode::kCodexHookEventPreToolUse, "*")}; + acecode::HookManager manager(registry); + std::atomic abort{false}, saw_marker{false}; + acecode::JoiningThread cancel([&] { + const auto deadline = std::chrono::steady_clock::now() + 2s; + while (std::chrono::steady_clock::now() < deadline) { + if (fs::exists(marker)) { saw_marker = true; break; } + std::this_thread::sleep_for(2ms); + } + abort = true; + }); + acecode::HookDispatchRequest request; + request.event_name = acecode::kCodexHookEventPreToolUse; + request.matcher_value = "bash"; + request.abort_flag = &abort; + const auto start = std::chrono::steady_clock::now(); + // A hook that does not read stdin must not block the caller while writing + // a large tool payload, before the cancellation loop even starts. + request.payload = {{"tool_output", std::string(1024 * 1024, 'x')}}; + const auto outcome = manager.dispatch_codex(request); + EXPECT_LT(std::chrono::steady_clock::now() - start, 3s); + cancel.join(); + EXPECT_TRUE(saw_marker); + EXPECT_EQ(outcome.invoked_count, 1); + EXPECT_TRUE(outcome.no_decision); + EXPECT_FALSE(outcome.blocked); + EXPECT_FALSE(outcome.replacement_output); + fs::remove(marker); +} + TEST(HookRuntime, MatcherAliasesMapCodexNamesToAceCodeTools) { EXPECT_TRUE(acecode::hook_matcher_matches( make_hook("h1", acecode::kCodexHookEventPreToolUse, "Bash"), diff --git a/tests/loop/scheduled_task_tool_test.cpp b/tests/loop/scheduled_task_tool_test.cpp new file mode 100644 index 000000000..479e0aa4b --- /dev/null +++ b/tests/loop/scheduled_task_tool_test.cpp @@ -0,0 +1,177 @@ +#include + +#include "loop/loop_request.hpp" +#include "loop/scheduled_task_tool.hpp" +#include "config/config.hpp" +#include "utils/uuid.hpp" +#include "workspace/workspace_registry.hpp" + +#include + +namespace { +using namespace acecode; +using namespace acecode::loop; +using nlohmann::json; + +class ScheduledTaskToolTest : public testing::Test { +protected: + std::filesystem::path root = std::filesystem::temp_directory_path() / + ("acecode-scheduled-tool-" + generate_uuid()); + AppConfig config; + std::shared_mutex config_mutex; + desktop::WorkspaceRegistry workspaces; + std::unique_ptr store; + std::shared_ptr service; + std::int64_t now = 1'800'000'000'000; + + void SetUp() override { + std::filesystem::create_directories(root); + store = std::make_unique(root / "loops.sqlite3"); + ASSERT_TRUE(store->initialize()); + ModelProfile model; + model.name = "test-model"; + config.saved_models.push_back(model); + config.default_model_name = model.name; + service = std::make_shared( + *store, config, config_mutex, workspaces, (root / "projects").string(), + nullptr, [this] { return now; }); + } + + void TearDown() override { + service.reset(); + store.reset(); + std::error_code error; + std::filesystem::remove_all(root, error); + } + + json arguments() const { + return {{"name", "Meeting"}, {"prompt", "Remind me about the meeting"}, + {"schedule", {{"kind", "once"}, {"once_at_ms", now + 60'000}}}}; + } + + json response(const std::string& mode) const { + return {{"answers", json::array({{ + {"question_id", "scheduled-task-permission"}, {"selected", json::array({mode})}}})}}; + } + + ToolContext context(const std::string& mode = "yolo") { + ToolContext ctx; + ctx.ask_user_questions = [this, mode](const json& questions) { + EXPECT_TRUE(store->list_loops().empty()); + EXPECT_EQ(questions[0]["options"][0]["value"], "yolo"); + EXPECT_TRUE(questions[0]["options"][0]["recommended"].get()); + return response(mode); + }; + return ctx; + } +}; + +TEST_F(ScheduledTaskToolTest, UserSelectionOverridesModelArgumentsAndPersistsExistingDefinition) { + auto args = arguments(); + args["permission_mode"] = "yolo"; + const auto result = create_scheduled_task_tool(service).execute(args.dump(), context("default")); + ASSERT_TRUE(result.success) << result.output; + const auto data = json::parse(result.output); + ASSERT_TRUE(data["created"].get()); + const auto saved = store->get_loop(data["task"]["id"]); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->permission_mode, "default"); + EXPECT_EQ(saved->model_name, "test-model"); + EXPECT_EQ(saved->next_run_at_ms, now + 60'000); + EXPECT_FALSE(saved->use_worktree); + EXPECT_TRUE(saved->workspace_cwd.empty()); + EXPECT_TRUE(store->list_runs(saved->id).empty()); +} + +TEST_F(ScheduledTaskToolTest, CancellationInterjectionTimeoutAndMissingAnswersNeverCreate) { + auto tool = create_scheduled_task_tool(service); + auto ctx = context(); + std::vector replies = {{{"cancelled", true}}, {{"interjected", true}}, + {{"timed_out", true}}, json::object(), response("unknown")}; + auto automatic = response("yolo"); + automatic["answers"][0]["auto_selected"] = true; + replies.push_back(automatic); + for (const auto& reply : replies) { + ctx.ask_user_questions = [reply](const json&) { return reply; }; + EXPECT_FALSE(tool.execute(arguments().dump(), ctx).success) << reply; + EXPECT_TRUE(store->list_loops().empty()); + } + EXPECT_FALSE(tool.execute(arguments().dump(), {}).success); +} + +TEST_F(ScheduledTaskToolTest, ValidatesBeforeAskingAndRechecksAfterUserAnswers) { + auto args = arguments(); + args["model_name"] = "missing"; + int questions = 0; + auto ctx = context(); + ctx.ask_user_questions = [this, &questions](const json&) { + ++questions; + config.saved_models.clear(); + return response("yolo"); + }; + auto tool = create_scheduled_task_tool(service); + EXPECT_FALSE(tool.execute(args.dump(), ctx).success); + EXPECT_EQ(questions, 0); + EXPECT_FALSE(tool.execute(arguments().dump(), ctx).success); + EXPECT_EQ(questions, 1); + EXPECT_TRUE(store->list_loops().empty()); +} + +TEST_F(ScheduledTaskToolTest, SharedValidationAndConflictSemanticsMatchManualCreation) { + const auto workspace = workspaces.register_new((root / "projects").string(), root.string()); + auto args = arguments(); + args["workspace_hash"] = workspace.hash; + args["workspace_cwd"] = workspace.cwd; + args["model_name"] = "test-model"; + LoopDefinition manual; + ValidationError error; + ASSERT_TRUE(parse_loop_request(args, now, {"test-model"}, + [&workspace](const std::string&) { return std::optional(workspace.cwd); }, manual, error)); + ASSERT_TRUE(store->create_loop(manual, now)); + auto ctx = context(); + ctx.ask_user_questions = [this](const json&) { return response("yolo"); }; + auto tool = create_scheduled_task_tool(service); + const auto result = tool.execute(args.dump(), ctx); + EXPECT_FALSE(result.success); + EXPECT_EQ(json::parse(result.output)["error"], "SCHEDULE_CONFLICT"); + EXPECT_EQ(store->list_loops().size(), 1u); + args["workspace_cwd"] = "wrong-directory"; + EXPECT_EQ(json::parse(tool.execute(args.dump(), ctx).output)["error"], "INVALID_WORKSPACE"); +} + +TEST_F(ScheduledTaskToolTest, ExpiredTimeAbortAndServiceShutdownDoNotCreate) { + auto tool = create_scheduled_task_tool(service); + auto args = arguments(); + args["schedule"]["once_at_ms"] = now - 1; + EXPECT_FALSE(tool.execute(args.dump(), context()).success); + std::atomic abort{true}; + auto ctx = context(); + ctx.abort_flag = &abort; + EXPECT_FALSE(tool.execute(arguments().dump(), ctx).success); + abort.store(false); + ctx.ask_user_questions = [this](const json&) { + auto reply = response("yolo"); + service.reset(); + return reply; + }; + EXPECT_FALSE(tool.execute(arguments().dump(), ctx).success); + EXPECT_TRUE(store->list_loops().empty()); +} + +TEST_F(ScheduledTaskToolTest, IntervalDefaultAndInclusiveDateRangeUseExistingSchedule) { + auto args = arguments(); + args["schedule"] = {{"kind", "interval"}, {"interval_value", 1}, {"interval_unit", "days"}, + {"valid_from_ms", now + kLoopDayMs}, {"valid_until_ms", now + 3 * kLoopDayMs}}; + const auto result = create_scheduled_task_tool(service).execute(args.dump(), context()); + ASSERT_TRUE(result.success) << result.output; + const auto saved = store->list_loops().front(); + EXPECT_EQ(saved.schedule.anchor_ms, now); + auto occurrence = saved.next_run_at_ms; + for (int day = 1; day <= 3; ++day) { + ASSERT_TRUE(occurrence); + EXPECT_EQ(*occurrence, now + day * kLoopDayMs); + occurrence = next_occurrence_ms(saved.schedule, *occurrence); + } + EXPECT_FALSE(occurrence); +} +} // namespace diff --git a/tests/platform/hook_runner_test.cpp b/tests/platform/hook_runner_test.cpp index eb4f4b0f1..8460329a2 100644 --- a/tests/platform/hook_runner_test.cpp +++ b/tests/platform/hook_runner_test.cpp @@ -27,6 +27,26 @@ TEST(HookRunner, SendsPayloadOnStdinWithZeroTimeoutAsInfinite) { EXPECT_NE(result.output.find("hello hook"), std::string::npos); } +TEST(HookRunner, LargeStdinIsDeliveredCompletelyWhileDrainingOutput) { + acecode::platform::ProcessSpec cmd; +#ifdef _WIN32 + cmd.command = "powershell.exe"; + cmd.args = {"-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + "[Console]::OpenStandardInput().CopyTo([Console]::OpenStandardOutput())"}; +#else + cmd.command = "/bin/cat"; +#endif + const std::string payload = std::string(256 * 1024, 'x') + "\n"; + acecode::HookProcessOptions options; + options.timeout_ms = 5000; + options.max_stdout_bytes = payload.size() + 1024; + const auto result = acecode::run_hook_process(cmd, payload, "", options); + EXPECT_TRUE(result.started) << result.error; + EXPECT_FALSE(result.timed_out); + EXPECT_EQ(result.exit_code, 0); + EXPECT_EQ(std::count(result.stdout_text.begin(), result.stdout_text.end(), 'x'), 256 * 1024); +} + TEST(HookRunner, ShellCommandRunsThroughPlatformShellWithStdin) { #ifdef _WIN32 const std::string command = "more"; diff --git a/tests/provider/openai_provider_abort_test.cpp b/tests/provider/openai_provider_abort_test.cpp index bdf4b7f15..251462897 100644 --- a/tests/provider/openai_provider_abort_test.cpp +++ b/tests/provider/openai_provider_abort_test.cpp @@ -22,6 +22,9 @@ #include "provider/openai_provider.hpp" #include "llm/llm_provider.hpp" #include "utils/logger.hpp" +#include "utils/joining_thread.hpp" +#include "utils/scope_exit.hpp" +#include "test_support/utils/concurrency_gate.hpp" #include @@ -268,4 +271,33 @@ TEST(OpenAiProviderAbortTest, SilentPhaseCancelWritesDiagnosticLog) { << "静默期取消时日志必须带 'no-data phase' 区分符,实际内容:\n" << log_contents; } +TEST(OpenAiProviderAbortTest, NonStreamingRequestCancelsDuringSilentResponse) { + auto entered = std::make_shared(); + auto release = std::make_shared(); + auto returned = std::make_shared(); + LocalHttpServer server([entered, release](httplib::Server& server) { + server.Post("/chat/completions", [entered, release](const httplib::Request&, httplib::Response& response) { + entered->open(); + release->wait(5s); + response.set_content(R"({"choices":[{"message":{"content":"late"},"finish_reason":"stop"}]})", "application/json"); + }); + }); + acecode::ScopeExit unblock([release] { release->open(); }); + std::atomic abort{false}; + auto provider = std::make_shared( + "http://127.0.0.1:" + std::to_string(server.port), "test", "model"); + acecode::ChatResponse response; + acecode::JoiningThread request([&response, &abort, provider, returned] { + response = provider->chat_cancellable({ChatMessage{"user", "test"}}, {}, &abort); + returned->open(); + }); + EXPECT_TRUE(entered->wait()); + abort = true; + EXPECT_TRUE(returned->wait(2s)); + release->open(); + request.join(); + EXPECT_EQ(response.provider_error.kind, acecode::ProviderErrorKind::UserCancelled); + EXPECT_EQ(response.finish_reason, "error"); +} + } // namespace diff --git a/tests/session_host/session_registry_test.cpp b/tests/session_host/session_registry_test.cpp index 30c7afe5b..88a829a0d 100644 --- a/tests/session_host/session_registry_test.cpp +++ b/tests/session_host/session_registry_test.cpp @@ -30,6 +30,7 @@ #include "tool/goal_tool.hpp" #include "tool/tool_executor.hpp" #include "utils/cwd_hash.hpp" +#include "utils/scope_exit.hpp" #include "platform/power_inhibitor.hpp" #include @@ -3234,7 +3235,7 @@ TEST_F(SessionReasoningTest, ResumeAndForkRetainOverrideAndInvalidResumeFallsBac EXPECT_FALSE(meta(id).reasoning_effort); } -TEST_F(SessionReasoningTest, BusyTurnAndQueuedChatBeforeBusyRejectChanges) { +TEST_F(SessionReasoningTest, BusyTurnAndQueuedChatBeforeBusyAcceptChanges) { const auto id = create("high"); auto entry = registry->acquire(id); auto blocker = std::make_shared(); @@ -3242,8 +3243,15 @@ TEST_F(SessionReasoningTest, BusyTurnAndQueuedChatBeforeBusyRejectChanges) { entry->loop->submit("hold active request"); EXPECT_TRUE(blocker->wait_for_started(2s)); EXPECT_EQ(registry->set_reasoning_effort(id, "low").status, - acecode::SessionReasoningStatus::Busy); - EXPECT_EQ(entry->model_binding->provider_snapshot(), blocker); + acecode::SessionReasoningStatus::Updated); + EXPECT_EQ(entry->model_binding->state_snapshot().reasoning_effort, "low"); + EXPECT_NE(entry->model_binding->provider_snapshot(), blocker); + EXPECT_EQ(meta(id).reasoning_effort, "low"); + EXPECT_TRUE(entry->loop->is_busy()); + EXPECT_EQ(registry->set_reasoning_effort(id, "max").status, + acecode::SessionReasoningStatus::InvalidEffort); + EXPECT_EQ(meta(id).reasoning_effort, "low"); + entry->loop->abort(); blocker->release(); auto drained = entry->loop->enqueue_control([] { return true; }); ASSERT_TRUE(drained.wait_for_completion(2s)); @@ -3265,9 +3273,12 @@ TEST_F(SessionReasoningTest, BusyTurnAndQueuedChatBeforeBusyRejectChanges) { } entry->loop->submit("queued request"); EXPECT_FALSE(entry->loop->is_busy()); - EXPECT_EQ(registry->set_reasoning_effort(id, "low").status, - acecode::SessionReasoningStatus::Busy); + EXPECT_EQ(registry->set_reasoning_effort(id, "high").status, + acecode::SessionReasoningStatus::Updated); EXPECT_EQ(entry->model_binding->state_snapshot().reasoning_effort, "high"); + EXPECT_EQ(meta(id).reasoning_effort, "high"); + // Keep the queue test offline; real request parameters are covered by HTTP. + install_test_provider(*entry, std::make_shared()); { std::lock_guard lock(gate_mu); release = true; @@ -3300,3 +3311,36 @@ TEST_F(SessionReasoningTest, PersistenceFailureRestoresProviderAndEffort) { entry->sm->on_message(message); EXPECT_EQ(meta(id).reasoning_effort, "high"); } + +TEST_F(SessionReasoningTest, BusyPersistenceFailurePreservesActiveProviderAndChoice) { + const auto id = create("high"); + auto entry = registry->acquire(id); + auto blocker = std::make_shared(); + install_test_provider(*entry, blocker); + acecode::ScopeExit unblock([&] { + entry->loop->abort(); + blocker->release(); + }); + entry->loop->submit("hold while persistence fails"); + ASSERT_TRUE(blocker->wait_for_started(2s)); + const auto meta_path = SessionStorage::meta_path( + SessionStorage::get_project_dir(cwd.string()), id); + ASSERT_TRUE(std::filesystem::remove(meta_path)); + ASSERT_TRUE(std::filesystem::create_directory(meta_path)); + const auto changed = registry->set_reasoning_effort(id, "low"); + EXPECT_EQ(changed.status, acecode::SessionReasoningStatus::Failed); + EXPECT_EQ(changed.state.reasoning_effort, "high"); + EXPECT_EQ(entry->model_binding->provider_snapshot(), blocker); + EXPECT_EQ(entry->sm->current_reasoning_effort(), "high"); + EXPECT_TRUE(entry->loop->is_busy()); + std::filesystem::remove(meta_path); + entry->loop->abort(); + blocker->release(); + auto drained = entry->loop->enqueue_control([] { return true; }); + ASSERT_TRUE(drained.wait_for_completion(2s)); + acecode::ChatMessage message; + message.role = "user"; + message.content = "persist the original effort after failure"; + entry->sm->on_message(message); + EXPECT_EQ(meta(id).reasoning_effort, "high"); +} diff --git a/tests/skills/scheduled_task_seed_test.cpp b/tests/skills/scheduled_task_seed_test.cpp new file mode 100644 index 000000000..5044f02c0 --- /dev/null +++ b/tests/skills/scheduled_task_seed_test.cpp @@ -0,0 +1,39 @@ +#include + +#include "skills/default_skill_seeder.hpp" +#include "skills/skill_registry.hpp" +#include "test_support/repo_root.hpp" +#include "utils/uuid.hpp" + +#include +#include + +TEST(ScheduledTaskSeed, UpgradeInstallsDiscoverableSkillWithoutChangingUserOverride) { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / ("acecode-scheduled-seed-" + acecode::generate_uuid()); + const auto packaged = acecode::test_support::find_repo_root(__FILE__) / "assets" / "seed"; + fs::create_directories(root); + struct Cleanup { + fs::path path; + ~Cleanup() { std::error_code error; fs::remove_all(path, error); } + } cleanup{root}; + std::ofstream(root / "seed.version") << "2026-09-28.1\n"; + const auto installed = acecode::reconcile_default_global_skills(root, packaged / "skills"); + ASSERT_TRUE(installed.error.empty()) << installed.error; + EXPECT_TRUE(installed.version_written); + acecode::SkillRegistry registry; + registry.set_scan_roots({root / "skills"}); + registry.scan(); + const auto meta = registry.find("scheduled-task"); + ASSERT_TRUE(meta); + EXPECT_EQ(meta->command_key, "scheduled-task"); + EXPECT_FALSE(registry.read_skill_body("scheduled-task").empty()); + + const auto skill_path = root / "skills" / "acecode" / "scheduled-task" / "SKILL.md"; + const std::string custom = "---\nname: scheduled-task\ndescription: Custom scheduling\n---\nMy instructions\n"; + std::ofstream(skill_path, std::ios::binary) << custom; + const auto repeated = acecode::reconcile_default_global_skills(root, packaged / "skills"); + EXPECT_TRUE(repeated.error.empty()) << repeated.error; + registry.scan(); + EXPECT_EQ(registry.read_skill_text("scheduled-task"), custom); +} diff --git a/tests/skills/skill_activation_test.cpp b/tests/skills/skill_activation_test.cpp index 316cd2149..fec3d4a34 100644 --- a/tests/skills/skill_activation_test.cpp +++ b/tests/skills/skill_activation_test.cpp @@ -260,6 +260,7 @@ TEST_F(SkillActivationTest, AgentLoopInjectsOnlyTheMentioningTurnAndKeepsDisplay acecode::UserInput input; input.text = "$review inspect this attachment"; + input.metadata[acecode::kLoadedSkillsMetadata] = nlohmann::json::array({"unread-skill"}); input.content_parts = nlohmann::json::array({ nlohmann::json{{"type", "text"}, {"text", input.text}}, nlohmann::json{{"type", "browser_context"}, @@ -275,6 +276,7 @@ TEST_F(SkillActivationTest, AgentLoopInjectsOnlyTheMentioningTurnAndKeepsDisplay message.metadata.value("display_text", std::string{}) == input.text; }); ASSERT_NE(first_user, first_request.end()); + EXPECT_EQ(first_user->metadata[acecode::kLoadedSkillsMetadata], nlohmann::json::array({"review"})); EXPECT_NE(first_user->content.find("review"), std::string::npos); ASSERT_TRUE(first_user->content_parts.is_array()); ASSERT_FALSE(first_user->content_parts.empty()); @@ -283,7 +285,10 @@ TEST_F(SkillActivationTest, AgentLoopInjectsOnlyTheMentioningTurnAndKeepsDisplay std::string::npos) << "structured attachment requests must carry the injected prompt too"; - loop.submit("This second turn has no explicit Skill mention."); + acecode::UserInput second_input; + second_input.text = "This second turn has no explicit Skill mention."; + second_input.metadata[acecode::kLoadedSkillsMetadata] = nlohmann::json::array({"unread-skill"}); + loop.submit(second_input); ASSERT_TRUE(provider->wait_for_count(2, std::chrono::seconds(5))); const auto second_request = provider->request(1); auto second_user = std::find_if( @@ -293,6 +298,7 @@ TEST_F(SkillActivationTest, AgentLoopInjectsOnlyTheMentioningTurnAndKeepsDisplay std::string::npos; }); ASSERT_NE(second_user, second_request.rend()); + EXPECT_FALSE(second_user->metadata.contains(acecode::kLoadedSkillsMetadata)); EXPECT_EQ(second_user->content.find(""), std::string::npos) << "an explicit selection must not be automatically re-injected next turn"; } diff --git a/tests/tool/skill_view_tool_test.cpp b/tests/tool/skill_view_tool_test.cpp index 663311c0d..d9e6e452c 100644 --- a/tests/tool/skill_view_tool_test.cpp +++ b/tests/tool/skill_view_tool_test.cpp @@ -80,6 +80,7 @@ TEST_F(SkillViewToolTest, SkillBodyKeepsFullOutputButAddsCompactSummary) { std::string::npos); ASSERT_TRUE(payload.contains("linked_files")); EXPECT_EQ(payload["linked_files"].size(), 1u); + EXPECT_EQ(result.metadata["loaded_skills"], nlohmann::json::array({"openspec-explore"})); } TEST_F(SkillViewToolTest, SupportingFileKeepsFullOutputButAddsCompactSummary) { @@ -98,6 +99,15 @@ TEST_F(SkillViewToolTest, SupportingFileKeepsFullOutputButAddsCompactSummary) { EXPECT_TRUE(payload.value("success", false)); EXPECT_EQ(payload.value("file_path", ""), "references/notes.md"); EXPECT_EQ(payload.value("content", ""), "supporting reference body\n"); + EXPECT_FALSE(result.metadata.contains("loaded_skills")); +} + +TEST_F(SkillViewToolTest, UnreadableSkillCannotActivateDeferredTools) { + std::filesystem::remove(root / "engineering" / "openspec-explore" / "SKILL.md"); + const auto result = acecode::create_skill_view_tool(registry).execute( + R"({"name":"openspec-explore"})", {}); + EXPECT_FALSE(result.success); + EXPECT_FALSE(result.metadata.contains("loaded_skills")); } TEST_F(SkillViewToolTest, ToolContextCwdLoadsWorkspaceLocalSkill) { diff --git a/tests/tool/tool_cancellation_test.cpp b/tests/tool/tool_cancellation_test.cpp new file mode 100644 index 000000000..a5f4c0fc9 --- /dev/null +++ b/tests/tool/tool_cancellation_test.cpp @@ -0,0 +1,214 @@ +#include "tool/tool_executor.hpp" +#include "tool/vision_subagent_tool.hpp" +#include "tool/web_search/backend_router.hpp" +#include "provider/openai_provider.hpp" +#include "provider/anthropic_provider.hpp" +#include "utils/abandonable_call.hpp" +#include "utils/joining_thread.hpp" +#include "utils/scope_exit.hpp" +#include "test_support/utils/concurrency_gate.hpp" + +#include +#include +#include +#include + +namespace acecode { +namespace { +using namespace std::chrono_literals; + +struct BlockedObservation { + test::ConcurrencyGate entered; + test::ConcurrencyGate release; + test::ConcurrencyGate returned; + std::atomic cancelled{false}; + std::atomic destroyed{0}; +}; + +TEST(CancellableCallTest, LateWorkerOwnsLatchedCancellationAfterCallerFlagIsDestroyed) { + auto state = std::make_shared(); + ScopeExit unblock([state] { state->release.open(); }); + auto abort = std::make_unique>(false); + JoiningThread caller([state, flag = abort.get()] { + const auto result = run_cancellable([state](const std::atomic* cancellation) { + state->entered.open(); + state->release.wait(); + state->cancelled = cancellation && cancellation->load(); + return 42; + }, flag); + EXPECT_FALSE(result); + state->returned.open(); + }); + EXPECT_TRUE(state->entered.wait()); + *abort = true; + EXPECT_TRUE(state->returned.wait(1s)); + caller.join(); + *abort = false; + abort.reset(); + state->release.open(); + EXPECT_TRUE(wait_for_abandoned_work(2s)); + EXPECT_TRUE(state->cancelled); +} + +TEST(CancellableCallTest, HandlesInlinePreCancelledSuccessAndExceptions) { + EXPECT_EQ(*run_cancellable([](auto flag) { EXPECT_EQ(flag, nullptr); return 7; }, nullptr), 7); + std::atomic abort{true}; + EXPECT_FALSE(run_cancellable([](auto) { ADD_FAILURE() << "cancelled work started"; }, &abort)); + abort = false; + auto result = run_cancellable>( + [](auto) { return std::make_unique(8); }, &abort); + ASSERT_TRUE(result); + EXPECT_EQ(**result, 8); + EXPECT_THROW(run_cancellable([](auto) -> int { throw std::runtime_error("failed"); }, &abort), std::runtime_error); + EXPECT_TRUE(run_cancellable([](auto) {}, &abort)); + EXPECT_TRUE(wait_for_abandoned_work(2s)); +} + +TEST(ToolCancellationTest, AdmissionStopsAnyToolButRetainsCompletedMutation) { + ToolExecutor executor; + std::atomic abort{true}; + int writes = 0; + ToolImpl tool; + tool.definition.name = "custom_mutation"; + tool.execute = [&writes, &abort](const std::string&, const ToolContext&) { + ++writes; + abort = true; + return ToolResult{"written", true}; + }; + ASSERT_TRUE(executor.register_tool(std::move(tool))); + ToolContext context; + context.abort_flag = &abort; + const auto skipped = executor.execute("custom_mutation", "{}", context); + EXPECT_FALSE(skipped.success); + EXPECT_TRUE(skipped.metadata.value("cancelled", false)); + EXPECT_EQ(writes, 0); + abort = false; + const auto completed = executor.execute("custom_mutation", "{}", context); + EXPECT_TRUE(completed.success); + EXPECT_EQ(completed.output, "written"); + EXPECT_EQ(writes, 1); + EXPECT_FALSE(executor.execute("custom_mutation", "{}", context).success); + EXPECT_EQ(writes, 1); +} + +class BlockingSearch final : public WebSearchBackend { +public: + BlockingSearch(std::string name, std::shared_ptr state) + : name_(std::move(name)), state_(std::move(state)) {} + ~BlockingSearch() override { ++state_->destroyed; } + std::string name() const override { return name_; } + bool requires_api_key() const override { return false; } + std::variant search( + std::string_view query, int, const std::atomic* cancellation) override { + state_->entered.open(); + state_->release.wait(); + state_->cancelled = cancellation && cancellation->load(); + EXPECT_EQ(query, "owned query"); + return SearchResponse{}; + } +private: + std::string name_; + // Shared by test synchronization and the independently owned backend. + std::shared_ptr state_; +}; + +void verify_search_cancellation(bool parallel) { + auto first = std::make_shared(); + auto second = std::make_shared(); + ScopeExit unblock([first, second] { first->release.open(); second->release.open(); }); + std::atomic abort{false}; + JoiningThread caller([first, second, &abort, parallel] { + WebSearchConfig cfg; + cfg.backend = parallel ? "parallel" : "rss"; + { + web_search::BackendRouter router(cfg); + router.register_backend(std::make_unique("rss", first)); + router.register_backend(std::make_unique("duckduckgo", second)); + router.resolve_active(web_search::Region::Unknown); + auto result = router.search_with_fallback("owned query", 3, &abort, {}); + EXPECT_TRUE(std::holds_alternative(result)); + } + first->returned.open(); + }); + EXPECT_TRUE(first->entered.wait()); + if (parallel) EXPECT_TRUE(second->entered.wait()); + abort = true; + EXPECT_TRUE(first->returned.wait(1s)); + caller.join(); + EXPECT_EQ(first->destroyed, 0); + // The router and caller are gone while blocked backend work remains alive. + abort = false; + first->release.open(); + second->release.open(); + EXPECT_TRUE(wait_for_abandoned_work(2s)); + EXPECT_TRUE(first->cancelled); + EXPECT_EQ(first->destroyed, 1); + EXPECT_EQ(second->destroyed, 1); + if (parallel) EXPECT_TRUE(second->cancelled); +} + +TEST(ToolCancellationTest, SerialSearchCancelsWithoutStartingFallback) { verify_search_cancellation(false); } +TEST(ToolCancellationTest, ParallelSearchCancelsAndRetainsBackendLifetimes) { verify_search_cancellation(true); } + +class BlockingVision final : public LlmProvider { +public: + explicit BlockingVision(std::shared_ptr state) : state_(std::move(state)) {} + ~BlockingVision() override { ++state_->destroyed; } + ChatResponse chat(const std::vector&, const std::vector&) override { + state_->entered.open(); + state_->release.wait(); + ChatResponse response; + response.content = "late result"; + return response; + } + void chat_stream(const std::vector&, const std::vector&, + const StreamCallback&, std::atomic*) override {} + std::string name() const override { return "blocking"; } + std::string model() const override { return "vision"; } + bool is_authenticated() override { return true; } + void set_model(const std::string&) override {} +private: + std::shared_ptr state_; // Shared by provider and test synchronization. +}; + +TEST(ToolCancellationTest, VisionReturnsBeforeLegacyProviderAndDiscardsLateResult) { + auto state = std::make_shared(); + ScopeExit unblock([state] { state->release.open(); }); + std::atomic abort{false}; + JoiningThread caller([state, &abort] { + AppConfig config; + ModelProfile profile; + profile.name = "vision"; + profile.provider = "openai"; + profile.capabilities = {"vision"}; + config.saved_models.push_back(profile); + VisionSubagentToolOptions options; + options.provider_factory = [state](const ModelProfile&) { return std::make_shared(state); }; + auto tool = create_vision_analyze_tool(config, options); + ToolContext context; + context.abort_flag = &abort; + context.active_model_can_read_images = false; + auto result = tool.execute(R"({"prompt":"inspect","attachment":{"id":"a","session_id":"s","name":"x.png","kind":"image","mime_type":"image/png","path":"x.png","blob_url":"/b","size_bytes":1}})", context); + EXPECT_FALSE(result.success); + EXPECT_EQ(result.output, "[Interrupted]"); + state->returned.open(); + }); + EXPECT_TRUE(state->entered.wait()); + abort = true; + EXPECT_TRUE(state->returned.wait(1s)); + caller.join(); + EXPECT_EQ(state->destroyed, 0); + state->release.open(); + EXPECT_TRUE(wait_for_abandoned_work(2s)); + EXPECT_EQ(state->destroyed, 1); +} + +TEST(ToolCancellationTest, NonStreamingProvidersRejectPreCancelledRequestsWithoutTransport) { + std::atomic abort{true}; + OpenAiCompatProvider openai("http://127.0.0.1:1/v1", "test", "model"); + EXPECT_EQ(openai.chat_cancellable({}, {}, &abort).provider_error.kind, ProviderErrorKind::UserCancelled); + AnthropicProvider anthropic("http://127.0.0.1:1/v1", "test", "model"); + EXPECT_EQ(anthropic.chat_cancellable({}, {}, &abort).provider_error.kind, ProviderErrorKind::UserCancelled); +} +} // namespace +} // namespace acecode diff --git a/tests/tool/vision_subagent_tool_test.cpp b/tests/tool/vision_subagent_tool_test.cpp index 521ff1db9..02cf77dd4 100644 --- a/tests/tool/vision_subagent_tool_test.cpp +++ b/tests/tool/vision_subagent_tool_test.cpp @@ -106,6 +106,31 @@ acecode::AttachmentRecord save_image( } // namespace +TEST(VisionSubagentTool, ProviderFailureIsNotReportedAsSuccessfulObservation) { + class ErrorProvider : public CapturingProvider { + acecode::ChatResponse chat(const std::vector&, + const std::vector&) override { + acecode::ChatResponse response; + response.finish_reason = "error"; + response.provider_error.kind = acecode::ProviderErrorKind::Http; + response.provider_error.display_message = "upstream rejected image"; + return response; + } + }; + acecode::AppConfig config; + config.saved_models.push_back(model_profile("vision", {"vision"})); + acecode::VisionSubagentToolOptions options; + options.provider_factory = [](const acecode::ModelProfile&) { return std::make_shared(); }; + auto tool = acecode::create_vision_analyze_tool(config, options); + acecode::ToolContext context; + context.active_model_can_read_images = false; + auto result = tool.execute(R"({"prompt":"inspect","attachment":{"id":"a","session_id":"s","name":"x.png","kind":"image","mime_type":"image/png","path":"x.png","blob_url":"/b","size_bytes":1}})", context); + EXPECT_FALSE(result.success); + const auto output = nlohmann::json::parse(result.output); + EXPECT_EQ(output["error"], "PROVIDER_ERROR"); + EXPECT_EQ(output["message"], "upstream rejected image"); +} + TEST(VisionSubagentTool, MissingVisionModelFailsClearly) { acecode::AppConfig cfg; cfg.saved_models.push_back(model_profile("text-only", {"tool_use"})); diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index ca6019299..d55c532e8 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -11856,11 +11856,123 @@ TEST(WebServerHttp, WorkspaceReasoningCreationAndBusyMutation) { EXPECT_TRUE(blocker->wait_for_started(2s)); auto busy = cpr::Post(cpr::Url{fx.url("/api/sessions/" + id + "/reasoning")}, cpr::Header{{"Content-Type", "application/json"}}, cpr::Body{R"({"effort":"low"})"}); + EXPECT_TRUE(entry->loop->is_busy()); + entry->loop->abort(); blocker->release(); - EXPECT_EQ(busy.status_code, 409) << busy.text; - EXPECT_EQ(json::parse(busy.text)["error"], "SESSION_BUSY"); - EXPECT_EQ(entry->model_binding->state_snapshot().reasoning_effort, "high"); - EXPECT_EQ(entry->model_binding->provider_snapshot(), blocker); + EXPECT_EQ(busy.status_code, 200) << busy.text; + EXPECT_EQ(json::parse(busy.text)["reasoning_effort"], "low"); + EXPECT_EQ(entry->model_binding->state_snapshot().reasoning_effort, "low"); + EXPECT_NE(entry->model_binding->provider_snapshot(), blocker); +} + +TEST(WebServerHttp, BusyReasoningChangesReachNextRequestWithinSameTurn) { + std::mutex mutex; + std::condition_variable cv; + std::vector bodies; + std::size_t released = 0; + std::atomic tool_calls{0}; + LocalUpdateServer model_server([&](httplib::Server& server) { + server.Post("/v1/chat/completions", [&](const httplib::Request& request, + httplib::Response& response) { + std::unique_lock lock(mutex); + bodies.push_back(json::parse(request.body)); + const auto index = bodies.size(); + cv.notify_all(); + if (index <= 2 && !cv.wait_for(lock, 10s, [&] { return released >= index; })) { + response.status = 500; + return; + } + lock.unlock(); + json delta; + if (index <= 2) { + delta = {{"tool_calls", json::array({{ + {"index", 0}, {"id", "reasoning-" + std::to_string(index)}, + {"type", "function"}, + {"function", {{"name", "reasoning_probe"}, {"arguments", "{}"}}} + }})}}; + } else { + delta = {{"content", "reasoning update completed"}}; + } + const json chunk = {{"choices", json::array({{ + {"delta", delta}, {"finish_reason", index <= 2 ? "tool_calls" : "stop"} + }})}}; + response.set_content("data: " + chunk.dump() + "\n\ndata: [DONE]\n\n", + "text/event-stream"); + }); + }); + WebServerFixture fx; + configure_reasoning_http_model(fx); + { + std::lock_guard lock(fx.app_config_mu); + fx.cfg.saved_models.back().base_url = model_server.base_url() + "v1"; + fx.cfg.saved_models.back().reasoning->default_effort = "high"; + } + acecode::ToolImpl probe; + probe.definition.name = "reasoning_probe"; + probe.definition.description = "Continue the reasoning request test"; + probe.definition.parameters = {{"type", "object"}, {"properties", json::object()}}; + probe.is_read_only = true; + probe.execute = [&](const std::string&, const acecode::ToolContext&) { + ++tool_calls; + return acecode::ToolResult{"ok", true}; + }; + ASSERT_TRUE(fx.tools.register_tool(probe)); + acecode::SessionOptions options; + options.model_name = "reasoning-http"; + options.reasoning_effort = "high"; + const auto id = fx.registry->create(options); + auto entry = fx.registry->acquire(id); + acecode::ScopeExit unblock([&] { + { + std::lock_guard lock(mutex); + released = 100; + } + cv.notify_all(); + }); + auto wait_request = [&](std::size_t count) { + std::unique_lock lock(mutex); + return cv.wait_for(lock, 5s, [&] { return bodies.size() >= count; }); + }; + auto release_request = [&](std::size_t count) { + { + std::lock_guard lock(mutex); + released = count; + } + cv.notify_all(); + }; + auto update = [&](const json& effort) { + return cpr::Post(cpr::Url{fx.url("/api/sessions/" + id + "/reasoning")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"effort", effort}}.dump()}, cpr::Timeout{5000}); + }; + entry->loop->submit("continue through two tools"); + ASSERT_TRUE(wait_request(1)); + for (const auto* effort : {"low", "high", "low"}) { + const auto result = update(effort); + ASSERT_EQ(result.status_code, 200) << result.text; + EXPECT_EQ(json::parse(result.text)["reasoning_effort"], effort); + EXPECT_TRUE(entry->loop->is_busy()); + } + release_request(1); + ASSERT_TRUE(wait_request(2)); + const auto invalid = update("max"); + EXPECT_EQ(invalid.status_code, 400) << invalid.text; + EXPECT_EQ(entry->model_binding->state_snapshot().reasoning_effort, "low"); + const auto reset = update(nullptr); + ASSERT_EQ(reset.status_code, 200) << reset.text; + EXPECT_TRUE(json::parse(reset.text)["reasoning_effort"].is_null()); + EXPECT_TRUE(entry->loop->is_busy()); + release_request(2); + ASSERT_TRUE(wait_request(3)); + auto drained = entry->loop->enqueue_control([] { return true; }); + ASSERT_TRUE(drained.wait_for_completion(5s)); + EXPECT_EQ(tool_calls.load(), 2); + EXPECT_FALSE(entry->loop->is_busy()); + std::lock_guard lock(mutex); + ASSERT_EQ(bodies.size(), 3u); + EXPECT_EQ(bodies[0]["reasoning_effort"], "high"); + EXPECT_EQ(bodies[1]["reasoning_effort"], "low"); + EXPECT_EQ(bodies[2]["reasoning_effort"], "high"); } TEST(WebServerHttp, WorkspaceHomeDraftPersistsWithoutCreatingASession) { diff --git a/web/public/vs-icons/PinOff.svg b/web/public/vs-icons/PinOff.svg new file mode 100644 index 000000000..c20b9b8e4 --- /dev/null +++ b/web/public/vs-icons/PinOff.svg @@ -0,0 +1 @@ + diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 4fcbfbe66..45d622a78 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,10 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '选择文件': 'Select file', + '无法读取目录': 'Unable to read directory', + '空目录': 'Empty directory', + '文件路径': 'File path', '滚动到底部': 'Scroll to bottom', '由 Microsoft Store 管理更新': 'Updates managed by Microsoft Store', '请在 Microsoft Store 的“库”中获取 ACECode 更新。': 'Get ACECode updates from Library in Microsoft Store.', @@ -595,11 +599,26 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '工作目录': 'Working directory', 'Git 分支': 'Git branch', '最近活动': 'Last active', - '循环': 'Loops', - '我的循环': 'My loops', - '添加循环': 'Add loop', - '编辑循环': 'Edit loop', - '删除循环': 'Delete loop', + '我的定时任务': 'My scheduled tasks', + '添加定时任务': 'Add scheduled task', + '编辑定时任务': 'Edit scheduled task', + '删除定时任务': 'Delete scheduled task', + '智能添加定时任务': 'Add scheduled task with AI', + '手动添加': 'Add manually', + '已有定时任务': 'Existing scheduled task', + '保存定时任务失败': 'Failed to save scheduled task', + '读取定时任务失败:{{p0}}': 'Failed to load scheduled tasks: {{p0}}', + '读取定时任务失败:': 'Failed to load scheduled tasks: ', + '/scheduled-task 我希望在明天X点提醒我参加会议,重复X天': '/scheduled-task Remind me to attend a meeting tomorrow at X, repeated for X days', + '在独立的 Git worktree 中运行此定时任务': 'Run this scheduled task in an isolated Git worktree', + '启用失败:该工作空间的执行时间与已有定时任务冲突': 'Could not enable: this workspace has a conflicting scheduled task', + '当前后台不支持定时任务': 'This server does not support scheduled tasks', + '管理定时提醒和计划执行的任务,查看最近运行记录。': 'Manage reminders and scheduled tasks, and view recent runs.', + '还没有定时任务,点击“智能添加定时任务”或“手动添加”开始。': 'No scheduled tasks yet. Add one with AI or add one manually.', + '删除定时任务“{{p0}}”?运行记录也会一并删除。': 'Delete scheduled task “{{p0}}”? Its run history will also be deleted.', + '请输入定时任务名称': 'Enter a scheduled task name', + '工作空间在该时间已有定时任务运行,已错过且不会补跑': 'Another scheduled task was running in this workspace; this run was missed and will not be retried', + '所选模型已不可用,定时任务已停用': 'The selected model is unavailable; the scheduled task was disabled', '运行记录': 'Run history', '默认权限': 'Default permissions', '完全访问权限': 'Full access', @@ -1794,4 +1813,11 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ "memory enable disable remember context tools 记忆开关": "memory enable disable remember context tools", "memory summary consolidation extraction inbox idle 记忆整理 自动提炼": "memory summary consolidation extraction inbox idle", "memory summary model 记忆摘要模型": "memory summary model", + "正在加载更早的消息…": "Loading earlier messages…", + "更早的消息加载失败": "Could not load earlier messages", + "会话历史已更新,请重试": "Conversation history changed. Try again", + "停止请求发送失败,连接恢复后请重试": "Could not send the stop request. Try again after reconnecting", + "连接已断开,停止状态尚未确认": "Disconnected. The stop has not been confirmed", + "正在等待当前任务停止": "Waiting for the current task to stop", + "正在停止本轮任务…": "Stopping this turn…", }); diff --git a/web/scripts/test-busy-reasoning.mjs b/web/scripts/test-busy-reasoning.mjs new file mode 100644 index 000000000..d180611ec --- /dev/null +++ b/web/scripts/test-busy-reasoning.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +// Run from web: node scripts/test-busy-reasoning.mjs [--baseline] +// Uses real ChatView with fixture HTTP/WebSocket traffic; no live daemon. +// ACE_PLAYWRIGHT_MODULE / ACE_CHROMIUM_EXECUTABLE select optional browser tools. +// ACE_REASONING_TEST_OUTPUT optionally saves desktop/narrow screenshots. +const web = fileURLToPath(new URL('../', import.meta.url)); +const modulePath = process.env.ACE_PLAYWRIGHT_MODULE; +const { chromium } = await import(modulePath ? pathToFileURL(path.resolve(modulePath)).href : 'playwright'); +const baseline = process.argv.includes('--baseline') + ? execFileSync('git', ['show', 'HEAD:web/src/components/ChatView.jsx'], { cwd: web, encoding: 'utf8' }) : null; +const entry = ` +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {ChatView} from '/src/components/ChatView.jsx'; +import {connection} from '/src/lib/connection.js'; +import {applyLocalePreference} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +await applyLocalePreference('zh-CN'); +const root = createRoot(document.getElementById('root')); +const empty = []; +window.fixture = { + render(sid) { + root.render(); + }, + busy(sid) { + connection.dispatchEvent(new CustomEvent('message', {detail:{type:'busy_changed',session_id:sid,payload:{busy:true,turn_id:'active-turn'}}})); + }, +}; +window.fixture.render('reasoning-a'); +`; +const html = ` + +
+`; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'busy-reasoning-fixture', enforce: 'pre', + resolveId(id) { if (id === 'virtual:reasoning-fixture') return '\0virtual:reasoning-fixture'; }, + async load(id) { + if (id === '\0virtual:reasoning-fixture') return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx', jsx: 'automatic' })).code; + if (baseline && id.replaceAll('\\', '/') === path.join(web, 'src/components/ChatView.jsx').replaceAll('\\', '/')) return baseline; + }, + configureServer(vite) { + vite.middlewares.use('/reasoning-fixture', async (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/reasoning-fixture', html)); + }); + }, + }], +}); +const reasoning = { supported: true, default_enabled: true, supported_efforts: ['low', 'high'], default_effort: 'high' }; +const model = (effort) => ({ name: 'fixture', provider: 'openai', model: 'fixture-model', + models_dev_provider_id: 'acemodel', reasoning: { ...reasoning, effort: effort ?? 'high' }, reasoning_effort: effort }); +const states = { 'reasoning-a': model('high'), 'reasoning-b': model('low') }; +const errors = []; +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + page.setDefaultTimeout(60000); + page.on('pageerror', error => errors.push(error.message)); + let onUpdate; + await page.route('**/api/**', async route => { + const pathname = new URL(route.request().url()).pathname; + const sid = pathname.match(/\/sessions\/([^/]+)/)?.[1]; + let data = {}; + if (pathname.endsWith('/reasoning')) { + const effort = route.request().postDataJSON().effort; + const result = await new Promise(resolve => onUpdate({ sid, effort, reply: resolve })); + if (result.status === 200) states[sid] = model(effort); + await route.fulfill({ status: result.status, contentType: 'application/json', + body: JSON.stringify(result.status === 200 ? states[sid] : { error: 'REASONING_UPDATE_FAILED' }) }); + return; + } + if (pathname.endsWith('/model')) data = states[sid]; + else if (pathname.includes('/messages')) data = { messages: [{ role: 'user', content: '正在运行的任务', uuid: 'active-turn' }], events: [], busy: true, status: 'running' }; + else if (pathname.includes('models')) data = { models: [model(null)], profiles: [model(null)] }; + else if (pathname.includes('workspaces')) data = { workspaces: [] }; + else if (pathname.includes('history')) data = { items: [], history: [] }; + else if (pathname.includes('commands')) data = { commands: [] }; + else if (pathname.includes('skills')) data = { skills: [] }; + else if (pathname.includes('experts')) data = { experts: [] }; + else if (pathname.includes('subagents')) data = { tasks: [] }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); + }); + const selector = () => page.getByRole('button', { name: /^思考深度:/ }); + const choose = async label => { + await selector().click(); + let timer; + const request = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('reasoning update was not sent')), 10000); + onUpdate = update => { clearTimeout(timer); resolve(update); }; + }); + // Attach the rejection handler before Playwright awaits the click. + request.catch(() => {}); + await page.getByRole('menuitemradio', { name: label, exact: true }).click(); + const received = await request; + assert.equal(await selector().isDisabled(), true, 'pending update prevents duplicate changes'); + return received; + }; + const waitLabel = async label => { + await page.getByRole('button', { name: `思考深度:${label}`, exact: true }).waitFor(); + await page.waitForFunction(() => !document.querySelector('[aria-label^="思考深度:"]')?.disabled); + }; + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/reasoning-fixture`, { waitUntil: 'commit' }); + await selector().waitFor(); + await page.evaluate(() => window.fixture.busy('reasoning-a')); + await page.waitForTimeout(100); + assert.equal(await selector().isEnabled(), true, 'busy session keeps reasoning selector enabled'); + let request = await choose('低'); + assert.equal(request.effort, 'low'); + assert.equal(request.sid, 'reasoning-a'); + request.reply({ status: 200 }); + await waitLabel('低'); + console.log('[pass] busy session changes effort and protects pending submission'); + + request = await choose('高'); + request.reply({ status: 500 }); + await waitLabel('低'); + console.log('[pass] failed update preserves selection'); + + request = await choose('高'); + await page.evaluate(() => window.fixture.render('reasoning-b')); + await waitLabel('低'); + request.reply({ status: 200 }); + await page.waitForTimeout(100); + assert.equal(await selector().getAttribute('aria-label'), '思考深度:低'); + console.log('[pass] late response cannot overwrite another session'); + + await page.evaluate(() => window.fixture.busy('reasoning-b')); + request = await choose('默认'); + assert.equal(request.effort, null); + request.reply({ status: 200 }); + await waitLabel('高'); + console.log('[pass] busy session restores saved default'); + for (const width of [1280, 420]) { + await page.setViewportSize({ width, height: 800 }); + await selector().click(); + await page.getByRole('menuitemradio', { name: '低', exact: true }).waitFor(); + if (process.env.ACE_REASONING_TEST_OUTPUT) { + await mkdir(process.env.ACE_REASONING_TEST_OUTPUT, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ACE_REASONING_TEST_OUTPUT, `busy-reasoning-${width}.png`) }); + } + await page.keyboard.press('Escape'); + } + assert.deepEqual(errors, []); + console.log('[pass] desktop and narrow layouts keep menu usable without runtime errors'); +} catch (error) { + if (errors.length) console.error(errors); + throw error; +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-chat-scroll-to-bottom.mjs b/web/scripts/test-chat-scroll-to-bottom.mjs new file mode 100644 index 000000000..88122496a --- /dev/null +++ b/web/scripts/test-chat-scroll-to-bottom.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +// Run from web: node scripts/test-chat-scroll-to-bottom.mjs [--baseline] +// Playwright is optional; ACE_PLAYWRIGHT_MODULE can point to its index.mjs. +// ACE_CHROMIUM_EXECUTABLE selects an installed browser; otherwise use Chromium. +// ACE_CHAT_SCROLL_TEST_OUTPUT optionally saves results and screenshots. +// All API/WebSocket traffic uses fixtures. No daemon or Desktop is started. +const web = fileURLToPath(new URL('../', import.meta.url)); +const modulePath = process.env.ACE_PLAYWRIGHT_MODULE; +const { chromium } = await import(modulePath ? pathToFileURL(path.resolve(modulePath)).href : 'playwright'); +const output = process.env.ACE_CHAT_SCROLL_TEST_OUTPUT; +const baseline = process.argv.includes('--baseline'); +const baselineSources = new Map(baseline ? [ + 'src/components/ChatView.jsx', 'src/lib/chatScrollFollow.js', +].map(relative => [ + path.join(web, relative).replaceAll('\\', '/'), + execFileSync('git', ['show', `HEAD:web/${relative}`], { cwd: web, encoding: 'utf8' }), +]) : []); +const entry = ` +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {ChatView} from '/src/components/ChatView.jsx'; +import {connection} from '/src/lib/connection.js'; +import {applyLocalePreference} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +await applyLocalePreference('zh-CN'); +const root = createRoot(document.getElementById('root')); +const empty = []; +let sid = 'scroll-long'; +window.fixture = { + render(id) { + sid = id; + root.render(); + }, + event(type, payload) { + connection.dispatchEvent(new CustomEvent('message', {detail:{type,session_id:sid,payload}})); + }, +}; +window.fixture.render(sid); +`; +const html = ` + +
+`; +const server = await createServer({ + root: web, + configFile: path.join(web, 'vite.config.js'), + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'chat-scroll-fixture', + enforce: 'pre', + resolveId(id) { if (id === 'virtual:chat-scroll-fixture') return '\0virtual:chat-scroll-fixture'; }, + async load(id) { + if (id === '\0virtual:chat-scroll-fixture') { + return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx', jsx: 'automatic' })).code; + } + return baselineSources.get(id.replaceAll('\\', '/')); + }, + configureServer(vite) { + vite.middlewares.use('/scroll-fixture', async (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/scroll-fixture', html)); + }); + }, + }], +}); +let browser; +const checks = []; +const errors = []; +try { + await server.listen(); + browser = await chromium.launch({ + headless: true, + executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined, + args: ['--force-device-scale-factor=1.5'], + }); + const page = await browser.newPage({ viewport: { width: 1325, height: 775 }, deviceScaleFactor: 1.5 }); + page.on('pageerror', error => errors.push(error.message)); + const messages = Array.from({ length: 12 }, (_, i) => ({ + id: `fixture-${i}`, role: i % 2 ? 'assistant' : 'user', + content: i % 2 ? '滚动验证\n\n' + '一段用于检查消息到底的测试文本。\n\n'.repeat(5) : `问题 ${i}`, + timestamp: '2026-10-04T01:00:00Z', + })); + await page.route('**/api/**', async route => { + const pathname = new URL(route.request().url()).pathname; + let data = {}; + if (pathname.includes('/messages')) { + data = { messages: pathname.includes('scroll-short') ? messages.slice(0, 1) : messages, events: [], busy: pathname.includes('scroll-running') }; + } else if (pathname.includes('models')) data = { models: [], profiles: [] }; + else if (pathname.includes('workspaces')) data = { workspaces: [] }; + else if (pathname.includes('history')) data = { items: [], history: [] }; + else if (pathname.includes('commands')) data = { commands: [] }; + else if (pathname.includes('skills')) data = { skills: [] }; + else if (pathname.includes('experts')) data = { experts: [] }; + else if (pathname.includes('subagents')) data = { tasks: [] }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); + }); + const viewport = page.locator('.ace-chat-transcript-scroll'); + const button = page.getByRole('button', { name: '滚动到底部', exact: true }); + const dots = button.locator('.ace-chat-tail-progress > span'); + const arrow = button.locator('[data-icon-name="ArrowDown"]'); + const settle = () => page.waitForTimeout(200); + const metrics = () => viewport.evaluate(el => ({ + top: el.scrollTop, distance: el.scrollHeight - el.clientHeight - el.scrollTop, + })); + const check = (name, valid, details) => { + assert.ok(valid, `${name}${details ? ': ' + JSON.stringify(details) : ''}`); + checks.push(name); + console.log(`[pass] ${name}`); + }; + const bottom = async name => { + const current = await metrics(); + check(name, current.distance <= 2 && await button.count() === 0, current); + }; + const away = async () => { + await viewport.evaluate(el => { el.scrollTop = 100; }); + await button.waitFor(); + await settle(); + }; + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/scroll-fixture`); + await page.locator('[data-chat-row="true"]').last().waitFor(); + await settle(); + await bottom('initial load hides the arrow at the tail'); + + // Choose a real fractional layout where the browser's clamped scrollTop + // leaves >1px after integer height rounding. Do not fake DOM metric getters. + const fractionalGap = await viewport.evaluate(el => { + document.documentElement.style.zoom = '0.9'; + const content = el.firstElementChild; + let found = null; + for (let i = 0; i < 20 && !found; i += 1) { + for (let j = 0; j < 20 && !found; j += 1) { + el.style.height = `${620 + i / 20}px`; + content.style.paddingBottom = `${j / 20}px`; + el.scrollTop = el.scrollHeight; + const gap = el.scrollHeight - el.clientHeight - el.scrollTop; + if (gap > 1 && gap <= 2) found = gap; + } + } + return found; + }); + check('scaled fixture reproduces a clamped bottom with more than 1px rounding error', fractionalGap > 1, fractionalGap); + await away(); + await button.click(); + await settle(); + await bottom('click hides the arrow at the scaled clamped bottom'); + await away(); + const box = await viewport.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, 10000); + await settle(); + await bottom('manual wheel to the bottom hides the arrow'); + await viewport.evaluate(el => { el.scrollTop = el.scrollHeight - el.clientHeight - 24; }); + await settle(); + check('24px of remaining content still displays the arrow', await button.count() === 1); + await away(); + check('idle history button shows the down arrow', await arrow.count() === 1 && await dots.count() === 0); + const idleBox = await button.boundingBox(); + await page.evaluate(() => window.fixture.event('reasoning', { text: 'Considering the next step.' })); + await dots.first().waitFor(); + await settle(); + check('reasoning replaces the arrow with three dots without scrolling', + await dots.count() === 3 && await arrow.count() === 0 && Math.abs((await metrics()).top - 100) <= 2); + const runningBox = await button.boundingBox(); + check('running indicator preserves the button position and size', + ['x', 'y', 'width', 'height'].every(key => Math.abs(idleBox[key] - runningBox[key]) < 1), { idleBox, runningBox }); + const animation = await dots.evaluateAll(nodes => ({ + delays: nodes.map(node => getComputedStyle(node).animationDelay), + samples: [100, 300, 500].map(time => nodes.map(node => { + const animation = node.getAnimations()[0]; + animation.pause(); + animation.currentTime = time; + return new DOMMatrixReadOnly(getComputedStyle(node).transform).m42; + })), + })); + check('dots hop one at a time from left to right', + animation.delays.join(',') === '0s,0.2s,0.4s' + && animation.samples.every((sample, index) => sample.every((y, dot) => dot === index ? y < -2.9 : Math.abs(y) < .1)), animation); + await dots.evaluateAll(nodes => nodes.forEach(node => node.getAnimations().forEach(animation => { + animation.currentTime = 0; + animation.play(); + }))); + if (output) { + await mkdir(output, { recursive: true }); + await page.screenshot({ path: path.join(output, 'history-running.png') }); + } + await page.emulateMedia({ reducedMotion: 'reduce' }); + await settle(); + check('reduced motion keeps static dots and a usable button', await button.isEnabled() + && await dots.evaluateAll(nodes => nodes.length === 3 && nodes.every(node => getComputedStyle(node).animationName === 'none'))); + await button.click(); + await settle(); + await bottom('clicking static running dots returns to the bottom'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await away(); + for (const type of ['done', 'error', 'turn_aborted']) { + await page.evaluate(() => window.fixture.event('busy_changed', { busy: true })); + await dots.first().waitFor(); + await page.evaluate(type => window.fixture.event(type, type === 'error' ? { reason: 'Fixture failure' } : {}), type); + await arrow.waitFor(); + await settle(); + check(`${type} restores the arrow without interrupting history review`, + await dots.count() === 0 && Math.abs((await metrics()).top - 100) <= 2); + } + await page.evaluate(() => window.fixture.event('token', { text: '\n\n' + 'Streaming content.\n\n'.repeat(12) })); + await settle(); + check('streaming preserves manual history review with running dots', Math.abs((await metrics()).top - 100) <= 2 && await dots.count() === 3); + await button.focus(); + await page.keyboard.press('Enter'); + await settle(); + await bottom('keyboard activation returns to the bottom'); + await page.evaluate(() => window.fixture.event('token', { text: '\n\n' + 'Later content.\n\n'.repeat(8) })); + await settle(); + await bottom('later stream growth follows with the arrow hidden'); + + await viewport.evaluate(el => { + document.documentElement.style.zoom = ''; + el.style.height = ''; + el.firstElementChild.style.paddingBottom = ''; + }); + await settle(); + await away(); + // Suppress native scroll delivery to verify that protected programmatic + // writes refresh visibility independently of another browser scroll event. + await viewport.evaluate(el => { + const suppress = event => event.stopImmediatePropagation(); + el.addEventListener('scroll', suppress, true); + window.removeScrollSuppression = () => el.removeEventListener('scroll', suppress, true); + }); + await button.click(); + await settle(); + await bottom('programmatic scrolling refreshes visibility without a scroll event'); + await page.evaluate(() => window.removeScrollSuppression()); + await away(); + if (output) { + await mkdir(output, { recursive: true }); + await page.screenshot({ path: path.join(output, 'history-arrow.png') }); + } + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; }); + await settle(); + // Re-establish history review after the responsive composer changes height. + await away(); + check('narrow dark history shows the running indicator', await dots.count() === 3); + if (output) await page.screenshot({ path: path.join(output, 'narrow-running.png') }); + await button.click(); + await settle(); + await bottom('narrow dark layout hides the arrow after returning'); + if (output) await page.screenshot({ path: path.join(output, 'narrow-bottom.png') }); + await page.evaluate(() => window.fixture.render('scroll-short')); + await settle(); + await bottom('short conversation hides the arrow'); + await page.evaluate(() => window.fixture.render('scroll-running')); + await settle(); + await away(); + await dots.first().waitFor(); + check('restored running session shows dots without a live token', await dots.count() === 3); + await page.evaluate(() => window.fixture.render('scroll-long')); + await settle(); + await away(); + check('switching to an idle session clears the previous running indicator', await arrow.count() === 1 && await dots.count() === 0); + check('no browser runtime errors', errors.length === 0, errors); +} finally { + if (output) { + await mkdir(output, { recursive: true }); + await writeFile(path.join(output, baseline ? 'baseline.json' : 'results.json'), JSON.stringify({ checks, errors }, null, 2)); + } + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-composer-selection.mjs b/web/scripts/test-composer-selection.mjs index 795987240..8c13017d7 100644 --- a/web/scripts/test-composer-selection.mjs +++ b/web/scripts/test-composer-selection.mjs @@ -61,10 +61,14 @@ const fixtures = { session:[text('ALPHA '+sessionToken+' BRAVO')], attachment:[text('ALPHA '),attachment('file-1'),text(' BRAVO')], image:[text('ALPHA '),attachment('image-1'),text(' BRAVO')], - duplicate:[text('ALPHA '),attachment('file-1'),text(' BRAVO '),attachment('file-1'),text(' CHARLIE')], - command:[text('/init BRAVO')], - multiline:[text('FIRST ALPHA '),pathTag,text(' BRAVO\\nSECOND CHARLIE '),skillTag,text(' DELTA\\nTHIRD ECHO '),attachment('file-1'),text(' FOXTROT')], - empty:[], + duplicate:[text('ALPHA '),attachment('file-1'),text(' BRAVO '),attachment('file-1'),text(' CHARLIE')], + adjacent:[pathTag,attachment('file-1'),skillTag], + edgeTags:[pathTag,text('\\n'),skillTag], + rtl:[text('\\u05d0\\u05d1 '),pathTag,text(' \\u05d2\\u05d3')], + command:[text('/init BRAVO')], + multiline:[text('FIRST ALPHA '),pathTag,text(' BRAVO\\nSECOND CHARLIE '),skillTag,text(' DELTA\\nTHIRD ECHO '),attachment('file-1'),text(' FOXTROT')], + typography:[text('/init AgTest \\u6d4b\\u8bd5\\n'),pathTag,text(' AgTest \\u6d4b\\u8bd5\\n'),skillTag,text(' AgTest \\u6d4b\\u8bd5\\n'+sessionToken+' AgTest \\u6d4b\\u8bd5\\n'),attachment('file-1'),text(' AgTest \\u6d4b\\u8bd5\\n'),attachment('image-1'),text(' AgTest \\u6d4b\\u8bd5')], + empty:[], }; window.previews = []; window.__ACECODE_DESKTOP_SHELL__ = true; @@ -147,14 +151,27 @@ try { async function keyboardTag(type){ await reset(type); if(type==='command'){await editor.click();await page.keyboard.press('Control+Home');} - else {const point=await pointAtText('ALPHA',5);await page.mouse.click(point.x,point.y);await page.keyboard.press('ArrowRight');} - await settle(); - for(let step=0;step<3;step++){ - await page.keyboard.press('ArrowRight');const s=await state();if(s.selected.includes(type))return; + else {const point=await pointAtText('ALPHA',5);await page.mouse.click(point.x,point.y);await settle();await page.keyboard.press('ArrowRight');} + await settle(); + for(let step=0;step<3;step++){ + await page.keyboard.press('Shift+ArrowRight');if((await state()).selected.includes(type))return; + } + assert.fail('Shift+ArrowRight did not select the tag'); } - assert.fail('ArrowRight did not enter tag'); - } - for(const type of ['command','path','skill','session','attachment']){ + for(const type of ['command','path','skill','session','attachment']){ + await run(type+' plain arrows skip the tag without selecting or replacing it',async()=>{ + for(const backwards of [false,true]){ + await reset(type);const initial=await state();const before=initial.content; + await clickTag(type);await page.keyboard.press(backwards?'ArrowRight':'ArrowLeft');await settle(); + await page.keyboard.press(backwards?'ArrowLeft':'ArrowRight'); + const moved=await state();assert.equal(moved.collapsed,true);assert.deepEqual(moved.selected,[]); + assert.deepEqual(moved.content,before,'movement cannot mutate the draft'); + await page.keyboard.type('X');const after=await state(); + const prefix=type==='command'?'':'ALPHA '; + const offset=backwards?prefix.length:initial.value.length-' BRAVO'.length; + assert.equal(after.value,initial.value.slice(0,offset)+'X'+initial.value.slice(offset),'typing lands exactly at the far boundary'); + } + }); await run(type+' single click selects whole tag',async()=>{ await reset(type);await clickTag(type);const s=await state();assert(s.selected.includes(type),'tag must highlight');assert(s.focused,'editor must retain focus'); await page.keyboard.press('Backspace');assert.equal(await tag(type).count(),0,'Backspace must remove selected tag'); @@ -185,22 +202,56 @@ try { await reset(type);await clickTag(type);await page.keyboard.press('Delete');assert.equal(await tag(type).count(),0); assert.equal((await state()).value,type==='command'?' BRAVO':'ALPHA BRAVO'); }); - await run(type+' keyboard void point supports copy, cut and undo',async()=>{ + await run(type+' Shift-selected tag supports copy, cut and undo',async()=>{ await keyboardTag(type);const original=(await state()).content;await page.keyboard.press('Control+c'); const copied=await page.evaluate(()=>navigator.clipboard.readText());const expected={command:'/init',path:'@src/main.cpp',skill:'$review',attachment:'[notes.txt]'}[type]; assert(expected ? copied===expected : copied.startsWith('@session:'),'copy must serialize keyboard-selected tag'); await page.keyboard.press('Control+x');assert.equal(await tag(type).count(),0);await page.keyboard.press('Control+z');assert.deepEqual((await state()).content,original); }); - await run(type+' keyboard void point supports replacement typing',async()=>{ + await run(type+' Shift-selected tag supports replacement typing',async()=>{ await keyboardTag(type);await page.keyboard.type('X');assert.equal(await tag(type).count(),0);assert.equal((await state()).value,type==='command'?'X BRAVO':'ALPHA X BRAVO'); }); - await run(type+' keyboard void point supports paste and line breaks',async()=>{ + await run(type+' Shift-selected tag supports paste and line breaks',async()=>{ await reset('empty');await page.getByRole('textbox',{name:'External clipboard target'}).click();await page.keyboard.type('PASTE');await page.keyboard.press('Control+a');await page.keyboard.press('Control+c'); await keyboardTag(type);await page.keyboard.press('Control+v');assert.equal(await tag(type).count(),0,'paste replaces keyboard-selected tag');assert.equal((await state()).value,type==='command'?'PASTE BRAVO':'ALPHA PASTE BRAVO'); await keyboardTag(type);await page.keyboard.press('Shift+Enter');assert.equal(await tag(type).count(),0,'line break replaces keyboard-selected tag');assert.equal((await state()).value,type==='command'?'\n BRAVO':'ALPHA \n BRAVO'); }); - } - for(const type of ['path','skill','session','attachment']){ + } + await run('plain arrows cross adjacent tags one at a time in either direction',async()=>{ + for(const backwards of [false,true]){ + await reset('adjacent');const original=(await state()).content; + await editor.click();await page.keyboard.press(backwards?'Control+End':'Control+Home');await settle(); + for(let step=1;step<=3;step++){ + await page.keyboard.press(backwards?'ArrowLeft':'ArrowRight'); + const s=await state();assert.equal(s.collapsed,true);assert.deepEqual(s.selected,[]); + await page.keyboard.type('X'); + const parts=(await state()).content.parts; + assert.equal(parts.findIndex(part=>part.type==='text' && part.text==='X'),backwards?3-step:step,JSON.stringify({backwards,step,parts})); + assert.deepEqual(parts.filter(part=>part.type!=='text'),original.parts); + await page.keyboard.press('Control+z');await settle(); + } + } + }); + await run('plain arrows retain line breaks and start/end tag caret stops',async()=>{ + await reset('edgeTags');await editor.click();await page.keyboard.press('Control+Home');await settle(); + for(const key of ['ArrowLeft','ArrowRight','ArrowRight','ArrowRight','ArrowRight']){ + await page.keyboard.press(key);const s=await state();assert(s.collapsed);assert.deepEqual(s.selected,[]); + } + await page.keyboard.type('X');assert.equal((await state()).value,'@src/main.cpp\n$reviewX'); + await page.keyboard.press('Control+z');await settle(); + for(let i=0;i<4;i++){await page.keyboard.press('ArrowLeft');assert.deepEqual((await state()).selected,[]);} + await page.keyboard.type('X');assert.equal((await state()).value,'X@src/main.cpp\n$review'); + }); + await run('plain arrows follow RTL paragraph direction across tags',async()=>{ + for(const backwards of [false,true]){ + await reset('rtl');await clickTag('path'); + await page.keyboard.press(backwards?'ArrowLeft':'ArrowRight');await settle(); + await page.keyboard.press(backwards?'ArrowRight':'ArrowLeft'); + const s=await state();assert(s.collapsed);assert.deepEqual(s.selected,[]); + await page.keyboard.type('X');assert.equal((await state()).value,backwards?'\u05d0\u05d1 X@src/main.cpp \u05d2\u05d3':'\u05d0\u05d1 @src/main.cppX \u05d2\u05d3'); + } + }); + for(const type of ['path','skill','session','attachment']){ await run(type+' drag from tag into following text',async()=>{ await reset(type);const box=await tag(type).boundingBox();await drag({x:box.x+box.width/2,y:box.y+box.height/2},await pointAtText('BRAVO',3)); const s=await state();assert.equal(s.collapsed,false,'drag must produce range');assert(s.selected.includes(type),'origin tag must join selection');assert(s.selection.includes('BRA'),'trailing text must join selection'); @@ -318,13 +369,44 @@ try { await page.keyboard.press('Control+z');assert.deepEqual((await state()).content,original); } finally {await page.setViewportSize({width:1200,height:760});} }); - await run('selection adds a rectangle without recoloring or moving tags across themes and font sizes',async()=>{ + await run('tags share the prose font, size, line height and baseline with only a heavier weight',async()=>{ + for(const sample of [ + {theme:'light',font:13,line:'20px'}, + {theme:'dark',font:14,line:'20px'}, + {theme:'light',font:20,line:'28px'}, + {theme:'dark',font:16,line:'28px'}, + ]){ + await reset('typography&'+new URLSearchParams(sample)); + const typography=await editor.evaluate(el=>[...el.querySelectorAll('[data-composer-inline-tag]')].map(tag=>{ + const label=tag.querySelector('.ace-cmd-token-name'); + const prose=tag.nextElementSibling.querySelector('[data-slate-string]'); + const labelCss=getComputedStyle(label),proseCss=getComputedStyle(prose); + const rect=node=>{const range=document.createRange();range.selectNodeContents(node);return range.getClientRects()[0];}; + const a=rect(label),b=rect(prose); + return {type:tag.dataset.composerInlineTag, + label:[labelCss.fontFamily,labelCss.fontSize,labelCss.lineHeight],prose:[proseCss.fontFamily,proseCss.fontSize,proseCss.lineHeight], + labelWeight:labelCss.fontWeight,proseWeight:proseCss.fontWeight,top:a.top-b.top,bottom:a.bottom-b.bottom, + height:tag.getBoundingClientRect().height}; + })); + assert.equal(typography.length,6); + for(const item of typography){ + const message=JSON.stringify({sample,item}); + assert.deepEqual(item.label,item.prose,message); + assert.equal(item.labelWeight,'600',message);assert.equal(item.proseWeight,'400',message); + assert(Math.abs(item.top)<1 && Math.abs(item.bottom)<1,message); + assert(Math.abs(item.height-parseFloat(sample.line))<1,message); + } + } + }); + await run('unboxed accent tags use native selection colors without moving across themes and font sizes',async()=>{ async function badgeStyles(){return editor.evaluate(el=>[...el.querySelectorAll('.ace-slate-inline-tag > .ace-cmd-token')].map(tag=>{ const css=getComputedStyle(tag),rect=tag.getBoundingClientRect(); - return {background:css.backgroundColor,border:css.borderColor,radius:css.borderRadius,opacity:css.opacity, + return {background:css.backgroundColor,border:css.borderWidth,radius:css.borderRadius, padding:[css.paddingLeft,css.paddingRight],margin:[css.marginLeft,css.marginRight],rect:[rect.x,rect.y,rect.width,rect.height], + color:css.color,opacity:css.opacity, colors:[...tag.querySelectorAll('.ace-cmd-token-name,.ace-cmd-token-glyph,.ace-file-type-glyph')].map(node=>getComputedStyle(node).color)}; }));} + const geometry=(styles)=>styles.map(({color,colors,opacity,...layout})=>layout); try { for(const sample of [ {theme:'light',font:13,line:20,width:1200}, @@ -337,18 +419,31 @@ try { await editor.waitFor();await page.evaluate(()=>document.fonts.ready);await settle(); const point=await pointAtText('ALPHA',2);await page.mouse.click(point.x,point.y);await page.mouse.move(1,1);await settle(); const before=await badgeStyles(); + const accent=await editor.evaluate(el=>getComputedStyle(el).getPropertyValue('--ace-accent').trim()); + const accentColor=await page.evaluate(color=>{ + const probe=document.createElement('span');probe.style.color=color;document.body.append(probe); + const resolved=getComputedStyle(probe).color;probe.remove();return resolved; + },accent); + for(const style of before){ + assert.equal(style.background,'rgba(0, 0, 0, 0)');assert.equal(style.border,'0px');assert.equal(style.radius,'0px'); + assert.deepEqual(style.padding,['0px','0px']);assert.deepEqual(style.margin,['0px','0px']); + assert.equal(style.color,accentColor);assert(style.colors.every(color=>color===accentColor),'tag text and icons use the theme accent'); + } + await tag('command').hover();assert.deepEqual(await badgeStyles(),before,'hover must not restore a badge');await page.mouse.move(1,1); await page.keyboard.press('Control+a');assert.equal((await state()).selected.length,6); - assert.deepEqual(await badgeStyles(),before,'selection must preserve badge surfaces, colors and geometry'); + assert.deepEqual(geometry(await badgeStyles()),geometry(before),'selection must preserve transparent surfaces and geometry'); const problems=await editor.evaluate(el=>{ const failures=[],canvas=document.createElement('canvas').getContext('2d'); const background=getComputedStyle(el,'::selection').backgroundColor; + const foreground=getComputedStyle(el,'::selection').color; for(const tag of el.querySelectorAll('[data-composer-selected="true"]')){ const badge=tag.querySelector('.ace-cmd-token'),css=getComputedStyle(badge),rect=badge.getBoundingClientRect(); const fill=getComputedStyle(tag,'::before'); if(fill.backgroundColor!==background || parseFloat(fill.height)[...el.querySelectorAll('[data-composer-inline-tag]')].every(tag=>getComputedStyle(tag,'::before').content==='none'))); + await clickTag('command'); + assert.equal(await tag('command').locator('.ace-cmd-token-name').evaluate(el=>getComputedStyle(el).color),await editor.evaluate(el=>getComputedStyle(el,'::selection').color),'single-click selection uses the native foreground'); await page.keyboard.press('Control+a');assert.equal((await state()).selected.length,6); await page.getByRole('textbox',{name:'External clipboard target'}).click();assert.deepEqual((await state()).selected,[],'blur removes the tag selection layer'); + assert.deepEqual(await badgeStyles(),before,'blur restores unselected tag colors'); } } finally {await page.setViewportSize({width:1200,height:760});} }); @@ -390,6 +488,7 @@ try { await page.setViewportSize({width:sample.width,height:sample.height});await page.emulateMedia({reducedMotion:sample.reducedMotion}); await page.goto('http://127.0.0.1:'+port+'/__composer-selection?fixture=all&theme='+sample.theme+'&locale='+sample.locale); await editor.waitFor();await page.evaluate(()=>document.fonts.ready);await settle(); + await page.screenshot({path:path.join(output,sample.name+'-unselected.png')}); const point=await pointAtText('ALPHA',2);await page.mouse.click(point.x,point.y);await page.keyboard.press('Control+a');await settle(); assert.equal((await state()).selected.length,6,'screenshot must show six selected tags');assert.equal((await state()).focused,true); await page.screenshot({path:path.join(output,sample.name+'.png')}); diff --git a/web/scripts/test-context-image-paste.mjs b/web/scripts/test-context-image-paste.mjs new file mode 100644 index 000000000..06933db2c --- /dev/null +++ b/web/scripts/test-context-image-paste.mjs @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +// Production InputBar/context menu and workspace modal; no daemon or account. +// ACE_PLAYWRIGHT_MODULE and ACE_CHROMIUM_EXECUTABLE select optional local tools. +// ACE_CONTEXT_TEST_OUTPUT saves results/screenshots; ACE_CONTEXT_TEST_FILTER filters names. +// --baseline loads the original menu/picker to reproduce the two reported bugs. +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(process.env.ACE_PLAYWRIGHT_MODULE).href : 'playwright'); +const output = process.env.ACE_CONTEXT_TEST_OUTPUT; +const filter = process.env.ACE_CONTEXT_TEST_FILTER ? new RegExp(process.env.ACE_CONTEXT_TEST_FILTER) : null; +const baseline = process.argv.includes('--baseline'); +const sources = new Map(baseline ? ['DesktopContextMenu.jsx', 'WorkspaceIconPicker.jsx'].map(name => [ + path.join(web, 'src/components', name).replaceAll('\\', '/'), + execFileSync('git', ['show', `HEAD:web/src/components/${name}`], { cwd: web, encoding: 'utf8' }), +]) : []); +const entry = ` +import React, {useRef, useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {InputBar} from '/src/components/InputBar.jsx'; +import {DesktopContextMenu} from '/src/components/DesktopContextMenu.jsx'; +import {EditWorkspaceModal} from '/src/components/EditWorkspaceModal.jsx'; +import {SlashCommandsProvider} from '/src/components/SlashCommandsContext.jsx'; +import {Toaster} from '/src/components/Toast.jsx'; +import {appendComposerImageAttachments} from '/src/lib/composerImagePresentation.js'; +import {composerContentText} from '/src/lib/composerContent.js'; +import {applyLocalePreference} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +await applyLocalePreference('zh-CN'); +window.uploads=[];window.saved=[];window.modalClosed=0;window.nativeReads=0; +if(new URLSearchParams(location.search).get('mode') === 'desktop'){ + window.__ACECODE_DESKTOP_SHELL__=true; + window.aceDesktop_readClipboardContextItems=async()=>{ + window.nativeReads++; + return window.nativeResult || {ok:true,filesystem_items:false,items:[]}; + }; +} +function Fixture(){ + const ref=useRef(); + const [content,setContent]=useState({version:1,parts:[]}); + const [attachments,setAttachments]=useState([]); + const [sid,setSid]=useState('first'); + const [disabled,setDisabled]=useState(false); + const [modal,setModal]=useState(false); + window.fixture={setSid,setDisabled,content:()=>ref.current.getComposerContent(), + seed:text=>ref.current.replaceText(text), + seedContent:(next,records=[])=>{setAttachments(records);ref.current.setComposerContent(next);}}; + const upload=files=>{ + const resources=files.map((file,index)=>({id:'image-'+(window.uploads.length+index),kind:'image',name:file.name,mime_type:file.type,url:URL.createObjectURL(file)})); + for(const file of files) window.uploads.push({file,type:file.type,size:file.size,name:file.name}); + setAttachments(previous=>[...previous,...resources]); + setContent(previous=>appendComposerImageAttachments(previous,resources)); + }; + return
+ + + setContent(next)} onMediaFiles={upload}/> + {modal && {window.saved.push(payload);return payload;}}} + onClose={()=>{window.modalClosed++;setModal(false);}}/>} + +
; +} +createRoot(document.getElementById('root')).render(); +`; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + plugins: [{ name: 'context-paste-fixture', enforce: 'pre', + resolveId(id) { if (id === 'virtual:context-fixture') return '\0virtual:context-fixture'; }, + async load(id) { + if (id === '\0virtual:context-fixture') return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx', jsx: 'automatic' })).code; + return sources.get(id.replaceAll('\\', '/')); + }, + configureServer(vite) { + vite.middlewares.use('/context-fixture', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/context-fixture', '
')); + }); + }, + }], server: { host: '127.0.0.1', port: 0 }, +}); +let browser; +const results = [], errors = []; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1100, height: 760 }, permissions: ['clipboard-read', 'clipboard-write'] }); + page.on('pageerror', error => { errors.push(error.message); console.error('[browser] '+error.message); }); + await page.route('**/api/**', route => route.fulfill({ contentType: 'application/json', body: '{"commands":[],"skills":[],"models":[]}' })); + const editor = page.locator('[data-ace-rich-composer]'); + const settle = () => page.waitForTimeout(180); + const state = () => page.evaluate(() => ({ content:window.fixture.content(),uploads:window.uploads.map(({file,...rest})=>rest),nativeReads:window.nativeReads })); + const reset = async (mode='browser') => { + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/context-fixture?mode=${mode}`); + await editor.waitFor(); await settle(); + }; + const seed = async text => { await page.evaluate(text=>window.fixture.seed(text),text); await editor.click();await page.keyboard.press('Control+End');await settle(); }; + const contextPaste = async (target=editor) => { + await target.click({button:'right'});await page.getByRole('menuitem',{name:'粘贴',exact:true}).click();await settle(); + }; + const writeImage = async () => page.evaluate(async()=>{ + const canvas=document.createElement('canvas');canvas.width=4;canvas.height=3; + canvas.getContext('2d').fillRect(0,0,4,3); + const png=await new Promise(resolve=>canvas.toBlob(resolve,'image/png')); + await navigator.clipboard.write([new ClipboardItem({'image/png':png,'text/plain':new Blob(['alternate image text'],{type:'text/plain'})})]); + }); + const deferRead = async () => page.evaluate(()=>{ + const read=navigator.clipboard.read.bind(navigator.clipboard); + Object.defineProperty(navigator.clipboard,'read',{configurable:true,value:async()=>{ + const items=await read();await new Promise(resolve=>{window.finishRead=resolve;});return items; + }}); + }); + async function run(name,test){ + if(filter && !filter.test(name))return; + try{await test();results.push({name,ok:true});console.log('[pass] '+name);} + catch(error){results.push({name,ok:false,error:error.message});console.error('[FAIL] '+name+': '+error.message);} + } + await run('real clipboard PNG produces the same thumbnail via Ctrl+V and context Paste',async()=>{ + const observed=[]; + for(const mode of ['browser','desktop'])for(const method of ['keyboard','context']){ + await reset(mode);await seed('before');await writeImage(); + if(method==='keyboard')await page.keyboard.press('Control+v');else await contextPaste(); + await page.locator('[data-composer-image-preview="true"] img').waitFor({timeout:5000}); + const current=await state();assert.equal(current.uploads.length,1);assert.equal(current.uploads[0].type,'image/png'); + assert.equal(composerText(current),'before'); + const pixels=await page.evaluate(async()=>{ + const file=window.uploads[0].file;const bitmap=await createImageBitmap(file); + const canvas=document.createElement('canvas');canvas.width=bitmap.width;canvas.height=bitmap.height; + canvas.getContext('2d').drawImage(bitmap,0,0);bitmap.close();return canvas.toDataURL(); + }); + observed.push(pixels); + if(output && method==='context') {await mkdir(output,{recursive:true});await page.screenshot({path:path.join(output,mode+'-image-paste.png')});} + await page.keyboard.type(' after');assert.equal(composerText(await state()),'before after'); + } + assert(observed.every(value=>value===observed[0])); + }); + await run('context Paste preserves text selection and undo',async()=>{ + await reset();await seed('replace me');await page.evaluate(()=>navigator.clipboard.writeText('new text')); + await page.keyboard.press('Control+a');await settle();await contextPaste(); + assert.equal(composerText(await state()),'new text');await page.keyboard.press('Control+z');await settle(); + assert.equal(composerText(await state()),'replace me'); + }); + await run('context Paste replaces exactly one zero-text attachment',async()=>{ + await reset();await page.evaluate(()=>window.fixture.seedContent({version:1,parts:[ + {type:'attachment',key:'one',id:'one',name:'one.txt',kind:'file'}, + {type:'attachment',key:'two',id:'two',name:'two.txt',kind:'file'}, + ]},[{id:'one',name:'one.txt',kind:'file'},{id:'two',name:'two.txt',kind:'file'}])); + const first=editor.locator('[data-composer-inline-tag="attachment"]').first();await first.click();await settle(); + await page.evaluate(()=>navigator.clipboard.writeText('replacement'));await contextPaste(first); + const parts=(await state()).content.parts; + assert.equal(parts.filter(part=>part.type==='attachment').length,1);assert.equal(parts.find(part=>part.type==='attachment').id,'two'); + assert.equal(composerText(await state()),'replacement'); + }); + await run('native file Paste remains available when browser clipboard reads fail',async()=>{ + await reset('desktop');await seed('before '); + await page.evaluate(()=>{ + for(const key of ['read','readText'])Object.defineProperty(navigator.clipboard,key,{value:async()=>{throw Error('denied');}}); + window.nativeResult={ok:true,filesystem_items:true,items:[{kind:'file',path:'C:/fixture/notes.txt',name:'notes.txt',reference_only:true,size_bytes:1}]}; + }); + await contextPaste();await editor.locator('[data-composer-inline-tag="path"]').waitFor(); + const pasted=await state(); + assert.equal(pasted.nativeReads,1); + assert.equal(pasted.content.parts.find(part=>part.type==='path')?.path,'C:/fixture/notes.txt'); + assert.equal(pasted.uploads.length,0); + }); + for(const action of ['session','disabled','reset'])await run('delayed image Paste is discarded after '+action,async()=>{ + await reset();await seed('before');await writeImage();await deferRead();await contextPaste(); + await page.waitForFunction(()=>typeof window.finishRead==='function'); + await page.evaluate(action=>{ + if(action==='session')window.fixture.setSid('second'); + if(action==='disabled')window.fixture.setDisabled(true); + if(action==='reset')window.fixture.seed('new draft'); + },action);await settle();await page.evaluate(()=>window.finishRead());await settle(); + assert.equal((await state()).uploads.length,0); + }); + await run('delayed text Paste tracks intervening edits without moving focus',async()=>{ + await reset();await seed('before');await page.evaluate(()=>navigator.clipboard.writeText('PASTE'));await deferRead();await contextPaste(); + await page.waitForFunction(()=>typeof window.finishRead==='function'); + await page.keyboard.press('Control+Home');await settle();await page.keyboard.type('X');await settle(); + await page.getByRole('textbox',{name:'Outside input'}).click(); + await page.evaluate(()=>window.finishRead());await settle();assert.equal(composerText(await state()),'XbeforePASTE'); + assert(await page.getByRole('textbox',{name:'Outside input'}).evaluate(el=>document.activeElement===el)); + }); + await run('empty clipboard keeps the selection and denied reads report an error',async()=>{ + await reset();await seed('keep');await page.keyboard.press('Control+a');await settle(); + await page.evaluate(()=>Object.defineProperty(navigator.clipboard,'read',{configurable:true,value:async()=>[]})); + await contextPaste();assert.equal(composerText(await state()),'keep'); + await page.evaluate(()=>{ + for(const key of ['read','readText'])Object.defineProperty(navigator.clipboard,key,{configurable:true,value:async()=>{throw Error('test clipboard denied');}}); + }); + await contextPaste();await page.getByRole('status').filter({hasText:'test clipboard denied'}).waitFor(); + assert.equal(composerText(await state()),'keep'); + }); + await run('ordinary input retains its text-only context Paste',async()=>{ + await reset();const input=page.getByRole('textbox',{name:'Outside input'}); + await input.fill('');await page.evaluate(()=>navigator.clipboard.writeText('ordinary')); + await contextPaste(input);assert.equal(await input.inputValue(),'ordinary'); + }); + const picker=page.locator('.ace-workspace-icon-picker'); + const openModal=async()=>{await reset();await page.getByRole('button',{name:'Open workspace'}).click();}; + const openPicker=async()=>{await page.getByRole('button',{name:'选择图标'}).click();await picker.waitFor();}; + await run('icon picker closes on outside input or backdrop without losing the modal draft',async()=>{ + await openModal();const name=page.getByRole('textbox',{name:'项目名称'});await name.fill('unsaved');await openPicker(); + await name.click();await picker.waitFor({state:'hidden',timeout:3000}); + assert.equal(await name.inputValue(),'unsaved');assert(await name.evaluate(el=>document.activeElement===el)); + await openPicker();await page.mouse.click(10,10);await picker.waitFor({state:'hidden',timeout:3000}); + assert.equal(await name.inputValue(),'unsaved');assert.equal(await page.evaluate(()=>window.modalClosed),0); + }); + await run('icon picker internal search and selection remain open, trigger and Escape close only once',async()=>{ + await openModal();await openPicker(); + await picker.getByRole('button').nth(2).click();assert(await picker.isVisible()); + await picker.getByRole('button').nth(10).click();assert(await picker.isVisible()); + await picker.getByRole('textbox').fill('folder');assert(await picker.isVisible()); + await page.getByRole('button',{name:'选择图标'}).click();assert.equal(await picker.count(),0); + await openPicker();await page.keyboard.press('Escape');assert.equal(await picker.count(),0); + assert.equal(await page.evaluate(()=>window.modalClosed),0); + assert(await page.getByRole('button',{name:'选择图标'}).evaluate(el=>document.activeElement===el)); + await page.getByRole('button',{name:'保存',exact:true}).click();await settle();assert.equal(await page.evaluate(()=>window.saved.length),1); + }); + await run('no browser runtime errors',async()=>assert.deepEqual(errors,[])); +} finally { + if(output){await mkdir(output,{recursive:true});await writeFile(path.join(output,baseline?'baseline.json':'results.json'),JSON.stringify(results,null,2));} + await browser?.close();await server.close(); +} +console.log(JSON.stringify({passed:results.filter(result=>result.ok).length,failed:results.filter(result=>!result.ok).length})); +if(results.some(result=>!result.ok))process.exitCode=1; +function composerText(state){return state.content.parts.filter(part=>part.type==='text').map(part=>part.text).join('');} diff --git a/web/scripts/test-file-breadcrumbs.mjs b/web/scripts/test-file-breadcrumbs.mjs new file mode 100644 index 000000000..e2fe31608 --- /dev/null +++ b/web/scripts/test-file-breadcrumbs.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +// Real preview panel, tabs, popup and CSS; deterministic filesystem responses. +// Optional ACE_PLAYWRIGHT_MODULE / ACE_CHROMIUM_EXECUTABLE match other UI tests. +const web = fileURLToPath(new URL('../', import.meta.url)); +const modulePath = process.env.ACE_PLAYWRIGHT_MODULE; +const { chromium } = await import(modulePath ? pathToFileURL(path.resolve(modulePath)).href : 'playwright'); +const output = process.env.ACE_BREADCRUMB_ARTIFACTS || await fs.mkdtemp(path.join(os.tmpdir(), 'ace-breadcrumbs-')); +await fs.mkdir(output, { recursive: true }); +const fixture = String.raw` +import React, {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {PreviewDetailsPanel} from '/src/components/PreviewDetailsPanel.jsx'; +import {openFileTab,activePreviewTab,visiblePreviewTabs,activatePreviewTab,updateFileTabDraft,discardFileTabDraft} from '/src/lib/previewTabs.js'; +import {createUnsavedFileGuard,runAfterFileApproval} from '/src/lib/unsavedFileGuard.js'; +import '/src/styles/globals.css'; +const context={scopeKey:'fixture',sessionId:'fixture'}; +const entries=(names,dir='')=>names.map(([name,kind])=>({name,kind,path:dir?dir+'/'+name:name})); +const directories={ + '':entries([['web','dir'],['docs','dir'],['empty','dir'],['denied','dir'],['slow','dir'],['README.md','file']]), + web:entries([['src','dir'],['index.html','file'],['style.css','file']],'web'), + 'web/src':entries([['app.js','file']],'web/src'), + docs:entries([['guide.md','file']],'docs'), empty:[], denied:entries([['recovered.txt','file']],'denied'), +}; +let deny=true, deferred=[]; +const requests=[]; +const api={ + listFiles:async(root,dir)=>{ + requests.push({root,dir}); + if(dir==='denied'&&deny){deny=false;throw Error('denied');} + if(dir==='slow')return new Promise(resolve=>deferred.push(resolve)); + return directories[dir]||[]; + }, + readFile:async(_root,p)=>p.endsWith('.md')?'# Project guide\n\nWorkspace navigation.':'\n\n Workspace navigation\n', + // Media loading is held to verify navigation independently of each renderer. + readFileBlob:()=>new Promise(()=>{}), +}; +function Fixture(){ + const [root,setRoot]=useState('C:/workspace/acecode'); + const [workspace,setWorkspace]=useState(true); + const [panelWidth,setPanelWidth]=useState(720); + const [tabState,setTabState]=useState(()=>openFileTab({}, {...context,cwd:root,path:'web/index.html'})); + const [pending,setPending]=useState(null); + const [guard]=useState(()=>createUnsavedFileGuard(setPending)); + const current=activePreviewTab(tabState,context); + const open=(p)=>{ + const approval=guard.request({getTabs:()=>[current],save:async()=>{},discard:tabs=>setTabState(s=>tabs.reduce((next,t)=>discardFileTabDraft(next,{...context,tabKey:t.key}),s))}); + return runAfterFileApproval(approval,()=>setTabState(s=>openFileTab(s,{...context,cwd:root,path:p}))); + }; + window.fixture={requests,pending, + get active(){return current?.path},get tabs(){return visiblePreviewTabs(tabState,context)}, + select(p,cwd=root){setTabState(s=>openFileTab(s,{...context,cwd,path:p}));}, + root(value){setRoot(value);setTabState(openFileTab({}, {...context,cwd:value,path:'web/index.html'}));}, + workspace:setWorkspace,width:setPanelWidth, + dirty(){setTabState(s=>updateFileTabDraft(s,{...context,tabKey:current.key,patch:{text:'edited',baselineText:'original'}}));}, + choose(choice){return guard.choose(choice);}, + release(){deferred.splice(0).forEach(resolve=>resolve(entries([['stale.txt','file']],'slow')));}, + }; + return
+ +
+ {window.listToggles=(window.listToggles||0)+1;}} + onActivateTab={key=>setTabState(s=>activatePreviewTab(s,{...context,tabKey:key}))} + onEditFileTab={(key,patch)=>setTabState(s=>updateFileTabDraft(s,{...context,tabKey:key,patch}))} + onOpenFilePreview={open} /> +
+
+
+
浏览器工具栏
+
+
; +} +createRoot(document.getElementById('root')).render(); +`; +const virtual = path.join(web, '__file-breadcrumbs.jsx').replaceAll('\\', '/'); +const html = `
`; +const server = await createServer({ root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + plugins: [{name:'file-breadcrumb-fixture',enforce:'pre', + resolveId(id){if(id==='/__file-breadcrumbs.jsx')return virtual;}, + load(id){if(id.replaceAll('\\','/')===virtual)return fixture;}, + configureServer(vite){vite.middlewares.use((req,res,next)=>{ + if(req.url?.split('?')[0]!=='/__file-breadcrumbs')return next(); + res.setHeader('Content-Type','text/html');res.end(html); + });}, + }], server:{host:'127.0.0.1',port:0,hmr:false}, +}); +let browser; +try { + await server.listen(); + browser = await chromium.launch({headless:true,...(process.env.ACE_CHROMIUM_EXECUTABLE ? {executablePath:process.env.ACE_CHROMIUM_EXECUTABLE} : {})}); + const page = await browser.newPage({viewport:{width:1200,height:760}}); + const errors=[]; + page.on('pageerror',error=>errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__file-breadcrumbs`); + const nav=page.getByRole('navigation',{name:'文件路径'}); + const menu=page.locator('.ace-breadcrumb-menu'); + const row=p=>menu.locator(`[data-path="${p}"]`); + const settle=()=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + async function active(expected){await page.waitForFunction(p=>window.fixture.active===p,expected);} + async function openRoot(){await nav.getByRole('button',{name:'acecode',exact:true}).click();await row('web').waitFor();} + async function theme(mode,color){await page.evaluate(([a,b])=>{ + document.documentElement.dataset.theme=a;document.documentElement.dataset.colorTheme=b; + },[mode,color]);await page.waitForTimeout(300);} + await nav.waitFor(); + for(const [mode,color] of [['light','blue'],['dark','blue'],['light','orange'],['light','ai-test']]){ + await theme(mode,color); + const colors=await page.evaluate(()=>{ + const css=s=>getComputedStyle(document.querySelector(s)).backgroundColor; + const probe=document.createElement('div');probe.style.background='var(--ace-surface-hi)';document.body.append(probe); + const high=getComputedStyle(probe).backgroundColor;probe.remove(); + return {sidebar:css('.ace-sidebar'),high,bars:['.ace-preview-details-tabs','.ace-agent-browser-toolbar','.ace-side-tabs'].map(css), + selected:getComputedStyle(document.querySelector('.ace-side-tab')).color,accent:getComputedStyle(document.documentElement).getPropertyValue('--ace-accent').trim()}; + }); + colors.bars.forEach(value=>assert.equal(value,mode==='light'&&color==='blue'?colors.sidebar:colors.high,`${mode}/${color}`)); + } + console.log('[pass] default light chrome matches sidebar; dark, orange and custom palettes retain their backgrounds'); + await theme('light','blue'); + await nav.getByRole('button',{name:'web',exact:true}).click(); + await row('web').waitFor(); + assert.equal(await row('web').getAttribute('aria-selected'),'true'); + await row('docs').click();await row('docs/guide.md').click();await active('docs/guide.md'); + assert.equal(await page.getByRole('tab').count(),2); + await openRoot();await row('web').click();await row('web/index.html').click();await active('web/index.html'); + assert.equal(await page.getByRole('tab').count(),2); + assert.equal(await page.getByRole('button',{name:'展开列表面板'}).getAttribute('aria-expanded'),'false'); + assert.equal(await page.evaluate(()=>window.listToggles||0),0); + console.log('[pass] directory segment browses siblings; nested file opens and existing tab is reused'); + await nav.getByRole('button',{name:'index.html',exact:true}).focus();await page.keyboard.press('ArrowDown'); + await row('web/index.html').waitFor(); + await page.waitForFunction(()=>document.activeElement?.dataset.path==='web/index.html'); + await page.keyboard.press('Home');await page.keyboard.press('ArrowRight');await row('web/src/app.js').waitFor(); + await page.waitForFunction(()=>document.activeElement?.dataset.path==='web/src/app.js'); + await page.keyboard.press('ArrowLeft');assert.equal(await row('web/src').evaluate(e=>e===document.activeElement),true); + await page.keyboard.press('ArrowLeft');assert.equal(await row('web/src').getAttribute('aria-expanded'),'false'); + await page.keyboard.press('ArrowDown');await page.keyboard.press('ArrowDown');await page.keyboard.press('Enter'); + await active('web/style.css'); + await nav.getByRole('button',{name:'style.css',exact:true}).click();await row('web/style.css').waitFor(); + await page.keyboard.press('Escape');await menu.waitFor({state:'detached'}); + assert.equal(await nav.getByRole('button',{name:'style.css',exact:true}).evaluate(e=>e===document.activeElement),true); + console.log('[pass] keyboard open, selected focus, tree navigation, Enter, Escape and focus restoration'); + await openRoot();await row('empty').click();await menu.getByText('空目录').waitFor(); + await row('denied').click();await menu.getByText('无法读取目录').waitFor(); + await menu.getByRole('button',{name:'重试'}).click();await row('denied/recovered.txt').waitFor(); + await row('slow').click();await menu.getByText('加载中...').waitFor(); + await page.evaluate(()=>window.fixture.root('C:/trees/feature'));await menu.waitFor({state:'detached'}); + await page.evaluate(()=>window.fixture.release());await settle();assert.equal(await page.getByText('stale.txt').count(),0); + await nav.getByRole('button',{name:'feature',exact:true}).click();await row('web').waitFor(); + assert.equal(await page.evaluate(()=>window.fixture.requests.at(-1).root),'C:/trees/feature'); + await row('slow').click();await menu.getByText('加载中...').waitFor(); + await page.evaluate(()=>window.fixture.select('docs/guide.md'));await menu.waitFor({state:'detached'}); + await page.evaluate(()=>window.fixture.release());await settle();assert.equal(await page.getByText('stale.txt').count(),0); + await nav.getByRole('button',{name:'guide.md',exact:true}).click();await row('docs/guide.md').waitFor(); + await page.keyboard.press('Escape'); + console.log('[pass] empty directory, retry, stale response isolation and actual worktree request root'); + await page.evaluate(()=>window.fixture.select('external.txt','C:/outside'));await nav.waitFor({state:'detached'}); + await page.evaluate(()=>window.fixture.select('web/index.html'));await nav.waitFor(); + await page.evaluate(()=>window.fixture.workspace(false));await nav.waitFor({state:'detached'}); + await page.evaluate(()=>window.fixture.workspace(true));await nav.waitFor(); + for(const name of ['file.cpp','file.md','file.png','file.pdf','file.docx','file.xlsx','file.pptx']){ + await page.evaluate(p=>window.fixture.select(p),name);await active(name); + await nav.getByRole('button',{name,exact:true}).waitFor(); + } + console.log('[pass] all seven file kinds expose shared navigation; outside/no-workspace files hide it'); + await page.evaluate(()=>window.fixture.root('C:/workspace/acecode'));await active('web/index.html'); + await page.evaluate(()=>window.fixture.dirty());await settle(); + await nav.getByRole('button',{name:'index.html',exact:true}).click();await row('web/style.css').click(); + await page.waitForFunction(()=>window.fixture.pending?.dirtyCount===1);assert.equal(await page.evaluate(()=>window.fixture.active),'web/index.html'); + await page.evaluate(()=>window.fixture.choose('cancel'));await settle();await active('web/index.html'); + await nav.getByRole('button',{name:'index.html',exact:true}).click();await row('web/style.css').click(); + await page.waitForFunction(()=>window.fixture.pending?.dirtyCount===1); + await page.evaluate(()=>window.fixture.choose('discard'));await active('web/style.css'); + assert.equal(await page.evaluate(()=>window.fixture.tabs.find(t=>t.path==='web/index.html').edit.text), 'original'); + console.log('[pass] breadcrumb callback respects unsaved-file cancel and discard before switching'); + await page.waitForFunction(()=>document.querySelector('.ace-preview-details-body')?.textContent.includes('Workspace navigation')); + await openRoot();await row('web').click();await row('web/src').click();await row('web/src/app.js').waitFor(); + await page.screenshot({path:path.join(output,'breadcrumbs-desktop.png')}); + await page.keyboard.press('Escape'); + await page.setViewportSize({width:430,height:650}); + await page.evaluate(()=>{document.querySelector('aside').style.display='none';document.querySelector('[data-fixture-chrome]').style.display='none';window.fixture.width(280);window.fixture.select('long-directory-name/another-long-directory/very-long-file-name.html');}); + const last=nav.getByRole('button',{name:'very-long-file-name.html',exact:true});await last.waitFor(); + await last.click();await menu.waitFor();await settle(); + const bounds=await menu.boundingBox();assert.ok(bounds.x>=0&&bounds.x+bounds.width<=430&&bounds.y+bounds.height<=650); + assert.ok(await nav.evaluate(e=>e.scrollWidth>e.clientWidth&&e.scrollLeft>0)); + await page.screenshot({path:path.join(output,'breadcrumbs-narrow.png')}); + await page.locator('main').click({position:{x:395,y:610}});await menu.waitFor({state:'detached'}); + console.log('[pass] long path scroll, narrow popup containment and outside dismissal'); + assert.deepEqual(errors,[]); + console.log(`Screenshots: ${output}`); +} finally { await browser?.close();await server.close(); } diff --git a/web/scripts/test-scheduled-task-creation.mjs b/web/scripts/test-scheduled-task-creation.mjs new file mode 100644 index 000000000..0eeb1e265 --- /dev/null +++ b/web/scripts/test-scheduled-task-creation.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +// Real page, manual form, icons and rich composer. No live scheduler is touched. +const web = fileURLToPath(new URL('../', import.meta.url)); +const modulePath = process.env.ACE_PLAYWRIGHT_MODULE; +const { chromium } = await import(modulePath ? pathToFileURL(path.resolve(modulePath)).href : 'playwright'); +const output = process.env.ACE_SCHEDULE_ARTIFACTS || await fs.mkdtemp(path.join(os.tmpdir(), 'ace-scheduled-ui-')); +await fs.mkdir(output, { recursive: true }); +const fixture = String.raw` +import React,{useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {LoopPage} from '/src/components/LoopPage.jsx'; +import {RichComposer} from '/src/components/RichComposer.jsx'; +import {scheduledTaskCreationRef} from '/src/lib/scheduledTaskCreation.js'; +import {commandsWithFallback} from '/src/lib/slashCommands.js'; +import {api} from '/src/lib/api.js'; +import '/src/styles/globals.css'; +const tasks=[]; +window.mutations=[];window.submissions=0; +Object.assign(api,{ + listLoops:async()=>({loops:[...tasks]}),listWorkspaces:async()=>[], + listModels:async()=>({models:[{name:'test-model'}]}),getDefaultModel:async()=>({name:'test-model'}), + createLoop:async(body)=>{window.mutations.push(body);const task={...body,id:'created',next_run_at_ms:Date.now()+3600000};tasks.push(task);return task;}, +}); +const commands=commandsWithFallback({skills:[{name:'scheduled-task',description:'创建定时任务'}]}); +function Fixture(){ + const [route,setRoute]=useState(null),[text,setText]=useState(''); + const smart=()=>{const next=scheduledTaskCreationRef({loop:true},{});window.next=next;setText(next.initialDraftText);setRoute(next);}; + window.fixture={reset:()=>setRoute(null),get text(){return text;}}; + return
+ {route?
+

新建任务

+
+ window.submissions++}/> +
+
:{}}/>} +
; +} +createRoot(document.getElementById('root')).render(); +`; +const virtual = path.join(web, '__scheduled-fixture.jsx').replaceAll('\\', '/'); +const server = await createServer({ + root: web, server: { host: '127.0.0.1', port: 0, open: false, hmr: false }, + plugins: [{ + name: 'scheduled-task-fixture', + enforce: 'pre', + resolveId(id) { return id === '/__scheduled-fixture.jsx' ? virtual : undefined; }, + load(id) { return id.replaceAll('\\', '/') === virtual ? fixture : undefined; }, + configureServer(vite) { + vite.middlewares.use(async (request, response, next) => { + if (request.url?.split('?')[0] !== '/scheduled-fixture') return next(); + response.setHeader('Content-Type', 'text/html'); + response.end(await vite.transformIndexHtml('/scheduled-fixture', '
')); + }); + }, + }], +}); +let browser; +const errors = []; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, ...(process.env.ACE_CHROMIUM_EXECUTABLE ? { executablePath: process.env.ACE_CHROMIUM_EXECUTABLE } : {}) }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/scheduled-fixture`); + const smart = page.getByRole('button', { name: '智能添加定时任务', exact: true }); + const manual = page.getByRole('button', { name: '手动添加', exact: true }); + await smart.waitFor(); + assert.equal(await smart.locator('svg').count(), 1); + assert.ok((await smart.boundingBox()).x < (await manual.boundingBox()).x); + for (const theme of ['light', 'dark']) { + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 800 }); + await page.evaluate(mode => { document.documentElement.dataset.theme = mode; }, theme); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)); + assert.ok(await smart.isVisible()); + assert.ok(await manual.isVisible()); + await page.screenshot({ path: path.join(output, `${theme}-${width}.png`), animations: 'disabled' }); + } + } + await page.setViewportSize({ width: 1280, height: 800 }); + await manual.click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('heading', { name: '添加定时任务' }).waitFor(); + await dialog.locator('input').first().fill('Browser reminder'); + await dialog.locator('textarea').fill('Remind me to attend the meeting.'); + assert.equal(await dialog.getByRole('combobox', { name: '权限模式' }).inputValue(), 'yolo'); + await dialog.getByRole('button', { name: '添加定时任务', exact: true }).click(); + await page.getByRole('heading', { name: 'Browser reminder' }).waitFor(); + assert.equal(await page.evaluate(() => window.mutations.length), 1); + assert.doesNotMatch(await page.locator('body').innerText(), /循环|LOOP/); + await smart.focus(); + await page.keyboard.press('Enter'); + const composer = page.getByRole('textbox', { name: '任务内容' }); + await composer.waitFor(); + await page.locator('[data-slash-chip-kind="skill"]').waitFor(); + assert.equal(await page.evaluate(() => window.fixture.text), '/scheduled-task 我希望在明天X点提醒我参加会议,重复X天'); + assert.equal(await page.evaluate(() => window.submissions), 0); + assert.equal(await page.evaluate(() => window.mutations.length), 1); + await composer.click(); + await page.keyboard.press('End'); + await page.keyboard.type(' edited'); + assert.ok((await page.evaluate(() => window.fixture.text)).endsWith(' edited')); + await page.screenshot({ path: path.join(output, 'smart-draft.png'), animations: 'disabled' }); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ result: 'passed', output, checks: ['button order and SVG', 'light/dark at 1280/390', 'manual form and save', 'keyboard smart entry', 'skill chip', 'exact editable unsent draft'], errors })); +} finally { + if (errors.length) console.error(JSON.stringify({ errors })); + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-transcript-history.mjs b/web/scripts/test-transcript-history.mjs new file mode 100644 index 000000000..9ddd81a5f --- /dev/null +++ b/web/scripts/test-transcript-history.mjs @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +// Real ChatView, isolated HTTP/WebSocket fixtures; never starts a daemon. +// Run from web with ACE_PLAYWRIGHT_MODULE / ACE_CHROMIUM_EXECUTABLE if needed. +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = process.env.ACE_HISTORY_TEST_OUTPUT; +const entry = ` +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {ChatView} from '/src/components/ChatView.jsx'; +import {connection} from '/src/lib/connection.js'; +import {applyLocalePreference} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +await applyLocalePreference('zh-CN'); +const root=createRoot(document.getElementById('root')), empty=[]; +let sid='history-main'; +window.fixture={ + render(id){sid=id;root.render();}, + event(type,payload){connection.dispatchEvent(new CustomEvent('message',{detail:{type,session_id:sid,payload}}));}, + async locale(value){await applyLocalePreference(value);window.fixture.render(sid);}, +}; +window.fixture.render(sid); +`; +const html = ` +
+ +`; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), server: { host: '127.0.0.1', port: 0 }, + plugins: [{ name: 'history-fixture', enforce: 'pre', + resolveId(id) { if (id === 'virtual:history-fixture') return '\0virtual:history-fixture'; }, + async load(id) { if (id === '\0virtual:history-fixture') return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx', jsx: 'automatic' })).code; }, + configureServer(vite) { vite.middlewares.use('/history-fixture', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); res.end(await vite.transformIndexHtml('/history-fixture', html)); + }); }, + }], +}); +const messages = (start, count) => Array.from({ length: count }, (_, n) => { + const i = start + n; + return { id: `message-${i}`, role: i % 2 ? 'assistant' : 'user', message_position: String(i), + content: i % 2 ? `第 ${i} 条回复\n\n用于验证历史阅读位置。\n\n`.repeat(3) : `第 ${i} 条问题`, timestamp: '2026-10-04T01:00:00Z' }; +}); +const checks = [], errors = [], pending = [], requests = []; +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1325, height: 775 } }); + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()); + let data = {}; + if (url.pathname.endsWith('/messages')) { + requests.push(url.href); + if (url.searchParams.has('before')) { + await new Promise(resolve => pending.push(async (body, status = 200) => { + await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); resolve(); + })); + return; + } + const local = url.pathname.includes('history-local'); + data = { messages: local ? messages(0, 300) : messages(100, 12), events: [], status: 'idle', has_more: !local, before: 'page-100' }; + } else if (url.pathname.includes('models')) data = { models: [], profiles: [] }; + else if (url.pathname.includes('workspaces')) data = { workspaces: [] }; + else if (url.pathname.includes('history')) data = { items: [], history: [] }; + else if (url.pathname.includes('commands')) data = { commands: [] }; + else if (url.pathname.includes('skills')) data = { skills: [] }; + else if (url.pathname.includes('experts')) data = { experts: [] }; + else if (url.pathname.includes('subagents')) data = { tasks: [] }; + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(data) }); + }); + const viewport = page.locator('.ace-chat-transcript-scroll'); + const boundary = page.locator('[data-transcript-history-boundary]'); + const wait = () => page.waitForTimeout(180); + const busy = () => page.waitForFunction(() => document.querySelector('[data-transcript-history-boundary]')?.getAttribute('aria-busy') === 'true'); + const idle = () => page.waitForFunction(() => document.querySelector('[data-transcript-history-boundary]')?.getAttribute('aria-busy') !== 'true'); + const check = (name, condition, detail) => { + assert.ok(condition, `${name}: ${JSON.stringify(detail)}`); checks.push(name); console.log(`[pass] ${name}`); + }; + const wheel = async delta => { + const box = await viewport.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(170, box.height / 2)); + await page.mouse.wheel(0, delta); + }; + const top = async () => { await viewport.evaluate(el => { el.scrollTop = 0; }); await wait(); }; + const rowTop = () => page.locator('[data-chat-message-position="100"]').first().evaluate(el => el.getBoundingClientRect().top); + const finish = async (body, status) => { + await page.waitForFunction(() => document.querySelector('[data-transcript-history-boundary]')?.getAttribute('aria-busy') === 'true'); + for (let i = 0; i < 30 && pending.length === 0; i++) await page.waitForTimeout(20); + assert.ok(pending.length, 'page request must have reached the fixture'); + await pending.shift()(body, status); await idle(); await wait(); + }; + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/history-fixture`); + await page.locator('[data-chat-message-position="100"]').waitFor(); await wait(); + check('initial load requests only the tail', requests.length === 1); + await top(); + check('programmatic top does not request history', pending.length === 0); + await wheel(-100); await busy(); await wait(); + check('loading is inline with no full-screen mask', await boundary.getByText('正在加载更早的消息…').count() === 1 + && await page.locator('[data-session-content-loading]').count() === 0); + check('show all is disabled during paging', await boundary.getByRole('button', { name: '显示全部' }).isDisabled()); + const anchorBefore = await rowTop(); + await page.evaluate(() => window.fixture.event('token', { text: '\n\n实时新增内容。\n\n'.repeat(30) })); await wait(); + await wheel(-100); await wheel(-100); await wait(); + check('streaming and repeated wheel keep one page request', pending.length === 1 && Math.abs(await rowTop() - anchorBefore) < 2); + if (output) { await mkdir(output, { recursive: true }); await page.screenshot({ path: path.join(output, 'inline-loading.png') }); } + await finish({ messages: messages(80, 20), has_more: true, before: 'page-80' }); + check('prepend preserves the reading row during streaming', Math.abs(await rowTop() - anchorBefore) < 2, { before: anchorBefore, after: await rowTop() }); + await page.locator('[data-chat-message-position="80"]').evaluate(el => { + const img = document.createElement('img'); img.alt = ''; img.style.height = '1px'; img.style.width = '10px'; + el.appendChild(img); setTimeout(() => { img.style.height = '220px'; }, 40); + }); await wait(); + check('delayed image height preserves the reading row', Math.abs(await rowTop() - anchorBefore) < 2); + check('layout does not drain another page', pending.length === 0); + + await page.getByRole('button', { name: '滚动到底部', exact: true }).click(); await wait(); + await top(); await wheel(-100); await busy(); + await finish({ error: 'OFFLINE', message: 'fixture failure' }, 503); + check('error stays at boundary with retry and no mask', await boundary.getByText('更早的消息加载失败').count() === 1 + && await boundary.getByRole('button', { name: '重试', exact: true }).count() === 1 + && await page.locator('[data-session-content-loading]').count() === 0); + await wheel(-100); await wait(); + check('failure does not retry automatically', pending.length === 0); + await boundary.getByRole('button', { name: '重试', exact: true }).click(); await busy(); + await finish({ messages: messages(60, 20), has_more: true, before: 'page-60' }); + check('explicit retry works', await page.locator('[data-chat-message-position="60"]').count() === 1); + + await page.getByRole('button', { name: '滚动到底部', exact: true }).click(); await wait(); + await top(); await wheel(-100); await busy(); await wait(); + await page.evaluate(() => window.fixture.render('history-next')); await wait(); + check('new session clears old paging state', await boundary.getAttribute('aria-busy') === 'false'); + await pending.shift()({ messages: messages(0, 30), has_more: false }); await wait(); + check('late page cannot reveal rows in another session', await page.locator('[data-chat-message-position="0"]').count() === 0); + + await page.evaluate(() => window.fixture.render('history-local')); await wait(); + await page.waitForFunction(() => document.querySelector('[data-transcript-history-boundary]')?.textContent.includes('条消息')); + const localBefore = await page.locator('[data-chat-row="true"]').count(); + const requestCount = requests.length; + await top(); await wheel(-100); await idle(); await wait(); + check('local hidden rows reveal without a network request', await page.locator('[data-chat-row="true"]').count() > localBefore && requests.length === requestCount); + + await page.evaluate(() => window.fixture.render('history-user-scroll')); await wait(); + await top(); await wheel(-100); await busy(); + await wheel(30); await wait(); + const latestReadingTop = await rowTop(); + await finish({ messages: messages(80, 20), has_more: true, before: 'page-80' }); + check('scrolling while waiting preserves the new reading position', Math.abs(await rowTop() - latestReadingTop) < 2); + await page.getByRole('button', { name: '滚动到底部', exact: true }).click(); await wait(); + await top(); await wheel(-100); await busy(); + await page.getByRole('button', { name: '滚动到底部', exact: true }).click(); await wait(); + await finish({ messages: messages(60, 20), has_more: true, before: 'page-60' }); + check('return to tail during loading overrides history anchoring', await viewport.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop <= 2)); + + await page.evaluate(() => window.fixture.render('history-keyboard')); await wait(); + await viewport.evaluate(el => { el.tabIndex = 0; el.focus(); }); + await page.keyboard.press('Control+Home'); await busy(); + await finish({ messages: [], has_more: true, before: 'page-100' }); + check('keyboard top triggers one page and no-progress offers retry', await boundary.getByText('更早的消息加载失败').count() === 1 && pending.length === 0); + await boundary.getByRole('button', { name: '重试', exact: true }).click(); await busy(); + await finish({ error: 'HISTORY_CURSOR_STALE' }, 409); + check('stale cursor reload stays inline and stops automatic paging', await boundary.getByText('会话历史已更新,请重试').count() === 1 + && await page.locator('[data-session-content-loading]').count() === 0 && pending.length === 0); + + await page.evaluate(() => window.fixture.render('history-all')); await wait(); + await top(); await boundary.getByRole('button', { name: '显示全部' }).click(); await busy(); await wait(); + const allAnchorTop = await rowTop(); + await pending.shift()({ messages: messages(80, 20), has_more: true, before: 'page-80' }); + await wait(); + check('explicit show all keeps a single inline operation across pages', await boundary.getAttribute('aria-busy') === 'true' && pending.length === 1); + await finish({ messages: messages(60, 20), has_more: false }); + check('show all preserves position and removes exhausted boundary', Math.abs(await rowTop() - allAnchorTop) < 2 && await boundary.count() === 0); + + await page.evaluate(() => window.fixture.render('history-mobile')); await wait(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; }); + await top(); await wheel(-100); await busy(); await wait(); + check('narrow dark loading stays within the viewport', await boundary.evaluate(el => el.getBoundingClientRect().right <= innerWidth) + && await page.locator('[data-session-content-loading]').count() === 0); + if (output) await page.screenshot({ path: path.join(output, 'inline-loading-narrow-dark.png') }); + await finish({ messages: messages(0, 12), has_more: false }); + check('exhausted history removes the boundary', await boundary.count() === 0); + // The application's locale change rebuilds memoized API clients. Verify an + // English paging operation in its new scope, not a cancelled Chinese request. + await page.evaluate(async () => { await window.fixture.locale('en-US'); window.fixture.render('history-english'); }); + await wait(); await top(); await wheel(-100); await busy(); await wait(); + check('loading has English localization', await boundary.getByText('Loading earlier messages…').count() === 1, + { text: await boundary.textContent(), locale: await page.evaluate(() => document.documentElement.lang) }); + await finish({ messages: messages(0, 12), has_more: false }); + check('no browser runtime errors', errors.length === 0, errors); +} finally { + if (output) { + await mkdir(output, { recursive: true }); + await writeFile(path.join(output, 'results.json'), JSON.stringify({ checks, errors }, null, 2)); + } + await browser?.close(); await server.close(); +} diff --git a/web/scripts/test-turn-cancellation.mjs b/web/scripts/test-turn-cancellation.mjs new file mode 100644 index 000000000..3bbaa2782 --- /dev/null +++ b/web/scripts/test-turn-cancellation.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +// Real ChatView + controlled HTTP/WebSocket, without touching a live daemon. +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const entry = ` +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {ChatView} from '/src/components/ChatView.jsx'; +import {connection} from '/src/lib/connection.js'; +import {applyLocalePreference} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +await applyLocalePreference('zh-CN'); +window.fixture = { + emit(type, payload) { connection.dispatchEvent(new CustomEvent('message', {detail:{type,payload,session_id:'cancel-fixture'}})); }, + disconnect() { connection.ws.readyState=3; connection.dispatchEvent(new CustomEvent('disconnect')); }, + reconnect() { connection.ws.readyState=1; }, +}; +createRoot(document.getElementById('root')).render(); +`; +const html = ` +
+`; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'cancel-fixture', + resolveId(id) { if (id === 'virtual:cancel-fixture') return '\0virtual:cancel-fixture'; }, + async load(id) { if (id === '\0virtual:cancel-fixture') return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx' })).code; }, + configureServer(vite) { + vite.middlewares.use('/cancel-fixture', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/cancel-fixture', html)); + }); + }, + }], +}); +const messages = [{ id: 'u1', uuid: 'u1', role: 'user', content: '检查项目' }]; +const submissions = []; +let busy = true, turnId = 't1'; +const errors = []; +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1280, height: 850 } }); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/api/**', async route => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + let data = {}; + if (pathname.endsWith('/messages') && request.method() === 'POST') { + submissions.push(request.postDataJSON()); + busy = true; + data = { queued: true }; + } else if (pathname.includes('/messages')) data = { messages, events: [], busy, active_turn_id: turnId }; + else if (pathname.endsWith('/model')) data = { name: 'fixture', provider: 'openai', model: 'test' }; + else if (pathname.includes('models')) data = { models: [], profiles: [] }; + else if (pathname.includes('workspaces')) data = { workspaces: [] }; + else if (pathname.includes('history')) data = { items: [], history: [] }; + else if (pathname.includes('commands')) data = { commands: [] }; + else if (pathname.includes('skills')) data = { skills: [] }; + else if (pathname.includes('experts')) data = { experts: [] }; + else if (pathname.includes('subagents')) data = { tasks: [] }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); + }); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/cancel-fixture`, { waitUntil: 'commit' }); + const emit = (type, payload) => page.evaluate(({ type, payload }) => window.fixture.emit(type, payload), { type, payload }); + await page.getByRole('button', { name: '中断', exact: true }).waitFor(); + for (const tool of ['vision_analyze', 'web_search', 'bash']) { + await emit('tool_start', { tool, tool_call_id: tool, args: {} }); + } + await page.getByRole('button', { name: '中断', exact: true }).click(); + await page.getByRole('button', { name: '正在停止…', exact: true }).waitFor(); + assert.equal(await page.getByRole('button', { name: '正在停止…', exact: true }).isDisabled(), true); + assert.equal(await page.evaluate(() => window.commands.filter(c => c.type === 'abort').length), 1); + await page.locator('[data-slate-editor="true"]').fill('停止后继续检查'); + await page.getByRole('button', { name: '排队', exact: true }).click(); + await page.getByText('停止后继续检查', { exact: true }).waitFor(); + assert.equal(submissions.length, 0, 'stop request cannot dispatch the next turn'); + for (const tool of ['vision_analyze', 'web_search', 'bash']) { + await emit('tool_end', { tool, tool_call_id: tool, success: false, output: '[Interrupted]' }); + await emit('message', { role: 'tool_call', content: '[Tool: ' + tool + '] {}' }); + await emit('message', { role: 'tool_result', content: 'late result' }); + } + const stop = { id: 'stop-t1', role: 'system', content: '[Interrupted]', metadata: { transcript_only: true, user_aborted: true, turn_id: 't1' } }; + messages.push(stop); + await emit('message', stop); + assert.equal(await page.getByText('用户已终止本轮任务', { exact: true }).count(), 1); + busy = false; + const accepted = page.waitForResponse(response => response.url().endsWith('/messages') && response.request().method() === 'POST'); + await emit('busy_changed', { busy: false, outcome: 'aborted', turn_id: 't1' }); + await page.waitForFunction(() => document.body.textContent.includes('停止后继续检查')); + await accepted; + assert.equal(submissions.length, 1); + const submitted = submissions[0]; + assert.ok(submitted.client_message_id); + await emit('done', { outcome: 'aborted', turn_id: 't1' }); + await page.getByRole('button', { name: '中断', exact: true }).waitFor(); + const canonical = { id: 'u2', role: 'user', content: submitted.text, metadata: { client_message_id: submitted.client_message_id } }; + messages.push(canonical); + await emit('message', canonical); + assert.equal(await page.getByText('停止后继续检查', { exact: true }).count(), 1); + turnId = 't2'; + await emit('busy_changed', { busy: true, turn_id: 't2' }); + await page.evaluate(() => window.fixture.disconnect()); + await page.getByRole('button', { name: '中断', exact: true }).click(); + assert.equal(await page.getByRole('button', { name: '正在停止…', exact: true }).count(), 0); + await page.evaluate(() => window.fixture.reconnect()); + await page.getByRole('button', { name: '中断', exact: true }).click(); + await page.getByRole('button', { name: '正在停止…', exact: true }).waitFor(); + if (process.env.ACE_CANCEL_TEST_OUTPUT) { + await mkdir(process.env.ACE_CANCEL_TEST_OUTPUT, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ACE_CANCEL_TEST_OUTPUT, 'stopping-turn.png') }); + } + busy = false; + turnId = ''; + await emit('done', { outcome: 'aborted', turn_id: 't2' }); + await page.locator('[data-slate-editor="true"]').fill('普通发送确认'); + const plainAccepted = page.waitForResponse(response => response.url().endsWith('/messages') && response.request().method() === 'POST'); + await page.getByRole('button', { name: '发送', exact: true }).click(); + await plainAccepted; + await page.getByText('普通发送确认', { exact: true }).waitFor(); + assert.ok(submissions[1].client_message_id, 'ordinary sends carry correlation too'); + const ordinary = { id: 'u3', role: 'user', content: submissions[1].text, metadata: { client_message_id: submissions[1].client_message_id } }; + messages.push(ordinary); + await emit('message', ordinary); + assert.equal(await page.getByText('普通发送确认', { exact: true }).count(), 1); + assert.deepEqual(errors, []); + console.log('[pass] ChatView stop confirmation, visible queue, correlated input, stale done and disconnected retry'); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/src/App.jsx b/web/src/App.jsx index acdb1e512..b1ef39bc5 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -111,6 +111,7 @@ import { ConfigRecoveryDialog } from './components/ConfigRecoveryDialog.jsx'; import { UpdateDialog } from './components/UpdateDialog.jsx'; import InteractiveHomeLogo from './components/InteractiveHomeLogo.jsx'; import { LoopPage } from './components/LoopPage.jsx'; +import { scheduledTaskCreationRef } from './lib/scheduledTaskCreation.js'; import { ExpertComponentsPage } from './components/ExpertComponentsPage.jsx'; import { CONSOLE_DOCK_DEFAULT_HEIGHT, @@ -1656,6 +1657,12 @@ export function App() { navigateToRef(next); }, [health, navigateToRef]); + const startScheduledTaskCreation = useCallback(() => { + const next = scheduledTaskCreationRef(activeRefRef.current || {}, health); + void refreshWorkspaceGitInfo(createApi(next), next).catch(() => {}); + navigateToRef(next); + }, [health, navigateToRef]); + const dispatchExpertToNewTask = useCallback((expert, prompt = expert?.quick_prompts?.[0] || '') => { const expertId = String(expert?.id || ''); if (!expertId) return false; @@ -1702,7 +1709,9 @@ export function App() { const replaceHomeWorkspace = useCallback((workspace) => { replaceActiveRef((current) => { const next = homeRefFromWorkspace(workspace, current, health); - if (current?.composerDraftScope === 'ai-theme') next.composerDraftScope = 'ai-theme'; + if (['ai-theme', 'scheduled-task'].includes(current?.composerDraftScope)) { + next.composerDraftScope = current.composerDraftScope; + } void refreshWorkspaceGitInfo(createApi(next), next).catch(() => {}); return next; }); @@ -2285,7 +2294,7 @@ export function App() { nativeSurfacesVisible={nativeSurfacesVisible} > {activeRef?.loop ? ( - + ) : activeRef?.expertComponents ? ( { - const el = scrollRef.current; - if (el) { - windowRevealScrollRef.current = { scrollHeight: el.scrollHeight, scrollTop: el.scrollTop }; - } - if (windowHiddenCountRef.current === 0) { - await loadEarlier(); - setTranscriptWindow({ sid, anchorKey: null }); - } else { - setTranscriptWindow((prev) => { - if (prev.sid !== sid || !prev.anchorKey) return prev; - return { sid, anchorKey: revealEarlierAnchorKey(itemsRef.current, prev.anchorKey) }; - }); - } - }, [sid, loadEarlier]); - const expandTranscriptWindow = useCallback(async (options = {}) => { - if (options.loadAll) { - while (transcript.getState().historyHasMore) { - const before = transcript.getState().historyBefore; - await loadEarlier(); - if (before === transcript.getState().historyBefore) break; - } - } - if (!windowHiddenCountRef.current && !options.loadAll) return; - if (options.compensateScroll) { - const el = scrollRef.current; - if (el) { - windowRevealScrollRef.current = { scrollHeight: el.scrollHeight, scrollTop: el.scrollTop }; - } - } - setTranscriptWindow((prev) => ( - prev.sid === sid && prev.anchorKey ? { sid, anchorKey: null } : prev - )); - }, [sid, loadEarlier, transcript.getState]); - useLayoutEffect(() => { - const saved = windowRevealScrollRef.current; - if (!saved) return; - windowRevealScrollRef.current = null; - const el = scrollRef.current; - if (!el) return; - el.scrollTop = saved.scrollTop + (el.scrollHeight - saved.scrollHeight); - }, [transcriptWindow, items]); + const historyPaging = useTranscriptHistory({ + sid, loadEarlier, getState: transcript.getState, scrollRef, + hiddenCount: windowHiddenCount, anchorKey: windowAnchorKey, + items: renderedItems, setWindow: setTranscriptWindow, + onReview: () => { + cancelActivityExpansionAnchor(); + cancelTailFollowScroll(); + setTailFollowFromAction({ type: 'review_pause' }); + }, + }); + const historyController = historyPaging.controller; + const revealEarlierTranscript = historyController.reveal; + const expandTranscriptWindow = historyController.expand; // 会话内查找(Ctrl+F)在 DOM 文本上搜索,窗口外的行搜不到 —— find 打开 // 时直接全量展开(GlobalFindOverlay 广播的事件)。 useEffect(() => { @@ -2653,7 +2624,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses conversationTurnRafRef.current = 0; const el = scrollRef.current; const rowMetrics = collectRowMetrics(el); - setShowScrollToBottom(!!el && el.clientHeight > 0 && !isChatNearTail(el, 1)); + setShowScrollToBottom(shouldShowChatScrollToBottom(el)); measureStickyContext(rowMetrics); measureConversationTurn(rowMetrics); }); @@ -2731,6 +2702,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }, [preserveActivityExpansionAnchor]); const beginActivityExpansionAnchor = useCallback((titleElement) => { + historyController.cancelAnchor(); const container = scrollRef.current; cancelActivityExpansionAnchor(); if ( @@ -2757,15 +2729,18 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses secondFrame: 0, }; return true; - }, [cancelActivityExpansionAnchor, cancelTailFollowScroll, setTailFollowFromAction]); + }, [cancelActivityExpansionAnchor, cancelTailFollowScroll, historyController, setTailFollowFromAction]); const scheduleTailFollowScroll = useCallback(() => { const scrollToBottom = () => { + if (historyController.hasAnchor()) return false; if (activityExpansionAnchorRef.current) return false; if (!shouldAutoFollowChatTail(tailFollowStateRef.current)) return false; const el = scrollRef.current; if (!el) return false; el.scrollTop = el.scrollHeight; + // At the clamped maximum another scroll event is not guaranteed. + setShowScrollToBottom(shouldShowChatScrollToBottom(el)); return true; }; @@ -2780,14 +2755,15 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses scrollToBottom(); }); }); - }, [cancelTailFollowScroll]); + }, [cancelTailFollowScroll, historyController]); const jumpToChatTail = useCallback(() => { + historyController.cancelAnchor(); cancelActivityExpansionAnchor(); setTailFollowFromAction({ type: 'jump_to_tail' }); scheduleTailFollowScroll(); scheduleTranscriptMeasures(); - }, [cancelActivityExpansionAnchor, scheduleTailFollowScroll, scheduleTranscriptMeasures, setTailFollowFromAction]); + }, [cancelActivityExpansionAnchor, historyController, scheduleTailFollowScroll, scheduleTranscriptMeasures, setTailFollowFromAction]); const pauseTailFollowForReview = useCallback(() => { cancelTailFollowScroll(); @@ -2796,6 +2772,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }, [busy, cancelTailFollowScroll, setTailFollowFromAction, transcriptStatus]); const handleMessagesScroll = useCallback(() => { + historyController.onScroll({ pointerActive: scrollActivityRef.current.pointerActive }); const el = scrollRef.current; if (el) { const metrics = chatScrollMetrics(el); @@ -2827,26 +2804,29 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses scrollActivityRef.current.prev = metrics; } scheduleTranscriptMeasures(); - }, [cancelActivityExpansionAnchor, scheduleTranscriptMeasures, setTailFollowFromAction]); + }, [cancelActivityExpansionAnchor, historyController, scheduleTranscriptMeasures, setTailFollowFromAction]); // 滚轮上滚是最明确的"用户想往回看"信号,不等 scroll 事件的启发式判定, // 直接暂停跟随(仅回合进行中生效,见 pauseTailFollowForReview 内的门)。 const handleMessagesWheel = useCallback((event) => { + historyController.onWheel(event); cancelActivityExpansionAnchor(); if (event.deltaY < 0) pauseTailFollowForReview(); - }, [cancelActivityExpansionAnchor, pauseTailFollowForReview]); + }, [cancelActivityExpansionAnchor, historyController, pauseTailFollowForReview]); const handleMessagesPointerDown = useCallback(() => { + historyController.onPointerDown(); cancelActivityExpansionAnchor(); scrollActivityRef.current.pointerActive = true; - }, [cancelActivityExpansionAnchor]); + }, [cancelActivityExpansionAnchor, historyController]); const handleMessagesKeyDownCapture = useCallback((event) => { + historyController.onKeyDown(event); if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) { return; } cancelActivityExpansionAnchor(); - }, [cancelActivityExpansionAnchor]); + }, [cancelActivityExpansionAnchor, historyController]); useEffect(() => { const clearPointerActive = () => { @@ -2871,6 +2851,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const containerRect = el.getBoundingClientRect(); const rowRect = targetRow.getBoundingClientRect(); + historyController.cancelAnchor(); el.scrollTo({ top: scrollTopForStickySourceRow({ scrollTop: el.scrollTop, @@ -2881,9 +2862,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); requestAnimationFrame(scheduleStickyMeasure); window.setTimeout(scheduleStickyMeasure, 220); - }, [scheduleStickyMeasure]); + }, [historyController, scheduleStickyMeasure]); const jumpToConversationTurn = useCallback((turn, index, retried = false) => { + historyController.cancelAnchor(); const el = scrollRef.current; const targetId = String(turn?.itemId || ''); if (!el || !targetId) return; @@ -2925,7 +2907,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses scrollTop: el.scrollTop, }; window.requestAnimationFrame(scheduleTranscriptMeasures); - }, [expandTranscriptWindow, pauseTailFollowForReview, scheduleTranscriptMeasures, sid]); + }, [expandTranscriptWindow, historyController, pauseTailFollowForReview, scheduleTranscriptMeasures, sid]); const focusChatInput = useCallback((force = false) => { if (questionRequest) return; @@ -2981,11 +2963,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (!sid || !lastUserTurnKey) return; const prev = lastUserTurnKeyRef.current; if (!prev || prev !== lastUserTurnKey) { + historyController.cancelAnchor(); cancelActivityExpansionAnchor(); setTailFollowFromAction({ type: 'new_turn' }); } lastUserTurnKeyRef.current = lastUserTurnKey; - }, [cancelActivityExpansionAnchor, lastUserTurnKey, setTailFollowFromAction, sid]); + }, [cancelActivityExpansionAnchor, historyController, lastUserTurnKey, setTailFollowFromAction, sid]); // 只在用户仍跟随底部时自动滚到底。审查栏会异步测量高度并给消息区补 // bottom padding,因此跟随模式下仍需在 padding 生效后补几帧滚动。 @@ -3009,9 +2992,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const handleTranscriptContentResize = useCallback(() => { scheduleTranscriptMeasures(); + if (historyController.preserveAnchor()) return; if (preserveActivityExpansionAnchor()) return; scheduleTailFollowScroll(); - }, [preserveActivityExpansionAnchor, scheduleTailFollowScroll, scheduleTranscriptMeasures]); + }, [historyController, preserveActivityExpansionAnchor, scheduleTailFollowScroll, scheduleTranscriptMeasures]); useEffect(() => observeChatTailContent( transcriptContentRef.current, @@ -3026,6 +3010,8 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (!sid || (searchJumpOrdinal === null && searchJumpPosition === null)) return undefined; + historyController.cancelAnchor(); + let cancelled = false; const task = { frame: 0, timer: 0, attempts: 0, settled: 0 }; searchJumpRetryRef.current = task; @@ -3078,6 +3064,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses cancelTailFollowScroll, changeDockBottomPadding, expandTranscriptWindow, + historyController, ref, renderedItems, scheduleStickyMeasure, @@ -3285,17 +3272,24 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const resumeQueue = useCallback(() => { const targetSid = sidRef.current; if (!targetSid) return; - updateQueueState((prev) => resumeQueuedInput(prev, targetSid)); - }, [updateQueueState]); + const current = transcript.getState(); + updateQueueState((prev) => resumeQueuedInput(prev, targetSid, { + afterAbortTurnId: current.abortPending ? (current.abortTurnId || 'pending-stop') : '', + })); + }, [updateQueueState, transcript.getState]); const retryQueued = useCallback((queuedId) => { // 点「重试」是用户明确要发这条消息,暂停态一并解除,否则按钮按了没反应。 updateQueueState((prev) => { const next = retryQueuedInput(prev, queuedId); const sessionId = next.items.find((item) => item?.queued?.id === queuedId)?.queued?.sessionId; - return sessionId ? resumeQueuedInput(next, sessionId) : next; + const current = transcript.getState(); + return sessionId ? resumeQueuedInput(next, sessionId, { + afterAbortTurnId: sessionId === sidRef.current && current.abortPending + ? (current.abortTurnId || 'pending-stop') : '', + }) : next; }); - }, [updateQueueState]); + }, [updateQueueState, transcript.getState]); const saveQueuedEdit = useCallback((queuedId, text, composerContent) => { const queuedItem = queueStore.getState().items.find( @@ -3784,8 +3778,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (composerSubmitting) return; // 用户再次主动发送 / 排队 = 解除「中断回合」带来的队列暂停:这条新消息先走, // 排队里的旧消息在它之后照常出队(与卡片栈上的「继续」同义)。 - updateQueueState((prev) => resumeQueuedInput(prev, sid)); - if (busy && !isBuiltin) { + const liveTranscript = transcript.getState(); + updateQueueState((prev) => resumeQueuedInput(prev, sid, { + afterAbortTurnId: liveTranscript.abortPending + ? (liveTranscript.abortTurnId || 'pending-stop') : '', + })); + if ((liveTranscript.busy || liveTranscript.abortPending) && !isBuiltin) { enqueueInput(payload); clearCurrentSessionDraft(); clearComposerExtras(); @@ -3807,9 +3805,15 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses : null; const sessionSendPayload = sessionWorktreeIntent ? { ...payload, worktree: sessionWorktreeIntent } - : payload; + : { ...payload }; + if (!isBuiltin) { + sessionSendPayload.client_message_id = `input-${targetSid}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`}`; + } sendInputOrBuiltin(targetSid, sessionSendPayload) .then((queued) => { + if (!isBuiltin && sidRef.current === targetSid) { + applyEvent(acceptedUserInputEvent(sessionSendPayload), { emitEffects: false }); + } if (sessionWorktreeIntent) { setLocalWorktree({ sid: targetSid, @@ -3828,11 +3832,13 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); } if (historyText.trim()) recordInputHistory(historyText); - if (clearCurrentSessionDraft({ expectedText: submittedComposerText, expectedContent: submittedComposerContent })) clearComposerExtras(); + if (sidRef.current === targetSid && clearCurrentSessionDraft({ expectedText: submittedComposerText, expectedContent: submittedComposerContent })) clearComposerExtras(); }) .catch((e) => { toast({ kind: 'err', text: '发送失败:' + (e.message || '') }); - applyEvent({ type: 'busy_changed', payload: { busy: false } }, { emitEffects: false }); + if (sidRef.current === targetSid) { + applyEvent({ type: 'busy_changed', payload: { busy: false } }, { emitEffects: false }); + } }) .finally(() => setComposerSubmitting(false)); }, [sid, busy, activeTurnId, api, homeSubmitting, recordInputHistory, enqueueInput, updateQueueState, applyEvent, sendInputOrBuiltin, executeBuiltinCommand, composerSubmitting, clearCurrentSessionDraft, composerAttachments, composerContexts, composerSwarmChoice, transcriptSwarmMode, clearComposerExtras, createHomeComposerSession, persistMediaFilesToSession, restoreChatInputFocusSoon, setTailFollowFromAction, runSideQuestion, draftWorkspaceHash, homeDraftWorkspaceHash, homeComposerDrafts, onHomeComposerDraftAccepted, ref?.noWorkspace, ref?.no_workspace, ref?.workspaceHash, ref?.workspace_hash, sessionRuntimeUnavailable, retryUserMessageId, transcript.getState, transcriptLoadState, readOnlyExternalSession, commitDeferredPastes, materializeWorkspaceDraftPastes, uploadPasteReservations]); @@ -3840,6 +3846,8 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const drainQueuedInput = useCallback(() => { const targetSid = sidRef.current; if (!targetSid || busy || drainRef.current || sessionRuntimeUnavailable) return; + const current = transcript.getState(); + if (current.busy || current.abortPending || current.loadState !== 'loaded') return; // 取出待发送项与标记 sending 在同一次提交内完成,避免这中间的取消/编辑被 // 一份过期快照覆盖。drainRef 仍在提交之前置位,保持原来的重入保护顺序。 drainRef.current = true; @@ -3881,7 +3889,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses .finally(() => { drainRef.current = false; }); - }, [applyEvent, busy, queueStore, sendInputOrBuiltin, setTailFollowFromAction, updateQueueState, sessionRuntimeUnavailable]); + }, [applyEvent, busy, queueStore, sendInputOrBuiltin, setTailFollowFromAction, updateQueueState, sessionRuntimeUnavailable, transcript.getState]); const prevBusyRef = useRef(busy); useEffect(() => { @@ -3904,6 +3912,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses state: queueState, sessionId: sid, lastTurnOutcome, + turnId: lastTerminalTurnId, })) { updateQueueState((prev) => pauseQueuedInput(prev, sid)); return; @@ -3911,7 +3920,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (wasBusy || !hasSendingQueuedInput(queueState, sid)) { drainQueuedInput(); } - }, [busy, drainQueuedInput, lastTurnOutcome, queueState, sid, transcriptLoadState, updateQueueState]); + }, [busy, drainQueuedInput, lastTurnOutcome, lastTerminalTurnId, queueState, sid, transcriptLoadState, updateQueueState]); useEffect(() => { if (!sid || items.length === 0) return; @@ -3934,23 +3943,36 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const abort = useCallback(() => { if (!sid) return; - // 用户点了停止:排队消息不能跟着自动出队。先暂停队列,再让 turn_aborted 把 - // busy 翻成 false —— 顺序反过来 drain effect 会先看到 busy=false 把下一条发出去。 - // 回归(bug 表现):排了一堆消息后点停止,下一条排队消息立刻上屏。 + const current = transcript.getState(); + if (!current.busy || current.abortPending) return; + if (!connection.sendAbort(sid)) { + toast({ kind: 'err', text: '停止请求发送失败,连接恢复后请重试' }); + return; + } + // 请求成功发出后保持 busy,等待服务端确认,期间新输入仍进入可见队列。 updateQueueState((prev) => pauseQueuedInput(prev, sid)); applyEvent({ - type: 'turn_aborted', - payload: { reason: '用户已终止本轮任务' }, + type: 'turn_abort_requested', + payload: { turn_id: current.activeTurnId }, timestamp_ms: Date.now(), }, { emitEffects: false }); - connection.sendAbort(sid); - }, [applyEvent, sid, updateQueueState]); + }, [applyEvent, sid, updateQueueState, transcript.getState]); const stopCurrentWork = useCallback(() => { if (!sid || !busy) return; abort(); }, [abort, busy, sid]); + useEffect(() => { + const onDisconnect = () => { + if (!transcript.getState().abortPending) return; + applyEvent({ type: 'turn_abort_failed' }, { emitEffects: false }); + toast({ kind: 'err', text: '连接已断开,停止状态尚未确认' }); + }; + connection.addEventListener('disconnect', onDisconnect); + return () => connection.removeEventListener('disconnect', onDisconnect); + }, [applyEvent, transcript.getState]); + const runGoalCommand = useCallback(async (action, objective = '') => { if (!sid) throw new Error('当前会话不可用'); const commandLabels = { @@ -4039,7 +4061,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }, [homeModelName, modelOptions]); const changeComposerReasoning = useCallback(async (effort) => { - if (busy || homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing) return; + if (homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing) return; const selected = sid ? modelState : modelOptions.find((option) => option.name === homeModelName); const choices = composerReasoningOptions(selected, sid ? undefined : homeReasoningEffort); if (!choices || !choices.items.some((item) => item.effort === effort)) return; @@ -4062,7 +4084,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { if (sidRef.current === targetSid && reasoningRequestRef.current === request) setReasoningSwitching(false); } - }, [api, busy, composerSubmitting, homeModelName, homeReasoningEffort, homeSubmitting, modelOptions, modelRefreshing, modelState, modelSwitching, reasoningSwitching, sid]); + }, [api, composerSubmitting, homeModelName, homeReasoningEffort, homeSubmitting, modelOptions, modelRefreshing, modelState, modelSwitching, reasoningSwitching, sid]); const switchHomeDefaultPermissionMode = useCallback(async (mode) => { const nextMode = normalizePermissionMode(mode); @@ -5195,6 +5217,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const conversationActivity = useMemo(() => selectConversationActivity({ foregroundBusy: busy, + foregroundStopping: abortPending, foregroundActivity: activity, permissionRequests, questionRequest: questionForView, @@ -5202,6 +5225,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }), [ activity, busy, + abortPending, permissionRequests, questionForView, subagentTasks.tasks, @@ -5731,7 +5755,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses modelSwitching: modelSwitching || reasoningSwitching, modelRefreshing, reasoningOptions: composerReasoningOptions(selectedHomeModel, homeReasoningEffort), - reasoningDisabled: busy || homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing, + reasoningDisabled: homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing, onReasoningChange: changeComposerReasoning, onModelChange: changeComposerModel, onRefreshModels: refreshSessionModels, @@ -6008,6 +6032,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses onScroll={handleMessagesScroll} onWheel={handleMessagesWheel} onPointerDown={handleMessagesPointerDown} + onTouchStart={historyController.onTouchStart} + onTouchMove={historyController.onTouchMove} + onTouchEnd={historyController.onTouchEnd} + onTouchCancel={historyController.onTouchEnd} onKeyDownCapture={handleMessagesKeyDownCapture} onClick={handleTranscriptFileLink} onKeyDown={handleTranscriptFileLink} @@ -6015,24 +6043,14 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses style={changeDockBottomPadding > 0 ? { paddingBottom: changeDockBottomPadding } : undefined} >
- {(windowHiddenCount > 0 || transcript.historyHasMore) && ( -
- - -
- )} + expandTranscriptWindow({ compensateScroll: true, loadAll: true })} + /> - + {status === 'running' ? ( +
@@ -6258,6 +6286,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses onRemoveExpert={detachComposerExpert} onOpenExpertComponents={() => setExpertPickerOpen(true)} busy={busy} + stopping={abortPending} goal={goal} onGoalEdit={editGoal} onGoalStatusChange={changeGoalStatus} @@ -6285,7 +6314,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses modelSwitching: modelSwitching || reasoningSwitching, modelRefreshing, reasoningOptions: composerReasoningOptions(modelState), - reasoningDisabled: busy || homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing, + reasoningDisabled: homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing, onReasoningChange: changeComposerReasoning, onModelChange: changeComposerModel, onRefreshModels: refreshSessionModels, @@ -6347,6 +6376,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses owner={workbenchOwner} api={api} cwd={sidePanelCwd} + workspaceCwd={sessionIsNoWorkspace ? '' : sidePanelCwd} tabs={previewTabs} activeTab={activePreview} changeGroups={changeGroups} diff --git a/web/src/components/DesktopContextMenu.jsx b/web/src/components/DesktopContextMenu.jsx index f58084eb1..2fa8e579e 100644 --- a/web/src/components/DesktopContextMenu.jsx +++ b/web/src/components/DesktopContextMenu.jsx @@ -25,6 +25,7 @@ import { notifyNativeSurfaceOverlayChange } from '../lib/agentBrowserSurfaceCoor import { captureRichComposerContextSelection, insertRichComposerContextText, + pasteRichComposerContextClipboard, richComposerRootFromTarget, } from '../lib/richComposerContextPaste.js'; import { api } from '../lib/api.js'; @@ -239,6 +240,11 @@ export async function copyImageWithToast(target) { async function pasteIntoTarget(target, richComposerSelection = null) { const editable = editableTargetFromElement(target); if (!editable) return; + const paste = pasteRichComposerContextClipboard(editable, richComposerSelection); + if (paste) { + await paste; + return; + } if (navigator.clipboard?.readText) { const text = await navigator.clipboard.readText(); insertTextIntoEditable(editable, text, richComposerSelection); @@ -411,7 +417,11 @@ async function runAction( } break; case DESKTOP_CONTEXT_ACTIONS.PASTE: - await pasteIntoTarget(target, rememberedRichComposerSelection); + try { + await pasteIntoTarget(target, rememberedRichComposerSelection); + } catch (error) { + toast({ kind: 'err', text: `操作失败:${error?.message || ''}` }); + } break; case DESKTOP_CONTEXT_ACTIONS.CUT: editableTargetFromElement(target)?.focus(); diff --git a/web/src/components/FileBreadcrumbs.jsx b/web/src/components/FileBreadcrumbs.jsx new file mode 100644 index 000000000..75b9075e7 --- /dev/null +++ b/web/src/components/FileBreadcrumbs.jsx @@ -0,0 +1,152 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { fileBreadcrumbs, breadcrumbDirectoryEntries } from '../lib/fileBreadcrumbs.js'; +import { previewAbsolutePath } from '../lib/previewTabs.js'; +import { fileChangeStatusTitle } from '../lib/fileTreeChangeStatus.js'; +import { AnchoredMenu } from './AnchoredMenu.jsx'; +import { FileTypeIcon, VsIcon } from './Icon.jsx'; +import '../styles/file-breadcrumbs.css'; + +function DirectoryEntries({ api, root, directory, selectedPath, onPick, level = 1, focusFrom, id }) { + const listRef = useRef(null); + const restoreFocusRef = useRef(false); + const [revision, setRevision] = useState(0); + const [state, setState] = useState({ status: 'loading', entries: [] }); + + useEffect(() => { + let cancelled = false; + restoreFocusRef.current = !!listRef.current?.contains(document.activeElement); + setState({ status: 'loading', entries: [] }); + Promise.resolve().then(() => api.listFiles(root, directory)).then((entries) => { + if (!cancelled) setState({ status: 'ready', entries: breadcrumbDirectoryEntries(entries, directory) }); + }).catch(() => { + if (!cancelled) setState({ status: 'error', entries: [] }); + }); + return () => { cancelled = true; }; + }, [api, root, directory, revision]); + + useLayoutEffect(() => { + if (state.status === 'loading' || (document.activeElement !== focusFrom?.current + && !(restoreFocusRef.current && document.activeElement === document.body))) return; + const list = listRef.current; + const target = list?.querySelector('[aria-selected="true"]') || list?.querySelector('button'); + target?.focus({ preventScroll: true }); + target?.scrollIntoView({ block: 'nearest' }); + }, [state, focusFrom]); + + return ( +
    + {state.status === 'loading' &&
  • 加载中...
  • } + {state.status === 'error' && ( +
  • + 无法读取目录 + +
  • + )} + {state.status === 'ready' && state.entries.length === 0 &&
  • 空目录
  • } + {state.entries.map((entry) => ( + + ))} +
+ ); +} + +function DirectoryEntry({ entry, api, root, selectedPath, onPick, level }) { + const groupId = useId(); + const itemRef = useRef(null); + const buttonRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [focusChild, setFocusChild] = useState(false); + const directory = entry.kind === 'dir'; + const statusTitle = fileChangeStatusTitle(entry.status, directory); + const selected = entry.path === selectedPath; + const handleKeyDown = (event) => { + if (event.key === 'ArrowRight' && directory) { + event.preventDefault(); + if (expanded) itemRef.current?.querySelector('[role="group"] button')?.focus(); + else { setFocusChild(true); setExpanded(true); } + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + if (expanded) setExpanded(false); + else itemRef.current?.parentElement?.closest('li')?.querySelector('button')?.focus(); + } + }; + return ( +
  • + + {directory && expanded && } +
  • + ); +} + +function WorkspaceFileBreadcrumbs({ model, api, onOpenFile }) { + const navRef = useRef(null); + const anchorRef = useRef(null); + const [openCrumb, setOpenCrumb] = useState(null); + + useLayoutEffect(() => { + if (navRef.current) navRef.current.scrollLeft = navRef.current.scrollWidth; + }, [model.relativePath]); + + return ( + <> + + {openCrumb && ( + setOpenCrumb(null)} + className="ace-breadcrumb-menu" width={360} maxHeightRatio={0.6}> + { setOpenCrumb(null); onOpenFile(path); }} /> + + )} + + ); +} + +export function FileBreadcrumbs({ api, workspaceCwd, cwd, path, onOpenFile }) { + const model = fileBreadcrumbs({ workspaceCwd, cwd, path }); + if (!model || typeof api?.listFiles !== 'function' || !onOpenFile) return null; + return ; +} diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index 6144fc526..ac5a23f48 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -10,6 +10,7 @@ const ICONS = { arrowRight: 'ArrowRight', back: 'Backwards', brain: 'Brain', + sparkle: 'Sparkle', brightness: 'Brightness', bug: 'Bug', chat: 'ChatBubble', @@ -65,6 +66,7 @@ const ICONS = { panelBottomFilled: 'PanelBottomFilled', palette: 'Palette', pin: 'Pin', + pinOff: 'PinOff', refresh: 'Refresh', rightBar: 'RightBar', run: 'Run', diff --git a/web/src/components/InputBar.jsx b/web/src/components/InputBar.jsx index 197706ad3..3f0320410 100644 --- a/web/src/components/InputBar.jsx +++ b/web/src/components/InputBar.jsx @@ -209,7 +209,7 @@ function ComposerBrowserContextCard({ item, onRemove }) { export const InputBar = forwardRef(function InputBar({ disabled, submitting = false, canRetryLastUserMessage = false, queuePaused = false, onResumeQueue, - placeholder = '输入消息或 / 命令…', onSubmit, onAbort, busy, goal = null, + placeholder = '输入消息或 / 命令…', onSubmit, onAbort, busy, stopping = false, goal = null, onGoalEdit, onGoalStatusChange, onGoalClear, history = [], historyEntries = [], variant = 'default', attentionRequest = 0, value: controlledValue, onChange, @@ -1318,7 +1318,7 @@ export const InputBar = forwardRef(function InputBar({ }; const actionState = getInputBarActionState({ value: draftValue, disabled, busy, hasExtras, submitting, canRetryLastUserMessage, queuePaused }); - const stopControl = getGoalStopControlState({ busy }); + const stopControl = getGoalStopControlState({ busy, stopping }); const composerSpacingClass = isHero ? 'px-4 pt-3 pb-1 text-[14px]' : 'px-3 pt-2 pb-1 text-[13px]'; const hasInlineContexts = otherContextItems.length > 0; const capabilityControl = ( diff --git a/web/src/components/LoopPage.jsx b/web/src/components/LoopPage.jsx index f15fddf12..2d1c944ee 100644 --- a/web/src/components/LoopPage.jsx +++ b/web/src/components/LoopPage.jsx @@ -72,9 +72,9 @@ function AddLoopDialog({ loop = null, template = null, models, defaultModelName, onSaved(saved); } catch (e) { if (e?.status === 409 && e?.body?.conflict) { - setError(`该工作空间的执行时间与“${e.body.conflict.loop_name || '已有循环'}”冲突(首次:${formatDate(e.body.conflict.first_conflict_at_ms)}),请调整时间`); + setError(`该工作空间的执行时间与“${e.body.conflict.loop_name || '已有定时任务'}”冲突(首次:${formatDate(e.body.conflict.first_conflict_at_ms)}),请调整时间`); } else { - setError(e?.message || '保存循环失败'); + setError(e?.message || '保存定时任务失败'); } } finally { setSaving(false); @@ -87,7 +87,7 @@ function AddLoopDialog({ loop = null, template = null, models, defaultModelName, <>
    -

    {loop ? '编辑循环' : '添加循环'}

    +

    {loop ? '编辑定时任务' : '添加定时任务'}

    @@ -121,7 +121,7 @@ function AddLoopDialog({ loop = null, template = null, models, defaultModelName, className={`h-8 px-2.5 rounded-md border border-border bg-bg flex items-center gap-1.5 text-[11px] ${ form.workspaceHash ? 'text-fg cursor-pointer' : 'text-fg-mute cursor-not-allowed' }`} - title={form.workspaceHash ? '在独立的 Git worktree 中运行此循环' : '请先选择工作空间'} + title={form.workspaceHash ? '在独立的 Git worktree 中运行此定时任务' : '请先选择工作空间'} >
    - +
    @@ -265,7 +265,7 @@ function RunList({ runs, onOpenSession }) { ); } -export function LoopPage({ onOpenSession }) { +export function LoopPage({ onOpenSession, onSmartAdd }) { const [loops, setLoops] = useState([]); const [models, setModels] = useState([]); const [defaultModelName, setDefaultModelName] = useState(''); @@ -295,7 +295,7 @@ export function LoopPage({ onOpenSession }) { setUnsupported(false); } catch (e) { if (e?.status === 404 || e?.status === 501 || e?.code === 'LOOP_UNAVAILABLE') setUnsupported(true); - else toast({ kind: 'err', text: '读取循环失败:' + (e?.message || '') }); + else toast({ kind: 'err', text: '读取定时任务失败:' + (e?.message || '') }); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); @@ -353,7 +353,7 @@ export function LoopPage({ onOpenSession }) { ? { ...updated, latest_run: item.latest_run || null } : item)); } catch (e) { - const conflict = e?.status === 409 ? '启用失败:该工作空间的执行时间与已有循环冲突' : `操作失败:${e?.message || ''}`; + const conflict = e?.status === 409 ? '启用失败:该工作空间的执行时间与已有定时任务冲突' : `操作失败:${e?.message || ''}`; toast({ kind: 'err', text: conflict }); } }; @@ -371,15 +371,18 @@ export function LoopPage({ onOpenSession }) { }; if (unsupported) { - return

    当前 daemon 不支持 LOOP

    请升级并重启 ACECode daemon 后再试。

    ; + return

    当前后台不支持定时任务

    请升级并重启 ACECode daemon 后再试。

    ; } return (
    -
    -

    循环

    管理计划执行的代码任务并查看最近运行记录。

    - +
    +

    定时任务

    管理定时提醒和计划执行的任务,查看最近运行记录。

    +
    + + +
    @@ -396,10 +399,10 @@ export function LoopPage({ onOpenSession }) {
    -

    我的循环

    +

    我的定时任务

    自动刷新运行状态
    - {loading ?
    加载中…
    : !loops.length ?
    还没有循环,选择模板或点击“添加”开始。
    : ( + {loading ?
    加载中…
    : !loops.length ?
    还没有定时任务,点击“智能添加定时任务”或“手动添加”开始。
    : (
    {loops.map((loop) => { const workspace = workspaceByHash.get(loop.workspace_hash); @@ -455,9 +458,9 @@ export function LoopPage({ onOpenSession }) { setDeleteConfirm(null)} width={440}> {({ close }) => (
    -
    删除循环
    +
    删除定时任务
    - {`删除循环“${deleteConfirm.name}”?运行记录也会一并删除。`} + {`删除定时任务“${deleteConfirm.name}”?运行记录也会一并删除。`}
    + {active.type === PREVIEW_TAB_TYPES.FILE && ( + + )}
    {renderedBody}
    diff --git a/web/src/components/RichComposer.jsx b/web/src/components/RichComposer.jsx index f3a8cd93f..f08448645 100644 --- a/web/src/components/RichComposer.jsx +++ b/web/src/components/RichComposer.jsx @@ -62,7 +62,7 @@ import { composerContentText, composerContentClipboardText, } from '../lib/composerContent.js'; -import { filesFromTransfer } from '../lib/composerFileTransfer.js'; +import { filesFromTransfer, readComposerClipboardData } from '../lib/composerFileTransfer.js'; import { isPasteBlockPart, shouldFoldPastedText } from '../lib/pastedText.js'; import { composerSelectedTag, composerTagSelection } from '../lib/composerSelection.js'; import { synchronizeComposerLeadingCommand } from '../lib/composerCommandSync.js'; @@ -748,6 +748,7 @@ function RichComposerShell({ } }, [capturePasteSelection, editor, foldLargePaste, publishSelection]); + const contextClipboardPasteRef = useRef(null); const handleContextPasteAction = useCallback((event) => { const detail = event?.detail; if (detail?.action === RICH_COMPOSER_CONTEXT_PASTE_ACTIONS.CAPTURE_SELECTION) { @@ -755,6 +756,11 @@ function RichComposerShell({ detail.handled = true; return; } + if (detail?.action === RICH_COMPOSER_CONTEXT_PASTE_ACTIONS.READ_CLIPBOARD) { + detail.handled = true; + if (!disabled) detail.result = contextClipboardPasteRef.current?.(detail.selection); + return; + } if (detail?.action !== RICH_COMPOSER_CONTEXT_PASTE_ACTIONS.INSERT_TEXT) return; // RichComposer owns this contenteditable. Consume the action even while @@ -1259,6 +1265,28 @@ function RichComposerShell({ return; } + if ( + (event.key === 'ArrowLeft' || event.key === 'ArrowRight') + && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey + && editor.selection && Range.isCollapsed(editor.selection) + ) { + const paragraph = editor.children[editor.selection.focus.path[0]]; + const isRTL = ReactEditor.toDOMNode(editor, paragraph).dir === 'rtl'; + const reverse = (event.key === 'ArrowLeft') !== isRTL; + const advance = reverse ? Editor.before : Editor.after; + const next = advance(editor, editor.selection.focus, { unit: 'character' }); + const tag = next && Editor.above(editor, { at: next, match: isComposerInlineTag, voids: true }); + if (tag) { + // Skip the void's internal selection stop, not the text after it. + const target = advance(editor, tag[1]); + if (target) { + event.preventDefault(); + Transforms.select(editor, target); + return; + } + } + } + if (event.key === 'Enter' && submitOnEnter) { if (event.ctrlKey && isDesktopShell()) { event.preventDefault(); @@ -1334,7 +1362,7 @@ function RichComposerShell({ .catch(() => {}); }, [applyPlainTextPaste, disabled]); - const handleClipboardPaste = useCallback((event, clipboardData) => { + const handleClipboardPaste = useCallback((event, clipboardData, context = null) => { const consume = () => { event.preventDefault?.(); event.stopPropagation?.(); @@ -1371,30 +1399,32 @@ function RichComposerShell({ const handlesFilesystemItems = typeof onPasteFilesystemItems === 'function'; if (files.length === 0 && !text && !hasTextFormat && !handlesFilesystemItems) return false; - const capturedSelection = capturePasteSelection(); + const capturedSelection = context?.selection || capturePasteSelection(); consume(); if (handlesFilesystemItems) { let uriList = ''; try { uriList = clipboardData?.getData?.('text/uri-list') || ''; } catch { /* ignored */ } - const transfer = beginFileTransfer(capturedSelection); - Promise.resolve(onPasteFilesystemItems({ files, uriList }, transfer)) + const transfer = context?.transfer || beginFileTransfer(capturedSelection); + return Promise.resolve(onPasteFilesystemItems({ files, uriList }, transfer)) .then(async (handled) => { if (handled || !transfer.isActive()) return; + if (context?.readError) throw context.readError; if (text) transfer.insertText(text); else if (!files.length && hasTextFormat) { const fallback = await window.navigator?.clipboard?.readText?.(); if (fallback) transfer.insertText(fallback); } }) - .catch(() => {}) + .catch(error => { if (context) throw error; }) .finally(() => transfer.dispose()); - return true; } else if (files.length > 0) { onPasteFiles?.(files); return true; } + if (context?.readError) throw context.readError; if (text) { - applyPlainTextPaste(text, capturedSelection); + if (context?.transfer) context.transfer.insertText(text); + else applyPlainTextPaste(text, capturedSelection); } else if (files.length === 0 && hasTextFormat) { requestClipboardTextFallback(capturedSelection); } @@ -1412,6 +1442,23 @@ function RichComposerShell({ requestClipboardTextFallback, ]); + contextClipboardPasteRef.current = async (selection) => { + const captured = selection || capturePasteSelection(); + const transfer = beginFileTransfer(captured); + try { + // Focus before asynchronous IO, never after the user has moved elsewhere. + try { ReactEditor.focus(editor); } catch { /* Slate may still be reconciling. */ } + let clipboardData; + let readError; + try { clipboardData = await readComposerClipboardData(); } catch (error) { readError = error; } + if (!transfer.isActive()) return; + if (readError && typeof onPasteFilesystemItems !== 'function') throw readError; + await handleClipboardPaste({}, clipboardData, { selection: captured, transfer, readError }); + } finally { + transfer.dispose(); + } + }; + const markPasteHandled = useCallback(() => { const token = pasteBeforeInputGuardRef.current + 1; pasteBeforeInputGuardRef.current = token; diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index 27976ab25..c8c1e5ff9 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -1145,15 +1145,15 @@ function SessionRow({ onTogglePin?.(s, !pinned); }} className={clsx( - 'ace-session-pin-btn ace-sidebar-row-hover-action w-[18px] h-7 rounded items-center justify-center shrink-0 transition', + 'ace-session-pin-btn ace-sidebar-row-hover-action w-[18px] h-7 rounded items-center justify-center shrink-0 transition text-fg-mute hover:text-fg hover:bg-surface-hi', pinned - ? 'flex text-accent' - : 'hidden group-hover:flex group-focus-within:flex text-fg-mute hover:text-fg hover:bg-surface-hi', + ? 'flex' + : 'hidden group-hover:flex group-focus-within:flex', )} title={pinned ? '取消置顶' : '置顶'} aria-label={pinned ? '取消置顶' : '置顶'} > - + )} + )} + +
    + ); +} diff --git a/web/src/components/WorkspaceIconPicker.jsx b/web/src/components/WorkspaceIconPicker.jsx index c34bb9383..1b7f54560 100644 --- a/web/src/components/WorkspaceIconPicker.jsx +++ b/web/src/components/WorkspaceIconPicker.jsx @@ -12,9 +12,10 @@ const VIEWPORT_MARGIN = 8; // 「编辑项目」左上角图标按钮弹出的选择层:搜索框 + 色板 + 图标网格。 // 放在对话框 DOM 内、用 fixed 定位:对话框本身 overflow:auto,绝对定位会被裁掉; // 留在对话框里则 Tab 循环与 Enter 约定(lib/dialogKeyboard.js)照常生效。 -// 点选择层外面的空白不收起(挑图标时误点旁边不该让它消失),只有再点一次图标按钮或 -// 按 Esc 才收起;网格里选中的那一格显示展开态,与侧栏项目展开时一致。 +// 点击选择层外部只收起选择层,保留编辑项目表单;触发按钮和 Esc 也可收起。 +// 网格里选中的那一格显示展开态,与侧栏项目展开时一致。 export function WorkspaceIconPicker({ anchorRef, value, onChange, onClose }) { + const pickerRef = useRef(null); const searchRef = useRef(null); const [query, setQuery] = useState(''); const [position, setPosition] = useState(null); @@ -40,6 +41,16 @@ export function WorkspaceIconPicker({ anchorRef, value, onChange, onClose }) { searchRef.current?.focus(); }, []); + useEffect(() => { + const onPointerDown = (event) => { + if (pickerRef.current?.contains(event.target) + || anchorRef?.current?.contains(event.target)) return; + onClose?.(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [anchorRef, onClose]); + useEffect(() => { // Esc 只收起选择层:捕获阶段先拦下,对话框(document 冒泡阶段监听)就不会一起关掉。 const onKey = (event) => { @@ -55,6 +66,7 @@ export function WorkspaceIconPicker({ anchorRef, value, onChange, onClose }) { return (
    \.ace-cmd-token\s*\{[^}]*margin:\s*1px 0;[^}]*padding:\s*0 5px;/); + assert.match(styles, /\.ace-slate-inline-tag > \.ace-cmd-token\s*\{[^}]*margin:\s*0;[^}]*padding:\s*0;/); assert.match(styles, /\.ace-cmd-token\s*\{[\s\S]*margin-right:\s*1px;/); }); diff --git a/web/src/lib/chatInputQueue.js b/web/src/lib/chatInputQueue.js index 3b57be146..0b35aacb5 100644 --- a/web/src/lib/chatInputQueue.js +++ b/web/src/lib/chatInputQueue.js @@ -72,6 +72,7 @@ export function createChatInputQueueState(overrides = {}) { nextLocalId: nextSequence(overrides), items: cloneItems(overrides), paused: clonePaused(overrides), + resumedAbortTurns: overrides?.resumedAbortTurns || {}, }; } @@ -248,7 +249,13 @@ export function acceptedQueuedInputEvent(item, { now = Date.now() } = {}) { text: item?.content, payload: item?.queued?.payload, }); - const content = payload.text || (payload.attachments.length > 0 ? '附件消息' : '上下文消息'); + return acceptedUserInputEvent({ ...payload, client_message_id: clientMessageId }, { now }); +} + +export function acceptedUserInputEvent(payload, { now = Date.now() } = {}) { + const clientMessageId = normalizeText(payload?.client_message_id).trim(); + if (!clientMessageId) return null; + const content = payload.text || (payload.attachments?.length > 0 ? '附件消息' : '上下文消息'); return { type: 'queued_input_accepted', payload: { @@ -376,6 +383,12 @@ export function pauseQueuedInput( ) { const sid = normalizeSessionId(sessionId); const current = createChatInputQueueState(state); + if (current.resumedAbortTurns[sid]) { + const resumedAbortTurns = { ...current.resumedAbortTurns }; + delete resumedAbortTurns[sid]; + state = { ...current, resumedAbortTurns }; + current.resumedAbortTurns = resumedAbortTurns; + } // 没有待发送消息就没有可暂停的东西;已暂停则保持首次暂停的时间与原因。 if (!sid || queuedInputsForSession(current, sid).length === 0) { return state && typeof state === 'object' ? state : current; @@ -387,13 +400,18 @@ export function pauseQueuedInput( }; } -export function resumeQueuedInput(state, sessionId) { +export function resumeQueuedInput(state, sessionId, { afterAbortTurnId = '' } = {}) { const sid = normalizeSessionId(sessionId); const current = createChatInputQueueState(state); - if (!sid || !current.paused[sid]) return state && typeof state === 'object' ? state : current; + if (!sid || (!current.paused[sid] && !afterAbortTurnId)) return state && typeof state === 'object' ? state : current; const paused = { ...current.paused }; delete paused[sid]; - return { ...current, paused }; + return { + ...current, paused, + resumedAbortTurns: afterAbortTurnId + ? { ...current.resumedAbortTurns, [sid]: afterAbortTurnId } + : current.resumedAbortTurns, + }; } // 回合以「中断」收尾时队列是否应该转入暂停。 @@ -405,9 +423,11 @@ export function shouldPauseQueuedInputAfterAbort({ state, sessionId = '', lastTurnOutcome = '', + turnId = '', } = {}) { const sid = normalizeSessionId(sessionId); if (!sid || lastTurnOutcome !== 'aborted') return false; + if (state?.resumedAbortTurns?.[sid] === (turnId || 'pending-stop')) return false; if (isQueuedInputPaused(state, sid)) return false; return queuedInputsForSession(state, sid).length > 0; } diff --git a/web/src/lib/chatScrollFollow.js b/web/src/lib/chatScrollFollow.js index 082db4527..bc96f927e 100644 --- a/web/src/lib/chatScrollFollow.js +++ b/web/src/lib/chatScrollFollow.js @@ -1,4 +1,7 @@ export const CHAT_TAIL_FOLLOW_THRESHOLD_PX = 80; +// Integer scroll/client heights and scaled scrollTop can differ by more than +// one CSS pixel even at the browser's clamped maximum scroll position. +export const CHAT_SCROLL_TO_BOTTOM_TOLERANCE_PX = 2; export const CHAT_TAIL_FOLLOW_STATE = Object.freeze({ FOLLOWING: 'following', @@ -32,6 +35,12 @@ export function isChatNearTail(metrics = {}, thresholdPx = CHAT_TAIL_FOLLOW_THRE return chatTailDistance(metrics) <= threshold; } +export function shouldShowChatScrollToBottom(source = {}) { + const metrics = chatScrollMetrics(source); + return metrics.clientHeight > 0 + && !isChatNearTail(metrics, CHAT_SCROLL_TO_BOTTOM_TOLERANCE_PX); +} + // 离开尾部的 scroll 事件里,只有"真实的用户上滚"才应该暂停跟随。流式渲染 // 替换 DOM 时,内容高度瞬时回落会让浏览器 clamp/调整 scrollTop 并触发 // scroll 事件 —— 把它误判成用户滚动会让跟随模式反复开关,消息区上下跳动。 diff --git a/web/src/lib/chatScrollFollow.test.js b/web/src/lib/chatScrollFollow.test.js index 13829ca97..a356ec0cb 100644 --- a/web/src/lib/chatScrollFollow.test.js +++ b/web/src/lib/chatScrollFollow.test.js @@ -8,6 +8,7 @@ import { nextChatTailFollowState, observeChatTailContent, shouldAutoFollowChatTail, + shouldShowChatScrollToBottom, } from './chatScrollFollow.js'; function run(name, fn) { @@ -30,10 +31,33 @@ run('away-from-tail metrics are not near the chat tail', () => { assert.equal(isChatNearTail({ scrollTop: 360, clientHeight: 400, scrollHeight: 1000 }, 80), false); }); -run('bottom control uses a pixel tolerance independently of the follow threshold', () => { - assert.equal(isChatNearTail({ scrollTop: 599.5, clientHeight: 400, scrollHeight: 1000 }, 1), true); - assert.equal(isChatNearTail({ scrollTop: 598, clientHeight: 400, scrollHeight: 1000 }, 1), false); - assert.equal(isChatNearTail({ scrollTop: 0, clientHeight: 400, scrollHeight: 300 }, 1), true); +run('bottom control hides at the scaled browser maximum despite rounded height metrics', () => { + // Captured from Edge at 150% device scale and 90% page zoom after + // assigning scrollTop = scrollHeight: the browser cannot scroll any farther. + const metrics = { scrollTop: 1112.5926513671875, clientHeight: 620, scrollHeight: 1734 }; + assert.ok(chatTailDistance(metrics) > 1); + assert.equal(shouldShowChatScrollToBottom(metrics), false); + assert.equal(shouldShowChatScrollToBottom({ scrollTop: 598, clientHeight: 400, scrollHeight: 1000 }), false); +}); + +run('bottom control tolerance remains independent of the automatic follow threshold', () => { + for (const distance of [3, 24, 80, 240]) { + const metrics = { scrollTop: 600 - distance, clientHeight: 400, scrollHeight: 1000 }; + assert.equal(shouldShowChatScrollToBottom(metrics), true); + assert.equal(isChatNearTail(metrics), distance <= 80); + } +}); + +run('bottom control hides for missing, hidden, short and overscrolled viewports', () => { + for (const metrics of [ + null, + {}, + { scrollTop: 0, clientHeight: 0, scrollHeight: 1000 }, + { scrollTop: 0, clientHeight: 400, scrollHeight: 300 }, + { scrollTop: 600.5, clientHeight: 400, scrollHeight: 1000 }, + ]) { + assert.equal(shouldShowChatScrollToBottom(metrics), false); + } }); run('explicit jump resumes tail follow after reviewing or activity expansion', () => { diff --git a/web/src/lib/chatScrollFollowArchitecture.test.js b/web/src/lib/chatScrollFollowArchitecture.test.js index 05e7c0590..c53decebb 100644 --- a/web/src/lib/chatScrollFollowArchitecture.test.js +++ b/web/src/lib/chatScrollFollowArchitecture.test.js @@ -86,3 +86,25 @@ run('activity expansion starts review anchoring and manual scroll gestures cance assert.match(chatView, /const handleMessagesPointerDown[\s\S]*?cancelActivityExpansionAnchor\(\);/); assert.match(chatView, /onKeyDownCapture=\{handleMessagesKeyDownCapture\}/); }); + +run('tail button reuses session status without changing visibility or navigation', () => { + const chatView = source('components/ChatView.jsx'); + const start = chatView.indexOf('{sid && showScrollToBottom && ('); + const end = chatView.indexOf('= 0 && end > start); + assert.match(button, /onClick=\{jumpToChatTail\}/); + assert.match(button, /aria-label="滚动到底部"/); + assert.match(button, /status === 'running' \? \(/); + assert.match(button, /className="ace-chat-tail-progress" aria-hidden="true"/); + assert.equal((button.match(//g) || []).length, 3); + assert.match(button, /: /); +}); + +run('tail progress dots animate left to right and respect reduced motion', () => { + const styles = source('styles/globals.css'); + assert.match(styles, /\.ace-chat-tail-progress > span\s*\{[^}]*background: currentColor;[^}]*animation: ace-chat-tail-dot-hop 1\.2s ease-in-out infinite;/); + assert.match(styles, /\.ace-chat-tail-progress > span:nth-child\(2\)\s*\{ animation-delay: \.2s;/); + assert.match(styles, /\.ace-chat-tail-progress > span:nth-child\(3\)\s*\{ animation-delay: \.4s;/); + assert.match(styles, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.ace-chat-tail-progress > span\s*\{ animation: none;/); +}); diff --git a/web/src/lib/composerFileTransfer.js b/web/src/lib/composerFileTransfer.js index ececf15af..3a2ec30b2 100644 --- a/web/src/lib/composerFileTransfer.js +++ b/web/src/lib/composerFileTransfer.js @@ -168,3 +168,38 @@ export function filesFromTransfer(dataTransfer, { source = 'drop' } = {}) { export function filesFromClipboardEvent(event) { return filesFromTransfer(event?.clipboardData || event?.nativeEvent?.clipboardData, { source: 'paste' }); } + +// Adapt the async browser clipboard to the same shape as a native paste event. +// One ClipboardItem can expose several representations of the same image. +export async function readComposerClipboardData(clipboard = globalThis.navigator?.clipboard) { + const files = []; + const text = new Map(); + let items; + if (typeof clipboard?.read === 'function') { + try { items = await clipboard.read(); } catch (error) { + if (typeof clipboard?.readText !== 'function') throw error; + } + } + if (items) { + for (const item of items) { + const types = Array.from(item.types || []); + const imageType = types.includes('image/png') ? 'image/png' : types.find(type => type.startsWith('image/')); + if (imageType) { + files.push(await item.getType(imageType)); + continue; + } + for (const type of ['text/plain', 'text/html', 'text/uri-list']) { + if (types.includes(type)) text.set(type, (text.get(type) || '') + await (await item.getType(type)).text()); + } + } + } else if (typeof clipboard?.readText === 'function') { + text.set('text/plain', await clipboard.readText()); + } else { + throw new Error('Clipboard read unavailable'); + } + return { + files: normalizeTransferFiles(files, 'paste'), + types: [...text.keys(), ...(files.length ? ['Files'] : [])], + getData: type => text.get(type) || '', + }; +} diff --git a/web/src/lib/composerFileTransfer.test.js b/web/src/lib/composerFileTransfer.test.js index 9b21631b5..69db0e7f0 100644 --- a/web/src/lib/composerFileTransfer.test.js +++ b/web/src/lib/composerFileTransfer.test.js @@ -8,6 +8,7 @@ import { hasFileTransfer, markFileSourcePath, markFileSourceReference, + readComposerClipboardData, } from './composerFileTransfer.js'; function run(name, fn) { @@ -143,3 +144,34 @@ run('composer file identity falls back to stable browser metadata', () => { assert.notEqual(composerFileIdentity(first), composerFileIdentity(changed)); assert.match(composerFileIdentity(first), /^file:/); }); + +const clipboardItem = (representations) => ({ + types: Object.keys(representations), + getType: async type => new Blob([representations[type]], { type }), +}); +const imageClipboard = await readComposerClipboardData({ read: async () => [clipboardItem({ + 'image/jpeg': 'jpeg', 'image/png': 'png', 'text/html': '', +})] }); +assert.equal(imageClipboard.files.length, 1); +assert.equal(imageClipboard.files[0].type, 'image/png'); +assert.equal(imageClipboard.files[0].name, 'pasted-image.png'); +assert.equal(await imageClipboard.files[0].text(), 'png'); +assert.equal(imageClipboard.getData('text/html'), ''); +console.log('[pass] context clipboard chooses one image representation without duplicate HTML'); + +const textClipboard = await readComposerClipboardData({ read: async () => [clipboardItem({ + 'text/plain': 'hello', 'text/html': 'hello', +})] }); +assert.equal(textClipboard.getData('text/plain'), 'hello'); +assert.equal(textClipboard.getData('text/html'), 'hello'); +assert.deepEqual(textClipboard.files, []); +const fallbackClipboard = await readComposerClipboardData({ + read: async () => { throw new Error('permission'); }, readText: async () => 'fallback', +}); +assert.equal(fallbackClipboard.getData('text/plain'), 'fallback'); +assert.deepEqual((await readComposerClipboardData({ read: async () => [] })).types, []); +await assert.rejects(readComposerClipboardData({}), /unavailable/); +await assert.rejects(readComposerClipboardData({ read: async () => [{ + types: ['image/png', 'text/plain'], getType: async () => { throw new Error('image decode failed'); }, +}], readText: async () => 'must not hide image failure' }), /image decode failed/); +console.log('[pass] context clipboard handles text and empty data while preserving image read errors'); diff --git a/web/src/lib/connection.js b/web/src/lib/connection.js index 9b1ff6f98..3a6bee974 100644 --- a/web/src/lib/connection.js +++ b/web/src/lib/connection.js @@ -237,7 +237,7 @@ export class AceConnection extends EventTarget { sendUserInput(text, sessionId=this.sessionId) { this._send({ type: 'user_input', payload: { session_id: sessionId, text } }); } sendDecision(request_id, choice, sessionId=this.sessionId) { this._send({ type: 'decision', payload: { session_id: sessionId, request_id, choice } }); } sendQuestionAnswer(payload) { this._send({ type: 'question_answer', payload }); } - sendAbort(sessionId=this.sessionId) { this._send({ type: 'abort', payload: { session_id: sessionId } }); } + sendAbort(sessionId=this.sessionId) { return this._send({ type: 'abort', payload: { session_id: sessionId } }); } markSessionRead({ sessionId=this.sessionId, workspaceHash='', cursor=0 } = {}) { if (!sessionId) return; this._send({ @@ -255,8 +255,13 @@ export class AceConnection extends EventTarget { ping() { this._send({ type: 'ping' }); } _send(msg) { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; - this.ws.send(JSON.stringify(msg)); + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false; + try { + this.ws.send(JSON.stringify(msg)); + return true; + } catch { + return false; + } } } diff --git a/web/src/lib/conversationActivity.js b/web/src/lib/conversationActivity.js index 960975b30..8b84596e2 100644 --- a/web/src/lib/conversationActivity.js +++ b/web/src/lib/conversationActivity.js @@ -54,6 +54,7 @@ function backgroundContext(tasks) { export function selectConversationActivity({ foregroundBusy = false, + foregroundStopping = false, foregroundActivity = null, permissionRequests = [], questionRequest = null, @@ -61,6 +62,14 @@ export function selectConversationActivity({ } = {}) { const runningTasks = runningBackgroundTasks(subagentTasks); const background = backgroundContext(runningTasks); + if (foregroundBusy && foregroundStopping) { + return { + kind: CONVERSATION_ACTIVITY_KIND.FOREGROUND, + phase: 'stopping', label: '正在停止…', detail: '', + startedAtMs: Number(foregroundActivity?.startedAtMs || 0), + needsAction: false, ...background, + }; + } const permission = (Array.isArray(permissionRequests) ? permissionRequests : []) .find(requestIsUnresolved); diff --git a/web/src/lib/conversationActivity.test.js b/web/src/lib/conversationActivity.test.js index 351fe0c59..73d5ee979 100644 --- a/web/src/lib/conversationActivity.test.js +++ b/web/src/lib/conversationActivity.test.js @@ -4,6 +4,11 @@ import { selectConversationActivity, } from './conversationActivity.js'; +const stopping = selectConversationActivity({ foregroundBusy: true, foregroundStopping: true, + foregroundActivity: { phase: 'tool_running', label: 'running a tool' } }); +assert.equal(stopping.phase, 'stopping'); +assert.equal(stopping.label, '正在停止…'); + function run(name, fn) { try { fn(); diff --git a/web/src/lib/expertPrefill.test.js b/web/src/lib/expertPrefill.test.js index 9eca37fd5..174963c3c 100644 --- a/web/src/lib/expertPrefill.test.js +++ b/web/src/lib/expertPrefill.test.js @@ -8,6 +8,7 @@ import { homeRefFromWorkspace } from './homeWorkspaceSelection.js'; import { homeComposerDraft, homeComposerDraftText, updateHomeComposerDrafts } from './homeComposerDrafts.js'; import { composerDraftFingerprint } from './composerDraft.js'; import { aiThemeCreationRef, homeComposerScopedWorkspace } from './aiThemeCreation.js'; +import { scheduledTaskCreationRef } from './scheduledTaskCreation.js'; import { commandsWithFallback } from './slashCommands.js'; import { COMPOSER_COMMAND_TAG, @@ -91,7 +92,7 @@ function fixture(workspaceHash = 'workspace-a') { }, }); const scope = { - ...refs, api, homeRefFromWorkspace, aiThemeCreationRef, health: {}, homeComposerDraft, composerDraftFingerprint, + ...refs, api, homeRefFromWorkspace, aiThemeCreationRef, scheduledTaskCreationRef, health: {}, homeComposerDraft, composerDraftFingerprint, createApi: () => api, refreshWorkspaceGitInfo: async () => {}, navigateToRef: navigate, replaceActiveRef: navigate, rememberRecentExpert() {}, onRememberExpert() {}, @@ -118,6 +119,7 @@ function fixture(workspaceHash = 'workspace-a') { const dispatch = evaluate(page.callback('dispatchToNewTask')); const create = evaluate(app.callback('startConversationalExpertCreation')); const createTheme = evaluate(app.callback('startAiThemeCreation')); + const createScheduled = evaluate(app.callback('startScheduledTaskCreation')); function render() { let commits = 0; @@ -166,6 +168,7 @@ function fixture(workspaceHash = 'workspace-a') { }, create() { create(); render(); }, createTheme() { createTheme(); render(); }, + createScheduled() { createScheduled(); render(); }, navigate(next) { navigate(next); render(); }, edit(text) { composer = text; @@ -178,6 +181,20 @@ function fixture(workspaceHash = 'workspace-a') { const expert = { id: 'expert-a', quick_prompts: ['first prompt', 'second prompt', 'third prompt'] }; const cases = [ + ['scheduled task callback prefills an isolated editable skill draft without creating or sending', () => { + const view = fixture(); + view.createScheduled(); + assert.equal(view.text, '/scheduled-task 我希望在明天X点提醒我参加会议,重复X天'); + assert.equal(view.route.composerDraftScope, 'scheduled-task'); + assert.equal(view.consumed, 1); + view.edit('/scheduled-task 明天九点提醒我开会,重复三天'); + view.render(); + assert.equal(view.text, '/scheduled-task 明天九点提醒我开会,重复三天'); + assert.equal(view.drafts['workspace-a'], 'older draft A'); + view.navigate(homeRefFromWorkspace({ workspaceHash: 'workspace-a' })); + assert.equal(view.text, 'older draft A'); + assert.deepEqual(view.loads, []); + }], ['AI theme callback closes settings and consumes its isolated draft without sending or replacing ordinary work', () => { const view = fixture(); view.createTheme(); diff --git a/web/src/lib/fileBreadcrumbs.js b/web/src/lib/fileBreadcrumbs.js new file mode 100644 index 000000000..959567edc --- /dev/null +++ b/web/src/lib/fileBreadcrumbs.js @@ -0,0 +1,38 @@ +import { normalizePickerPath, sortPickerEntries } from './pathPicker.js'; +import { previewAbsolutePath, previewFileLocation } from './previewTabs.js'; + +// The preview's cwd can be an external file's parent. Only the workspace root +// supplied by the session decides whether navigation should be available. +export function fileBreadcrumbs({ workspaceCwd = '', cwd = workspaceCwd, path = '' } = {}) { + if (!workspaceCwd || !path) return null; + const root = normalizePickerPath(previewAbsolutePath({ path: workspaceCwd })); + const absolute = normalizePickerPath(previewAbsolutePath({ cwd, path })); + if (!root || !absolute) return null; + const location = previewFileLocation({ cwd: root, path: absolute }); + if (location.cwd !== root || !location.path) return null; + const parts = location.path.split('/'); + const crumbs = [{ + label: root.split('/').filter(Boolean).pop() || root, + path: '', directory: '', selectedPath: parts[0], kind: 'dir', + }]; + let directory = ''; + parts.forEach((label, index) => { + const entryPath = directory ? `${directory}/${label}` : label; + crumbs.push({ + label, path: entryPath, directory, selectedPath: entryPath, + kind: index === parts.length - 1 ? 'file' : 'dir', + }); + directory = entryPath; + }); + return { root, relativePath: location.path, crumbs }; +} + +// Directory entries are immediate children. Rebuild their relative paths from +// names so a malformed response cannot turn the picker into outside navigation. +export function breadcrumbDirectoryEntries(entries, directory = '') { + return sortPickerEntries((Array.isArray(entries) ? entries : []) + .filter((entry) => (entry?.kind === 'dir' || entry?.kind === 'file') + && typeof entry.name === 'string' && entry.name.length > 0 + && entry.name !== '.' && entry.name !== '..' && !/[\\/\u0000]/.test(entry.name)) + .map((entry) => ({ ...entry, path: directory ? `${directory}/${entry.name}` : entry.name }))); +} diff --git a/web/src/lib/fileBreadcrumbs.test.js b/web/src/lib/fileBreadcrumbs.test.js new file mode 100644 index 000000000..0115995bd --- /dev/null +++ b/web/src/lib/fileBreadcrumbs.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { fileBreadcrumbs, breadcrumbDirectoryEntries } from './fileBreadcrumbs.js'; + +function run(name, fn) { + fn(); + console.log(` PASS fileBreadcrumbs: ${name}`); +} + +run('directory segments browse their parent and file segment browses siblings', () => { + const model = fileBreadcrumbs({ workspaceCwd: '/repo', path: 'web/src/index.html' }); + assert.equal(model.root, '/repo'); + assert.deepEqual(model.crumbs.map(({ label, directory, selectedPath }) => [label, directory, selectedPath]), [ + ['repo', '', 'web'], ['web', '', 'web'], ['src', 'web', 'web/src'], + ['index.html', 'web/src', 'web/src/index.html'], + ]); +}); + +run('Windows case, extended paths, UNC roots and root-level files', () => { + for (const [workspaceCwd, path] of [ + ['C:\\Repo', 'c:\\repo\\README.md'], + ['\\\\?\\C:\\Repo', '\\\\?\\C:\\Repo\\README.md'], + ['\\\\server\\Share\\Repo', '\\\\SERVER\\share\\repo\\README.md'], + ['\\\\?\\UNC\\server\\Share\\Repo', '\\\\server\\share\\repo\\README.md'], + ['C:/', 'C:/README.md'], ['/', '/README.md'], + ]) { + const model = fileBreadcrumbs({ workspaceCwd, path }); + assert.equal(model?.relativePath, 'README.md', workspaceCwd); + assert.equal(model.crumbs.length, 2); + } +}); + +run('external, sibling-prefix, missing-workspace and POSIX case boundaries', () => { + for (const args of [ + { workspaceCwd: '/repo', cwd: '/tmp', path: 'image.png' }, + { workspaceCwd: '/repo', path: '/repo-other/a.txt' }, + { workspaceCwd: '/repo', path: '/Repo/a.txt' }, + { cwd: '/tmp/artifacts', path: 'a.xlsx' }, + { workspaceCwd: 'C:/repo', path: 'D:/repo/a.txt' }, + { workspaceCwd: '/repo', path: '/repo' }, + { workspaceCwd: '/repo', path: '' }, + ]) assert.equal(fileBreadcrumbs(args), null, JSON.stringify(args)); +}); + +run('normalizes dot segments before testing containment', () => { + assert.equal(fileBreadcrumbs({ workspaceCwd: '/repo', path: '../outside/a.txt' }), null); + assert.equal(fileBreadcrumbs({ workspaceCwd: '/repo', path: '/repo/../other/a.txt' }), null); + assert.equal(fileBreadcrumbs({ workspaceCwd: '/repo', path: 'web/../a.txt' }).relativePath, 'a.txt'); +}); + +run('worktree navigation stays in the actual execution root for every format', () => { + for (const path of ['a.cpp', 'a.md', 'a.png', 'a.pdf', 'a.docx', 'a.xlsx', 'a.pptx']) { + const model = fileBreadcrumbs({ workspaceCwd: '/trees/feature', cwd: '/trees/feature', path }); + assert.equal(model.crumbs[0].label, 'feature'); + assert.equal(model.relativePath, path); + } + assert.equal(fileBreadcrumbs({ workspaceCwd: '/trees/feature', cwd: '/repo', path: 'a.cpp' }), null); +}); + +run('lists directories first with natural ordering and excludes invalid names', () => { + const entries = breadcrumbDirectoryEntries([ + { name: 'file10.txt', kind: 'file', path: '/outside' }, + { name: 'file2.txt', kind: 'file' }, { name: 'web', kind: 'dir' }, + { name: '..', kind: 'dir' }, { name: '../bad', kind: 'file' }, + { name: 'bad\\path', kind: 'file' }, { name: 'device', kind: 'other' }, + ], 'src'); + assert.deepEqual(entries.map((entry) => entry.path), ['src/web', 'src/file2.txt', 'src/file10.txt']); +}); diff --git a/web/src/lib/goalControl.js b/web/src/lib/goalControl.js index 3688dbb64..40503c34f 100644 --- a/web/src/lib/goalControl.js +++ b/web/src/lib/goalControl.js @@ -152,8 +152,8 @@ export function getGoalStopControlState({ busy = false, stopping = false } = {}) visible: isBusy, action: isBusy ? 'abort' : 'none', disabled: !!stopping, - label: isBusy ? '中断' : '', - title: isBusy ? '中断当前任务' : '', + label: isBusy ? (stopping ? '正在停止…' : '中断') : '', + title: isBusy ? (stopping ? '正在等待当前任务停止' : '中断当前任务') : '', }; } diff --git a/web/src/lib/icons/system.js b/web/src/lib/icons/system.js index fe0fb782d..a2d5c5938 100644 --- a/web/src/lib/icons/system.js +++ b/web/src/lib/icons/system.js @@ -1,5 +1,9 @@ // Original ACECode functional artwork on a 20-unit grid. // Paint and size-aware stroke weight are inherited from the shared renderer. +const pin = [ + ['path', { d: 'M11.8 3.1L16.9 8.2Q17.3 8.6 16.7 8.7L14.4 8.7L12.2 12.6C12.9 14 12.9 15.2 12 16.1Q11.7 16.4 11.3 16L4 8.7Q3.6 8.3 3.9 8C4.8 7.1 6 7.1 7.4 7.8L11.3 5.6V3.3Q11.4 2.7 11.8 3.1ZM7.7 12.3L3.1 16.9' }], +]; + const globe = [ ['circle', { cx: 10, cy: 10, r: 7.1 }], ['ellipse', { cx: 10, cy: 10, rx: 3.2, ry: 7.1 }], @@ -129,9 +133,8 @@ export const systemIcons = { ['circle', { cx: 12.8, cy: 6.8, r: 0.85, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 5.9, cy: 12.1, r: 0.85, fill: 'currentColor', stroke: 'none' }], ], - Pin: [ - ['path', { d: 'M11.8 3.1L16.9 8.2Q17.3 8.6 16.7 8.7L14.4 8.7L12.2 12.6C12.9 14 12.9 15.2 12 16.1Q11.7 16.4 11.3 16L4 8.7Q3.6 8.3 3.9 8C4.8 7.1 6 7.1 7.4 7.8L11.3 5.6V3.3Q11.4 2.7 11.8 3.1ZM7.7 12.3L3.1 16.9' }], - ], + Pin: pin, + PinOff: [...pin, ['path', { d: 'M3 3L17 17' }]], SearchSparkle: [ ['path', { d: 'M10.7 3.4C8.7 2.7 6.6 3.1 5 4.6C2.6 6.9 2.6 10.7 5 13C7.3 15.3 11 15.3 13.3 13C14.9 11.4 15.3 9.2 14.6 7.2M13.3 13.3L17.1 17.1M14.8 2.6C15 4.1 15.5 4.6 17 4.8C15.5 5 15 5.5 14.8 7C14.6 5.5 14.1 5 12.6 4.8C14.1 4.6 14.6 4.1 14.8 2.6Z' }], ], diff --git a/web/src/lib/loops.js b/web/src/lib/loops.js index a469abf26..9143ec23e 100644 --- a/web/src/lib/loops.js +++ b/web/src/lib/loops.js @@ -102,7 +102,7 @@ function localInputMs(value) { } export function validateLoopForm(form) { - if (!String(form?.name || '').trim()) return '请输入循环名称'; + if (!String(form?.name || '').trim()) return '请输入定时任务名称'; if (!String(form?.prompt || '').trim()) return '请输入提示词'; if (!String(form?.modelName || '').trim()) return '请选择模型'; if (form.scheduleKind === 'interval' && Number(form.intervalValue) < 1) return '执行间隔必须大于 0'; @@ -192,10 +192,10 @@ export function loopRunPresentation(run = {}) { completed: '已完成', failed: '失败', missed: '已错过', }; const reasons = { - workspace_busy: '工作空间在该时间已有循环运行,已错过且不会补跑', + workspace_busy: '工作空间在该时间已有定时任务运行,已错过且不会补跑', daemon_offline: 'daemon 未运行,已错过且不会补跑', daemon_interrupted: 'daemon 退出时运行被中断', - model_unavailable: '所选模型已不可用,循环已停用', + model_unavailable: '所选模型已不可用,定时任务已停用', workspace_unavailable: '工作空间不可用', }; // 写边界事后检测:run 结束时主 checkout 里新出现的、落在 worktree 之外的改动。 diff --git a/web/src/lib/loops.test.js b/web/src/lib/loops.test.js index ca56ebdbd..c58782d19 100644 --- a/web/src/lib/loops.test.js +++ b/web/src/lib/loops.test.js @@ -18,7 +18,7 @@ const defaults = defaultLoopForm('model-a', Date.UTC(2026, 6, 13)); assert.equal(defaults.permissionMode, 'yolo'); assert.equal(defaults.useWorktree, false); assert.equal(defaults.scheduleKind, 'period'); -assert.equal(validateLoopForm(defaults), '请输入循环名称'); +assert.equal(validateLoopForm(defaults), '请输入定时任务名称'); const form = { ...defaults, name: 'Review', prompt: 'Review code', workspaceHash: 'h1' }; assert.equal(validateLoopForm(form), ''); diff --git a/web/src/lib/modalPresentationArchitecture.test.js b/web/src/lib/modalPresentationArchitecture.test.js index 0b3013883..8c7d543c2 100644 --- a/web/src/lib/modalPresentationArchitecture.test.js +++ b/web/src/lib/modalPresentationArchitecture.test.js @@ -89,7 +89,7 @@ run('loop delete and desktop context-menu confirmations share Modal', () => { const contextMenu = source('components/DesktopContextMenu.jsx'); assert.match(loopPage, / setDeleteConfirm\(null\)\}/); - assert.match(loopPage, />\s*删除循环\s*\s*删除定时任务\s* setPendingConfirm\(null\)\}/); assert.match(contextMenu, /pendingConfirm\.action\.confirm/); diff --git a/web/src/lib/navigationHistory.js b/web/src/lib/navigationHistory.js index 48aaf5d8e..8c0026b2f 100644 --- a/web/src/lib/navigationHistory.js +++ b/web/src/lib/navigationHistory.js @@ -31,7 +31,9 @@ function transferableRef(ref) { const displayTitle = boundedRefValue(ref, ['displayTitle', 'display_title'], MAX_LABEL_LENGTH); const title = boundedRefValue(ref, ['title'], MAX_LABEL_LENGTH); const workspaceName = boundedRefValue(ref, ['workspaceName', 'workspace_name'], MAX_LABEL_LENGTH); - if (ref.home && ref.composerDraftScope === 'ai-theme') out.composerDraftScope = 'ai-theme'; + if (ref.home && ['ai-theme', 'scheduled-task'].includes(ref.composerDraftScope)) { + out.composerDraftScope = ref.composerDraftScope; + } if (workspaceHash) out.workspaceHash = workspaceHash; if (sessionId) out.sessionId = sessionId; diff --git a/web/src/lib/richComposerContextPaste.js b/web/src/lib/richComposerContextPaste.js index 33d39800c..387ac6c7b 100644 --- a/web/src/lib/richComposerContextPaste.js +++ b/web/src/lib/richComposerContextPaste.js @@ -4,6 +4,7 @@ export const RICH_COMPOSER_ROOT_ATTRIBUTE = 'data-ace-rich-composer'; export const RICH_COMPOSER_CONTEXT_PASTE_ACTIONS = Object.freeze({ CAPTURE_SELECTION: 'capture-selection', INSERT_TEXT: 'insert-text', + READ_CLIPBOARD: 'read-clipboard', }); export function normalizeRichComposerContextSelection(value) { @@ -15,7 +16,17 @@ export function normalizeRichComposerContextSelection(value) { const direction = value?.direction === 'backward' || value?.direction === 'forward' ? value.direction : 'none'; - return { start: safeStart, end: safeEnd, direction }; + const selection = { start: safeStart, end: safeEnd, direction }; + const validPoint = point => Array.isArray(point?.path) && point.path.length > 0 + && point.path.every(index => Number.isInteger(index) && index >= 0) + && Number.isInteger(point.offset) && point.offset >= 0; + if (validPoint(value?.slateRange?.anchor) && validPoint(value?.slateRange?.focus)) { + selection.slateRange = { + anchor: { path: [...value.slateRange.anchor.path], offset: value.slateRange.anchor.offset }, + focus: { path: [...value.slateRange.focus.path], offset: value.slateRange.focus.offset }, + }; + } + return selection; } export function richComposerRootFromTarget(target) { @@ -65,3 +76,14 @@ export function insertRichComposerContextText(target, text, selection, options) }; return dispatchRichComposerContextPasteAction(target, detail, options); } + +export function pasteRichComposerContextClipboard(target, selection, options) { + const detail = { + action: RICH_COMPOSER_CONTEXT_PASTE_ACTIONS.READ_CLIPBOARD, + handled: false, + selection: normalizeRichComposerContextSelection(selection), + result: null, + }; + return dispatchRichComposerContextPasteAction(target, detail, options) + ? Promise.resolve(detail.result) : null; +} diff --git a/web/src/lib/richComposerContextPaste.test.js b/web/src/lib/richComposerContextPaste.test.js index a6a496410..2d426f371 100644 --- a/web/src/lib/richComposerContextPaste.test.js +++ b/web/src/lib/richComposerContextPaste.test.js @@ -3,6 +3,7 @@ import { captureRichComposerContextSelection, insertRichComposerContextText, normalizeRichComposerContextSelection, + pasteRichComposerContextClipboard, RICH_COMPOSER_CONTEXT_PASTE_ACTIONS, RICH_COMPOSER_CONTEXT_PASTE_EVENT, RICH_COMPOSER_ROOT_ATTRIBUTE, @@ -165,3 +166,22 @@ run('child dispatch bubbles once to the owning composer listener', () => { }); assert.equal(rootDispatchCount, 1); }); + +run('context selection retains exact Slate boundaries for zero-length attachments', () => { + const slateRange = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 2], offset: 0 } }; + const normalized = normalizeRichComposerContextSelection({ start: 0, end: 0, slateRange }); + assert.deepEqual(normalized.slateRange, slateRange); + slateRange.anchor.path[1] = 99; + assert.deepEqual(normalized.slateRange.anchor.path, [0, 0]); + assert.equal(normalizeRichComposerContextSelection({ start: 0, end: 0, slateRange: {} }).slateRange, undefined); +}); + +const clipboardHarness = eventHarness(event => { + assert.equal(event.detail.action, RICH_COMPOSER_CONTEXT_PASTE_ACTIONS.READ_CLIPBOARD); + event.detail.handled = true; + event.detail.result = Promise.resolve('received'); +}); +assert.equal(await pasteRichComposerContextClipboard(clipboardHarness.target, null, clipboardHarness), 'received'); +const unhandledClipboard = eventHarness(); +assert.equal(pasteRichComposerContextClipboard(unhandledClipboard.target, null, unhandledClipboard), null); +console.log('[pass] context clipboard action carries asynchronous completion without generic fallback'); diff --git a/web/src/lib/richComposerKeyboard.test.js b/web/src/lib/richComposerKeyboard.test.js index 82a9042d2..1a47a2075 100644 --- a/web/src/lib/richComposerKeyboard.test.js +++ b/web/src/lib/richComposerKeyboard.test.js @@ -70,7 +70,7 @@ function fixture({ kind = 'path', key = 'Backspace', selected = false } = {}) { submitOnEnter: true, compositionStateRef: { current: state }, isComposingKeyEvent: () => state.parentComposing, - ReactEditor: { isComposing: () => state.slateComposing }, + ReactEditor: { isComposing: () => state.slateComposing, toDOMNode: () => ({ dir: state.rtl ? 'rtl' : '' }) }, isDesktopShell: () => state.desktop, onKeyDown: () => { state.parentCalls += 1; }, onSubmit: () => { state.submissions += 1; }, @@ -186,7 +186,7 @@ const signals = { for (const [name, applySignal] of Object.entries(signals)) { run(`IME keyboard protection honors ${name} independently`, () => { - for (const key of ['Backspace', 'Delete', 'Enter', 'ArrowUp', 'ArrowDown']) { + for (const key of ['Backspace', 'Delete', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) { const test = fixture({ key }); applySignal(test); assertImeOwnsKey(test); @@ -366,6 +366,63 @@ run('queue editor lets Enter reach Slate when submitOnEnter is disabled', () => assert.equal(test.event.defaultPrevented, false); }); +for (const kind of ['path', 'command', 'session', 'attachment']) { + for (const reverse of [false, true]) { + run(`plain ${reverse ? 'left' : 'right'} arrow skips one ${kind} without selecting it`, () => { + const test = fixture({ kind, key: reverse ? 'ArrowLeft' : 'ArrowRight' }); + const [, path] = [...Editor.nodes(test.editor, { at: [], match: composerModel.isComposerInlineTag })][0]; + const range = composerSelection.composerTagSelection(test.editor, path); + const before = structuredClone(test.editor.children); + Transforms.select(test.editor, reverse ? range.focus : range.anchor); + test.handleKeyDown(test.event); + const expected = reverse ? range.anchor : range.focus; + assert.deepEqual(test.editor.selection, { anchor: expected, focus: expected }); + assert.equal(test.event.defaultPrevented, true); + assert.equal(composerSelection.composerSelectedTag(test.editor), null); + assert.deepEqual(test.editor.children, before); + assert.equal(test.editor.history.undos.length, 0); + }); + } +} + +run('plain arrow tag crossing follows the Slate paragraph RTL direction', () => { + for (const key of ['ArrowLeft', 'ArrowRight']) { + const test = fixture({ key }); + test.state.rtl = true; + const range = composerSelection.composerTagSelection(test.editor, [0, 1]); + Transforms.select(test.editor, key === 'ArrowLeft' ? range.anchor : range.focus); + test.handleKeyDown(test.event); + const expected = key === 'ArrowLeft' ? range.focus : range.anchor; + assert.deepEqual(test.editor.selection, { anchor: expected, focus: expected }); + assert.equal(test.event.defaultPrevented, true); + } +}); + +run('modified arrows and expanded selections stay with existing keyboard handlers', () => { + for (const modifier of ['shiftKey', 'ctrlKey', 'altKey', 'metaKey', 'expanded']) { + const test = fixture({ key: 'ArrowRight' }); + const range = composerSelection.composerTagSelection(test.editor, [0, 1]); + if (modifier === 'expanded') Transforms.select(test.editor, range); + else { Transforms.select(test.editor, range.anchor); test.event[modifier] = true; } + const before = structuredClone(test.editor.selection); + test.handleKeyDown(test.event); + assert.deepEqual(test.editor.selection, before); + assert.equal(test.event.defaultPrevented, false); + } +}); + +run('ordinary text and a parent-consumed arrow are not intercepted', () => { + for (const parentConsumed of [false, true]) { + const test = fixture({ key: 'ArrowLeft' }); + test.context.onKeyDown = event => { if (parentConsumed) event.preventDefault(); }; + Transforms.select(test.editor, Editor.end(test.editor, [])); + const before = structuredClone(test.editor.selection); + test.handleKeyDown(test.event); + assert.deepEqual(test.editor.selection, before); + assert.equal(test.event.defaultPrevented, parentConsumed); + } +}); + run('repeated attachment references paste twice and Backspace removes only the selected occurrence', () => { const test = orderedFixture(); const one = { version: 1, parts: [test.content.parts.find((part) => part.type === 'attachment')] }; diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 671779597..41556c201 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -1,4 +1,5 @@ import './questionPicker.test.js'; +import './turnCancellation.test.js'; import './modelReasoningSync.test.js'; import './homeComposerDraftStore.test.js'; import './homeComposerDraftLifecycle.test.js'; @@ -31,6 +32,7 @@ import './themeSurfaces.test.js'; import './themeExports.test.js'; import './themeManagementUi.test.js'; import './aiThemeCreation.test.js'; +import './scheduledTaskCreation.test.js'; import './desktopWindowBackground.test.js'; import './desktopTaskbarBadge.test.js'; import './topBarWindowDrag.test.js'; @@ -62,6 +64,7 @@ import './sessionWorkbench.test.js'; import './fileLink.test.js'; import './threadLinkArchitecture.test.js'; import './fileTreeLocate.test.js'; +import './fileBreadcrumbs.test.js'; import './format.test.js'; import './inputHistoryNavigation.test.js'; import './inputBarState.test.js'; @@ -306,6 +309,7 @@ import './todoChecklistArchitecture.test.js'; import './transcriptProjection.test.js'; import './trajectoryModel.test.js'; import './transcriptWindow.test.js'; +import './transcriptHistoryController.test.js'; import './homeWorkspaceSelection.test.js'; import './homeComposerDrafts.test.js'; import './projectCreation.test.js'; diff --git a/web/src/lib/scheduledTaskCreation.js b/web/src/lib/scheduledTaskCreation.js new file mode 100644 index 000000000..10eda0d53 --- /dev/null +++ b/web/src/lib/scheduledTaskCreation.js @@ -0,0 +1,9 @@ +import { homeRefFromWorkspace } from './homeWorkspaceSelection.js'; + +export function scheduledTaskCreationRef(current, health) { + return { + ...homeRefFromWorkspace(current, current, health), + composerDraftScope: 'scheduled-task', + initialDraftText: '/scheduled-task 我希望在明天X点提醒我参加会议,重复X天', + }; +} diff --git a/web/src/lib/scheduledTaskCreation.test.js b/web/src/lib/scheduledTaskCreation.test.js new file mode 100644 index 000000000..a04c17066 --- /dev/null +++ b/web/src/lib/scheduledTaskCreation.test.js @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { scheduledTaskCreationRef } from './scheduledTaskCreation.js'; +import { homeComposerScopedWorkspace } from './aiThemeCreation.js'; +import { homeComposerDraftText, updateHomeComposerDrafts } from './homeComposerDrafts.js'; +import { composerDocumentFromText, composerTextFromDocument, COMPOSER_COMMAND_TAG } from './richComposerModel.js'; +import { deserializeNavigationHistory, serializeNavigationHistory } from './navigationHistory.js'; + +const current = { workspaceHash: 'project-a', sessionId: 'existing', loop: true }; +const next = scheduledTaskCreationRef(current, {}); +assert.equal(next.sessionId, undefined); +assert.equal(next.loop, undefined); +assert.equal(next.home, true); +assert.equal(next.initialDraftText, '/scheduled-task 我希望在明天X点提醒我参加会议,重复X天'); +assert.equal(deserializeNavigationHistory(serializeNavigationHistory({ back: [next] })).back[0].composerDraftScope, 'scheduled-task'); +const key = homeComposerScopedWorkspace(next.workspaceHash, next.composerDraftScope); +assert.notEqual(key, next.workspaceHash); +let drafts = updateHomeComposerDrafts({}, next.workspaceHash, 'ordinary draft'); +drafts = updateHomeComposerDrafts(drafts, key, next.initialDraftText); +assert.equal(homeComposerDraftText(drafts, next.workspaceHash), 'ordinary draft'); +assert.equal(homeComposerDraftText(drafts, key), next.initialDraftText); +const command = { name: 'scheduled-task', type: 'skill', source: 'skill', description: 'Create scheduled tasks' }; +const document = composerDocumentFromText(next.initialDraftText, [command]); +assert.equal(composerTextFromDocument(document), next.initialDraftText); +assert.ok(document.some((paragraph) => paragraph.children.some((node) => node.type === COMPOSER_COMMAND_TAG))); +console.log('scheduledTaskCreation tests passed'); diff --git a/web/src/lib/sessionTranscript.js b/web/src/lib/sessionTranscript.js index 6bde86c07..85d15db08 100644 --- a/web/src/lib/sessionTranscript.js +++ b/web/src/lib/sessionTranscript.js @@ -293,7 +293,7 @@ export function isTranscriptActivelyRunning(state = {}) { function markTranscriptRunning(next, turnId = '') { if (!next || next.status === 'error') return next; next.busy = true; - next.status = 'running'; + next.status = next.abortPending ? 'stopping' : 'running'; if (turnId && !next.activeTurnId) next.activeTurnId = String(turnId); return next; } @@ -405,9 +405,44 @@ function isUserAbortMessage(message) { } function appendTerminationNotice(next, msg, payload = {}) { - const text = terminationNoticeText(payload); + const pending = payload.pending === true; + const text = pending ? '正在停止本轮任务…' : terminationNoticeText(payload); + const turnId = String(payload.metadata?.turn_id || payload.turn_id || next.activeTurnId || next.abortTurnId || ''); + let match = -1; + for (let i = next.items.length - 1; i >= 0; i -= 1) { + const item = next.items[i]; + if (item.kind === 'termination_notice' && item.source === 'user' && payload.source !== 'user' && + isAbortLikeReason(payload.reason || payload.message) && + (!turnId || item.turnId === turnId)) return; + if (payload.id && item.kind === 'termination_notice' && item.messageId === payload.id) { + match = i; + break; + } + if (item.kind === 'termination_notice' && item.source === 'user' && payload.source === 'user' && + turnId && item.turnId === turnId) { + match = i; + break; + } + // Old transcripts have no turn id. Only merge inside the same user turn. + if (item.kind === 'msg' && item.role === 'user' && !turnId) break; + if (!turnId && item.kind === 'termination_notice' && item.source === 'user' && + payload.source === 'user' && !item.messageId) { + match = i; + break; + } + } + if (match >= 0) { + const item = next.items[match]; + if (pending && !item.pending) return; + next.items = next.items.map((entry, i) => i === match ? { + ...entry, content: text, pending, turnId: turnId || entry.turnId, + ...(payload.id ? { messageId: payload.id } : {}), + ...(payload.metadata ? { metadata: payload.metadata } : {}), + } : entry); + return; + } const last = next.items[next.items.length - 1]; - if (last?.kind === 'termination_notice') { + if (last?.kind === 'termination_notice' && (!turnId || !last.turnId || last.turnId === turnId)) { if (last.content === text) { if (payload.metadata?.user_aborted === true) { next.items = [...next.items.slice(0, -1), { @@ -426,6 +461,8 @@ function appendTerminationNotice(next, msg, payload = {}) { kind: 'termination_notice', id: allocateItemId(next), source: payload.source || 'server', + pending, + turnId, ...(payload.metadata ? { metadata: payload.metadata, messageId: payload.id || '' } : {}), content: text, ts: eventTs(msg), @@ -433,6 +470,26 @@ function appendTerminationNotice(next, msg, payload = {}) { ]; } +function settlePendingAbort(next, msg, outcome) { + if (!next.abortPending) return; + if (!outcome || outcome === 'aborted') { + appendTerminationNotice(next, msg, { source: 'user', turn_id: next.abortTurnId }); + } else { + next.items = next.items.filter((item) => !item.pending || item.kind !== 'termination_notice'); + } + next.abortPending = false; + next.abortTurnId = ''; +} + +function preserveAcceptedInputs(next, current) { + for (const item of current.items || []) { + const clientId = clientMessageIdFromMetadata(item.metadata); + if (item.kind !== 'msg' || item.role !== 'user' || !item.metadata?.optimistic_queued_input || !clientId) continue; + if (next.items.some((loaded) => clientMessageIdFromMetadata(loaded.metadata) === clientId)) continue; + next.items.push({ ...item, id: allocateItemId(next) }); + } +} + function normalizeSummaryMetrics(metrics) { if (!Array.isArray(metrics)) return []; return metrics @@ -738,6 +795,7 @@ function historyItemFromMessage(next, m, messageOrdinal = null) { return { kind: 'termination_notice', id: allocateItemId(next), source: 'user', content: terminationNoticeText({ source: 'user' }), + turnId: String(metadata?.turn_id || ''), metadata, messageId: m.id || '', ts, }; } @@ -1115,7 +1173,9 @@ export function preserveLiveRuntimeOnLoad(loadedState, liveState) { if (!loadedState) return loadedState; const liveSeq = Number(liveState?.lastSeq) || 0; const loadedSeq = Number(loadedState?.lastSeq) || 0; - if (liveSeq <= loadedSeq) return loadedState; + const samePendingTurn = liveState?.abortPending && loadedState.busy && + (!loadedState.activeTurnId || loadedState.activeTurnId === liveState.abortTurnId); + if (liveSeq <= loadedSeq && !samePendingTurn) return loadedState; const liveIsRunning = isTranscriptActivelyRunning(liveState); if (!liveIsRunning) return loadedState; @@ -1127,7 +1187,9 @@ export function preserveLiveRuntimeOnLoad(loadedState, liveState) { ...loadedState, busy: true, activeTurnId: String(liveState.activeTurnId || loadedState.activeTurnId || ''), - status: 'running', + status: liveState.abortPending ? 'stopping' : 'running', + abortPending: !!liveState.abortPending, + abortTurnId: liveState.abortTurnId || '', activity: liveState.activity && typeof liveState.activity === 'object' ? { ...liveState.activity } : (loadedState.activity && typeof loadedState.activity === 'object' @@ -1213,6 +1275,8 @@ export function createTranscriptState(overrides = {}) { items: [], busy: false, abortPending: false, + abortTurnId: '', + lastTerminalTurnId: '', activeTurnId: '', turns: 0, // 会话显示标题的三个服务端字段(与侧栏会话列表同源):title 是用户改名 / @@ -1286,6 +1350,13 @@ export function reduceTranscriptEvent(state, msg) { markEventSeqApplied(next, msg); + const terminalTurnId = String(p.turn_id || ''); + if ((t === 'done' || (t === 'busy_changed' && !p.busy)) && terminalTurnId && + (terminalTurnId === next.lastTerminalTurnId || + (next.activeTurnId && terminalTurnId !== next.activeTurnId))) { + return { state: next, effects }; + } + switch (t) { case 'transcript_replace': { finalizeStreaming(next); @@ -1296,6 +1367,7 @@ export function reduceTranscriptEvent(state, msg) { next.turnNetDiffs = turnNetDiffs; next.items = applyTurnMetadataToItems( historyItemsFromMessages(next, messages), turnTimings, turnNetDiffs); + preserveAcceptedInputs(next, current); // 标题不从消息正文现推:title / summary 只认服务端(session_updated 与 // messages 快照),否则顶部标题会变成最后一条 user 消息的全文,与侧栏不一致。 next.tokenUsage = null; @@ -1712,12 +1784,17 @@ export function reduceTranscriptEvent(state, msg) { } case 'busy_changed': { const wasBusy = !!state?.busy; - const outcome = typeof p.outcome === 'string' ? p.outcome : ''; + const closingTurnId = terminalTurnId || next.activeTurnId || next.abortTurnId; + const outcome = (typeof p.outcome === 'string' ? p.outcome : '') || + (!p.busy && next.abortPending ? 'aborted' : ''); const completedOutcome = !outcome || outcome === 'completed'; next.busy = !!p.busy; - next.abortPending = false; - next.activeTurnId = next.busy ? String(p.turn_id || '') : ''; - next.status = next.busy ? 'running' : 'idle'; + if (!next.busy) settlePendingAbort(next, msg, outcome); + else if (!wasBusy || (p.turn_id && next.abortTurnId && p.turn_id !== next.abortTurnId)) { + settlePendingAbort(next, msg, 'completed'); + } + next.activeTurnId = next.busy ? String(p.turn_id || next.activeTurnId || '') : ''; + next.status = next.busy ? (next.abortPending ? 'stopping' : 'running') : 'idle'; if (next.busy && !wasBusy) { // 回合开始 → 重置桌面通知用的回合标记 next.turnHadAssistantText = false; @@ -1726,6 +1803,7 @@ export function reduceTranscriptEvent(state, msg) { next.lastTurnOutcome = ''; } if (!next.busy) { + next.lastTerminalTurnId = closingTurnId || next.lastTerminalTurnId; next.activity = null; next.trajectoryPartial = null; finalizeStreaming(next); @@ -1750,10 +1828,13 @@ export function reduceTranscriptEvent(state, msg) { } case 'done': { const wasBusy = !!state?.busy; - const outcome = typeof p.outcome === 'string' ? p.outcome : ''; + const closingTurnId = terminalTurnId || next.activeTurnId || next.abortTurnId; + const outcome = (typeof p.outcome === 'string' ? p.outcome : '') || + (next.abortPending ? 'aborted' : ''); const completedOutcome = !outcome || outcome === 'completed'; next.busy = false; - next.abortPending = false; + settlePendingAbort(next, msg, outcome); + next.lastTerminalTurnId = closingTurnId || next.lastTerminalTurnId; next.activeTurnId = ''; next.status = 'idle'; next.activity = null; @@ -1771,6 +1852,7 @@ export function reduceTranscriptEvent(state, msg) { break; } case 'error': + settlePendingAbort(next, msg, isAbortLikeReason(p.reason) ? 'aborted' : 'error'); next.busy = false; next.abortPending = false; next.activeTurnId = ''; @@ -1785,6 +1867,20 @@ export function reduceTranscriptEvent(state, msg) { appendTerminationNotice(next, msg, { ...p, source: p.source || 'server' }); effects.push({ type: 'error', payload: p }); break; + case 'turn_abort_requested': + if (!next.busy || next.abortPending) break; + next.abortPending = true; + next.abortTurnId = String(p.turn_id || next.activeTurnId || ''); + next.status = 'stopping'; + appendTerminationNotice(next, msg, { ...p, pending: true, source: 'user' }); + break; + case 'turn_abort_failed': + if (!next.abortPending) break; + next.items = next.items.filter((item) => item.kind !== 'termination_notice' || !item.pending); + next.abortPending = false; + next.abortTurnId = ''; + next.status = next.busy ? 'running' : next.status; + break; case 'turn_aborted': next.busy = false; next.abortPending = true; @@ -1947,6 +2043,7 @@ export function loadTranscriptHistory(state, data = {}) { next.activeTurnId = String(data.active_turn_id || data.activeTurnId || ''); next.status = 'running'; } else if (data.busy === false && next.status !== 'error') { + settlePendingAbort(next, {}, data.last_turn_outcome || 'aborted'); next.busy = false; next.activeTurnId = ''; next.status = 'idle'; @@ -1955,6 +2052,20 @@ export function loadTranscriptHistory(state, data = {}) { finalizeStreaming(next); } + // Local acceptance is not part of the server event ring yet. A concurrent + // history refresh must not erase input already acknowledged over HTTP. + preserveAcceptedInputs(next, current); + if (current.abortPending && next.busy && + (!next.activeTurnId || next.activeTurnId === current.abortTurnId)) { + next.abortPending = true; + next.abortTurnId = current.abortTurnId; + next.status = 'stopping'; + const notice = current.items.find((item) => item.kind === 'termination_notice' && item.pending); + if (notice && !next.items.some((item) => item.kind === 'termination_notice' && item.turnId === notice.turnId)) { + next.items.push({ ...notice, id: allocateItemId(next) }); + } + } + return { state: next, effects }; } @@ -2069,14 +2180,23 @@ export function useSessionTranscript(sessionRef, options = {}) { return data; }, [api, sid, store]); - const loadEarlier = useCallback(async (target = null) => { - if (pageRequestRef.current) return pageRequestRef.current; + const loadEarlier = useCallback(async (target = null, options = {}) => { + const result = (value) => options.detailed ? value : value.position ?? null; const scope = historyScopeRef.current; + if (pageRequestRef.current) { + const pending = await pageRequestRef.current; + if (historyScopeRef.current !== scope) return result({ status: 'stale' }); + // Search must still resolve its own target after an ordinary page finishes. + if (target) return loadEarlier(target, options); + return result(pending); + } const previous = store.getState(); - if (previous.loadState !== 'loaded') return null; + if (previous.loadState !== 'loaded') return result({ status: 'idle' }); const position = target?.messagePosition; - if (position != null && previous.items.some((item) => item.messagePosition === String(position))) return String(position); - if (!target && !previous.historyHasMore) return null; + if (position != null && previous.items.some((item) => item.messagePosition === String(position))) { + return result({ status: 'loaded', position: String(position) }); + } + if (!target && !previous.historyHasMore) return result({ status: 'idle' }); const query = { limit: HISTORY_PAGE_SIZE }; if (previous.historyBefore && target?.messageOrdinal == null) query.before = previous.historyBefore; if (target?.messagePosition != null) query.from_position = String(target.messagePosition); @@ -2084,7 +2204,7 @@ export function useSessionTranscript(sessionRef, options = {}) { const pending = (async () => { try { const data = await api.getMessages(sid, query); - if (historyScopeRef.current !== scope) return null; + if (historyScopeRef.current !== scope) return { status: 'stale' }; // A legacy ordinal may resolve inside the already loaded tail. In that // case its response is used only to resolve the position, without overlap. store.commit((current) => { @@ -2094,16 +2214,24 @@ export function useSessionTranscript(sessionRef, options = {}) { const rows = (data.messages || []).filter((m) => !existing.has(String(m.message_position))); return mergeTranscriptHistoryPage(current, { ...data, messages: rows }, 'before'); }); - return data.messages?.[0]?.message_position ?? null; + return { status: 'loaded', position: data.messages?.[0]?.message_position ?? null }; } catch (error) { - if (historyScopeRef.current !== scope) return null; - if (error?.status === 409) { await reloadHistoryTail(); return null; } - optionsRef.current.onError?.('加载会话失败:' + (error?.message || '')); - return null; + if (historyScopeRef.current !== scope) return { status: 'stale' }; + if (error?.status === 409) { + try { + await reloadHistoryTail(); + return { status: historyScopeRef.current === scope ? 'reset' : 'stale' }; + } catch (reloadError) { + error = reloadError; + } + } + if (historyScopeRef.current !== scope) return { status: 'stale' }; + if (!options.silent) optionsRef.current.onError?.('加载会话失败:' + (error?.message || '')); + return { status: 'error' }; } })(); pageRequestRef.current = pending; - try { return await pending; } + try { return result(await pending); } finally { if (pageRequestRef.current === pending) pageRequestRef.current = null; } }, [api, sid, store, reloadHistoryTail]); diff --git a/web/src/lib/sessionTranscriptRecovery.test.js b/web/src/lib/sessionTranscriptRecovery.test.js index 4b77dfc69..44d561bf1 100644 --- a/web/src/lib/sessionTranscriptRecovery.test.js +++ b/web/src/lib/sessionTranscriptRecovery.test.js @@ -153,3 +153,42 @@ await staleOlder; assert.equal(paged.getState().items[0].content, 'final'); pageHarness.unmount(); console.log('[pass] paged history preserves anchors, polls incrementally and reloads stale cursors'); + +const paging = harness(); +const pagingRef = { sessionId: 'details', resumePending: true }; +const errors = []; +const pagingOptions = { live: false, onError: (message) => errors.push(message) }; +paging.render(pagingRef, pagingOptions); +paging.requests[0].resolve({ ...history, has_more: true, before: 'first' }); +await flush(); +let details = paging.render(pagingRef, pagingOptions); +const detailOptions = { detailed: true, silent: true }; +const failedPage = details.loadEarlier(null, detailOptions); +const duplicatePage = details.loadEarlier(null, detailOptions); +assert.equal(paging.requests.length, 2, 'concurrent page requests share one HTTP call'); +paging.requests[1].reject(new Error('offline')); +assert.equal((await failedPage).status, 'error'); +assert.equal((await duplicatePage).status, 'error'); +assert.equal(errors.length, 0, 'inline paging errors do not emit a global toast'); +const resetPage = details.loadEarlier(null, detailOptions); +paging.requests[2].reject({ status: 409 }); +await flush(); +paging.requests[3].resolve({ ...history, has_more: true, before: 'rewritten' }); +assert.equal((await resetPage).status, 'reset'); +const stalePage = details.loadEarlier(null, detailOptions); +paging.render({ sessionId: 'next', resumePending: true }, pagingOptions); +paging.requests[4].resolve({ ...history, has_more: false }); +assert.equal((await stalePage).status, 'stale'); +paging.requests[5].resolve({ ...history, has_more: true, before: 'next' }); +await flush(); +details = paging.render({ sessionId: 'next', resumePending: true }, pagingOptions); +const ordinaryPage = details.loadEarlier(null, detailOptions); +const searchPage = details.loadEarlier({ messagePosition: '42' }); +paging.requests[6].resolve({ messages: [], has_more: true, before: 'older' }); +await ordinaryPage; +await flush(); +assert.equal(paging.requests[7].since.from_position, '42', 'search is not satisfied by an unrelated in-flight page'); +paging.requests[7].resolve({ messages: [{ role: 'user', content: 'target', message_position: '42' }] }); +assert.equal(await searchPage, '42', 'legacy positional result remains compatible'); +paging.unmount(); +console.log('[pass] detailed paging isolates inline failures, resets, stale responses and concurrent search'); diff --git a/web/src/lib/slateComposerArchitecture.test.js b/web/src/lib/slateComposerArchitecture.test.js index 3ff4b8d5f..75cb6e2f8 100644 --- a/web/src/lib/slateComposerArchitecture.test.js +++ b/web/src/lib/slateComposerArchitecture.test.js @@ -44,7 +44,7 @@ run('command, skill, path, session, and attachment tags use Slate inline void el assert.match(composer, /types\.includes\('application\/x-slate-fragment'\)/); }); -run('composer command tag reuses the sent-message badge without a visible slash', () => { +run('composer command tag reuses the sent-message markup without a visible slash', () => { const composer = source('components/RichComposer.jsx'); const message = source('components/Message.jsx'); const styles = source('styles/globals.css'); @@ -59,7 +59,21 @@ run('composer command tag reuses the sent-message badge without a visible slash' assert.doesNotMatch(styles, /\.ace-rich-command-token\s*\{/); }); -run('path tags keep canonical text while using the compact badge surface', () => { +run('composer tags use unboxed accent text and native selection colors', () => { + const styles = source('styles/globals.css'); + assert.match(styles, /--ace-composer-selection-bg:\s*Highlight;/); + assert.match(styles, /--ace-composer-selection-fg:\s*HighlightText;/); + assert.match(styles, /\.ace-slate-inline-tag \*\s*\{[^}]*transition:\s*none;/); + assert.match(styles, /\.ace-slate-inline-tag > \.ace-cmd-token\s*\{[^}]*align-items:\s*baseline;[^}]*margin:\s*0;[^}]*padding:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*0;[^}]*background:\s*transparent;[^}]*font:\s*inherit;[^}]*font-weight:\s*600;/); + assert.match(styles, /\.ace-slate-inline-tag \.ace-cmd-token-name\s*\{[^}]*font:\s*inherit;/); + assert.match(styles, /\.ace-slate-inline-tag \.ace-cmd-token-glyph\s*\{[^}]*align-self:\s*center;/); + assert.match(styles, /\.ace-slate-inline-tag \.ace-cmd-token-name\s*\{[^}]*color:\s*inherit;/); + assert.match(styles, /\.ace-slate-inline-tag \.ace-cmd-token-glyph\s*\{[^}]*color:\s*inherit !important;/); + assert.match(styles, /\.ace-slate-inline-tag\[data-composer-selected="true"\] > \.ace-cmd-token\s*\{[^}]*color:\s*var\(--ace-composer-selection-fg\);[^}]*opacity:\s*1;/); + assert.match(styles, /\.ace-slate-inline-tag\[data-composer-selected="true"\]::before\s*\{[^}]*background:\s*var\(--ace-composer-selection-bg\);/); +}); + +run('path tags keep canonical text while using inline tag markup', () => { const composer = source('components/RichComposer.jsx'); assert.match(composer, /data-composer-inline-tag="path"/); assert.match(composer, /className="ace-slate-inline-tag ace-slate-path-tag"/); @@ -67,7 +81,7 @@ run('path tags keep canonical text while using the compact badge surface', () => assert.match(composer, / { +run('session tags keep stable identity while reusing inline tag markup', () => { const composer = source('components/RichComposer.jsx'); assert.match(composer, /data-composer-inline-tag="session"/); assert.match(composer, /className="ace-slate-inline-tag ace-slate-session-tag"/); @@ -248,6 +262,7 @@ run('desktop context-menu paste captures Slate selection and bridges before DOM assert.match(menu, /captureRichComposerContextSelection\(editableTarget\)/); assert.match(menu, /openWithSwitchGap\(\{[\s\S]*richComposerSelection,[\s\S]*\}\);/); assert.match(menu, /pasteIntoTarget\(target, rememberedRichComposerSelection\)/); + assert.match(menu, /pasteRichComposerContextClipboard\(editable, richComposerSelection\)/); assert.match(menu, /editableTargetFromElement\(target\)/); assert.doesNotMatch(menu, /\.isContentEditable/); assert.ok(insertBody.indexOf('insertRichComposerContextText') >= 0); @@ -264,6 +279,8 @@ run('rich context paste mutates Slate state while send gating reads the controll assert.match(composer, /addEventListener\(RICH_COMPOSER_CONTEXT_PASTE_EVENT, handleContextPasteAction\)/); assert.match(composer, /CAPTURE_SELECTION[\s\S]*capturePasteSelection\(\)/); + assert.match(composer, /READ_CLIPBOARD[\s\S]*contextClipboardPasteRef\.current\?\.\(detail\.selection\)/); + assert.match(composer, /const transfer = beginFileTransfer\(captured\);[\s\S]*await readComposerClipboardData\(\);[\s\S]*if \(!transfer\.isActive\(\)\) return;[\s\S]*await handleClipboardPaste/); assert.match(composer, /INSERT_TEXT[\s\S]*applyPlainTextPaste\(detail\.text, detail\.selection\)/); assert.match(composer, /const applyPlainTextPaste = useCallback\([\s\S]*ensureLegalEditorDocument\(editor\)[\s\S]*Transforms\.select[\s\S]*insertPlainText\(editor, normalizedText\)/); assert.doesNotMatch(composer, /execCommand/); diff --git a/web/src/lib/subagentTasks.js b/web/src/lib/subagentTasks.js index 1706cf6a3..c969e452d 100644 --- a/web/src/lib/subagentTasks.js +++ b/web/src/lib/subagentTasks.js @@ -66,6 +66,10 @@ export function mergeSubagentTaskList(prevTasks, sessions) { if (prev) { task.toolCount = prev.toolCount; task.lastTool = prev.lastTool; + task.abortPending = !!prev.abortPending && task.status === SUBAGENT_TASK_STATUS.RUNNING; + if (prev.abortPending && task.status !== SUBAGENT_TASK_STATUS.RUNNING) { + task.status = SUBAGENT_TASK_STATUS.ABORTED; + } if (prev.status === SUBAGENT_TASK_STATUS.ABORTED && task.status !== SUBAGENT_TASK_STATUS.RUNNING) { task.status = SUBAGENT_TASK_STATUS.ABORTED; @@ -120,12 +124,16 @@ export function applySubagentSessionEvent(tasks, msg) { const p = msg?.payload || {}; let patch = null; - if (type === 'busy_changed' || type === 'session_status') { + if (type === 'busy_changed' || type === 'session_status' || type === 'done') { const busy = p.busy === true; if (busy && task.status !== SUBAGENT_TASK_STATUS.RUNNING) { patch = { status: SUBAGENT_TASK_STATUS.RUNNING }; } else if (!busy && task.status === SUBAGENT_TASK_STATUS.RUNNING) { - patch = { status: SUBAGENT_TASK_STATUS.COMPLETED, updatedAtMs: Date.now() }; + patch = { + status: p.outcome === 'aborted' || (task.abortPending && !p.outcome) + ? SUBAGENT_TASK_STATUS.ABORTED : SUBAGENT_TASK_STATUS.COMPLETED, + abortPending: false, updatedAtMs: Date.now(), + }; } } else if (type === 'usage') { const total = Number(p.total_tokens ?? p.totalTokens); @@ -151,14 +159,14 @@ export function applySubagentSessionEvent(tasks, msg) { return next; } -// 本地中止标记:sendAbort 之后立即把运行中任务显示为「已中止」,不等 -// busy_changed 往返(之后 busy_changed(false) 到达时 status 已非 RUNNING, -// applySubagentSessionEvent 不会覆盖回 completed)。 +// Keep the subscription and running group until authoritative completion. +// This legacy export name denotes the user's request, not a completed stop. export function markSubagentTaskAborted(tasks, id) { const index = (tasks || []).findIndex((t) => t.id === id); if (index < 0) return tasks; const next = tasks.slice(); - next[index] = { ...next[index], status: SUBAGENT_TASK_STATUS.ABORTED, updatedAtMs: Date.now() }; + if (next[index].status !== SUBAGENT_TASK_STATUS.RUNNING) return tasks; + next[index] = { ...next[index], abortPending: true }; return next; } diff --git a/web/src/lib/subagentTasks.test.js b/web/src/lib/subagentTasks.test.js index 7c735c799..3e6299833 100644 --- a/web/src/lib/subagentTasks.test.js +++ b/web/src/lib/subagentTasks.test.js @@ -283,4 +283,16 @@ run('taskDisplayTitle 标题 → 摘要 → id 逐级退化', () => { assert.equal(taskDisplayTitle({ title: '[Error] Failed to parse response JSON', summary: 'S', id: 'i' }), 'S'); }); +run('停止请求保留运行订阅直到确认,迟到 busy 不会重置请求', () => { + let tasks = mergeSubagentTaskList([], [makeSession({ busy: true })]); + tasks = markSubagentTaskAborted(tasks, 'child-1'); + assert.equal(tasks[0].status, SUBAGENT_TASK_STATUS.RUNNING); + assert.equal(tasks[0].abortPending, true); + tasks = applySubagentSessionEvent(tasks, { type: 'busy_changed', session_id: 'child-1', payload: { busy: true } }); + assert.equal(tasks[0].abortPending, true); + tasks = applySubagentSessionEvent(tasks, { type: 'done', session_id: 'child-1', payload: { outcome: 'aborted' } }); + assert.equal(tasks[0].status, SUBAGENT_TASK_STATUS.ABORTED); + assert.equal(tasks[0].abortPending, false); +}); + console.log('subagentTasks.test.js: all tests passed'); diff --git a/web/src/lib/transcriptHistoryController.js b/web/src/lib/transcriptHistoryController.js new file mode 100644 index 000000000..d360d211a --- /dev/null +++ b/web/src/lib/transcriptHistoryController.js @@ -0,0 +1,181 @@ +// Owns one history-reading operation. ChatView remains the owner of input and +// resize events; this controller never installs a second scroll listener. +export function captureTranscriptReadingAnchor(container) { + if (!container) return null; + const bounds = container.getBoundingClientRect(); + const element = Array.from(container.querySelectorAll('[data-chat-row="true"]')) + .find((row) => { + const rect = row.getBoundingClientRect(); + return rect.height > 0 && rect.bottom > bounds.top && rect.top < bounds.bottom; + }); + return element ? { element, top: element.getBoundingClientRect().top - bounds.top } : null; +} + +export function createTranscriptHistoryController(options) { + let active = true; + let generation = 0; + let pending = null; + let phase = 'idle'; + let anchor = null; + let armed = false; + let scrollGesture = false; + let previousTop = null; + let programmedTop = null; + let touchY = null; + + const publish = (next) => { phase = next; options.onPhase(next); }; + const cancelAnchor = () => { anchor = null; armed = false; scrollGesture = false; programmedTop = null; }; + const atTop = () => { + const el = options.getViewport(); + if (!el || el.clientHeight <= 0) return false; + const height = options.getBoundary()?.getBoundingClientRect().height || 0; + return el.scrollTop <= Math.max(2, Math.min(height, el.clientHeight * 0.1)); + }; + const preserveAnchor = () => { + const el = options.getViewport(); + if (!active || !anchor || !el) return false; + if (!el.contains(anchor.element)) { anchor = null; return false; } + const delta = anchor.element.getBoundingClientRect().top - el.getBoundingClientRect().top - anchor.top; + if (Math.abs(delta) > 0.25) { + el.scrollTop += delta; + programmedTop = el.scrollTop; + } + previousTop = el.scrollTop; + return true; + }; + const run = ({ all = false, compensate = true } = {}) => { + if (!active) return Promise.resolve(); + if (pending) return pending; + const snapshot = options.getSnapshot(); + if (snapshot.loadState !== 'loaded') return Promise.resolve(); + if (!snapshot.hiddenCount && !snapshot.historyHasMore) { + if (phase !== 'idle') publish('idle'); + return Promise.resolve(); + } + const epoch = generation; + const current = () => active && generation === epoch; + armed = false; + if (compensate) { + options.onReview(); + anchor = captureTranscriptReadingAnchor(options.getViewport()); + } else cancelAnchor(); + publish('loading'); + const task = (async () => { + // Let the inline state paint, including when the batch is already local. + await options.afterPaint(); + if (!current()) return; + let outcome = 'idle'; + try { + if (!all && options.getSnapshot().hiddenCount > 0) { + options.revealLocal(); + } else { + while (current() && options.getSnapshot().historyHasMore) { + const before = options.getSnapshot().historyBefore; + const result = await options.loadPage(); + if (!current()) return; + if (result.status === 'stale') { cancelAnchor(); outcome = 'reset'; break; } + if (result.status === 'reset') { cancelAnchor(); outcome = 'reset'; break; } + if (result.status === 'error') { outcome = 'error'; break; } + const next = options.getSnapshot(); + if (next.historyHasMore && next.historyBefore === before) { outcome = 'error'; break; } + if (!all) break; + } + if (current() && outcome === 'idle') options.showAll(); + } + await options.afterPaint(); + if (!current()) return; + preserveAnchor(); + publish(outcome); + } catch { + if (current()) publish('error'); + } finally { + if (current()) { + // A gesture received during loading cannot queue another page. + armed = false; + scrollGesture = false; + pending = null; + } + } + })(); + pending = task; + return task; + }; + const maybeLoad = () => { + if (active && armed && !pending && phase === 'idle' && atTop()) { + armed = false; + void run(); + } + }; + const gesture = (upward) => { + if (!active) return; + if (pending && anchor) anchor = captureTranscriptReadingAnchor(options.getViewport()); + else anchor = null; + programmedTop = null; + scrollGesture = true; + armed = upward && !pending; + if (upward) maybeLoad(); + }; + return { + reveal: () => run(), + async expand({ loadAll = false, compensateScroll = false } = {}) { + if (pending) await pending; + if (!active) return; + if (loadAll) return run({ all: true, compensate: compensateScroll }); + cancelAnchor(); + options.showAll(); + }, + cancelAnchor, + preserveAnchor, + hasAnchor: () => active && !!anchor, + onScroll({ pointerActive = false } = {}) { + const el = options.getViewport(); + if (!active || !el) return; + const top = el.scrollTop; + if (programmedTop !== null && Math.abs(top - programmedTop) <= 2) { + programmedTop = null; + } else if (scrollGesture || pointerActive || touchY !== null) { + if (pending && anchor) anchor = captureTranscriptReadingAnchor(el); + else anchor = null; + if (!pending && pointerActive && previousTop !== null && top < previousTop) armed = true; + maybeLoad(); + } + previousTop = el.scrollTop; + }, + onWheel(event) { + if (!event.deltaY) return; + const el = options.getViewport(); + // An inner code block/tool scroller owns its gesture until it reaches its edge. + for (let node = event.target; node && node !== el; node = node.parentElement) { + if (node.scrollHeight > node.clientHeight && node.clientHeight > 0 + && /auto|scroll/.test(globalThis.getComputedStyle?.(node)?.overflowY || '') + && (event.deltaY < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight)) return; + } + gesture(event.deltaY < 0); + }, + onPointerDown() { + if (pending && anchor) anchor = captureTranscriptReadingAnchor(options.getViewport()); + else anchor = null; + armed = false; + previousTop = options.getViewport()?.scrollTop ?? null; + }, + onKeyDown(event) { + if (event.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return; + if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) gesture(true); + else if (['ArrowDown', 'PageDown', 'End', ' '].includes(event.key)) gesture(false); + }, + onTouchStart(event) { touchY = event.touches?.[0]?.clientY ?? null; }, + onTouchMove(event) { + const next = event.touches?.[0]?.clientY; + if (touchY !== null && next != null && next !== touchY) gesture(next > touchY); + touchY = next ?? null; + }, + onTouchEnd() { touchY = null; }, + activate() { active = true; }, + dispose() { + active = false; + generation += 1; + pending = null; + cancelAnchor(); + }, + }; +} diff --git a/web/src/lib/transcriptHistoryController.test.js b/web/src/lib/transcriptHistoryController.test.js new file mode 100644 index 000000000..eff593756 --- /dev/null +++ b/web/src/lib/transcriptHistoryController.test.js @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { createTranscriptHistoryController } from './transcriptHistoryController.js'; + +const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; +function fixture() { + let rowTop = 50; + const el = { + scrollTop: 0, clientHeight: 600, scrollHeight: 3000, + getBoundingClientRect: () => ({ top: 0, bottom: 600 }), + querySelectorAll: () => [row], contains: (node) => node === row, + }; + const row = { getBoundingClientRect: () => ({ top: rowTop - el.scrollTop, bottom: rowTop + 100 - el.scrollTop, height: 100 }) }; + const state = { loadState: 'loaded', hiddenCount: 0, historyBefore: 'b1', historyHasMore: true }; + const phases = [], requests = []; + let localCount = 0, shown = 0; + const controller = createTranscriptHistoryController({ + getViewport: () => el, + getBoundary: () => ({ getBoundingClientRect: () => ({ height: 40 }) }), + getSnapshot: () => state, + onPhase: (phase) => phases.push(phase), onReview() {}, + afterPaint: async () => {}, + revealLocal: () => { localCount++; state.hiddenCount = 0; rowTop += 200; el.scrollHeight += 200; }, + showAll: () => { shown++; }, + loadPage: () => new Promise((resolve) => requests.push(resolve)), + }); + return { + el, row, state, controller, requests, phases, + get localCount() { return localCount; }, get shown() { return shown; }, + prepend(height) { rowTop += height; el.scrollHeight += height; state.historyBefore += '-older'; }, + upward: () => controller.onWheel({ deltaY: -100, target: el }), + }; +} + +{ + const f = fixture(); + f.controller.onScroll(); + await flush(); + assert.equal(f.requests.length, 0, 'programmatic or initial top is not a paging request'); + f.upward(); await flush(); + assert.equal(f.requests.length, 1); + f.upward(); f.controller.onScroll(); await flush(); + assert.equal(f.requests.length, 1, 'repeated gestures during loading do not queue pages'); + // Tail growth must not be compensated or consume the reading anchor. + f.el.scrollHeight += 1000; + f.controller.preserveAnchor(); + assert.equal(f.el.scrollTop, 0); + f.prepend(300); + f.requests[0]({ status: 'loaded' }); await flush(); + assert.equal(f.el.scrollTop, 300); + assert.equal(f.row.getBoundingClientRect().top, 50); + f.controller.onScroll(); + // Delayed image layout above the same row must still preserve its position. + f.prepend(80); f.controller.preserveAnchor(); + assert.equal(f.el.scrollTop, 380); + assert.equal(f.row.getBoundingClientRect().top, 50); + await flush(); + assert.equal(f.requests.length, 1, 'layout and completion cannot auto-drain history'); + f.controller.cancelAnchor(); + f.el.scrollTop = 0; + f.upward(); await flush(); + assert.equal(f.requests.length, 2, 'another deliberate visit can request the next page'); + f.state.historyHasMore = false; + f.requests[1]({ status: 'loaded' }); await flush(); + f.upward(); await flush(); + assert.equal(f.requests.length, 2, 'exhausted history does not request again'); + console.log('[pass] history intent, single flight, tail growth, late layout and exhaustion'); +} +{ + const f = fixture(); + f.upward(); await flush(); + f.controller.onWheel({ deltaY: 20, target: f.el }); + f.el.scrollTop = 20; + f.controller.onScroll(); + f.prepend(300); f.requests[0]({ status: 'loaded' }); await flush(); + assert.equal(f.el.scrollTop, 320, 'new user scroll supersedes the request-start position'); + f.controller.onScroll(); + f.controller.cancelAnchor(); + f.el.scrollTop = 0; + f.upward(); await flush(); + f.controller.cancelAnchor(); // Explicit jump to tail while HTTP is pending. + f.el.scrollTop = 1200; + f.prepend(400); f.requests[1]({ status: 'loaded' }); await flush(); + assert.equal(f.el.scrollTop, 1200, 'explicit navigation is never undone'); + console.log('[pass] scrolling and explicit navigation win over an old reading anchor'); +} +{ + const f = fixture(); + f.state.hiddenCount = 300; + f.upward(); await flush(); + assert.equal(f.localCount, 1); + assert.equal(f.requests.length, 0, 'local hidden history is revealed before remote paging'); + assert.equal(f.el.scrollTop, 200); + assert.deepEqual(f.phases, ['loading', 'idle']); + console.log('[pass] local history uses the same loading and anchor transaction'); +} +for (const outcome of ['error', 'reset', 'loaded']) { + const f = fixture(); + f.upward(); await flush(); + f.requests[0]({ status: outcome }); await flush(); + assert.equal(f.phases.at(-1), outcome === 'reset' ? 'reset' : 'error'); + f.upward(); f.controller.onScroll(); await flush(); + assert.equal(f.requests.length, 1, 'failure/reset/no progress requires explicit retry'); + const retry = f.controller.reveal(); await flush(); + f.state.historyHasMore = false; + f.requests[1]({ status: 'loaded' }); await retry; + assert.equal(f.phases.at(-1), 'idle'); +} +console.log('[pass] errors, cursor reset and no progress stop automatic retry'); +{ + const f = fixture(); + f.upward(); await flush(); + f.controller.dispose(); + f.prepend(500); f.requests[0]({ status: 'loaded' }); await flush(); + assert.equal(f.el.scrollTop, 0); + assert.equal(f.shown, 0); + assert.deepEqual(f.phases, ['loading'], 'disposed scope cannot publish or reveal another session'); + console.log('[pass] disposal ignores late page completions'); +} +{ + const f = fixture(); + f.el.scrollTop = 500; + f.controller.onPointerDown(); + f.el.scrollTop = 0; + f.controller.onScroll({ pointerActive: true }); await flush(); + assert.equal(f.requests.length, 1); + f.controller.dispose(); + f.requests[0]({ status: 'loaded' }); await flush(); + const keyboard = fixture(); + keyboard.controller.onKeyDown({ key: 'Home', target: { closest: () => ({}) } }); + await flush(); + assert.equal(keyboard.requests.length, 0, 'editing keys do not page the transcript'); + keyboard.controller.onKeyDown({ key: 'Home' }); await flush(); + assert.equal(keyboard.requests.length, 1); + keyboard.controller.dispose(); keyboard.requests[0]({ status: 'loaded' }); await flush(); + console.log('[pass] scrollbar and keyboard intent with editable exclusion'); +} +{ + const f = fixture(); + f.controller.onTouchStart({ touches: [{ clientY: 100 }] }); + f.controller.onTouchMove({ touches: [{ clientY: 150 }] }); await flush(); + assert.equal(f.requests.length, 1, 'finger movement towards older history triggers at top'); + f.controller.onTouchEnd(); + f.controller.dispose(); f.requests[0]({ status: 'loaded' }); await flush(); + const reset = fixture(); + reset.upward(); await flush(); + reset.state.historyHasMore = false; + reset.requests[0]({ status: 'reset' }); await flush(); + await reset.controller.reveal(); + assert.equal(reset.phases.at(-1), 'idle', 'retry clears a reset notice even when the rewritten history is short'); + console.log('[pass] touch intent and exhausted reset recovery'); +} diff --git a/web/src/lib/transcriptWindow.test.js b/web/src/lib/transcriptWindow.test.js index 2b390d378..8e397054d 100644 --- a/web/src/lib/transcriptWindow.test.js +++ b/web/src/lib/transcriptWindow.test.js @@ -245,7 +245,7 @@ run('架构: ChatView 行渲染使用 windowedItems,保留揭示入口与滚动 assert.match(chatViewSource, / { + state = reduceTranscriptEvent(state, { type, payload }).state; +}; +const begin = (id) => { + apply('message', { id: `user-${id}`, role: 'user', content: '继续' }); + apply('busy_changed', { busy: true, turn_id: id }); +}; +const notices = () => state.items.filter((item) => item.kind === 'termination_notice'); +const confirm = (id) => apply('message', { id: `stop-${id}`, role: 'system', content: '[Interrupted]', metadata: { transcript_only: true, user_aborted: true, turn_id: id } }); + +state = createTranscriptState(); +begin('t1'); +apply('turn_abort_requested', { turn_id: 't1' }); +apply('turn_abort_requested', { turn_id: 't1' }); +assert.equal(state.busy, true); +assert.equal(state.activeTurnId, 't1'); +assert.equal(state.status, 'stopping'); +assert.equal(notices().length, 1); +assert.equal(notices()[0].pending, true); +for (const tool of ['vision_analyze', 'web_search', 'bash', 'mcp_example']) { + apply('agent_progress', { phase: 'tool_running', tool }); + apply('message', { role: 'tool_call', content: tool }); + apply('message', { role: 'tool_result', content: 'late result' }); + assert.equal(state.status, 'stopping'); + assert.equal(state.abortPending, true); +} +confirm('t1'); +assert.equal(notices().length, 1); +assert.equal(notices()[0].messageId, 'stop-t1'); +assert.equal(notices()[0].content, '用户已终止本轮任务'); +assert.equal(state.busy, true, '持久化提示不抢先结束运行状态'); +apply('busy_changed', { busy: false, outcome: 'aborted', turn_id: 't1' }); +assert.equal(state.abortPending, false); +assert.equal(state.busy, false); +confirm('t1'); +assert.equal(notices().length, 1); + +// HTTP submission can begin before the preceding terminal done is delivered. +apply('busy_changed', { busy: true }); +apply('done', { outcome: 'aborted', turn_id: 't1' }); +assert.equal(state.busy, true); +begin('t2'); +apply('done', { outcome: 'aborted', turn_id: 't1' }); +assert.equal(state.activeTurnId, 't2'); +apply('turn_abort_requested', { turn_id: 't2' }); +confirm('t2'); +assert.equal(notices().length, 2); +apply('done', { outcome: 'aborted', turn_id: 't2' }); +assert.equal(state.busy, false); + +for (const terminal of ['done', 'busy_changed']) { + state = createTranscriptState(); + begin('legacy-terminal'); + apply('token', { text: 'partial answer' }); + apply('turn_abort_requested'); + const ended = reduceTranscriptEvent(state, { type: terminal, payload: { busy: false } }); + assert.equal(ended.state.lastTurnOutcome, 'aborted'); + assert.equal(ended.state.lastTerminalTurnId, 'legacy-terminal'); + assert.deepEqual(ended.effects, [], 'legacy stop acknowledgement is not a completion notification'); +} + +// Legacy confirmation without a turn id still merges across hidden tool rows. +state = createTranscriptState(); +apply('busy_changed', { busy: true }); +apply('turn_abort_requested'); +apply('message', { role: 'tool_result', content: 'late' }); +apply('message', { id: 'old-stop', role: 'system', metadata: { transcript_only: true, user_aborted: true } }); +assert.equal(notices().length, 1); + +// A current busy snapshot must not erase a local stop that has no event seq. +state = createTranscriptState(); +begin('t3'); +apply('turn_abort_requested', { turn_id: 't3' }); +const restored = preserveLiveRuntimeOnLoad(createTranscriptState({ busy: true, activeTurnId: 't3' }), state); +assert.equal(restored.abortPending, true); +assert.equal(restored.status, 'stopping'); +assert.equal(loadTranscriptHistory(state, { messages: [], busy: true, active_turn_id: 't3' }).state.abortPending, true); +apply('turn_abort_failed'); +assert.equal(state.abortPending, false); +assert.equal(state.busy, true); +assert.equal(notices().length, 0); +apply('turn_abort_requested', { turn_id: 't3' }); +assert.equal(state.abortPending, true, '断线后可重试停止'); + +// Accepted plain submissions reconcile by client id, including identical text. +state = createTranscriptState(); +for (const id of ['send-1', 'send-2']) { + const event = acceptedUserInputEvent({ text: '继续', client_message_id: id }); + state = reduceTranscriptEvent(state, event).state; + const refresh = loadTranscriptHistory(state, { messages: [], busy: true }).state; + assert.ok(refresh.items.some((item) => item.metadata?.client_message_id === id)); + const replacement = reduceTranscriptEvent(state, { type: 'transcript_replace', payload: { messages: [] } }).state; + assert.ok(replacement.items.some((item) => item.metadata?.client_message_id === id)); + apply('message', { id, role: 'user', content: '继续', metadata: { client_message_id: id } }); + state = reduceTranscriptEvent(state, event).state; +} +assert.equal(state.items.length, 2); +assert.deepEqual(state.items.map((item) => item.messageId), ['send-1', 'send-2']); + +let queue = enqueueQueuedInput(createChatInputQueueState(), { sessionId: 's1', text: '旧消息' }); +queue = pauseQueuedInput(queue, 's1'); +queue = resumeQueuedInput(queue, 's1', { afterAbortTurnId: 't1' }); +queue = enqueueQueuedInput(queue, { sessionId: 's1', text: '新消息' }); +assert.equal(shouldDrainQueuedInput({ sessionId: 's1', busy: true }), false); +assert.equal(shouldPauseQueuedInputAfterAbort({ state: queue, sessionId: 's1', lastTurnOutcome: 'aborted', turnId: 't1' }), false); +assert.equal(shouldPauseQueuedInputAfterAbort({ state: queue, sessionId: 's1', lastTurnOutcome: 'aborted', turnId: 't2' }), true); +queue = pauseQueuedInput(queue, 's1'); +assert.equal(queue.resumedAbortTurns.s1, undefined); +console.log('[pass] turn cancellation, late tools, terminal identity, accepted input and queue intent'); diff --git a/web/src/lib/useSubagentTasks.js b/web/src/lib/useSubagentTasks.js index 479d656d8..7e5f4d714 100644 --- a/web/src/lib/useSubagentTasks.js +++ b/web/src/lib/useSubagentTasks.js @@ -119,8 +119,16 @@ export function useSubagentTasks(parentSessionId, { onSpawnStart } = {}) { const abortTask = useCallback((id) => { if (!id) return; - connection.sendAbort(id); + if (!connection.sendAbort(id)) return false; setTasks((prev) => markSubagentTaskAborted(prev, id)); + return true; + }, []); + + useEffect(() => { + const onDisconnect = () => setTasks((prev) => prev.map((task) => task.abortPending + ? { ...task, abortPending: false } : task)); + connection.addEventListener('disconnect', onDisconnect); + return () => connection.removeEventListener('disconnect', onDisconnect); }, []); return { diff --git a/web/src/lib/useTranscriptHistory.js b/web/src/lib/useTranscriptHistory.js new file mode 100644 index 000000000..cf5d8467f --- /dev/null +++ b/web/src/lib/useTranscriptHistory.js @@ -0,0 +1,42 @@ +import { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { revealEarlierAnchorKey } from './transcriptWindow.js'; +import { createTranscriptHistoryController } from './transcriptHistoryController.js'; + +const afterPaint = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + +export function useTranscriptHistory(options) { + const latest = useRef(options); + latest.current = options; + const boundaryRef = useRef(null); + const [status, setStatus] = useState(null); + const controller = useMemo(() => createTranscriptHistoryController({ + getViewport: () => latest.current.scrollRef.current, + getBoundary: () => boundaryRef.current, + getSnapshot: () => ({ + ...latest.current.getState(), + hiddenCount: latest.current.hiddenCount, + }), + onPhase: (phase) => setStatus({ controller, phase }), + onReview: () => latest.current.onReview(), + afterPaint, + loadPage: () => options.loadEarlier(null, { detailed: true, silent: true }), + revealLocal: () => { + const current = latest.current; + current.setWindow((previous) => previous.sid === options.sid && previous.anchorKey + ? { sid: options.sid, anchorKey: revealEarlierAnchorKey(current.items, previous.anchorKey) } + : previous); + }, + showAll: () => latest.current.setWindow((previous) => previous.sid === options.sid + ? { sid: options.sid, anchorKey: null } : previous), + }), [options.sid, options.loadEarlier]); + useLayoutEffect(() => { + controller.activate(); + return () => controller.dispose(); + }, [controller]); + useLayoutEffect(() => { controller.preserveAnchor(); }, [controller, options.items, options.anchorKey, status]); + return { + controller, + boundaryRef, + phase: status?.controller === controller ? status.phase : 'idle', + }; +} diff --git a/web/src/lib/workspaceIcons.test.js b/web/src/lib/workspaceIcons.test.js index c4021b838..48efc37ee 100644 --- a/web/src/lib/workspaceIcons.test.js +++ b/web/src/lib/workspaceIcons.test.js @@ -116,13 +116,10 @@ test('折叠 / 展开两态接到侧栏与选图标网格', () => { assert.ok(picker.includes('open={selected}')); }); -// 场景:打开选图标层后点它旁边的空白(对话框里其它地方或遮罩)。期望:选择层不收起, -// 「编辑项目」对话框也不关闭;只有再点图标按钮或按 Esc 才收起。回归:原来选择层在 -// document 上监听 pointerdown,点外面任何地方都会立刻消失,点到遮罩还会连同未保存的 -// 改动把整个对话框关掉。 -test('选图标层点外面空白不消失', () => { +// 选择层可以因外部点击收起,但编辑项目对话框仍保留草稿。 +// 真实点击、焦点和草稿保留由 test-context-image-paste.mjs 覆盖。 +test('收起选图标层保留编辑项目对话框', () => { const picker = source('components/WorkspaceIconPicker.jsx'); - assert.doesNotMatch(picker, /pointerdown|mousedown/); assert.ok(picker.includes("event.key !== 'Escape'")); const modal = source('components/EditWorkspaceModal.jsx'); assert.match(modal, / :not(.ace-breadcrumb-entry-name) { flex-shrink: 0; } +.ace-breadcrumb-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ace-breadcrumb-arrow-spacer { width: 14px; } +.ace-breadcrumb-entry:hover { background: var(--ace-surface-hi); } +.ace-breadcrumb-entry[aria-selected="true"] { background: var(--ace-accent-bg); color: var(--ace-accent); } +.ace-breadcrumb-message { padding: 8px; color: var(--ace-fg-mute); } +.ace-breadcrumb-retry { margin-left: 8px; color: var(--ace-accent); cursor: pointer; } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index ff9b511b2..85e40bcc8 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -4158,6 +4158,31 @@ a.ace-thread-link:focus-visible { cursor: default; } +.ace-chat-tail-progress { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + width: 18px; + height: 18px; +} +.ace-chat-tail-progress > span { + flex: 0 0 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + animation: ace-chat-tail-dot-hop 1.2s ease-in-out infinite; +} +.ace-chat-tail-progress > span:nth-child(2) { animation-delay: .2s; } +.ace-chat-tail-progress > span:nth-child(3) { animation-delay: .4s; } +@keyframes ace-chat-tail-dot-hop { + 0%, 16.667%, 100% { transform: translateY(0); } + 8.333% { transform: translateY(-3px); } +} +@media (prefers-reduced-motion: reduce) { + .ace-chat-tail-progress > span { animation: none; } +} + /* hover actions on chat messages (复制 / 分叉) */ .ace-msg-actions { flex: 0 0 auto; @@ -4363,10 +4388,6 @@ a.ace-thread-link:focus-visible { opacity: 1; outline: none; } -.ace-slate-attachment-tag:focus-within > .ace-cmd-token { - background: var(--ace-accent-bg); - border-color: var(--ace-accent); -} /* 用户气泡和 composer 共用的命令/tag 徽标:图标 + 蓝色名称。 inline-flex 让徽标与 whitespace-pre-wrap 正文内联同行排版。 */ @@ -4387,11 +4408,6 @@ a.ace-thread-link:focus-visible { cursor: default; outline: none; } -.ace-slate-inline-tag > .ace-cmd-token { - max-width: 100%; - margin: 1px 0; - padding: 0 5px; -} .ace-slate-inline-tag .ace-file-type-glyph { /* Seti glyph ink fills about half its em square. Scale only the ink so the icon matches the label while its layout box and hit target remain 1em. */ @@ -4412,8 +4428,40 @@ a.ace-thread-link:focus-visible { border-color: var(--ace-accent); } -/* The rectangular selection sits behind the original rounded badge. Keep this - layer in the editor's stacking context, below the tag's own surface/content. */ +/* Composer tags are inline accent text; sent-message badges stay unchanged. */ +.ace-slate-inline-tag, +.ace-slate-inline-tag * { + /* Native text selection changes ink immediately, without theme transitions. */ + transition: none; +} +.ace-slate-inline-tag > .ace-cmd-token { + align-items: baseline; + max-width: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + font: inherit; + font-weight: 600; + cursor: text; +} +.ace-slate-inline-tag .ace-cmd-token-name { + font: inherit; + color: inherit; +} +.ace-slate-inline-tag .ace-cmd-token-glyph { + /* Let the label, not the icon font, establish the text baseline. */ + align-self: center; + /* FileTypeIcon sets an inline color; composer icons follow their tag ink. */ + color: inherit !important; + opacity: 1; +} +.ace-slate-inline-tag[data-composer-selected="true"] > .ace-cmd-token { + color: var(--ace-composer-selection-fg); + opacity: 1; +} +/* Match adjacent native text selection, including a collapsed void selection. */ .ace-slate-inline-tag[data-composer-selected="true"]::before { content: ""; position: absolute; @@ -4429,13 +4477,13 @@ a.ace-thread-link:focus-visible { } .ace-slate-inline-tag::selection, .ace-slate-inline-tag ::selection { - /* Native selection still paints adjacent text; it must not recolor the badge. */ + /* The whole-tag layer supplies the fill; avoid double-painting native ranges. */ background: transparent; color: currentColor; } -.ace-slate-inline-tag .ace-file-type-icon::selection, -.ace-slate-inline-tag .ace-file-type-glyph::selection { - color: var(--ace-file-type-color); +.ace-slate-inline-tag[data-composer-selected="true"]::selection, +.ace-slate-inline-tag[data-composer-selected="true"] ::selection { + color: var(--ace-composer-selection-fg); } /* 描述气泡:portal 到 body + position:fixed,坐标由 JS 计算(锚右对齐徽标右缘)。 @@ -5185,6 +5233,12 @@ a.ace-thread-link:focus-visible { min-width: 0; padding: 0; } +/* Default blue/light chrome shares the sidebar surface, without changing hover + tokens or other palettes. Missing attributes are the initial light defaults. */ +:root:not([data-theme="dark"]):is([data-color-theme="blue"], :not([data-color-theme])) + :is(.ace-preview-details-tabs, .ace-agent-browser-toolbar, .ace-side-tabs) { + background: var(--ace-shell-bg); +} .ace-side-tab { flex: 0 0 auto; white-space: nowrap;