From 86657a5aabf87223135f0f5a654dfb9701dca6e1 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Thu, 27 Aug 2026 18:09:47 +0000 Subject: [PATCH 01/27] docs: propose OS-level computer use tools Add the add-computer-use-tools OpenSpec change: screen capture and synthetic pointer/keyboard control as gated built-in tools. Borrows the operator contract, action vocabulary, and coordinate handling from UI-TARS-desktop (Apache-2.0) but keeps ACECode's own agent loop and JSON tool protocol instead of its Thought/Action DSL and nested GUIAgent loop. Scope is the backend contract plus a Windows implementation; macOS and X11 are additive follow-ups and Wayland is out of scope. Capability is disabled by default and guarded by a session-scoped, non-persisted control grant. Capture cost is bounded at capture time rather than by retroactively pruning image history, which would break the prompt-cache prefix invariant. --- .../add-computer-use-tools/.openspec.yaml | 2 + .../changes/add-computer-use-tools/design.md | 219 +++++++++++++++++ .../add-computer-use-tools/proposal.md | 70 ++++++ .../specs/computer-use-tools/spec.md | 221 ++++++++++++++++++ .../changes/add-computer-use-tools/tasks.md | 46 ++++ 5 files changed, 558 insertions(+) create mode 100644 openspec/changes/add-computer-use-tools/.openspec.yaml create mode 100644 openspec/changes/add-computer-use-tools/design.md create mode 100644 openspec/changes/add-computer-use-tools/proposal.md create mode 100644 openspec/changes/add-computer-use-tools/specs/computer-use-tools/spec.md create mode 100644 openspec/changes/add-computer-use-tools/tasks.md diff --git a/openspec/changes/add-computer-use-tools/.openspec.yaml b/openspec/changes/add-computer-use-tools/.openspec.yaml new file mode 100644 index 000000000..f05b045c8 --- /dev/null +++ b/openspec/changes/add-computer-use-tools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-27 diff --git a/openspec/changes/add-computer-use-tools/design.md b/openspec/changes/add-computer-use-tools/design.md new file mode 100644 index 000000000..a37ef7eb7 --- /dev/null +++ b/openspec/changes/add-computer-use-tools/design.md @@ -0,0 +1,219 @@ +## Context + +ACECode's tool boundary already carries everything an OS-level operator needs. +`ToolResult::attachments` accepts a `{name, mime_type, path}` descriptor; +`AgentLoop::materialize_result_attachments` turns it into a session attachment, +`output_attachments_to_content_parts` turns that into content parts, and the +provider layer emits an `image_url` part for vision-capable models. This is the +exact path `browser_screenshot` uses today, so a desktop capture needs no new +transport. + +What is missing is entirely below that boundary. No source file in the +repository calls `SendInput`, `XTestFakeButtonEvent`, `CGEventPost`, `BitBlt`, +or any equivalent. `agent_browser` synthesizes input through the CDP `Input` +domain, which cannot leave the page. + +UI-TARS-desktop (Apache-2.0) is the reference for the missing layer. Its +`BaseOperator` is two methods, `screenshot()` and `execute(action)`, and its +`nut-js` operator is roughly 325 lines that are mostly a key-name map. The parts +worth taking are the action vocabulary, the physical-to-logical downsample +before the image reaches the model, and the resize-factor arithmetic in +`action-parser`. The parts worth refusing are the `Thought:/Action:` DSL, the +regex action parser, and the separate `GUIAgent` loop, all of which exist to +serve a model family trained on that specific text format. + +Two ACECode invariants constrain the design more than the platform work does. +Permission decisions are per tool call, but a GUI task is tens of calls. And the +prompt-cache prefix invariant recorded in `CLAUDE.md` means any retroactive edit +to already-sent conversation content invalidates the cache from that point to +the end of the turn. + +## Goals / Non-Goals + +**Goals:** + +- Expose screen capture and synthetic pointer/keyboard control as ordinary + built-in tools that the existing `AgentLoop` drives. +- Keep the model's coordinate space unambiguous and independent of DPI scaling, + monitor layout, and capture downsampling. +- Make the capability opt-in, revocable mid-session, and observable while it is + active. +- Bound image cost per turn without rewriting conversation history. +- Keep the backend contract narrow enough that macOS and X11 implementations are + additive. + +**Non-Goals:** + +- Porting the UI-TARS `Thought:/Action:` DSL, its action parser, or its + `GUIAgent` loop. +- Requiring or bundling a specific vision model. Model choice stays with + `saved_models` and the existing `vision` capability tag. +- macOS and Linux backends. This change lands the contract and the Windows + backend; the other platforms are separate follow-ups. +- Wayland. Screen capture and input injection there require + `xdg-desktop-portal` plus `libei` and do not fit this contract. +- Remote or sandboxed desktops, including the UI-TARS AIO HTTP operator. +- Element-level semantics such as an accessibility tree. This layer is + deliberately pixel-only; `agent_browser` remains the semantic path for web + content. + +## Decisions + +### Expose plain JSON tools rather than a nested agent + +Each action is a normal tool with a JSON schema, so tool calls appear in the +transcript, flow through `PermissionManager`, honor `ctx.abort_flag`, render +through the existing TUI and web tool rows, and count toward the session's own +token accounting. A single `gui_task(instruction)` tool that runs an inner loop +was rejected: it would hide every intermediate step from the permission gate and +the UI, and would spend tokens against a model the session does not track. + +The action set is taken from UI-TARS's vocabulary because it is small and +proven, split so that each verb maps to one tool rather than one DSL string: +`computer_screenshot`, `computer_click` (with `button` and `count`), +`computer_move`, `computer_drag`, `computer_scroll`, `computer_key` (chords and +explicit hold/release), and `computer_type`. `wait`, `finished`, and `call_user` +are not ported; ACECode already terminates on a text-only reply, has +`task_complete`, and has `AskUserQuestion`. + +Only `computer_screenshot` is registered with `is_read_only = true`. Every +action tool is write-class so it cannot be auto-approved. + +### Split a narrow backend interface from platform code + +`ComputerBackend` declares `capture()`, `pointer_move`, `pointer_button`, +`pointer_scroll`, `key_event`, and `type_text`, plus a `ScreenGeometry` query. +Platform bodies live in `computer_backend_win.cpp`, and later +`computer_backend_mac.mm` and `computer_backend_x11.cpp`, following the +`_win` / `_mac` / `_posix` split already used by `notifications`, +`folder_picker`, `single_instance`, and `proxy_resolver`. + +The Windows backend uses `SendInput` for pointer and keyboard events and a +`BitBlt` desktop capture. DXGI Desktop Duplication is faster but requires +device-lost handling and a redistributable code path; the capture rate here is +one frame per model turn, so `BitBlt` is chosen for simplicity and is an +internal detail the interface does not expose. + +When no backend exists for the target platform, `register_computer_use_tools` +registers nothing, exactly as `register_agent_browser_tools` does outside +`_WIN32 || __APPLE__`. Tools that are absent are absent from the tool list +rather than present-and-failing, so the model never plans around a capability it +cannot use. + +### Make the captured image the single coordinate space + +Three spaces exist: logical desktop coordinates that `SendInput` consumes, +physical pixels that the framebuffer holds, and the pixel grid of the image the +model actually sees after downsampling. Bugs in this area are silent — clicks +land near, but not on, the target — so the contract is fixed explicitly. + +Every action tool takes `x` and `y` in **captured-image pixels of the most +recent `computer_screenshot` in this session**, with the origin at the image's +top-left. A pure `computer_coords` translation unit converts that to logical +desktop coordinates using the capture's recorded scale and virtual-desktop +origin. The capture records its geometry so a later action can be rejected when +the screen configuration changed after the screenshot, instead of clicking a +stale location. + +Capture downsamples physical pixels to logical pixels before encoding, following +the `nut-js` operator's `pixelDensity` handling, so a HiDPI display does not +double every coordinate. The resize-factor arithmetic in UI-TARS's +`smartResizeForV15` is ported into the same unit: models in the Qwen-VL family +are trained on images resized to a multiple of a fixed factor, and feeding a +native-resolution frame degrades grounding without any error surfacing. This +arithmetic is pure and unit-testable, and it is the piece most worth taking from +the reference implementation. + +Normalized 0–1000 coordinates, which UI-TARS models emit natively, are not +accepted. They exist to serve one model family's output format; a general tool +contract in absolute image pixels is checkable and does not silently rescale. + +### Bound capture cost without rewriting history + +A capture is clamped to a configured maximum edge length before encoding, and a +per-turn capture count limit rejects further screenshots with an explanatory +result rather than a hard failure. + +Screenshots are deliberately **not** pruned retroactively. A sliding image +window of the kind UI-TARS uses in `processVlmParams` is correct for a +stateless per-request assembly, but in ACECode the same technique would mutate +already-sent conversation content and invalidate the prompt cache from the +mutation point through the end of every subsequent request in the turn. That +cost grows with exactly the long tool-heavy turns this feature produces. Cost is +therefore controlled at capture time — fewer, smaller images — and long sessions +fall back to the existing compaction path, which is already a cache-boundary +event. + +### Grant control once per session, not once per click + +The first action tool call in a session raises one permission decision that +describes the capability rather than the individual click. Once granted, action +tools in that session proceed without further prompts. The grant is stored in +session-scoped runtime state, is never persisted, and is dropped on session end, +on user abort, and on `/computer off`. + +Per-call confirmation was rejected as unusable: a short GUI task is tens of +calls, and a prompt the user answers reflexively is not a safety control. A +persisted grant was rejected because the blast radius — full synthetic input to +every application the user is signed into — is larger than any other tool in the +codebase and should not survive a restart silently. + +`computer_screenshot` is read-only and does not require the grant, so the model +can look before asking to act. + +While a grant is active the surfaces that already show session state show that +the session can control the machine, and the existing abort path drops the grant +so the visible stop control is also the kill switch. + +### Keep the capability disabled by default + +`ComputerUseConfig` carries `enabled` (default `false`), plus the capture bound +and per-turn capture limit. Registration is skipped when disabled, matching +`config.web_search.enabled` and `config.lsp.enabled`. Unlike those, the default +is off: an unaware user upgrading ACECode should not gain a tool that can drive +their desktop. + +`/computer` reports backend availability, screen geometry, grant state, and the +configured bounds, and revokes an active grant. It follows `/lsp` and +`/websearch` in sharing one dispatch function across the TUI command table, the +daemon builtin allowlist, and the web slash-command list. + +### Record third-party attribution + +The action vocabulary, the physical-to-logical capture handling, and the +resize-factor arithmetic derive from UI-TARS-desktop, which is Apache-2.0. The +ported arithmetic carries an attribution comment naming the upstream file, and +the repository NOTICE records the dependency. No upstream source is vendored +wholesale and no npm package is added; the runtime remains a self-contained C++ +binary. + +## Risks / Trade-offs + +- [Grounding quality is a model property, not a tool property] → Pixel-accurate + clicking is what UI-TARS's specialized models are trained for, and a general + vision model will misclick more often. The tools are therefore additive and + opt-in rather than promoted as a default workflow, and model selection stays + with the existing `vision` capability tag so a user who configures a + GUI-grounding model gets the better behavior without a code change. +- [Full-screen captures are expensive in tokens] → Bounded capture size and a + per-turn limit keep a single turn's image cost predictable, at the cost of + detail on large displays. Region capture would help further and is left for a + follow-up once real usage shows which regions matter. +- [A session-scoped grant is coarser than per-call confirmation] → It covers a + sequence the user cannot practically review click by click. The mitigations + are that the grant is never persisted, is visible while active, is dropped by + the existing abort control, and covers only action tools. +- [Coordinates can go stale between capture and action] → Captures record their + geometry and actions are rejected when the screen configuration changed, so a + monitor change produces an explicit error instead of a click at a wrong + location. Content that moves without a geometry change is not detectable here + and remains the model's responsibility to re-verify by capturing again. +- [Windows-only in this change] → Users on other platforms see no tools at all + rather than a broken capability. The backend interface is the only thing the + tools depend on, so a macOS or X11 body is additive; macOS additionally needs + Accessibility and Screen Recording TCC grants from a signed bundle, which is + the main reason it is scoped separately. +- [Automating a signed-in desktop can trip application anti-automation rules] → + Synthetic input is indistinguishable from a script to the target application. + The capability being opt-in and non-persistent keeps this a deliberate user + choice rather than a default behavior. diff --git a/openspec/changes/add-computer-use-tools/proposal.md b/openspec/changes/add-computer-use-tools/proposal.md new file mode 100644 index 000000000..040cf05ab --- /dev/null +++ b/openspec/changes/add-computer-use-tools/proposal.md @@ -0,0 +1,70 @@ +## Why + +ACECode can already drive a web page. `src/tool/agent_browser/` registers +fifteen CDP-backed tools that navigate, read a semantic accessibility snapshot, +click by element reference, and capture a PNG that reaches the model through +`ToolResult::attachments`. Everything outside a Chromium page is unreachable: +the repository contains no screen capture and no synthetic input path on any +platform, so an agent cannot operate a native installer, a desktop IDE, a +settings dialog, or any application that is not a browser. + +The GUI-agent stacks that do solve this — `bytedance/UI-TARS-desktop` being the +most complete open one — converge on a very small operator contract: capture the +screen, execute one pointer or keyboard action, repeat. Their value is not the +surrounding machinery. UI-TARS wraps that contract in its own agent loop, its +own `Thought:/Action:` text DSL, its own parser, and a model family trained to +emit that DSL. ACECode already owns an agent loop, a tool protocol, permission +gating, abort propagation, and an image-to-context pipeline, so adopting the +wrapper would mean running a second opaque agent inside a tool call. + +This change takes the operator contract and the coordinate handling that make +those stacks work, and exposes them as ordinary ACECode tools driven by the +existing `AgentLoop` and any vision-capable configured model. + +## What Changes + +- Add a `ComputerBackend` abstraction under `src/tool/computer_use/` that + captures the screen and executes single pointer/keyboard actions, with a + Windows implementation. +- Register a built-in tool group — `computer_screenshot`, `computer_click`, + `computer_move`, `computer_drag`, `computer_scroll`, `computer_key`, and + `computer_type` — using ordinary JSON arguments and the existing + `ToolResult::attachments` path for captures. +- Gate the whole group behind a new `config.computer_use` section that is + disabled by default, and skip registration entirely on platforms without a + backend, mirroring how `agent_browser` and `lsp` are gated. +- Add a pure coordinate layer that converts between captured-image pixels, + physical screen pixels, and logical desktop coordinates so DPI scaling and + multi-monitor origins do not silently misplace clicks. +- Add a session-scoped control grant: the first action tool in a session raises + one permission decision that covers the sequence, rather than one decision per + click, and the grant is revoked on session end, abort, or `/computer off`. +- Add a capture budget that bounds screenshot pixel dimensions and per-turn + capture count without retroactively rewriting earlier conversation content. +- Add a `/computer` command that reports backend availability, grant state, and + screen geometry, and revokes an active grant. + +## Capabilities + +### New Capabilities + +- `computer-use-tools`: Defines OS-level screen capture and synthetic pointer + and keyboard control as gated built-in tools, including the coordinate + contract, the session-scoped control grant, and the capture budget. + +### Modified Capabilities + +None. + +## Impact + +- New `src/tool/computer_use/` sources and the built-in registration chain in + `src/tool/builtin_tool_registry.hpp`. +- `AppConfig` gains a `computer_use` section; `config.json` schema notes and + documentation follow. +- Command registration for `/computer` in both the TUI builtin command table and + the daemon builtin allowlist. +- Windows link surface gains `User32`/`Gdi32` usage for input and capture. +- Focused unit tests for the pure coordinate, budget, and grant-state logic, and + a gated manual smoke path for the native backend. +- `README.md`, `README_CN.md`, and the repository capability notes. diff --git a/openspec/changes/add-computer-use-tools/specs/computer-use-tools/spec.md b/openspec/changes/add-computer-use-tools/specs/computer-use-tools/spec.md new file mode 100644 index 000000000..ad70599f8 --- /dev/null +++ b/openspec/changes/add-computer-use-tools/specs/computer-use-tools/spec.md @@ -0,0 +1,221 @@ +## ADDED Requirements + +### Requirement: Computer use capability gate + +ACECode SHALL register the computer use tool group only when +`config.computer_use.enabled` is true and a screen backend exists for the +running platform. The setting SHALL default to false. + +#### Scenario: Capability disabled by default + +- **WHEN** a configuration without a `computer_use` section is loaded +- **THEN** no computer use tool is registered +- **AND** the model's tool list contains no screen capture or synthetic input + capability + +#### Scenario: Capability enabled on a supported platform + +- **WHEN** `config.computer_use.enabled` is true and the platform has a backend +- **THEN** `computer_screenshot`, `computer_click`, `computer_move`, + `computer_drag`, `computer_scroll`, `computer_key`, and `computer_type` are + registered + +#### Scenario: Unsupported platform + +- **WHEN** `config.computer_use.enabled` is true and the platform has no backend +- **THEN** no computer use tool is registered +- **AND** startup succeeds without an error + +### Requirement: Screen capture as a model-visible attachment + +ACECode SHALL provide `computer_screenshot`, which captures the current screen +and returns it as a PNG attachment through the tool result attachment path so +vision-capable models receive it as image content. The tool SHALL be read-only. + +#### Scenario: Capture reaches the model as an image + +- **WHEN** the model calls `computer_screenshot` in a session using a + vision-capable model +- **THEN** the result carries a PNG attachment +- **AND** the attachment is materialized into the session and sent to the + provider as image content + +#### Scenario: Capture reports its coordinate geometry + +- **WHEN** a capture succeeds +- **THEN** the result reports the captured image's pixel width and height and + the screen geometry the capture was taken against + +#### Scenario: Capture requires no control grant + +- **WHEN** no control grant is active in the session +- **THEN** `computer_screenshot` still executes because it is read-only + +### Requirement: Captured-image pixel coordinate contract + +ACECode SHALL interpret the `x` and `y` arguments of every computer use action +tool as pixel coordinates in the most recent capture of the current session, +with the origin at the captured image's top-left corner, and SHALL translate +them to logical desktop coordinates before dispatching input. + +#### Scenario: Coordinates translate across display scaling + +- **WHEN** a capture is downsampled from physical to logical pixels on a scaled + display and the model targets a point in that image +- **THEN** the dispatched pointer event lands at the corresponding logical + desktop position rather than at the raw image coordinate + +#### Scenario: Multi-monitor origin is applied + +- **WHEN** the captured virtual desktop begins at a non-zero origin +- **THEN** the capture origin is applied during translation so a point in the + image maps to the same point on the physical desktop + +#### Scenario: Action before any capture + +- **WHEN** an action tool with coordinates is called before any capture in the + session +- **THEN** the call fails with a result directing the model to capture first +- **AND** no input event is dispatched + +#### Scenario: Screen geometry changed since capture + +- **WHEN** the screen configuration changed after the most recent capture +- **THEN** a coordinate-bearing action fails with an explicit staleness error + directing the model to capture again +- **AND** no input event is dispatched + +#### Scenario: Coordinates outside the captured image + +- **WHEN** an action targets a coordinate outside the captured image bounds +- **THEN** the call fails +- **AND** no input event is dispatched + +### Requirement: Session-scoped control grant + +ACECode SHALL require a session-scoped control grant before executing any +computer use action tool. The first such call in a session SHALL raise one +permission decision covering the capability. The grant SHALL NOT be persisted +and SHALL be dropped on session end, on user abort, and on explicit revocation. + +#### Scenario: First action requests the grant + +- **WHEN** the model calls an action tool in a session with no active grant +- **THEN** ACECode raises a permission decision describing desktop control +- **AND** the action executes only after approval + +#### Scenario: Subsequent actions reuse the grant + +- **WHEN** a grant is active in the session +- **THEN** further action tools execute without an additional permission prompt + +#### Scenario: Denied grant blocks the action + +- **WHEN** the user denies the control grant +- **THEN** the action fails with a result explaining that desktop control was + denied +- **AND** no input event is dispatched +- **AND** no grant is recorded + +#### Scenario: Abort drops the grant + +- **WHEN** the user aborts the running turn while a grant is active +- **THEN** the grant is dropped +- **AND** the next action tool call raises a new permission decision + +#### Scenario: Grant does not survive the session + +- **WHEN** a session with an active grant ends and a new session starts +- **THEN** the new session has no grant +- **AND** its first action tool call raises a permission decision + +### Requirement: Synthetic pointer and keyboard actions + +ACECode SHALL provide pointer and keyboard action tools that dispatch synthetic +input through the platform backend: `computer_click` with button and repeat +count, `computer_move`, `computer_drag` between two points, `computer_scroll` +with a direction and amount, `computer_key` for named keys and chords including +explicit hold and release, and `computer_type` for literal text. + +#### Scenario: Click dispatches at a translated position + +- **WHEN** `computer_click` is called with in-bounds coordinates and an active + grant +- **THEN** a pointer button event is dispatched at the translated logical + position +- **AND** the result reports the action and the position used + +#### Scenario: Key chord dispatches modifiers around the main key + +- **WHEN** `computer_key` is called with a modifier chord +- **THEN** modifiers are pressed before and released after the main key + +#### Scenario: Held keys are released + +- **WHEN** `computer_key` holds a key without a matching release and the turn + ends or is aborted +- **THEN** the backend releases keys it is holding so no modifier is left stuck + +#### Scenario: Abort stops a running action + +- **WHEN** the session abort flag is set while an action tool is executing +- **THEN** the tool returns promptly without dispatching further input + +#### Scenario: Unknown key name + +- **WHEN** `computer_key` is called with a key name the backend does not know +- **THEN** the call fails with a result naming the unknown key +- **AND** no input event is dispatched + +### Requirement: Capture budget without history rewriting + +ACECode SHALL bound captured image dimensions to the configured maximum edge +length and SHALL bound the number of captures per turn. ACECode SHALL NOT remove +or downgrade image content already present in earlier conversation messages. + +#### Scenario: Oversized capture is downscaled + +- **WHEN** the screen exceeds the configured maximum edge length +- **THEN** the capture is downscaled before encoding +- **AND** the reported image dimensions match the delivered image so coordinate + translation stays correct + +#### Scenario: Per-turn capture limit reached + +- **WHEN** the model requests more captures in one turn than the configured + limit +- **THEN** the call returns an explanatory result instead of a new image +- **AND** the turn continues + +#### Scenario: Earlier captures are left intact + +- **WHEN** several captures accumulate across a session +- **THEN** earlier image content in the conversation is left unmodified so the + request prefix stays byte-stable across iterations within a turn + +### Requirement: Computer use status command + +ACECode SHALL provide a `/computer` command in the TUI, the daemon builtin +command set, and the web slash-command list that reports backend availability, +screen geometry, grant state, and configured bounds, and that revokes an active +grant on request. + +#### Scenario: Report status + +- **WHEN** the user runs `/computer` +- **THEN** ACECode reports whether the capability is enabled, whether a backend + is available, the current screen geometry, whether a grant is active, and the + configured capture bounds + +#### Scenario: Revoke an active grant + +- **WHEN** a grant is active and the user runs `/computer off` +- **THEN** the grant is dropped +- **AND** the next action tool call raises a new permission decision + +#### Scenario: Status without a backend + +- **WHEN** the platform has no backend and the user runs `/computer` +- **THEN** the command reports that the capability is unavailable on this + platform +- **AND** exits successfully diff --git a/openspec/changes/add-computer-use-tools/tasks.md b/openspec/changes/add-computer-use-tools/tasks.md new file mode 100644 index 000000000..d757d8bd3 --- /dev/null +++ b/openspec/changes/add-computer-use-tools/tasks.md @@ -0,0 +1,46 @@ +## 1. Configuration and Gating + +- [ ] 1.1 Add `ComputerUseConfig` (`enabled` default false, max capture edge, per-turn capture limit) to `AppConfig` with sparse serialization. +- [ ] 1.2 Add `register_computer_use_tools` to the built-in registration chain, skipped when disabled and compiled out where no backend exists. +- [ ] 1.3 Add config schema notes for the `computer_use` section. + +## 2. Backend Contract and Coordinates + +- [ ] 2.1 Declare `ComputerBackend` (`capture`, `pointer_move`, `pointer_button`, `pointer_scroll`, `key_event`, `type_text`, geometry query) and `ScreenGeometry`. +- [ ] 2.2 Add the pure `computer_coords` unit: captured-image pixels to logical desktop coordinates, capture downscale factors, virtual-desktop origin, and bounds checks. +- [ ] 2.3 Port the resize-factor arithmetic from UI-TARS `action-parser` with an attribution comment naming the upstream file. +- [ ] 2.4 Add the pure capture-budget unit for maximum edge clamping and per-turn capture counting. +- [ ] 2.5 Add unit tests for coordinate translation, scaled and multi-monitor geometry, staleness detection, out-of-bounds rejection, and budget clamping. + +## 3. Windows Backend + +- [ ] 3.1 Implement desktop capture and PNG encoding in `computer_backend_win.cpp`, downsampling physical to logical pixels. +- [ ] 3.2 Implement pointer move, button, scroll, and drag through `SendInput`. +- [ ] 3.3 Implement the key-name map, chords, explicit hold/release, and literal text entry, releasing held keys on teardown or abort. +- [ ] 3.4 Add the Windows link dependencies and confirm the capability is absent from non-Windows builds. + +## 4. Tool Surface + +- [ ] 4.1 Add `computer_screenshot` as read-only, returning a PNG through `ToolResult::attachments` and reporting image dimensions and capture geometry. +- [ ] 4.2 Add `computer_click`, `computer_move`, `computer_drag`, `computer_scroll`, `computer_key`, and `computer_type` with JSON schemas, abort polling, and structured results. +- [ ] 4.3 Reject coordinate-bearing actions with no prior capture, with stale geometry, or with out-of-bounds coordinates before dispatching input. +- [ ] 4.4 Add tool summaries and compact call previews so TUI and web tool rows render usefully. + +## 5. Control Grant and Safety + +- [ ] 5.1 Add session-scoped grant state with a single capability-level permission decision on the first action tool call. +- [ ] 5.2 Drop the grant on session end, on user abort, and on explicit revocation; never persist it. +- [ ] 5.3 Surface an active grant in the session status surfaces so an operating session is visible. +- [ ] 5.4 Add unit tests for grant acquisition, reuse, denial, abort-drop, and non-persistence. + +## 6. Command Surface + +- [ ] 6.1 Add a shared `/computer` dispatch reporting availability, geometry, grant state, and bounds, with an `off` subcommand. +- [ ] 6.2 Register `/computer` in the TUI command table, the daemon builtin allowlist, and the web slash-command list. + +## 7. Verification and Documentation + +- [ ] 7.1 Add a gated manual smoke path for the native backend, following the notification smoke-test precedent. +- [ ] 7.2 Document the capability, its default-off posture, the coordinate contract, and the grant model in `README.md`, `README_CN.md`, and the repository capability notes. +- [ ] 7.3 Record the UI-TARS-desktop Apache-2.0 attribution in NOTICE. +- [ ] 7.4 Build the relevant targets, run focused tests, manually verify capture and a click sequence on Windows, and strictly validate the OpenSpec change. From e1d11b991030c27cb07fd758d7f78c7cc6f917b8 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 18 Sep 2026 05:12:42 +0000 Subject: [PATCH 02/27] docs: add Agent Browser performance plan benchmarked against jev-ultrafast Analyze browser-use/jev-ultrafast to locate ACECode's actual Agent Browser latency bottlenecks, and record a staged plan. Key finding: jev's speed comes from its model shape (constrained-decoding classifier, one request per decision cycle, ~10 steps), not from browser-side work. Cutting its browser protocol calls by 91% bought only 25% task time. ACECode's bottleneck is therefore model round trips and prompt size, not browser_tools.cpp micro-optimization. Documents four levers in dependency order, with verified file:line evidence: snapshot slimming (~15k -> ~3k tokens per read), browser-side constants (7 IPC requests and 380ms fixed waits per click -> 2 and 50ms), returning page state from mutation tools (2 -> 1 model round trip per action), and a batch action tool (N -> 1). Also records what not to change, a measurement protocol, and two non-performance issues found while verifying: no occlusion hit test before clicking, and password field values reaching model context and session JSONL. --- docs/agent-browser-performance.md | 328 ++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/agent-browser-performance.md diff --git a/docs/agent-browser-performance.md b/docs/agent-browser-performance.md new file mode 100644 index 000000000..7659a280c --- /dev/null +++ b/docs/agent-browser-performance.md @@ -0,0 +1,328 @@ +# Agent Browser 性能改造方案 + +对标项目:[browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast)(2026-09 抓取)。 + +本文只谈**延迟**。正确性与隐私类问题另见文末「附录 B」,不在本方案范围内。 + +--- + +## 一、背景:jev 的「7 秒」是怎么来的 + +jev-ultrafast 的卖点是一句话:一条自然语言目标,Google Flights 上从苏黎世搜到伦敦, +**7.1 秒**,含模型调用、文本生成、浏览器操作和加载等待。 + +拆开看,这 7 秒的构成与直觉相反: + +| 来源 | 占比 | 说明 | +| --- | --- | --- | +| 模型时间 | 绝对主导 | 每步一次模型请求,约 10 余步 | +| 浏览器协议时间 | 零点几秒 | 整个任务 101 次 CDP 调用,持久会话上每次约毫秒级 | + +它自己公布的优化前后对比更说明问题: + +| 指标 | 优化前 | 优化后 | 变化 | +| --- | --- | --- | --- | +| 浏览器协议调用 | 1092 | 101 | 降低 91% | +| 任务耗时中位数 | 9.450 秒 | 7.092 秒 | 降低 25% | + +**把浏览器协议调用砍掉 91%,任务只快了 25%。** 这是本方案最重要的一条背景事实: +jev 的速度主要不来自浏览器代码,而来自它的**模型形态**和**每步只发一次请求**。 + +它快的三个真实原因: + +1. **模型不是通用 LLM。** 决策模型是 TypeSafe 的 Jev,一个约束解码的分类器:输出一个 + 操作枚举(`CLICK` / `TYPE_TEXT` / `SELECT` / `SCROLL_UP` / `SCROLL_DOWN` / `WAIT` / + `DONE` / `BLOCKED`)加一个元素序号。只有需要输入文本时才调一次小而快的文本模型, + 当前 demo 用 `inception/mercury-2.5` 且关闭 reasoning。 +2. **每个决策周期一次网络往返。** 操作头和目标头共享同一份观测状态,投机式扇出, + 一次请求同时拿到「做什么」和「对谁做」。目标头里只包含与该操作兼容的元素。 +3. **步数少,且每步的状态是替换而非累积。** 模型每步只看当前元素表,历史是一份 + 紧凑的决策列表,不是一堆过期页面全文。 + +它明确不做的:默认循环里**没有截图**;结构化状态就是全部输入。截图只给它的可视化 +inspector 用,演示视频走独立的 screencast。 + +### jev 在浏览器侧做的事(值得借的部分) + +即使浏览器侧只值 25%,这 25% 的做法仍然干净,值得参考: + +- **一次快照一次调用。** `jev_ultrafast/snapshot.js` 一个 `Runtime.evaluate` 原子返回 + url、title、viewport、可见文本、动作表、新鲜度标记、逐节点守卫。 +- **只发可见内容。** 元素中心点落在视口外的直接丢弃;正文用 Range 的 rect 判断是否 + 真在屏幕上,上限 6000 字符。屏幕外的正文和页脚不进模型上下文。 +- **动作空间而非元素空间。** 表里每一行都是可执行动作:` update('sampling', { ...form.sampling, [field]: event.target.value })} + placeholder="继承模型" + aria-invalid={!!errors.sampling?.[field]} + aria-describedby={`expert-sampling-${field}-hint`} + className={clsx(inputClass, 'mt-1.5')} + /> + + {errors.sampling?.[field] || range} + + + ))} + +
", "s_f627a3a526dc9da0": "在当前分支开始", "s_f628f6c3e43734b2": "清除筛选后查看全部可用组件。", + "s_f630012cea065bd1": "参数需受所用模型支持。", "s_f69273dbbebfb3a1": "格式化", "s_f699d16db6c37a35": "当前平台不能真正断网:关闭网络访问时只注入离线环境(代理指向无效端口、ssh 桩),不走代理的程序仍能联网。", "s_f6b3aadcf1df1ad2": "允许 AI 控制 {{p0}}", @@ -3061,6 +3069,7 @@ export const sourceCatalogs = { "s_13e0991638226a6d": "Allow the agent to view windows and use the mouse, keyboard, and app controls", "s_13f6a415ac664be2": "Detect the actual model list from this provider", "s_140b07fe3f9f7d48": "Download size:", + "s_1414769bed7c6551": "Sampling parameters (optional)", "s_1417bae3d17d6405": "Collapse images", "s_142cf02f83d4e7ab": "Writing reply", "s_14357c0d6ca270df": "Check that the address is correct, then try again.", @@ -3343,6 +3352,7 @@ export const sourceCatalogs = { "s_2d1d8c1e38956bea": "Back", "s_2d5492fa38ef2f75": "Establishing connection", "s_2d55d67a4923e9fa": "Unsupported goal action", + "s_2d8ff2bb909846ec": "Inherit model", "s_2da0b2a8111d771b": "Open session", "s_2dd59fd0593a8e93": "The path picker is unavailable", "s_2de0f5a9afd47152": "Flask", @@ -3350,6 +3360,7 @@ export const sourceCatalogs = { "s_2df73bf9535cb691": "Dispatched to the current conversation; the opening line will only fill in the input box and will not be sent automatically.", "s_2df7697dbb612878": "agent", "s_2e2fc2c95a2a682c": "No matching command", + "s_2e55fd2728f3d723": "{{p0}}: enter a value in the range {{p1}}, or leave blank to inherit", "s_2e659c0c617332d7": "read isolation active", "s_2e6bfec616ad6945": "Team members", "s_2e7e1c747a435c14": "(Multi)", @@ -3478,6 +3489,7 @@ export const sourceCatalogs = { "s_3a68c45441b531e0": "Archived conversations will not appear in the sidebar, and archive recovery can be canceled at any time.", "s_3a765e0fef6131d5": "The simulator control component is missing. Reinstall the full ACECode package.", "s_3a7c10115641eb4c": "Choose a file on the server filesystem.", + "s_3ac23fa27c7b0894": "Blank fields inherit model settings. Each subtask can override them when started.", "s_3ae030dd220465c9": "Work area usage", "s_3afa87e94673b0d1": "Dispatch failed: {{p0}}", "s_3b6fefc50febace4": "Hour", @@ -3909,6 +3921,7 @@ export const sourceCatalogs = { "s_622de9749cb55343": "Could not load developer settings", "s_624b5799ebd036b0": "No workspace", "s_6258a2c6f9c89143": "Copy all", + "s_62711127c9ee73df": "Greater than 0, at most 1", "s_62731d95bc805803": "Exporting…", "s_6278c2f645ab3982": "Public address", "s_629bb52ce0e5270f": "Added {{p0}} to chat", @@ -4366,6 +4379,7 @@ export const sourceCatalogs = { "s_89ce481a4b23d2a4": "Keyboard shortcuts", "s_89d3e8892ae8fd51": "More experts", "s_89db648162e7cea1": "Connection lost, retrying automatically…", + "s_89e028004391a408": "A positive integer", "s_89e0ebd9d750f2c1": "The current backend does not support image generation. Update ACECode, restart, and try again.", "s_8a32f1c8f96d5905": "Sandbox backend unavailable: {{p0}}", "s_8a687fb42860ccd0": "Reasoning depth: {{p0}}", @@ -5122,6 +5136,7 @@ export const sourceCatalogs = { "s_d0ca08d604551151": "Queue paused", "s_d0f680208ed8796c": "git times out, click to try again", "s_d0fb5bbca8184bdd": "Jump to current question:{{p0}}", + "s_d12291a6d3a755a0": "Team defaults. Each member’s own sampling settings take priority. Blank fields inherit model settings.", "s_d1aeba9123ecd734": "Workspaces folder", "s_d1cd931be84cce03": "trust", "s_d1d044826a45ef65": "Loading failed", @@ -5540,6 +5555,7 @@ export const sourceCatalogs = { "s_f60f6f444d92e1b1": "Usage: /turn ", "s_f627a3a526dc9da0": "Start on current branch", "s_f628f6c3e43734b2": "Clear filters to view all available components.", + "s_f630012cea065bd1": "The selected model must support these parameters.", "s_f69273dbbebfb3a1": "format", "s_f699d16db6c37a35": "This platform cannot fully cut the network: turning network access off only injects an offline environment (proxy pointed at a dead port, ssh stubs); programs that ignore the proxy can still connect.", "s_f6b3aadcf1df1ad2": "Allow AI control of {{p0}}", diff --git a/web/src/lib/composerFileIntake.js b/web/src/lib/composerFileIntake.js index 42a37d5ce..1ffdee171 100644 --- a/web/src/lib/composerFileIntake.js +++ b/web/src/lib/composerFileIntake.js @@ -25,7 +25,7 @@ async function storeLocalFiles(files, win) { for (let offset = 0; offset < bytes.length; offset += 8192) { binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); } - payload.push({ name: file.name || 'clipboard-file', data_base64: btoa(binary) }); + payload.push({ name: file.name || 'clipboard_file', data_base64: btoa(binary) }); } return parseNativeFilesystemItemsResult(await win.aceDesktop_storeContextFiles(payload)); } diff --git a/web/src/lib/composerFileTransfer.js b/web/src/lib/composerFileTransfer.js index 3a2ec30b2..56e2e2948 100644 --- a/web/src/lib/composerFileTransfer.js +++ b/web/src/lib/composerFileTransfer.js @@ -1,11 +1,11 @@ const PASTE_IMAGE_NAMES = { - 'image/png': 'pasted-image.png', - 'image/jpeg': 'pasted-image.jpg', - 'image/jpg': 'pasted-image.jpg', - 'image/webp': 'pasted-image.webp', - 'image/gif': 'pasted-image.gif', - 'image/bmp': 'pasted-image.bmp', - 'image/tiff': 'pasted-image.tiff', + 'image/png': 'pasted_image.png', + 'image/jpeg': 'pasted_image.jpg', + 'image/jpg': 'pasted_image.jpg', + 'image/webp': 'pasted_image.webp', + 'image/gif': 'pasted_image.gif', + 'image/bmp': 'pasted_image.bmp', + 'image/tiff': 'pasted_image.tiff', }; const DESKTOP_SOURCE_PATH = Symbol.for('acecode.desktopSourcePath'); @@ -110,8 +110,8 @@ function listToArray(list) { function fallbackPasteName(file, index) { const type = String(file?.type || '').toLowerCase(); if (PASTE_IMAGE_NAMES[type]) return PASTE_IMAGE_NAMES[type]; - const suffix = index > 0 ? `-${index + 1}` : ''; - return `pasted-attachment${suffix}`; + const suffix = index > 0 ? `_${index + 1}` : ''; + return `pasted_attachment${suffix}`; } function ensurePasteFileName(file, index) { diff --git a/web/src/lib/composerFileTransfer.test.js b/web/src/lib/composerFileTransfer.test.js index 69db0e7f0..3f0def33c 100644 --- a/web/src/lib/composerFileTransfer.test.js +++ b/web/src/lib/composerFileTransfer.test.js @@ -81,7 +81,7 @@ run('unnamed pasted images get a stable filename when File is available', () => const files = filesFromClipboardEvent(event); assert.equal(files.length, 1); - assert.equal(files[0].name, 'pasted-image.png'); + assert.equal(files[0].name, 'pasted_image.png'); assert.equal(files[0].type, 'image/png'); }); @@ -154,7 +154,7 @@ const imageClipboard = await readComposerClipboardData({ read: async () => [clip })] }); 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(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'); diff --git a/web/src/lib/expertComponents.js b/web/src/lib/expertComponents.js index d3bdfbb6d..22e14429b 100644 --- a/web/src/lib/expertComponents.js +++ b/web/src/lib/expertComponents.js @@ -1,3 +1,12 @@ +import { MODEL_SAMPLING_FIELDS, normalizeSampling, samplingDraftFields, samplingFieldValid } from './modelSettings.js'; + +export const EXPERT_SAMPLING_INPUTS = [ + { field: 'temperature', label: 'Temperature', range: '0–2', min: 0, max: 2, step: '0.01' }, + { field: 'top_p', label: 'Top P', range: '大于 0,最多 1', min: 0, max: 1, step: '0.01' }, + { field: 'top_k', label: 'Top K', range: '正整数', min: 1, max: 2147483647, step: '1' }, + { field: 'min_p', label: 'Min P', range: '0–1', min: 0, max: 1, step: '0.01' }, +]; + export const EXPERT_PRIMARY_TABS = Object.freeze([ Object.freeze({ id: 'agent', label: '专家' }), Object.freeze({ id: 'team', label: '专家团' }), @@ -190,6 +199,7 @@ export function normalizeExperts(value) { created_at: String(item.created_at || ''), updated_at: String(item.updated_at || ''), capabilities: normalizeExpertCapabilities(item.capabilities), + sampling: normalizeSampling(item.sampling, 'expert.sampling'), agents: Array.isArray(item.agents) ? item.agents.map(normalizeAgent).filter(Boolean) : [], references_existing_experts: item.references_existing_experts === true, lead_expert_id: String(item.lead_expert_id || item.lead_agent_id || ''), @@ -283,6 +293,7 @@ export function emptyExpertForm(type = 'agent', initialExpertId = '') { instructions: '', quickPromptsText: '', capabilities: {}, + sampling: samplingDraftFields(), stateAvatars: {}, stateAvatarUrls: {}, selectedExpertIds, @@ -311,6 +322,7 @@ export function expertFormFromDetail(expert) { instructions: normalized.type === 'agent' ? String(lead.instructions || '') : '', quickPromptsText: (normalized.quick_prompts || []).join('\n'), capabilities: normalizeExpertCapabilities(normalized.capabilities), + sampling: samplingDraftFields(normalized.sampling), stateAvatars: normalizeExpertStateAvatars(normalized.state_avatars), stateAvatarUrls: normalizeExpertStateAvatarUrls(normalized.state_avatar_urls), selectedExpertIds, @@ -320,6 +332,14 @@ export function expertFormFromDetail(expert) { export function validateExpertFormFields(form, selectableExperts) { const errors = {}; + const samplingErrors = {}; + for (const { field, label, range } of EXPERT_SAMPLING_INPUTS) { + const raw = String(form?.sampling?.[field] ?? '').trim(); + if (raw && !samplingFieldValid(field, Number(raw))) { + samplingErrors[field] = `${label}:请输入${range}的数值,或留空继承`; + } + } + if (Object.keys(samplingErrors).length) errors.sampling = samplingErrors; if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(String(form?.id || ''))) { errors.id = '无法保存,请重新打开后再试'; } @@ -355,7 +375,8 @@ export function validateExpertFormFields(form, selectableExperts) { } export function validateExpertForm(form) { - return Object.values(validateExpertFormFields(form))[0] || ''; + const first = Object.values(validateExpertFormFields(form))[0]; + return typeof first === 'object' ? (Object.values(first)[0] || '') : (first || ''); } function capabilitiesPayload(form) { @@ -374,6 +395,12 @@ export function expertPayloadFromForm(form) { tags: normalizeStringList(form.tags), expertise: parseLineList(form.expertiseText), quick_prompts: parseLineList(form.quickPromptsText), + ...(Object.prototype.hasOwnProperty.call(form, 'sampling') ? { + sampling: Object.fromEntries(MODEL_SAMPLING_FIELDS.flatMap((field) => { + const raw = String(form.sampling?.[field] ?? '').trim(); + return raw ? [[field, Number(raw)]] : []; + })), + } : {}), ...(stateAvatarsPresent ? { state_avatars: normalizeExpertStateAvatars(form.stateAvatars) } : {}), diff --git a/web/src/lib/expertComponents.test.js b/web/src/lib/expertComponents.test.js index b66141306..2c44447eb 100644 --- a/web/src/lib/expertComponents.test.js +++ b/web/src/lib/expertComponents.test.js @@ -33,6 +33,34 @@ import { validateExpertFormFields, } from './expertComponents.js'; +test('专家采样参数保存与回填保留零值,清空后明确恢复继承', () => { + const form = emptyExpertForm(); + form.displayName = '采样专家'; + form.instructions = '认真分析任务'; + form.sampling = { temperature: '0', top_p: '0.95', top_k: '20', min_p: '0' }; + assert.deepEqual(validateExpertFormFields(form), {}); + const payload = expertPayloadFromForm(form); + assert.deepEqual(payload.sampling, { temperature: 0, top_p: 0.95, top_k: 20, min_p: 0 }); + const loaded = expertFormFromDetail({ ...payload, agents: [payload.lead], lead_agent_id: 'lead' }); + assert.deepEqual(loaded.sampling, form.sampling); + loaded.sampling = { temperature: '', top_p: ' ', top_k: '', min_p: '' }; + assert.deepEqual(expertPayloadFromForm(loaded).sampling, {}); +}); + +test('专家采样字段校验拒绝越界、小数 Top K 和非数字', () => { + const valid = { ...emptyExpertForm(), displayName: '专家', instructions: '分析' }; + for (const [field, value] of [ + ['temperature', '2.1'], ['temperature', 'NaN'], ['temperature', '-1'], + ['top_p', '0'], ['top_p', '1.1'], ['top_k', '1.5'], + ['top_k', '2147483648'], ['min_p', '-0.1'], + ]) { + const errors = validateExpertFormFields({ ...valid, sampling: { [field]: value } }); + assert.ok(errors.sampling?.[field], `${field}=${value}`); + } + const team = { ...emptyExpertForm('team'), sampling: { temperature: '0.5' } }; + assert.deepEqual(expertPayloadFromForm(team).sampling, { temperature: 0.5 }); +}); + function test(name, fn) { try { fn(); @@ -181,6 +209,7 @@ test('expert payload keeps Tags, expertise, prompts and optional scopes separate tags: ['开发', 'OPC-一人公司'], expertise: ['架构设计', '代码质量'], quick_prompts: ['审查当前改动', '给出重构建议'], + sampling: {}, state_avatars: {}, capabilities: { skills: ['code-review'], @@ -366,6 +395,7 @@ test('expert team payload references selected existing experts and one lead', () tags: ['项目质量'], expertise: ['需求拆解', '体验验收'], quick_prompts: ['推进这个需求'], + sampling: {}, state_avatars: {}, lead_expert_id: 'reviewer', member_expert_ids: ['tester'], diff --git a/web/src/lib/iosSimulator.js b/web/src/lib/iosSimulator.js index a9da2a8d8..03cf6ef08 100644 --- a/web/src/lib/iosSimulator.js +++ b/web/src/lib/iosSimulator.js @@ -431,11 +431,11 @@ export function simulatorNeedsSettings({ status = null, message = '' } = {}) { } export function simulatorScreenshotFilename(name = '', date = new Date()) { - const safe = String(name || 'simulator').replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'simulator'; + const safe = String(name || 'simulator').replace(/[-\\/:*?"<>|\s]+/g, '_').replace(/^_+|_+$/g, '') || 'simulator'; const pad = (value) => String(value).padStart(2, '0'); const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` - + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; - return `${safe}-${stamp}.png`; + + `_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; + return `${safe}_${stamp}.png`; } export function downloadSimulatorBlob(blob, filename, { diff --git a/web/src/lib/iosSimulator.test.js b/web/src/lib/iosSimulator.test.js index 5f40923ba..7e6d51ccf 100644 --- a/web/src/lib/iosSimulator.test.js +++ b/web/src/lib/iosSimulator.test.js @@ -331,9 +331,9 @@ run('panel copy helpers', () => { assert.equal(simulatorNeedsSettings({ message: 'device is not booted' }), false); assert.equal( simulatorScreenshotFilename('iPhone 17 Pro', new Date(2026, 9, 16, 8, 5, 9)), - 'iPhone-17-Pro-20261016-080509.png', + 'iPhone_17_Pro_20261016_080509.png', ); - assert.equal(simulatorScreenshotFilename('', new Date(2026, 0, 2, 3, 4, 5)), 'simulator-20260102-030405.png'); + assert.equal(simulatorScreenshotFilename('', new Date(2026, 0, 2, 3, 4, 5)), 'simulator_20260102_030405.png'); }); run('screenshot downloads go through a temporary object URL', () => { diff --git a/web/src/lib/mermaidExport.js b/web/src/lib/mermaidExport.js index ab3a28883..a1754b169 100644 --- a/web/src/lib/mermaidExport.js +++ b/web/src/lib/mermaidExport.js @@ -97,7 +97,7 @@ export function mermaidFamilyFromSource(source) { export function mermaidExportFilename(detail, format) { const family = mermaidFamilyFromSource(detail?.source); const extension = format === 'source' ? 'mmd' : String(format || '').toLowerCase(); - return `mermaid-${family}.${extension || 'txt'}`; + return `mermaid_${family.replace(/-/g, '_')}.${extension || 'txt'}`; } export function mermaidPngDimensions(width, height, { diff --git a/web/src/lib/mermaidExport.test.js b/web/src/lib/mermaidExport.test.js index 2929edfd6..d0cebbd3c 100644 --- a/web/src/lib/mermaidExport.test.js +++ b/web/src/lib/mermaidExport.test.js @@ -113,9 +113,9 @@ await test('Mermaid families produce deterministic export filenames', () => { ]; for (const [source, family] of cases) { assert.equal(mermaidFamilyFromSource(source), family); - assert.equal(mermaidExportFilename({ source }, 'svg'), `mermaid-${family}.svg`); + assert.equal(mermaidExportFilename({ source }, 'svg'), `mermaid_${family}.svg`); } - assert.equal(mermaidExportFilename(detail, 'source'), 'mermaid-flowchart.mmd'); + assert.equal(mermaidExportFilename(detail, 'source'), 'mermaid_flowchart.mmd'); }); await test('PNG dimensions prefer 2x but obey dimension and pixel caps', () => { @@ -131,14 +131,14 @@ await test('PNG dimensions prefer 2x but obey dimension and pixel caps', () => { await test('SVG and source export download exact retained artifacts and revoke URLs', async () => { const svgHarness = downloadHarness(); const svgResult = exportMermaidSvg(detail, { BlobType: Blob, ...svgHarness.options }); - assert.deepEqual(svgResult, { ok: true, filename: 'mermaid-flowchart.svg' }); - assert.equal(svgHarness.anchors[0].download, 'mermaid-flowchart.svg'); + assert.deepEqual(svgResult, { ok: true, filename: 'mermaid_flowchart.svg' }); + assert.equal(svgHarness.anchors[0].download, 'mermaid_flowchart.svg'); assert.equal(await svgHarness.blobs.get(svgHarness.anchors[0].href).text(), detail.svg); assert.deepEqual(svgHarness.revoked, [svgHarness.anchors[0].href]); const sourceHarness = downloadHarness(); const sourceResult = exportMermaidSource(detail, { BlobType: Blob, ...sourceHarness.options }); - assert.deepEqual(sourceResult, { ok: true, filename: 'mermaid-flowchart.mmd' }); + assert.deepEqual(sourceResult, { ok: true, filename: 'mermaid_flowchart.mmd' }); assert.equal(await sourceHarness.blobs.get(sourceHarness.anchors[0].href).text(), detail.source); assert.deepEqual(sourceHarness.revoked, [sourceHarness.anchors[0].href]); }); @@ -198,7 +198,7 @@ await test('PNG export paints the theme, downloads 2x output, and cleans both UR ImageType: FakeImage, ...harness.options, }); - assert.deepEqual(result, { ok: true, filename: 'mermaid-flowchart.png' }); + assert.deepEqual(result, { ok: true, filename: 'mermaid_flowchart.png' }); assert.equal(canvas.width, 320); assert.equal(canvas.height, 160); assert.deepEqual(drawCalls[0], ['fill', '#333333', 0, 0, 320, 160]); diff --git a/web/src/lib/modelSettings.js b/web/src/lib/modelSettings.js index 29b1e14d6..51c7b453e 100644 --- a/web/src/lib/modelSettings.js +++ b/web/src/lib/modelSettings.js @@ -398,7 +398,7 @@ export function normalizeProviderModelQuery(payload, expectedProviderId = '') { }; } -function normalizeSampling(value, label) { +export function normalizeSampling(value, label) { if (value === undefined || value === null) return {}; const object = requireObject(value, label); const output = {}; @@ -415,7 +415,7 @@ function normalizeSampling(value, label) { return output; } -function samplingDraftFields(sampling = {}) { +export function samplingDraftFields(sampling = {}) { return Object.fromEntries(MODEL_SAMPLING_FIELDS.map((field) => [ field, typeof sampling?.[field] === 'number' ? String(sampling[field]) : '', @@ -914,7 +914,7 @@ export function modelSamplingFieldSupport(draft, provider) { return { temperature: true, top_p: true, top_k: true, min_p: true }; } -function samplingFieldValid(field, value, provider) { +export function samplingFieldValid(field, value, provider) { if (!Number.isFinite(value)) return false; switch (field) { case 'temperature': diff --git a/web/src/lib/pastedText.js b/web/src/lib/pastedText.js index ad2a9445a..08434e49a 100644 --- a/web/src/lib/pastedText.js +++ b/web/src/lib/pastedText.js @@ -468,14 +468,14 @@ let lastFileStamp = ''; let fileStampSerial = 0; const pad = (value, width = 2) => String(value).padStart(width, '0'); -// pasted-text-YYYYMMDD-HHMMSS[_s][-k].txt:ASCII、同一页面内唯一。 +// pasted_text_YYYYMMDD_HHMMSS[_s][_k].txt:ASCII、同一页面内唯一。 // 同一秒内的第二次粘贴加 _2、_3…(否则 composerFileIdentity 会把两次相同粘贴去重); // 多段时 index 为 1..n 的段号,单段传 0。一次粘贴的各段须传同一个 now,且从 // index 0 或 1 开始按顺序调用(index <= 1 视为新的一次粘贴)。 export function pastedTextFileName(now = new Date(), index = 0) { const date = now instanceof Date ? now : new Date(now); const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` - + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; + + `_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; if (index <= 1) { if (stamp === lastFileStamp) fileStampSerial += 1; else { @@ -484,8 +484,8 @@ export function pastedTextFileName(now = new Date(), index = 0) { } } const serial = fileStampSerial > 1 ? `_${fileStampSerial}` : ''; - const chunk = index > 0 ? `-${index}` : ''; - return `pasted-text-${stamp}${serial}${chunk}.txt`; + const chunk = index > 0 ? `_${index}` : ''; + return `pasted_text_${stamp}${serial}${chunk}.txt`; } // 上传请求体里的来源描述,服务端存进附件记录 metadata(origin / pasted_text)。 diff --git a/web/src/lib/pastedText.test.js b/web/src/lib/pastedText.test.js index 193a9cc7d..527c0bd3b 100644 --- a/web/src/lib/pastedText.test.js +++ b/web/src/lib/pastedText.test.js @@ -124,11 +124,11 @@ run('pasted file names are ASCII and unique', () => { const now = new Date(2026, 8, 24, 10, 15, 0); const first = pastedTextFileName(now, 0); const second = pastedTextFileName(now, 0); - assert.equal(first, 'pasted-text-20260924-101500.txt'); + assert.equal(first, 'pasted_text_20260924_101500.txt'); assert.notEqual(first, second); const chunk1 = pastedTextFileName(now, 1); const chunk2 = pastedTextFileName(now, 2); - assert.ok(chunk1.endsWith('-1.txt') && chunk2.endsWith('-2.txt')); + assert.ok(chunk1.endsWith('_1.txt') && chunk2.endsWith('_2.txt')); assert.equal(chunk1.slice(0, -6), chunk2.slice(0, -6)); assert.ok(new Set([first, second, chunk1, chunk2]).size === 4); for (const name of [first, second, chunk1, chunk2]) assert.match(name, /^[\x20-\x7e]+$/); diff --git a/web/src/lib/securityCenter.js b/web/src/lib/securityCenter.js index a65a393df..d95089cbe 100644 --- a/web/src/lib/securityCenter.js +++ b/web/src/lib/securityCenter.js @@ -462,8 +462,8 @@ export function auditSummaryCounts(summary) { // 导出文件名跟着后端的 Content-Disposition 走,拿不到就自己拼一个。 export function auditExportFilename(format, now = new Date()) { const pad = (value) => String(value).padStart(2, '0'); - const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; - return `acecode-audit-${stamp}.${format === 'csv' ? 'csv' : 'jsonl'}`; + const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + return `acecode_audit_${stamp}.${format === 'csv' ? 'csv' : 'jsonl'}`; } // --------------------------------------------------------------------------- diff --git a/web/src/lib/securityCenter.test.js b/web/src/lib/securityCenter.test.js index 7b41b7f8d..cde74fe9e 100644 --- a/web/src/lib/securityCenter.test.js +++ b/web/src/lib/securityCenter.test.js @@ -194,8 +194,8 @@ import { assert.deepEqual(auditSummaryCounts({ total: 9, by_decision: { allow: 4, allow_session: 1, deny: 2, forbidden: 1, blocked: 1 } }), { total: 9, allowed: 5, denied: 3, blocked: 1 }); assert.deepEqual(auditSummaryCounts(null), { total: 0, allowed: 0, denied: 0, blocked: 0 }); - assert.equal(auditExportFilename('csv', new Date(2026, 8, 17, 9, 5, 3)), 'acecode-audit-20260917-090503.csv'); - assert.equal(auditExportFilename('jsonl', new Date(2026, 0, 1, 0, 0, 0)), 'acecode-audit-20260101-000000.jsonl'); + assert.equal(auditExportFilename('csv', new Date(2026, 8, 17, 9, 5, 3)), 'acecode_audit_20260917_090503.csv'); + assert.equal(auditExportFilename('jsonl', new Date(2026, 0, 1, 0, 0, 0)), 'acecode_audit_20260101_000000.jsonl'); } // 场景:错误码归一。期望:404 → 不支持,401 → 认证,503+AUDIT_UNAVAILABLE 保留, From a4f0250e53cfef3051efe248538cca5028890a19 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 16:40:23 +0800 Subject: [PATCH 21/27] Keep companion resize grip stable and clear of session cards --- assets/desktop_pet/classic_pet.js | 9 +++++++-- web/scripts/test-classic-desktop-pets.mjs | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/assets/desktop_pet/classic_pet.js b/assets/desktop_pet/classic_pet.js index 99cc5521f..fbeb6f8a5 100644 --- a/assets/desktop_pet/classic_pet.js +++ b/assets/desktop_pet/classic_pet.js @@ -221,9 +221,14 @@ if (hovered && hoverPoint) bodies.push(toWindow([hoverPoint.x - 3, hoverPoint.y - 3, hoverPoint.x + 3, hoverPoint.y + 3])); const grip = $('grip'); grip.hidden = !bodyBounds; - if (bodyBounds) { + // It follows the animated outline, but holds still under the pointer so it can be grabbed. + // It also stays below the collapsed card stack: that box (with its shadow padding) sits above + // the grip and would swallow the pointer, and macOS treats it as foreground, not the grip. + if (bodyBounds && !grip.matches(':hover')) { + const belowCards = stack.hidden || expanded ? 0 : stack.getBoundingClientRect().bottom; + const top = Math.max(0, belowCards, bodyBounds[1] * r.height / cv.height - grip.offsetHeight); grip.style.left = Math.max(0, bodyBounds[0] * r.width / cv.width - grip.offsetWidth) + 'px'; - grip.style.top = Math.max(0, bodyBounds[1] * r.height / cv.height - grip.offsetHeight) + 'px'; + grip.style.top = Math.min(Math.max(0, innerHeight - grip.offsetHeight), top) + 'px'; } // No card, no rectangle: the empty space above the pet stays click-through. const message = { type: 'overlay', controls: null, diff --git a/web/scripts/test-classic-desktop-pets.mjs b/web/scripts/test-classic-desktop-pets.mjs index 1804b747f..1fb290161 100644 --- a/web/scripts/test-classic-desktop-pets.mjs +++ b/web/scripts/test-classic-desktop-pets.mjs @@ -171,7 +171,14 @@ try { await page.mouse.move(215, 240); await page.waitForFunction(() => ClassicPet.hovered); assert.equal((await pet()).animation, expect.jumping, `${kind} jumps on hover`); - assert.equal((await overlay()).bubbles.length, 2, 'hovered pet keeps its whole box hit-testable'); + // 场景:鼠标停在宠物身上(openspec tighten-companion-pet-hit-area)。旧行为:悬停时整张精灵框都拦截点击, + // 宠物四周的透明部分也挡住后面的窗口。期望:悬停时仍按轮廓横条上报,只在鼠标下多一个 6×6 画布像素的 + // 锚点(动作跳开时悬停不闪断);缩放三角形不混在轮廓里,单独作为 grip 上报。 + const hoverRegion = await overlay(), anchor = hoverRegion.bubbles.at(-1); + assert.ok(hoverRegion.bubbles.length > 2, 'hovered pet still reports its silhouette bands, not one sprite box'); + assert.ok(Math.abs((anchor[2] - anchor[0]) * 344 - 6) < 0.01 && Math.abs((anchor[3] - anchor[1]) * 252 - 6) < 0.01, 'hover anchor is a small square'); + assert.ok(anchor[0] <= 215 / 430 && anchor[2] >= 215 / 430 && anchor[1] <= 240 / 315 && anchor[3] >= 240 / 315, 'hover anchor sits under the pointer'); + assert.ok(Array.isArray(hoverRegion.grip), 'the resize grip is reported on its own'); await page.mouse.click(215, 240); assert.equal((await pet()).animation, expect.click, `${kind} reacts to a click`); await page.waitForTimeout(500); @@ -311,9 +318,17 @@ try { const gripOpacity = () => page.locator('#grip').evaluate(node => Number(getComputedStyle(node).opacity)); await page.waitForTimeout(200); assert.equal(await gripOpacity(), 0, 'resize handle hidden while hovering the pet'); - const gripBox = await page.locator('#grip').boundingBox(); - await page.mouse.move(gripBox.x + 8, gripBox.y + 8); + // 三角形跟着动画轮廓的左上角走,量完位置再移过去时它可能已经挪开,所以对准几次; + // 一旦鼠标停在上面它就不再移动(bug 表现:三角形一直跟着动作跑,悬停和按住都抓不住)。 + const onGrip = () => page.evaluate(() => document.getElementById('grip').matches(':hover')); + for (let attempt = 0; attempt < 20 && !(await onGrip()); attempt++) { + const gripBox = await page.locator('#grip').boundingBox(); + await page.mouse.move(gripBox.x + 8, gripBox.y + 8); + } + assert.ok(await onGrip(), 'pointer reaches the resize handle'); await page.waitForFunction(() => Number(getComputedStyle(document.getElementById('grip')).opacity) === 1); + await page.waitForTimeout(1200); + assert.ok(await onGrip(), 'the resize handle holds still under the pointer while the pet keeps moving'); await away(); await page.waitForTimeout(200); assert.equal(await gripOpacity(), 0, 'resize handle hides again once the pointer leaves'); From b2ceb464f5f97cd480545bfe704c9a056b9f85bc Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 16:46:19 +0800 Subject: [PATCH 22/27] Update merged browser performance documentation to the current source layout --- docs/agent-browser-performance.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/agent-browser-performance.md b/docs/agent-browser-performance.md index 7659a280c..04f372793 100644 --- a/docs/agent-browser-performance.md +++ b/docs/agent-browser-performance.md @@ -90,7 +90,7 @@ MVP 明确不处理 shadow root、iframe、canvas、文件上传、弹出标签 ### 3.1 一次 `browser_click` 是 7 次 IPC 请求 -`src/tool/agent_browser/browser_tools.cpp:653-686`: +`src/adapters/tool/agent_browser/browser_tools.cpp:653-686`: | # | 调用 | 位置 | 备注 | | --- | --- | --- | --- | @@ -110,7 +110,7 @@ MVP 明确不处理 shadow root、iframe、canvas、文件上传、弹出标签 ### 3.2 传输层每个请求新建一条连接 `AgentBrowserCdpClient::request()` 每次调用都完整走一遍:`open_proxy_pipe` / -`open_proxy_socket`(`src/tool/agent_browser/cdp_client.cpp:551` / `:562`)、写 u32 长度 + +`open_proxy_socket`(`src/adapters/tool/agent_browser/cdp_client.cpp:551` / `:562`)、写 u32 长度 + JSON、读 u32 长度 + JSON、写 1 字节 ack、`close_pipe()`(`:622-627`)。 即:**一次点击建立并拆除 7 条命名管道或 Unix socket**,没有任何连接复用。 @@ -294,7 +294,7 @@ ACECode 已有两处同形状的先例可以复用设计:`spawn_subagent` 的 只看墙钟会把模型侧的抖动误判成改造效果。jev 自己也强调它那组数据是「单任务、单 浏览器配置、三次重复」,不是通用可靠性基准;我们的口径同样要写清样本条件。 -改动集中在 `src/tool/agent_browser/browser_tools.cpp` 与 `cdp_client.cpp`, +改动集中在 `src/adapters/tool/agent_browser/browser_tools.cpp` 与 `cdp_client.cpp`, 按 AGENTS.md 的命令集跑 `acecode_unit_tests`;杠杆 4 的连接复用需要 Windows 与 macOS 两端实测,因为传输实现不同(命名管道与 Unix socket)。 From 6277603f94dca97271bed988ec91f7b498f83dc6 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 16:52:05 +0800 Subject: [PATCH 23/27] Detect both legacy and underscore daemon directories before rebuilding --- .../use-underscore-runtime-names/tasks.md | 1 + scripts/dev_environment.py | 13 +++++--- tests/scripts/dev_environment_review_test.py | 31 ++++++++++++------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/openspec/changes/use-underscore-runtime-names/tasks.md b/openspec/changes/use-underscore-runtime-names/tasks.md index 9be1a33e6..8da07b354 100644 --- a/openspec/changes/use-underscore-runtime-names/tasks.md +++ b/openspec/changes/use-underscore-runtime-names/tasks.md @@ -21,3 +21,4 @@ - layer、文档路径、所有权静态闸门与严格 OpenSpec 验证通过。 - FTXUI overlay 在 vcpkg 私有构建目录复制源码后修改输入追踪文件名,原子模块保持干净;port-version 为 9。 - 最终 MinSizeRel CLI 与 Desktop 编译成功,并启动包含改动的新 Desktop 实例;macOS 源码调整未在本机编译或运行。 +- 发布集成复核:修正开发启动器重建前的占用检查,同时扫描 `dev-run` / `dev_run` 和连字符 / 下划线会话目录;覆盖两代目录同时存在且旧目录为空的四种组合,保持仅检查 daemon 身份、不自动停止进程。开发脚本回归通过。无工作区路径断言已同步为 `no_workspace`。 diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py index 3b08c3f14..c09ff669f 100644 --- a/scripts/dev_environment.py +++ b/scripts/dev_environment.py @@ -655,13 +655,18 @@ def vite_options(extra: list[str]) -> list[str]: def web_runtime_is_available(root: Path, candidate: BuildCandidate | None, extra: list[str]) -> bool: run_dir = selected_web_runtime_dir(root, extra) explicit = any(arg == "--run-dir" or arg.startswith("--run-dir=") for arg in extra) - if not explicit and run_dir.parent.is_dir(): + if not explicit: # A new commit changes the default run-dir name but an older worker # can still hold this build's executable open. Inspect only this # worktree's own launcher directories, without stopping any process. - prefix = worktree_prefix(root) - for previous in sorted(run_dir.parent.iterdir()): - if previous.name.startswith(prefix) and (previous / "daemon.pid").exists(): + prefixes = (worktree_prefix(root), re.sub(r"[^A-Za-z0-9_.-]", "-", root.name) + "-") + bases = (run_dir.parent, root / ".acecode" / "dev_run", root / ".acecode" / "dev-run") + for base in dict.fromkeys(bases): + if not base.is_dir(): + continue + previous = next((path for path in sorted(base.iterdir()) + if path.name.startswith(prefixes) and (path / "daemon.pid").exists()), None) + if previous is not None: run_dir = previous break if not (run_dir / "daemon.pid").exists(): diff --git a/tests/scripts/dev_environment_review_test.py b/tests/scripts/dev_environment_review_test.py index 84895b381..17c0d23d7 100644 --- a/tests/scripts/dev_environment_review_test.py +++ b/tests/scripts/dev_environment_review_test.py @@ -201,17 +201,26 @@ def test_runtime_failure_prevents_configuration_build_and_launch(self): launch.assert_not_called() def test_runtime_from_previous_commit_also_blocks_rebuild(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - candidate = self.make_candidate(root) - previous = root / ".acecode/dev-run" / (root.name + "-oldcommit") - previous.mkdir(parents=True) - (previous / "daemon.pid").write_text("123", encoding="ascii") - with patch.object(launcher, "current_commit", return_value="newcommit"), \ - patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run, \ - contextlib.redirect_stderr(io.StringIO()): - self.assertFalse(launcher.web_runtime_is_available(root, candidate, [])) - self.assertEqual(run.call_args.args[0][-1], f"--run-dir={previous}") + for base in ("dev-run", "dev_run"): + for identity in ("work-tree-oldcommit", "work_tree_oldcommit"): + with self.subTest(base=base, identity=identity), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() / "work-tree" + root.mkdir() + candidate = self.make_candidate(root) + # Both generations can coexist after an upgrade; an empty legacy + # root must not hide a worker using the underscore naming scheme. + for name in ("dev-run", "dev_run"): + (root / ".acecode" / name).mkdir(parents=True) + previous = root / ".acecode" / base / identity + previous.mkdir() + (previous / "daemon.pid").write_text("123", encoding="ascii") + with patch.object(launcher, "current_commit", return_value="newcommit"), \ + patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run, \ + contextlib.redirect_stderr(io.StringIO()): + self.assertFalse(launcher.web_runtime_is_available(root, candidate, [])) + self.assertEqual(run.call_args.args[0][-1], f"--run-dir={previous}") + self.assertEqual(run.call_args.args[0][1:3], ["daemon", "status"]) + self.assertEqual((previous / "daemon.pid").read_text(encoding="ascii"), "123") def test_explicit_runtime_does_not_inspect_other_launcher_daemons(self): with tempfile.TemporaryDirectory() as directory: From b39b813f0d4bde6dec03c5da9d0d3fefbcc91fdb Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 16:54:00 +0800 Subject: [PATCH 24/27] Check canonical underscore filenames in the subprocess session regression --- tests/daemon/concurrent_session_e2e_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/daemon/concurrent_session_e2e_test.cpp b/tests/daemon/concurrent_session_e2e_test.cpp index f48cedc30..4dd539e02 100644 --- a/tests/daemon/concurrent_session_e2e_test.cpp +++ b/tests/daemon/concurrent_session_e2e_test.cpp @@ -78,8 +78,8 @@ TEST(SessionConcurrencyE2E, TwoRealSubprocessesProduceCanonicalSessionFiles) { << "project_dir should be created by a writer subprocess: " << project_dir.string(); - std::regex canonical_re(R"(^\d{8}-\d{6}-[0-9a-f]{4}\.jsonl$)"); - std::regex pid_re(R"(^\d{8}-\d{6}-[0-9a-f]{4}-\d+\.jsonl$)"); + std::regex canonical_re(R"(^\d{8}_\d{6}_[0-9a-f]{4}\.jsonl$)"); + std::regex pid_re(R"(^\d{8}[-_]\d{6}[-_][0-9a-f]{4}[-_]\d+\.jsonl$)"); std::vector jsonl_paths; for (const auto& e : fs::directory_iterator(project_dir)) { if (!e.is_regular_file()) continue; From 318cc871465917b89c0b105b3a3a4be027e4079f Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 17:01:00 +0800 Subject: [PATCH 25/27] Preserve both redirect filenames when migrating and cleaning data directories --- .../specs/runtime-file-naming/spec.md | 9 +++++++ .../use-underscore-runtime-names/tasks.md | 1 + src/base/environment/data_dir_migration.cpp | 14 ++++++++--- tests/environment/data_dir_migration_test.cpp | 25 ++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/openspec/changes/use-underscore-runtime-names/specs/runtime-file-naming/spec.md b/openspec/changes/use-underscore-runtime-names/specs/runtime-file-naming/spec.md index 66907c5e7..bb9a5034f 100644 --- a/openspec/changes/use-underscore-runtime-names/specs/runtime-file-naming/spec.md +++ b/openspec/changes/use-underscore-runtime-names/specs/runtime-file-naming/spec.md @@ -19,3 +19,12 @@ ACECode MUST continue discovering and reading existing files with hyphen names. #### Scenario: Feedback collects mixed log names - **WHEN** old hyphen logs and new underscore logs coexist - **THEN** log discovery includes the applicable logs and selects recent files using modification times + +#### Scenario: Clean up a relocated data directory +- **WHEN** the default directory contains either or both generations of the data-directory redirect file +- **THEN** cleanup preserves both redirect names, keeps the active redirect readable and does not count redirect files as remaining user data +- **AND** subsequent data migrations exclude both redirect names from copied data + +#### Scenario: Rebuild with a daemon from a previous commit +- **WHEN** an earlier daemon still owns a runtime directory using either naming generation +- **THEN** the development launcher detects it across both runtime roots and blocks the rebuild without stopping the process diff --git a/openspec/changes/use-underscore-runtime-names/tasks.md b/openspec/changes/use-underscore-runtime-names/tasks.md index 8da07b354..f951fae00 100644 --- a/openspec/changes/use-underscore-runtime-names/tasks.md +++ b/openspec/changes/use-underscore-runtime-names/tasks.md @@ -22,3 +22,4 @@ - FTXUI overlay 在 vcpkg 私有构建目录复制源码后修改输入追踪文件名,原子模块保持干净;port-version 为 9。 - 最终 MinSizeRel CLI 与 Desktop 编译成功,并启动包含改动的新 Desktop 实例;macOS 源码调整未在本机编译或运行。 - 发布集成复核:修正开发启动器重建前的占用检查,同时扫描 `dev-run` / `dev_run` 和连字符 / 下划线会话目录;覆盖两代目录同时存在且旧目录为空的四种组合,保持仅检查 daemon 身份、不自动停止进程。开发脚本回归通过。无工作区路径断言已同步为 `no_workspace`。 +- 发布集成补验发现数据目录迁移的排除、清理与剩余数据统计仍只识别旧重定向文件名;已统一识别两代名称并保留两代并存时的旧文件优先级,新增回归待本轮原生重编译验证。两个真实子进程的会话文件用例已按新下划线命名更新,并继续禁止两代 PID 后缀。 diff --git a/src/base/environment/data_dir_migration.cpp b/src/base/environment/data_dir_migration.cpp index 4fe98318c..8401183a4 100644 --- a/src/base/environment/data_dir_migration.cpp +++ b/src/base/environment/data_dir_migration.cpp @@ -3,6 +3,7 @@ #include "data_dir_migration.hpp" #include "utils/encoding.hpp" +#include "utils/generated_file_names.hpp" #include "utils/logger.hpp" #include "utils/utf8_path.hpp" #include "utils/uuid.hpp" @@ -25,6 +26,11 @@ namespace fs = std::filesystem; namespace { std::shared_mutex write_gate; std::atomic writes_blocked{false}; + +bool is_data_dir_redirect_file(const fs::path& relative) { + return relative == fs::path(kDataDirRedirectFileName) || + relative == fs::path(generated_file_name(kDataDirRedirectFileName)); +} } std::shared_mutex& data_dir_write_mutex() { return write_gate; } bool data_dir_writes_blocked() { return writes_blocked.load(); } @@ -350,7 +356,7 @@ bool migration_excludes_entry(const fs::path& relative) { // edge-app-profile:webapp 兼容模式下 Edge --app 的 user-data-dir,每次启动都用全新的 // PID 子目录并清掉旧的,持续在写又没有要保留的数据,整个排除。 if (first == "run" || first == "tmp" || first == "edge-app-profile" || first == "edge_app_profile") return true; - if (first == kDataDirRedirectFileName && std::distance(relative.begin(), relative.end()) == 1) { + if (is_data_dir_redirect_file(relative)) { return true; } // cache/no-workspace//.acecode/tmp/**:无工作区会话的 ACECODE_TMPDIR(临时脚本、 @@ -465,7 +471,7 @@ MigrationProgress run_data_dir_migration(const std::string& current_dir, const fs::path relative = it->path().lexically_relative(source_io); if (migration_excludes_entry(relative)) { // 指针文件清理时保留,不算旧数据。 - if (relative != fs::path(kDataDirRedirectFileName)) { + if (!is_data_dir_redirect_file(relative)) { left_behind_bytes += measure_left_behind_bytes(it->path()); } if (it->is_directory(ec)) it.disable_recursion_pending(); @@ -802,7 +808,7 @@ std::string cleanup_previous_data_dir(const std::string& previous_dir, prefix_key(canonical_or_normal(path_from_utf8(default_dir))); std::string first_error; for (const auto& entry : fs::directory_iterator(prev_io, ec)) { - if (keep_pointer && entry.path().filename() == kDataDirRedirectFileName) continue; + if (keep_pointer && is_data_dir_redirect_file(entry.path().filename())) continue; std::error_code rm_ec; fs::remove_all(entry.path(), rm_ec); if (rm_ec && first_error.empty()) { @@ -849,7 +855,7 @@ DataDirStatus data_dir_status(RunMode mode) { // 默认目录只剩指针文件时不算"仍有旧数据"。 bool has_content = false; for (const auto& entry : fs::directory_iterator(prev, ec)) { - if (entry.path().filename() == kDataDirRedirectFileName) continue; + if (is_data_dir_redirect_file(entry.path().filename())) continue; has_content = true; break; } diff --git a/tests/environment/data_dir_migration_test.cpp b/tests/environment/data_dir_migration_test.cpp index 555bf022f..8f4c5076c 100644 --- a/tests/environment/data_dir_migration_test.cpp +++ b/tests/environment/data_dir_migration_test.cpp @@ -222,6 +222,8 @@ TEST_F(DataDirMigrationTest, ExclusionRules) { EXPECT_TRUE(migration_excludes_entry(fs::path("config.json.lock"))); EXPECT_TRUE(migration_excludes_entry(fs::path("projects") / "a" / ".writer.lock")); EXPECT_TRUE(migration_excludes_entry(fs::path(acecode::kDataDirRedirectFileName))); + EXPECT_TRUE(migration_excludes_entry(fs::path("data_dir.redirect.json"))); + EXPECT_FALSE(migration_excludes_entry(fs::path("projects") / "data_dir.redirect.json")); EXPECT_FALSE(migration_excludes_entry(fs::path("projects") / "a" / "s.jsonl")); EXPECT_FALSE(migration_excludes_entry(fs::path("runbook.md"))); EXPECT_FALSE(migration_excludes_entry(fs::path("memory"))); @@ -284,6 +286,7 @@ TEST_F(DataDirMigrationTest, MigrationCopiesAndWritesPointer) { EXPECT_FALSE(fs::exists(target / "tmp")); EXPECT_FALSE(fs::exists(target / "config.json.lock")); EXPECT_FALSE(fs::exists(target / acecode::kDataDirRedirectFileName)); + EXPECT_FALSE(fs::exists(target / "data_dir.redirect.json")); // 复制字节数 = 被复制文件大小之和(排除项不计)。 unsigned long long expected = 0; @@ -365,7 +368,7 @@ TEST_F(DataDirMigrationTest, CleanupPreviousKeepsPointerOnlyInDefaultDir) { ASSERT_TRUE(acecode::write_data_dir_redirect(s(default_dir), r)); EXPECT_EQ(cleanup_previous_data_dir(s(default_dir), s(default_dir)), ""); - EXPECT_TRUE(fs::exists(default_dir / acecode::kDataDirRedirectFileName)); + EXPECT_TRUE(fs::exists(acecode::path_from_utf8(acecode::data_dir_redirect_path(s(default_dir))))); EXPECT_FALSE(fs::exists(default_dir / "config.json")); EXPECT_FALSE(fs::exists(default_dir / "projects")); @@ -389,6 +392,26 @@ TEST_F(DataDirMigrationTest, CleanupPreviousKeepsPointerOnlyInDefaultDir) { EXPECT_FALSE(back->cleanup_pending); } +TEST_F(DataDirMigrationTest, CleanupPreservesBothRedirectNamingGenerations) { + populate_source(default_dir); + write_file(default_dir / acecode::kDataDirRedirectFileName, "{}"); + acecode::DataDirRedirect redirect; + redirect.data_dir = s(root / "elsewhere"); + redirect.previous_data_dir = s(default_dir); + redirect.cleanup_pending = true; + ASSERT_TRUE(acecode::write_data_dir_redirect(s(default_dir), redirect)); + // An older installation still owns the hyphenated pointer when both exist. + write_file(default_dir / "data_dir.redirect.json", "{}"); + + EXPECT_EQ(cleanup_previous_data_dir(s(default_dir), s(default_dir)), ""); + EXPECT_TRUE(fs::exists(default_dir / acecode::kDataDirRedirectFileName)); + EXPECT_TRUE(fs::exists(default_dir / "data_dir.redirect.json")); + const auto retained = acecode::read_data_dir_redirect(s(default_dir)); + ASSERT_TRUE(retained.has_value()); + EXPECT_EQ(retained->data_dir, redirect.data_dir); + EXPECT_FALSE(data_dir_status(acecode::RunMode::User).previous_exists); +} + // 场景:状态汇总与 100 MB 阈值。 // 期望:大迁移 + pending + 旧目录仍有内容 → cleanup_prompt;小迁移 → 不提示但 // previous_dir 仍报出;acknowledge 后不再提示;没有指针 → 纯默认状态。 From b58a1b4a3f252ca67e61583d460f597d9529c7ca Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 17:14:32 +0800 Subject: [PATCH 26/27] Align integration regressions with generated filenames and record Windows validation --- .../changes/use-underscore-runtime-names/tasks.md | 4 +++- tests/tool/ios_simulator/xcode_locator_test.cpp | 2 +- tests/upgrade/upgrade_apply_test.cpp | 6 +++--- tests/web/web_server_smoke_test.cpp | 14 +++++++------- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/openspec/changes/use-underscore-runtime-names/tasks.md b/openspec/changes/use-underscore-runtime-names/tasks.md index f951fae00..39f448a4e 100644 --- a/openspec/changes/use-underscore-runtime-names/tasks.md +++ b/openspec/changes/use-underscore-runtime-names/tasks.md @@ -22,4 +22,6 @@ - FTXUI overlay 在 vcpkg 私有构建目录复制源码后修改输入追踪文件名,原子模块保持干净;port-version 为 9。 - 最终 MinSizeRel CLI 与 Desktop 编译成功,并启动包含改动的新 Desktop 实例;macOS 源码调整未在本机编译或运行。 - 发布集成复核:修正开发启动器重建前的占用检查,同时扫描 `dev-run` / `dev_run` 和连字符 / 下划线会话目录;覆盖两代目录同时存在且旧目录为空的四种组合,保持仅检查 daemon 身份、不自动停止进程。开发脚本回归通过。无工作区路径断言已同步为 `no_workspace`。 -- 发布集成补验发现数据目录迁移的排除、清理与剩余数据统计仍只识别旧重定向文件名;已统一识别两代名称并保留两代并存时的旧文件优先级,新增回归待本轮原生重编译验证。两个真实子进程的会话文件用例已按新下划线命名更新,并继续禁止两代 PID 后缀。 +- 发布集成补验发现数据目录迁移的排除、清理与剩余数据统计仍只识别旧重定向文件名;已统一识别两代名称并保留两代并存时的旧文件优先级,新增回归及相关原生复测 119/119 通过。两个真实子进程的会话文件用例已按新下划线命名更新,并继续禁止两代 PID 后缀。 + +- 最终 Windows 集成全量运行 6,436 项;发现的 10 个失败(命名断言、Windows 路径夹具和重定向清理)已修复并通过 119 项定向复测。合并全量与复测结果:6,428 项通过、9 项平台/外部条件跳过、1 项原有禁用;记录为 `build/release-integration-20261016/native-validation.json`。 diff --git a/tests/tool/ios_simulator/xcode_locator_test.cpp b/tests/tool/ios_simulator/xcode_locator_test.cpp index c9a85fb21..d0d132237 100644 --- a/tests/tool/ios_simulator/xcode_locator_test.cpp +++ b/tests/tool/ios_simulator/xcode_locator_test.cpp @@ -72,7 +72,7 @@ TEST_F(XcodeLocatorTest, InspectsGreenInstallsAndRejectsIncompleteCopies) { EXPECT_EQ(install->version, "26.6"); EXPECT_EQ(install->build, "17F113"); EXPECT_TRUE(install->simulator_kit); - EXPECT_TRUE(inspect_xcode((app / "Contents/Developer").string()).has_value()); + EXPECT_TRUE(inspect_xcode((app / "Contents/Developer").generic_string()).has_value()); fs::create_directories(root / "Broken.app/Contents"); std::string why; diff --git a/tests/upgrade/upgrade_apply_test.cpp b/tests/upgrade/upgrade_apply_test.cpp index 4ed536831..69d603e1f 100644 --- a/tests/upgrade/upgrade_apply_test.cpp +++ b/tests/upgrade/upgrade_apply_test.cpp @@ -165,11 +165,11 @@ TEST(UpgradeApply, BuildsAndParsesRunnerArguments) { "\"C:/Program Files/ACECode/acecode.exe\""); auto runner = make_runner_path(1234, "C:/Program Files/ACECode"); - EXPECT_EQ(runner.parent_path().filename(), ".acecode-update-runner"); + EXPECT_EQ(runner.parent_path().filename(), ".acecode_update_runner"); #ifdef _WIN32 - EXPECT_EQ(runner.filename(), "acecode-update-runner-1234.exe"); + EXPECT_EQ(runner.filename(), "acecode_update_runner_1234.exe"); #else - EXPECT_EQ(runner.filename(), "acecode-update-runner-1234"); + EXPECT_EQ(runner.filename(), "acecode_update_runner_1234"); #endif } diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index a5119918a..20b6a0e00 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -9224,7 +9224,7 @@ TEST(WebServerHttp, CustomThemeExportProtectsBuiltinsAndDownloadsAuthenticatedZi ASSERT_EQ(downloaded.status_code, 200) << downloaded.text; EXPECT_EQ(response_header(downloaded, "Content-Type"), "application/zip"); EXPECT_EQ(response_header(downloaded, "X-Content-Type-Options"), "nosniff"); - EXPECT_NE(response_header(downloaded, "Content-Disposition").find("filename*=UTF-8''ACECode-"), std::string::npos); + EXPECT_NE(response_header(downloaded, "Content-Disposition").find("filename*=UTF-8''ACECode_"), std::string::npos); EXPECT_NE(response_header(downloaded, "Cache-Control").find("no-store"), std::string::npos); EXPECT_EQ(downloaded.text.substr(0, 2), "PK"); EXPECT_EQ(cpr::Get(cpr::Url{fx.url("/api/themes/exports/missing")}).status_code, 404); @@ -9588,7 +9588,7 @@ TEST(WebServerHttp, DesktopFeedbackUploadsSelectedSessionOnly) { ASSERT_EQ(r.status_code, 200) << r.text; auto body = json::parse(r.text); EXPECT_EQ(body["selected_session_id"], sid); - EXPECT_EQ(read_zip_entry(received_zip, "session/" + sid + ".jsonl"), + EXPECT_EQ(read_zip_entry(received_zip, "session/20260618_030000_abcf.jsonl"), "{\"role\":\"user\",\"content\":\"selected\"}\n"); EXPECT_FALSE(zip_entry_exists(received_zip, "session/other.jsonl")); auto metadata = json::parse(read_zip_entry(received_zip, "feedback.json")); @@ -9637,8 +9637,8 @@ TEST(WebServerHttp, DesktopFeedbackMaterializesEmptySelectedSession) { auto body = json::parse(r.text); EXPECT_EQ(body["selected_session_id"], sid); EXPECT_TRUE(std::filesystem::is_regular_file(session_jsonl)); - EXPECT_TRUE(zip_entry_exists(received_zip, "session/" + sid + ".jsonl")); - EXPECT_TRUE(read_zip_entry(received_zip, "session/" + sid + ".jsonl").empty()); + EXPECT_TRUE(zip_entry_exists(received_zip, "session/20260618_031500_abc0.jsonl")); + EXPECT_TRUE(read_zip_entry(received_zip, "session/20260618_031500_abc0.jsonl").empty()); std::error_code ec; std::filesystem::remove(received_zip, ec); @@ -9708,7 +9708,7 @@ TEST(WebServerHttp, DesktopFeedbackUploadsNoWorkspaceSessionTranscript) { auto body = json::parse(r.text); EXPECT_EQ(body["selected_session_id"], sid); EXPECT_EQ(body["workspace_hash"], ""); - EXPECT_EQ(read_zip_entry(received_zip, "session/" + sid + ".jsonl"), + EXPECT_EQ(read_zip_entry(received_zip, "session/20260923_170433_bd13.jsonl"), "{\"role\":\"user\",\"content\":\"no workspace\"}\n"); auto metadata = json::parse(read_zip_entry(received_zip, "feedback.json")); EXPECT_EQ(metadata["selected_session_id"], sid); @@ -11904,7 +11904,7 @@ TEST(WebServerHttp, ToolRewritesRoundTripPersistsJsonAndAppliesToProcess) { EXPECT_EQ(json::parse(enable.text)["rewrites"]["file_read"], "peek"); EXPECT_EQ(acecode::model_tool_name_for_native("file_read"), "peek"); { - std::ifstream ifs(fx.tmp_dir / acecode::tool_rewrites::kSettingsFileName); + std::ifstream ifs(fx.tmp_dir / "tool_rewrites.json"); ASSERT_TRUE(ifs.is_open()); auto saved = json::parse(ifs); EXPECT_EQ(saved["enabled"], true); @@ -13213,7 +13213,7 @@ TEST(SecurityCenterSmoke, AuditRoutesListSummarizeExportAndClear) { auto csv = cpr::Get(cpr::Url{fx.url("/api/security/audit/export?format=csv")}); ASSERT_EQ(csv.status_code, 200) << csv.text; EXPECT_NE(csv.header["Content-Type"].find("text/csv"), std::string::npos); - EXPECT_NE(csv.header["Content-Disposition"].find("attachment; filename=\"acecode-audit-"), std::string::npos); + EXPECT_NE(csv.header["Content-Disposition"].find("attachment; filename=\"acecode_audit_"), std::string::npos); EXPECT_EQ(csv.text.rfind("id,ts_ms,category,", 0), 0u); EXPECT_NE(csv.text.find("git status"), std::string::npos); auto jsonl = cpr::Get(cpr::Url{fx.url("/api/security/audit/export")}); From d28a1ea81e12db7f809649d77bffcb420ac135b0 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 17:15:04 +0800 Subject: [PATCH 27/27] Correct consolidated Windows validation counts --- openspec/changes/use-underscore-runtime-names/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/use-underscore-runtime-names/tasks.md b/openspec/changes/use-underscore-runtime-names/tasks.md index 39f448a4e..f165be534 100644 --- a/openspec/changes/use-underscore-runtime-names/tasks.md +++ b/openspec/changes/use-underscore-runtime-names/tasks.md @@ -24,4 +24,4 @@ - 发布集成复核:修正开发启动器重建前的占用检查,同时扫描 `dev-run` / `dev_run` 和连字符 / 下划线会话目录;覆盖两代目录同时存在且旧目录为空的四种组合,保持仅检查 daemon 身份、不自动停止进程。开发脚本回归通过。无工作区路径断言已同步为 `no_workspace`。 - 发布集成补验发现数据目录迁移的排除、清理与剩余数据统计仍只识别旧重定向文件名;已统一识别两代名称并保留两代并存时的旧文件优先级,新增回归及相关原生复测 119/119 通过。两个真实子进程的会话文件用例已按新下划线命名更新,并继续禁止两代 PID 后缀。 -- 最终 Windows 集成全量运行 6,436 项;发现的 10 个失败(命名断言、Windows 路径夹具和重定向清理)已修复并通过 119 项定向复测。合并全量与复测结果:6,428 项通过、9 项平台/外部条件跳过、1 项原有禁用;记录为 `build/release-integration-20261016/native-validation.json`。 +- 最终 Windows 集成全量运行 6,436 项;发现的 10 个失败(命名断言、Windows 路径夹具和重定向清理)已修复并通过 119 项定向复测。合并全量与复测结果:6,426 项通过、11 项平台/外部条件跳过、1 项原有禁用;记录为 `build/release-integration-20261016/native-validation.json`。