Compare commits

...
Author SHA1 Message Date
esengine dc6c69a5f8 feat(workbench): add a selection from an Office preview to the chat with its position
Drag across cells in an Excel preview or select text in a Word preview, and
the existing quote path carries it to the composer with a structural place:
sheet and cell range, or the body blocks it covers. The frame becomes
allow-same-origin so the page can read the selection; scripts stay off
through the sandbox and the CSP.

Refs #14622
2026-11-01 09:22:08 -08:00
YHH f6aa704059 Merge pull request #14915 from esengine/feat/office-preview
feat(workbench): preview Word and Excel files read-only
2026-11-01 08:55:17 -08:00
YHH dc5a49b8fa Merge pull request #14914 from esengine/fx/serve-password-file
fix(serve): read the password from --password-file instead of argv
2026-11-01 08:39:19 -08:00
esengine fb08cdc0d5 feat(workbench): preview Word and Excel files read-only
.docx and .xlsx were classed unsupported, so a delivered report or sheet
could only be opened in the system app. The kernel now serves them as an
office preview kind (binary, 32 MB), and the workbench renders them to static
HTML (docx-preview; ExcelJS with SSF number formats) shown through
staticDocument() in a sandbox="" frame, so nothing in a file runs or fetches.
Both parsers are lazy chunks, and a machine setting turns the preview off.

Refs #14622
2026-11-01 08:14:16 -08:00
esengine 2bd960b09d fix(serve): read the password from --password-file instead of argv
--password put the serve password in argv, where ps and /proc/<pid>/cmdline
show it to every local user; --hash-password needed it there too. Add
--password-file, held to the same single-line, owner-only rule as
--token-file, and accept it for --hash-password.

Fixes #14913
2026-11-01 08:12:54 -08:00
YHH f770f33791 Merge pull request #14912 from kevin9327/fix/driver-refusal-rune
fix(computer): cut a long CUA refusal reason on a rune boundary
2026-11-01 07:52:17 -08:00
YHH b58d62b589 Merge pull request #14911 from kevin9327/fix/eval-result-rune
fix(browser): cut a long eval result on a rune boundary
2026-11-01 07:52:09 -08:00
kevin 37e203408d fix(computer): cut a long CUA refusal reason on a rune boundary 2026-11-02 00:15:50 +09:00
kevin 2407ebd639 fix(browser): cut a long eval result on a rune boundary 2026-11-02 00:15:50 +09:00
YHH f247be74ea Merge pull request #14909 from esengine/fx/seed-aged-meta
test(serve): age seeded sessions through their branch meta
2026-11-01 06:22:56 -08:00
YHH f88faddf41 Merge pull request #14907 from kevin9327/fix/bounded-segment-rune
fix(evidence): cut quoted mutation-scan segments on a rune boundary
2026-11-01 06:22:45 -08:00
YHH 027668a0ee Merge pull request #14908 from kevin9327/fix/args-summary-rune
fix(recovery): cut ArgsSummary on a rune boundary
2026-11-01 05:55:08 -08:00
esengine b0c5d3287e test(serve): age seeded sessions through their branch meta
seedAged set file times only, but listings read activity from the branch
meta, which SaveSnapshot stamps with the write time. Every seeded session
was therefore 'now', ordered only by milliseconds of write order, and the
recent-order tests failed whenever two writes shared a millisecond.

Write the intended time into the meta as well as the transcript and event
log, so the seeded ages are the ones the listing reads.
2026-11-01 05:31:08 -08:00
kevin 498b834b71 fix(recovery): cut ArgsSummary on a rune boundary 2026-11-01 22:04:09 +09:00
kevin 2051fc937f fix(evidence): cut quoted mutation-scan segments on a rune boundary 2026-11-01 22:03:20 +09:00
YHH ee47753988 Merge pull request #14906 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-1dcfd208cf
chore(deps): bump the npm group in /desktop/frontend-next with 10 updates
2026-11-01 04:55:53 -08:00
YHH 5ecf313c3d Merge pull request #14901 from esengine/dependabot/go_modules/go-ccc6468e39
chore(deps): bump the go group with 4 updates
2026-11-01 04:55:14 -08:00
YHH fea800bac6 Merge pull request #14905 from esengine/dependabot/github_actions/actions-4430c5f07e
chore(deps): bump actions/setup-java from 4.9.1 to 6.1.0 in the actions group
2026-11-01 04:46:10 -08:00
esengine ba6bf964bb test(surface): read the default catalog as optional under a2ui 0.14
a2ui 0.14 types SurfaceModel.defaultCatalog as possibly undefined. Studio's
surface always names its catalog in createSurface, so the catalogue checks
still hold; an absent catalog now fails them rather than the type check.
2026-11-01 04:37:51 -08:00
esengine 82de6d9622 chore(deps): tidy the desktop module for the go group bump 2026-11-01 04:27:05 -08:00
dependabot[bot] 14dbabfec2 chore(deps): bump the npm group
Bumps the npm group in /desktop/frontend-next with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@a2ui/react](https://github.com/a2ui-project/a2ui/tree/HEAD/typescript/renderers/react) | `0.13.0` | `0.14.0` |
| [@a2ui/web_core](https://github.com/a2ui-project/a2ui/tree/HEAD/typescript/core/web_core) | `0.13.0` | `0.14.0` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.14` | `6.43.15` |
| [@mantine/charts](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/charts) | `9.7.3` | `9.7.4` |
| [@mantine/core](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/core) | `9.7.3` | `9.7.4` |
| [@mantine/hooks](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/hooks) | `9.7.3` | `9.7.4` |
| [katex](https://github.com/KaTeX/KaTeX) | `0.19.0` | `0.19.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.61.0` | `1.64.0` |
| [maplibre-gl](https://github.com/maplibre/maplibre-gl-js) | `6.15.0` | `6.16.0` |
| [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) | `8.0.7` | `8.0.8` |


Updates `@a2ui/react` from 0.13.0 to 0.14.0
- [Release notes](https://github.com/a2ui-project/a2ui/releases)
- [Changelog](https://github.com/a2ui-project/a2ui/blob/main/typescript/renderers/react/CHANGELOG.md)
- [Commits](https://github.com/a2ui-project/a2ui/commits/HEAD/typescript/renderers/react)

Updates `@a2ui/web_core` from 0.13.0 to 0.14.0
- [Release notes](https://github.com/a2ui-project/a2ui/releases)
- [Changelog](https://github.com/a2ui-project/a2ui/blob/main/typescript/core/web_core/CHANGELOG.md)
- [Commits](https://github.com/a2ui-project/a2ui/commits/HEAD/typescript/core/web_core)

Updates `@codemirror/view` from 6.43.14 to 6.43.15
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@mantine/charts` from 9.7.3 to 9.7.4
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/9.7.4/packages/@mantine/charts)

Updates `@mantine/core` from 9.7.3 to 9.7.4
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/9.7.4/packages/@mantine/core)

Updates `@mantine/hooks` from 9.7.3 to 9.7.4
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/9.7.4/packages/@mantine/hooks)

Updates `katex` from 0.19.0 to 0.19.1
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.19.0...v0.19.1)

Updates `lucide-react` from 1.61.0 to 1.64.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.64.0/packages/lucide-react)

Updates `maplibre-gl` from 6.15.0 to 6.16.0
- [Release notes](https://github.com/maplibre/maplibre-gl-js/releases)
- [Changelog](https://github.com/maplibre/maplibre-gl-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-gl-js/compare/v6.15.0...v6.16.0)

Updates `@babel/parser` from 8.0.7 to 8.0.8
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v8.0.8/packages/babel-parser)

---
updated-dependencies:
- dependency-name: "@a2ui/react"
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@a2ui/web_core"
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@mantine/charts"
  dependency-version: 9.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@mantine/core"
  dependency-version: 9.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@mantine/hooks"
  dependency-version: 9.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: katex
  dependency-version: 0.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: lucide-react
  dependency-version: 1.64.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: maplibre-gl
  dependency-version: 6.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@babel/parser"
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:59:19 +00:00
dependabot[bot] 6bccc90b84 chore(deps): bump actions/setup-java
Bumps the actions group with 1 update: [actions/setup-java](https://github.com/actions/setup-java).


Updates `actions/setup-java` from 4.9.1 to 6.1.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/cf277c60eb25467037889841efdb72551f06f6c3...b843ebf9b913146b38da2eaa7aeddf76074ae1ad)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:59:01 +00:00
dependabot[bot] 34707b672f chore(deps): bump the go group with 4 updates
---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/pkg/sftp
  dependency-version: 1.13.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/tdewolff/parse/v2
  dependency-version: 2.8.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:56:25 +00:00
YHH 839114a857 Merge pull request #14900 from esengine/fx/bg-job-resume-note
fix(jobs): tell the model a running background job resumes the session when it ends
2026-11-01 03:29:21 -08:00
esengine d56abf84d1 fix(jobs): tell the model a running background job resumes the session when it ends
The host already opens a turn when a background job ends, but bash's launch
message, bash_output and wait only offered more polling. A long build outlived
several capped waits and the model closed the turn reporting it unfinished.

The manager now answers whether a completion will resume a given session, and
the three readers state it for running jobs only where that holds.

Refs #14879
2026-11-01 03:03:27 -08:00
YHH e52e22bcce Merge pull request #14898 from esengine/fx/host-row-label
fix(queue): label a background job's follow-on that needs a decision as background
2026-10-31 23:55:01 -07:00
esengine de35fb7b0f fix(queue): label a background job's follow-on that needs a decision as background 2026-10-31 23:31:15 -07:00
YHH a8fe2001c8 Merge pull request #14894 from kevin9327/fix-failure-summary-rune
fix(plugin): cut MCP startup failure summaries on a rune boundary
2026-10-31 22:51:12 -07:00
YHH 1efd6dcae6 Merge pull request #14893 from esengine/fx/chip-steer-label
fix(composer): name a running send with a skill card as queued
2026-10-31 22:25:16 -07:00
kevin 41cd0d7021 fix(plugin): cut MCP startup failure summaries on a rune boundary 2026-11-01 14:05:40 +09:00
esengine 5f82ea087f fix(composer): name a running send with a skill card as queued 2026-10-31 22:01:06 -07:00
YHH 14a85d29cb Merge pull request #14891 from kevin9327/fix-semantic-truncate-rune
fix(capability): cut semantic-router candidate descriptions on a rune boundary
2026-10-31 20:51:42 -07:00
YHH 4367b57921 Merge pull request #14890 from kevin9327/fix-byte-size-rounding-carry
fix(size): move byte sizes to the next unit when rounding reaches 1024
2026-10-31 20:51:04 -07:00
kevin 40a7506965 fix(capability): cut semantic-router candidate descriptions on a rune boundary 2026-11-01 12:02:33 +09:00
kevin de34556d42 fix(size): move byte sizes to the next unit when rounding reaches 1024 2026-11-01 12:01:31 +09:00
YHH 1a4a8e2366 Merge pull request #14887 from esengine/c5-notes-2.56.1
docs(release): studio 2.56.1 notes
2026-10-31 19:22:53 -07:00
esengine eea2d3d2f1 docs(release): studio 2.56.1 notes 2026-10-31 19:10:52 -07:00
YHH ae7ff78e78 Merge pull request #14886 from esengine/fx/sec-pyv
fix(shellsafe): read-only only for a bare node or python version probe
2026-10-31 19:10:43 -07:00
esengine ca08f277f4 fix(shellsafe): read-only only for a bare node or python version probe 2026-10-31 18:29:40 -07:00
esengine aaa4f586b1 fix(shellsafe): treat only the exact version-probe spellings of node and python as reads 2026-10-31 09:56:54 -07:00
YHH b9daee966a Merge pull request #14877 from esengine/fx/queue-row-actions
fix(queue): draw edit and move only on lines the kernel can still change
2026-10-31 04:25:23 -07:00
esengine cda88398cc fix(queue): draw edit and move only on lines the kernel can still change 2026-10-31 04:03:12 -07:00
YHH 5f372e2229 Merge pull request #14873 from esengine/fx/phone-layout
fix(studio): phone-width spacing for the sidebar logo and the Settings header
2026-10-31 01:25:50 -07:00
esengine c0c5acb382 fix(studio): phone-width spacing for the sidebar logo and the Settings header
At phone width the sidebar menu button touched the logo, Settings put Close on
its own row under the search box, and each settings tab kept a 6.5em name
minimum so only three fit. The logo gets a left gap, the header grid holds
expand and Close side by side, and tab names size to their content.
2026-10-31 01:03:51 -07:00
YHH 9371541e1c Merge pull request #14871 from esengine/fx/primary-button-fill-2
fix(studio): a primary button in settings keeps its fill with its foreground
2026-10-30 23:25:54 -07:00
esengine 8981f49c6a fix(studio): a primary button in settings keeps its fill with its foreground
The settings type rule recoloured every enabled primary button's text to the
accent foreground and relied on each container to supply the fill. A container
with no fill rule of its own, such as the scheduled-run form, showed light text
on no fill, so Preview and Confirm looked blank. The rule now carries the fill
and border with the foreground.
2026-10-30 23:08:50 -07:00
YHH cec99a96d1 Merge pull request #14866 from esengine/fx/browser-tab-cap
fix(browser): bound the pages the agent can have open at once
2026-10-30 22:55:06 -07:00
esengine d1852af99f fix(browser): reserve the page count atomically so overlapping opens cannot exceed the limit
The check counted open pages and then created one, so concurrent agent opens at
the limit could all pass. A reservation counts pages still being created, and is
released once the page is in the list.
2026-10-30 22:28:49 -07:00
esengine 945568ce89 fix(browser): bound the pages the agent can have open at once
A model that opened a page per search result left dozens of renderer processes
running; one report shows 30 pages and about 2 GB held. Only a prompt hint asked
the model to close pages, and the shell's own sleep cap cannot keep up with a
burst of opens. The session now refuses a new agent page once 12 are open and
says how to make room; a page the person opens is never refused, though it
counts toward the total.
2026-10-30 21:30:12 -07:00
YHH a736f120bc Merge pull request #14851 from kevin9327/fix/capdiag-rune-boundary
fix(capdiag): cap sanitized diagnostic text on a rune boundary
2026-10-30 15:55:03 -07:00
kevin9327 d2a603eb9e fix(capdiag): keep collect.go under the file-size ceiling
Move the rune-boundary cut into a small helper in text.go so the fix
does not push collect.go over repolint's 800-line limit.
2026-10-31 06:58:57 +09:00
kevin9327 5b4909fcab fix(capdiag): cap sanitized diagnostic text on a rune boundary 2026-10-31 06:51:04 +09:00
YHH 6248f800a4 Merge pull request #14850 from esengine/fx/migrate-tests-skip-windows
test(migration): skip the unreadable-source cases where the platform cannot build one
2026-10-30 13:55:12 -07:00
esengine cdc749cca8 test(migration): skip the unreadable-source cases where the platform cannot build one
On Windows a regular file at the sessions path is reported as absent, which is
the normal fresh install, so the fixtures could not make an unreadable source
and the two tests failed on every Windows push run. The fixtures now skip there
and no longer claim the file fallback works on every platform.
2026-10-30 12:58:41 -07:00
YHH 837689dfa4 Merge pull request #14823 from chuiwenwei/fix/migrate-says-when-it-could-not-read
fix(migrate): an unreadable source is not an empty one
2026-10-30 11:56:22 -07:00
YHH b92e5affba Merge pull request #14848 from esengine/fx/ci-windows-full-timeout
ci(windows): give the full push suite room for a slow boot package
2026-10-30 11:25:45 -07:00
YHH 93105b148f Merge pull request #14849 from jf200s/fix/picker-clean-attribute-keys
fix(studio): clip attribute keys like their values
2026-10-30 11:25:08 -07:00
F dd8b40eb53 fix(studio): clip attribute keys like their values
clean() clipped attribute values to VALUE_CLIP but left keys unbounded, so a page could return thousands of long attribute names. Keys are now clipped with the same limit, with a unit assertion for a 600-character key.
2026-10-31 01:45:11 +08:00
esengine eccc45eca2 ci(windows): give the full push suite room for a slow boot package
The Windows full-suite step failed twice within a few hours on push runs, with
internal/assembly/boot at 1630s and then at 1680s, which is its 28m package
limit. The package alone can take over 25 minutes on a runner, so the limit
becomes 36m inside a 45m step budget.
2026-10-30 10:26:42 -07:00
YHH d7a11e2d27 Merge pull request #14795 from jf200s/feat/element-picker
feat(studio): pick an element in the built-in browser
2026-10-30 09:25:04 -07:00
YHH f16e5e3f04 Merge pull request #14824 from chuiwenwei/fix/an-unreadable-config-is-not-a-missing-one
fix(doctor): an unreadable config is not a missing one
2026-10-30 08:27:18 -07:00
F 3e7c42bc2b fix(studio): keep verify in the picker's world and cap attributes
verify() now asks the isolated world like lands() does, the attribute map is capped at 64 entries, and a held Ctrl+Shift+C no longer re-arms the picker on every repeat.
2026-10-30 22:53:54 +08:00
YHH 613165df93 Merge pull request #14843 from kevin9327/fix/completestep-cmd-rune-boundary
fix(complete_step): cut listed commands in the citation hint on a rune boundary
2026-10-30 07:26:26 -07:00
YHH 7d261d9edd Merge pull request #14842 from kevin9327/fix/evidence-quote-short-rune-boundary
fix(evidence): cut a cited command in a rejection message on a rune boundary
2026-10-30 07:25:08 -07:00
kevin9327 fc939ca7a6 fix(complete_step): cut listed commands in the citation hint on a rune boundary
Signed-off-by: kevin9327 <[email protected]>
2026-10-30 22:30:51 +09:00
kevin9327 c9a3131e77 fix(evidence): cut a cited command in a rejection message on a rune boundary
Signed-off-by: kevin9327 <[email protected]>
2026-10-30 22:29:40 +09:00
YHH af0e954f56 Merge pull request #14841 from ShryukGrandhi/fix/issue-14413-studio-feedback-decode-branch-meta-c-use
fix(sessionstore): decode the branch-meta sidecar as UTF-8 before transcoding
2026-10-30 05:55:25 -07:00
YHH 9976474a6c Merge pull request #14825 from chuiwenwei/fix/runtime-doctor-says-unknown
fix(doctor): a runtime nobody can ask is not clean
2026-10-30 00:29:39 -07:00
ShryukGrandhi b126505b4f fix(sessionstore): decode the branch-meta sidecar as UTF-8 before transcoding
The sidecar is JSON written by json.MarshalIndent. Reading it through the
GB18030 detection cascade meant a single stray non-UTF-8 byte re-decoded the
whole file as GB18030, where a lead byte before a Windows path separator
swallowed the backslash of an escaped "\\" and left an invalid \W escape.
Decode the bytes as written first; transcode only when that fails.

Signed-off-by: ShryukGrandhi <[email protected]>
2026-10-30 00:20:57 -07:00
Will 9b6a0f3327 docs(release-notes): record #14824 2026-10-30 14:16:24 +08:00
Will 5b4cfd0607 docs(release-notes): record #14823 2026-10-30 14:16:19 +08:00
Will 74ef86d84a docs(release-notes): record #14825 2026-10-30 14:15:48 +08:00
YHH c7a36b43b1 Merge pull request #14821 from esengine/fx/android-verification-lint
fix(android): verify the 24 artifacts the release lint task resolves on CI
2026-10-29 21:59:23 -07:00
esengine 8ce6939120 fix(android): verify the 24 artifacts the release lint task resolves on CI 2026-10-29 21:30:54 -07:00
YHH 1c147780ff Merge pull request #14676 from SivanCola/fix/environment-snapshot-precedence
fix(boot): preserve environment snapshot precedence / 保持环境探测快照前缀稳定
2026-10-29 21:26:18 -07:00
YHH 50661c33db Merge pull request #14820 from esengine/fx/android-verification-aapt2
fix(android): verify the Linux aapt2 jar the CI build resolves
2026-10-29 21:21:59 -07:00
esengine 6fda6bf868 fix(android): verify the Linux aapt2 jar the CI build resolves 2026-10-29 20:53:17 -07:00
YHH ccec12753c Merge pull request #14819 from esengine/fx/android-verification-metadata
fix(android): verify the two POMs the Gradle classpath needs on CI, and store the file with LF
2026-10-29 20:47:24 -07:00
esengine 524bf552fb fix(android): verify the two POMs the Gradle classpath needs on CI, and store the file with LF 2026-10-29 20:23:46 -07:00
YHH 34bac65194 Merge pull request #14817 from esengine/fx/android-verify-token2
ci(android): let the workflow build a branch as a trial run that publishes nothing
2026-10-29 20:17:37 -07:00
Will 383178d22c fix(doctor): a runtime nobody can ask is not clean
`doctor runtime` always passed a nil BuildResult, so it read the empty
process-local DefaultRuntimeOwner and printed clean=true/cleanRollback=true as
facts about a session it could not observe. The receipt ledger is in-process
only, so a fresh CLI cannot know whether a crash or mid-migration left
irreversible work.

Report recoverability and resume as nil (JSON null) when no runtime was
available to ask; the text says unknown and names why. The BuildResult seam
still computes and prints the observed verdicts exactly as before.
2026-10-30 11:06:59 +08:00
Will 41578aff84 fix(doctor): an unreadable config is not a missing one 2026-10-30 11:03:05 +08:00
esengine 38b253563c ci(android): let the workflow build a branch as a trial run that publishes nothing 2026-10-29 19:57:11 -07:00
YHH 154743d3b8 Merge pull request #14813 from esengine/fx/criteria-walk-parallel
perf(agent): read the criteria walk's directories and files concurrently
2026-10-29 19:52:51 -07:00
YHH d92e52b580 Merge pull request #14814 from esengine/fx/android-verify-token
fix(ci): the Android page check writes its token file owner-only
2026-10-29 19:52:15 -07:00
esengine 916f5943fa perf(agent): read the criteria walk's directories and files concurrently 2026-10-29 19:08:42 -07:00
esengine 7f259f2f96 fix(ci): the Android page check writes its token file owner-only 2026-10-29 18:55:28 -07:00
YHH f26fb981be Merge pull request #14812 from esengine/fx/android-script-modes
fix(ci): run the Android scripts through bash and build the kernel at the tag's version
2026-10-29 18:46:15 -07:00
esengine f93eb63929 fix(ci): run the Android scripts through bash and build the kernel at the tag's version 2026-10-29 18:25:53 -07:00
YHH a75ab7728b Merge pull request #14811 from esengine/fx/android-pnpm-order
fix(ci): set up pnpm before the Node cache that needs it in the Android workflow
2026-10-29 17:55:47 -07:00
esengine 23cb22d3cb fix(ci): set up pnpm before the Node cache that needs it in the Android workflow 2026-10-29 17:25:40 -07:00
YHH cbcc2ebb1e Merge pull request #14810 from esengine/release/studio-2.56.0
docs(release-notes): studio 2.56.0
2026-10-29 17:01:19 -07:00
F fb0b7396e3 fix(studio): bound what a pick may return
The page wrote the answer, so the shell now rebuilds it from an allow-list, clips every string to the page script's own limits, drops unknown fields and treats a shape it cannot read as gone. A rect that is not numbers is dropped; a negative origin is clamped by the screenshot.
2026-10-30 07:56:06 +08:00
esengine 674c2b45e7 docs(release-notes): studio 2.56.0 2026-10-29 16:25:53 -07:00
YHH ec6929c157 Merge pull request #14808 from esengine/fx/parity-scheduled
docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap
2026-10-29 14:09:01 -07:00
esengine dcd4135049 docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap 2026-10-29 13:56:57 -07:00
YHH 08ea935ea9 Merge pull request #14784 from lancy69/feat/14319-recent-sessions
feat(studio): add globally paged recent session discovery
2026-10-29 12:58:29 -07:00
YHH 2a3a3738db Merge pull request #14801 from lancy69/fix/12303-yielded-claim-evidence
fix(agent): preserve unknown scope across yielded workspace claims
2026-10-29 12:28:51 -07:00
YHH d1eb9c968e Merge pull request #14800 from lancy69/fix/12080-workspace-startup-read
fix(studio): share pane startup workspace metadata
2026-10-29 12:27:39 -07:00
YHH a2597fb17c Merge pull request #14805 from xxoingr/fix/worktree-commands-one-at-a-time
fix(worktree): run one repository's worktree commands one at a time
2026-10-29 12:26:40 -07:00
YHH 41ea14ccc9 Merge pull request #14806 from esengine/fx/import-orphan-index
fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed
2026-10-29 11:58:11 -07:00
esengine b3bea0d076 fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed 2026-10-29 11:29:54 -07:00
YHH efff0a825f Merge pull request #14803 from xxoingr/fix/earlier-version-delete-shows
fix(studio): an earlier version in the sidebar shows its delete button
2026-10-29 11:25:38 -07:00
xxoingr 44712c3aad fix(worktree): run one repository's worktree commands one at a time
`git worktree add` creates the new entry's directory under
<common>/worktrees before it writes the entry's files, and every worktree
command resolves each entry's HEAD, reading its commondir and dying on one
it finds empty: "failed to read .../commondir: Success". Two isolated
tasks started in parallel each create a candidate from the same
repository, and on a loaded Linux runner the second add read the first's
entry in that window (TestPreparationConcurrentCandidatesDoNotShareResultState,
run 40689344657).

Every `git worktree` command in the package now goes through runWorktree,
which holds one gate per repository, keyed by its common Git directory,
and gives up waiting when its context ends.
2026-10-30 02:09:35 +08:00
YHH d188af4207 Merge pull request #14783 from lancy69/feat/14699-scheduled-appearance
feat(studio): add a daily dark-mode schedule
2026-10-29 10:59:14 -07:00
xxoingr 8a89f0874b fix(studio): an earlier version in the sidebar shows its delete button
An earlier version or recovery copy has no row menu; its × is the only
way a pointer deletes it. `.rail .sessrow > .wsdel { display: none }`
came in with the Studio rail when session rows moved deletion into their
menu, and since then the copy row is the only one that renders .wsdel,
so the rule hid exactly that button. The hover, focus-within and
hover:none rules beside it were already there to show it.

perf/sessionrows.mjs now puts the button on its earlier-version row and
holds it shown and inside the row on hover and on keyboard focus.
2026-10-30 01:47:47 +08:00
Lancy 350379ffff feat(studio): add globally paged recent session discovery 2026-10-30 01:27:28 +08:00
Lancy 65ecbce661 feat(studio): add a daily dark-mode schedule 2026-10-30 01:19:11 +08:00
Lancy 2656f33f28 fix(agent): preserve unknown scope across yielded workspace claims 2026-10-30 01:13:00 +08:00
Lancy e31c2347b4 fix(studio): share pane startup workspace metadata 2026-10-30 01:05:03 +08:00
F 74c2f11209 feat(studio): pick an element in the built-in browser
The shell half: the in-page picker script, pick / cancelPick / verify on BrowserViews, the three IPC handlers and preload methods, and the unit and live suites. Inert until a control calls it.
2026-10-30 00:27:39 +08:00
YHH be65b117e0 Merge pull request #14493 from imcreazy/ci/android-release
ci(android): build the APK on a tag and attach it to the release
2026-10-29 08:52:55 -07:00
imcreazy 19f05e8509 Attach the APK only when it is signed, and wait long enough for the release
An unsigned package cannot be installed at all, so publishing one put an
unusable file on every release page. The wait was also shorter than the
release actually takes.
2026-10-29 23:11:20 +08:00
YHH 4fde96dccc Merge pull request #14781 from lancy69/fix/14759-dim-composer-placeholder
feat(studio): add a default-on composer hint visibility setting
2026-10-29 07:39:33 -07:00
YHH 9c38dd1129 Merge pull request #14771 from 1012905/fx/session-remove-many-kernel
feat(serve): 通过 POST /tree/sessions/remove-many 批量删除会话
2026-10-29 07:20:19 -07:00
Lancy 355e047c27 feat(studio): add a default-on composer hint visibility setting 2026-10-29 22:07:16 +08:00
1012905 0abc13d3c0 chore(release-notes): name the pull request in the batch-remove fragment
The fragment lost its issue reference when the lines were cut to one
sentence each, and the release-note lint asks every change to name the
issue or pull request it belongs to. Both lines now end with the
reference, and both stay under the 200-column limit.
2026-10-29 21:42:55 +08:00
YHH 4055d9e404 Merge pull request #14791 from 1012905/fx/settings-search-storage-race
test(desktop): the settings search is measured without the storage tally
2026-10-29 06:40:23 -07:00
1012905 ddd0db3ee7 feat(serve): batch session deletion via POST /tree/sessions/remove-many
Kernel half of session batch delete. The handler validates the whole batch up front (bad path -> 400, empty -> 400), refuses a running session (409), a leased transcript (409), and a path outside the adopted workspace (403), then erases all guarded sessions.

No desktop UI in this change; the management view lands in a follow-up PR.

Go tests cover the handler, including two new edge cases:
- a path inside the session dir but belonging to another project (refused wholesale, 403, the in-workspace file left untouched);
- a running session in the middle of the list (refused with 409, nothing erased before the refusal).

Cuts the release-note fragment to one sentence per line and drops the
self-reference, so each line stays within the 200-column limit.
2026-10-29 21:02:04 +08:00
1012905 ba95fc74f1 test(desktop): the settings search is measured without the storage tally
The settings pane schedules a tally of the disk usage a beat after it opens,
and that walk reads the port. The search test measures the port as it stood
before the search, and reads it again afterwards to prove the search read
nothing. When the two moments fell more than a beat apart — which the full
suite's load makes likely — the tally landed in between and the test failed
on a read the search never asked for.

Seeding the tally before the pane opens means no walk is scheduled at all, so
the search is measured against the port as it stood, with nothing else reading
it on the way past.

The failing assertion is unchanged: the search still has to read nothing.
2026-10-29 20:40:20 +08:00
YHH 1da925d0f9 Merge pull request #14790 from esengine/fx/shortcut-cards-order
fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page
2026-10-29 05:14:47 -07:00
esengine eaf5329101 fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page 2026-10-29 04:59:32 -07:00
YHH dc659c7509 Merge pull request #14788 from esengine/fx/full-installer-link
fix(studio): the full-installer link after a failed update opens the system browser
2026-10-29 04:48:06 -07:00
esengine 310567fd08 fix(studio): the full-installer link after a failed update opens the system browser 2026-10-29 04:31:49 -07:00
YHH 77059988f8 Merge pull request #14780 from lancy69/docs/14603-cross-project-dispatch
docs: explain cross-project task dispatch
2026-10-29 04:23:39 -07:00
YHH 4b87d87883 Merge pull request #14774 from kevin9327/fix/worktree-stderr-rune-boundary
fix(worktree): cut git stderr in error messages on a rune boundary
2026-10-29 03:54:40 -07:00
Lancy b8997b34eb docs: explain cross-project task dispatch 2026-10-29 18:38:40 +08:00
YHH 3c6ad78273 Merge pull request #14773 from kevin9327/fix/recovery-task-summary-runes
fix(control): bound the fallback recovery task summary by runes
2026-10-29 03:23:41 -07:00
kevin 2cbd60e326 fix(worktree): cut git stderr in error messages on a rune boundary
stderrSuffix capped git's stderr at 500 bytes with a plain slice, so a localized
(multi-byte) git message was split mid-character and the error ended in invalid
UTF-8. Back the cut up to a rune start.
2026-10-29 19:00:58 +09:00
YHH 7b41ec8a08 Merge pull request #14769 from chuiwenwei/test/every-aggregate-field-declares-its-provenance
test(stats): every aggregate field declares its provenance
2026-10-29 02:40:34 -07:00
kevin 9be610d41d fix(control): bound the fallback recovery task summary by runes 2026-10-29 18:31:57 +09:00
YHH d16063929c Merge pull request #14726 from SivanCola/codex/worktree-apply-ancestor-boundary-14025
fix(worktree): guard apply ancestors and confine deletions / 防止父目录链接重定向隔离结果删除
2026-10-29 01:53:20 -07:00
YHH 0ab4329305 Merge pull request #14748 from lancy69/feat/4262-line-code-references
feat(control): support precise @path:start-end file references
2026-10-29 01:51:26 -07:00
YHH 692a46d8d3 Merge pull request #14768 from jf200s/fix/fork-inherit-compaction
fix(control): a fork inherits its source's context projection
2026-10-29 01:50:05 -07:00
YHH 8066440bb7 Merge pull request #14749 from lancy69/fix/14738-stale-mcp-removal
fix(control): remove stale MCP entries without losing source authority
2026-10-29 01:48:35 -07:00
YHH aa09229ab2 Merge pull request #14750 from lancy69/feat/11915-running-message-default
feat(studio): choose steer or follow-up as the running-message default
2026-10-29 01:47:11 -07:00
Lancy 87d59a1816 feat(control): resolve precise line-range file references 2026-10-29 15:03:12 +08:00
Lancy f98edeec7b fix(control): reconcile MCP entries after their source disappears 2026-10-29 14:55:25 +08:00
Lancy cab93a8b02 feat(studio): choose default intent for messages sent mid-run 2026-10-29 14:46:40 +08:00
F 2b517203be docs(release-notes): name the fragment after #14768
Refs #14768
2026-10-29 14:39:31 +08:00
Will 08a1288603 test(stats): every aggregate field declares its provenance 2026-10-29 14:38:30 +08:00
F e5eb7cbee2 docs(release-notes): point the fragment at the rebased commit
Refs 02bf796dc
2026-10-29 14:35:54 +08:00
F d5db1cc446 docs(release-notes): a fork keeps its source's compaction
Refs 4566c19b8
2026-10-29 14:34:49 +08:00
F 02bf796dcc fix(control): a fork inherits its source's context projection
A conversation branched from a compacted session reopened on the full
history. createBranchPrefix copied the transcript, the branch metadata and
the checkpoints but never the projection sidecar, and resumeInto binds with
loadSidecar=true, so the fork found no sidecar and visibleByFullScan handed
the model the whole transcript. Recovery forks and 1.x continuations already
go through InheritCompactionState; the user-facing branch path never called it.

BranchAt now inherits inside the branch's own save window, next to the
checkpoint copy, so a branch is never published with a fold it cannot read.
Controller.Branch, which swaps panes in place, binds with loadSidecar=true so
the same-pane path loads what it just inherited.

The inherit helper keeps its fail-soft contract: no source sidecar, a corrupt
or unsupported one, a projection whose covered prefix reaches past the fork
point, and a failed write all leave the fork on the full history. Recall
budget, blocked fields and a non-applied receipt belong to the source
session's runtime generation, so the copy clears them; the projection,
generation, lineage key and an applied receipt still travel.
2026-10-29 14:34:49 +08:00
YHH 396b2c6b6d Merge pull request #14764 from esengine/fx/browser-open-steer
fix(tools): tell the model browser_open is visible and web_fetch reads static pages
2026-10-28 23:10:45 -07:00
YHH 79a2232e55 Merge pull request #14763 from esengine/fx/remote-link-linger
fix(serve): hold a request's remote link past its answer
2026-10-28 22:59:56 -07:00
YHH af1628b8ea Merge pull request #14747 from lancy69/feat/14466-collapsed-plan-progress
feat(studio): optional progress track for collapsed plans
2026-10-28 22:51:48 -07:00
YHH 73da13ee39 Merge pull request #14746 from lancy69/feat/14724-unread-completion-marker
feat(studio): show green unread markers instead of session dates
2026-10-28 22:47:27 -07:00
YHH 955474f6fc Merge pull request #14677 from SivanCola/codex/carried-proof-file-state-14025
fix(agent): invalidate carried proof after file-mode changes / 文件模式变化时拒绝旧证明
2026-10-28 22:42:41 -07:00
YHH 1ba8ed87cb Merge pull request #14757 from chuiwenwei/feat/bench-batches-emit-an-auditable-manifest
feat(e2ebench): a batch emits an auditable manifest
2026-10-28 22:42:03 -07:00
esengine 181e697147 fix(tools): tell the model browser_open is visible and web_fetch reads static pages 2026-10-28 22:36:18 -07:00
esengine bccf76ab7e fix(serve): hold a request's remote link past its answer 2026-10-28 22:17:27 -07:00
YHH 8a1fda41a7 Merge pull request #14730 from SivanCola/codex/reported-total-token-budget-14025
fix(agent): count reported totals in token budgets / 将已报告总量计入 token 预算
2026-10-28 21:57:59 -07:00
YHH da91464adb Merge pull request #14684 from SivanCola/codex/ancestor-copy-admission-14025
fix(delegation): reserve ancestor copy admission / 预留祖先副本续跑准入
2026-10-28 21:57:19 -07:00
YHH 1619856458 Merge pull request #14761 from esengine/docs/release-notes-2.55.0
docs(release-notes): studio 2.55.0
2026-10-28 21:49:09 -07:00
esengine 51eb1bb24f docs(release-notes): studio 2.55.0 2026-10-28 21:37:37 -07:00
YHH 812e405a8d Merge pull request #14735 from esengine/fx/sandbox-git-bash
fix(sandbox): a Windows session passes over a Git Bash that cannot start under the token
2026-10-28 21:35:53 -07:00
YHH 17fe3c7110 Merge pull request #14734 from kevin9327/fix/gitignore-dotdot-names
fix(gitignore): treat a root name starting with .. as inside the repo
2026-10-28 21:35:15 -07:00
YHH c58b1242c8 Merge pull request #14729 from chuiwenwei/fix/register-the-unguarded-wire-mirrors
fix(repolint): register the wire mirrors that had no guard
2026-10-28 21:34:37 -07:00
YHH 90f2ad2c3b Merge pull request #14727 from chuiwenwei/fix/an-unprovable-lease-is-its-own-state
fix(sessionstore): an unprovable lease is its own answer
2026-10-28 21:33:58 -07:00
YHH 9c973fae65 Merge pull request #14716 from chuiwenwei/fix/restore-keeps-an-image-only-turn
fix(tui): a restored image-only turn is still a turn
2026-10-28 21:33:20 -07:00
Will ce2c3acd44 docs(release-notes): record #14757 2026-10-29 09:39:05 +08:00
Lancy 6202465e3e feat(studio): optionally show progress on collapsed plans 2026-10-28 16:48:34 +00:00
Lancy bb2a71f959 feat(studio): replace unread session dates with green markers 2026-10-28 16:47:14 +00:00
YHH 7dec45ad05 Merge pull request #14711 from lancy69/fix/14539-isolated-test-config-locks
fix(testenv): isolate config locks from the real user home
2026-10-28 05:37:27 -07:00
kevin 9a89dc2d34 fix(gitignore): treat a root name starting with .. as inside the repo 2026-10-28 21:19:32 +09:00
esengine 6ac89de76e fix(sandbox): a Windows session passes over a Git Bash that cannot start under the token 2026-10-28 05:19:02 -07:00
YHH 54c5d01574 Merge pull request #14733 from esengine/fx/primary-action-ink
fix(studio): primary actions in settings keep their paired text colour
2026-10-28 05:16:54 -07:00
YHH 8412374533 Merge pull request #14709 from lancy69/fix/14607-rename-dialog-typography
fix(studio): use semantic typography in session rename dialog
2026-10-28 05:04:12 -07:00
YHH 35e79a8d46 Merge pull request #14708 from lancy69/fix/14600-sticky-project-header
fix(studio): keep project controls outside the scrolling tree
2026-10-28 05:03:24 -07:00
YHH b554637a26 Merge pull request #14707 from lancy69/fix/14601-settings-button-height
fix(studio): align settings header button heights
2026-10-28 05:02:38 -07:00
YHH 3e269c1362 Merge pull request #14732 from esengine/fx/graphics-on-window-page
feat(studio): graphics rendering lives on the window and performance page
2026-10-28 05:01:52 -07:00
YHH 02bf511ba2 Merge pull request #14731 from esengine/fx/menu-over-plan
fix(studio): composer menus open above the plan bar
2026-10-28 05:01:06 -07:00
Will 74ad7e5f39 feat(e2ebench): a batch emits an auditable manifest
A batch printed its aggregates and kept nothing they could be recomputed
from, so a published figure could not be tied to the runs behind it after
the temp directory was gone. -batch DIR now writes results.jsonl (one row
per attempt) beside batch.json (manifest v1), and -mode audit re-derives
the report's headline figures from those rows with the harness's own
aggregation, printing the claim beside the recomputation and naming every
figure that moved.

The manifest records what the figures are over (command axes, a task-id
digest, filters, and a denominator defined in tasks), which run produced
them (commit or an explicit unknown, model/provider names, seed when
known, timestamps, harness and runtime), and why they can still be
checked (results_sha256, the claimed figures, and a ledger of planned /
attempted / completed / scored / failed / unmeasured / excluded that the
audit checks for consistency). Absent figures are omitted and read as
unknown, never as zero.

This is an aggregation audit, not a replay: it proves the published
figures follow from the retained rows. It does not re-execute the model,
tools or environment, and a real replay (commit, corpus fingerprint,
model/provider configuration, seed, flags, raw results, runtime version)
is not implemented.
2026-10-28 19:52:12 +08:00
esengine 25a97bb683 fix(studio): primary actions in settings keep their paired text colour 2026-10-28 04:46:02 -07:00
esengine 972e910722 feat(studio): graphics rendering lives on the window and performance page 2026-10-28 04:31:11 -07:00
esengine a0f197a535 fix(studio): composer menus open above the plan bar 2026-10-28 04:22:51 -07:00
YHH f665a00794 Merge pull request #14700 from esengine/fx/settings-no-page-header
feat(desktop): drop the title block above each settings page
2026-10-28 04:04:57 -07:00
YHH acb47daee9 Merge pull request #14686 from esengine/fx/activity-title-size
fix(studio): the Execution row uses the same text size as the Thinking row
2026-10-28 04:03:58 -07:00
SivanCola fbf0b99ccd fix(agent): count reported totals in local and root token budgets
Problem: Accepted total-only usage remained visible to reporting but did not
cross token budgets, allowing additional tool rounds after the known spend.

Root cause: Both local and shared-root enforcement added only input/output
components, so missing or partial splits hid a known reported total.

Fix: Use one accounting helper for the larger of the nonnegative total and
component sum, and saturate both counters. Keep usage, pricing, shadow component
telemetry, context ownership and existing soft-gate/finalization behavior.

Verification: Real OpenAI HTTP/SSE adapter and Boot execute real file tools.
Final19named regressions pass; exact preceding owners fail10 events with9controls.
Root240packages once:24330named passes/215existing skips. Complete Agent race
2162pass/10existing skips; focused Boot race7pass. Vet, pinned lint, check, format,
workflow and release gates pass; four Win/Linuxamd64 checks compile only.
2026-10-28 18:36:33 +08:00
Will 9b666634e6 docs(release-notes): record #14727 2026-10-28 17:11:54 +08:00
Will 40ab228ab6 docs(release-notes): record #14716 2026-10-28 17:11:54 +08:00
SivanCola 19c7b76de1 fix(worktree): reject redirected apply ancestors and confine deletion
Problem: An isolated candidate deletion could follow a source directory link
and remove a file outside the repository while reporting success.

Root cause: Leaf comparison followed ancestor links and publication deleted
through an ordinary pathname, without an ancestor precondition or held root.

Fix: Check ancestors before path-by-path leaf hashing and before publishing
deletions, and delete through an open repository root. Preserve whole-workspace
file-to-directory conversion and the Store's existing same-ID conflict retry.
Extend the existing Windows isolation inventory with junction regressions.

Verification: Final root inventory covers 240 packages once with 24324 named
passes and 215 existing skips. Both required package commands exit zero.
Complete worktree/isolation race has 242 passes and no skips. The final focused
13 names pass; the exact preceding owner fails 9 events with 4 controls.
Vet, pinned lint, check, format, workflow and release-note gates pass. Windows
and Linux amd64 compile checks pass; native hosted verification is pending.
2026-10-28 17:00:26 +08:00
Will d803f11506 fix(migrate): an unreadable source is not an empty one
The implicit legacy-session import passed no report, so a ReadDir failure
became a no-op skip and returned (0, nil). /migrate then told a user whose
legacy sessions directory existed but could not be read that there was
nothing to migrate.

- sessionstore: return a typed LegacySourceError (a SkippedSession plus the
  cause) from the implicit path; an absent directory stays the silent no-op.
  The explicit --from path keeps reporting through its LegacyReport.
- migration: /migrate names the directory and the read failure; the summary
  counts it as a warning instead of "no legacy data needed migration".
- boot: unchanged, stays quiet — the boot caller is non-verbose, so the new
  error is recorded but not emitted, exactly like every other failed import.
- markers: the failure returns before the marker write, so a source that was
  never read is never recorded as imported; a repaired source imports and
  stamps on the retry.
2026-10-28 14:55:17 +08:00
YHH 70b90453a5 Merge pull request #14656 from SivanCola/codex/subagent-status-admission-14025
fix(delegation): refuse unknown saved run states / 拒绝未知子任务恢复状态
2026-10-27 23:34:23 -07:00
YHH 7205271b07 Merge pull request #14661 from SivanCola/codex/goal-usage-delegation-scope-14025
fix: retain delegated Goal usage ownership / 保留委派任务的 Goal 用量归属
2026-10-27 23:33:26 -07:00
YHH a6ffc30339 Merge pull request #14667 from lancy69/feat/14649-adjustable-viewport
feat(studio): add adjustable conversation viewport width
2026-10-27 23:32:55 -07:00
YHH f3e3ac9f2e Merge pull request #14654 from esengine/fx/openwith-explains-inside-project
feat(openwith): say why an installed program is not offered
2026-10-27 23:32:35 -07:00
SivanCola f133704176 fix(ci): make proof fixture independent of host sandbox availability
Problem: Linux coverage could not establish the carried-proof fixture;
CodeQL also flagged the shared inventory parser for exponential backtracking.
Root cause: the fixture inherited an unavailable host bash sandbox, and
repeated name-suffix groups could repartition the same slash sequence.
Fix: select sandbox off only in the isolated test user configuration;
parse the entire optional suffix once and add a bounded long-input test.
Verification: required builtin/Boot exit0, focused coverage four named
passes, 14-name shared contract, two Node tests, vet, pinned lint and check.
Original regex fails the bounded regression with one passing control.
No production Go, assertion, workflow, timeout, skip or baseline change.
Hosted Linux coverage, CodeQL and own-head Windows evidence remain due.
2026-10-28 14:28:12 +08:00
SivanColaandesengine ac956885c1 fix(deps): sync upstream x/net security update
Problem: The govulncheck job on #14676 reports GO-2026-6860 through
the HTML parser used by the price-checking tool.

Root cause: This branch still selects golang.org/x/net v0.61.0, although
upstream #14685 has already upgraded both Go modules to the fixed version.

Fix: Apply upstream commit 80ebd88bf7
unchanged, selecting x/net v0.62.0 in the root and desktop modules.
No workflow bypasses or suppression rules are introduced.

Verification: govulncheck v1.9.0 with Go 1.26.10 exits successfully for
both modules with zero reachable vulnerabilities. Desktop build, network
and environment package tests, full builtin/boot and pricecheck tests,
go vet, make lint, make check and whitespace checks pass. The staged
four-file patch is byte-identical to the upstream commit.

Co-authored-by: esengine <[email protected]>
2026-10-28 14:23:41 +08:00
SivanCola 382016dfe1 fix(ci): avoid backtracking in concurrency inventory names
Problem: CodeQL reports exponential backtracking in the shared Windows
inventory test-name parser.
Root cause: repeated suffix groups can partition the same slash sequence.
Fix: accept the entire optional non-whitespace suffix once, preserving the
previous accepted language. Cover nested names and a long malformed suffix.
Verification: two Node tests pass; the original regex fails the bounded
regression while the existing contract test passes. Go and inventory bytes
are unchanged; prior exact-head Go qualification remains separate.
2026-10-28 14:18:44 +08:00
Will 4fefec819c fix(sessionstore): an unprovable lease is its own answer
SessionLeaseHeldByOtherRuntime folded every lease-lock probe failure into
"held", so a lock it could not open became the same answer as a live holder.
Redaction then skipped a session nobody held and reported an active lease,
leaving the secret scrub silently incomplete; session and task listings
claimed active/running on the same non-evidence.

Add ErrSessionLeaseUnprovable beside ErrSessionLeaseHeld and expose
SessionLeaseOwnership as the three-state probe. Only ErrSessionLeaseHeld
proves a holder; any other lock failure is unprovable with its cause kept.
The bool wrappers keep their old "may be busy" answers, so callers that only
skip work are unchanged.

Redaction refuses an unprovable session, reports it as a skipped file with
the reason (and exits non-zero), and never calls it an active lease. Machine
session state and running task/subagent status become unknown when ownership
cannot be proven; held and free keep their current answers.
2026-10-28 13:56:02 +08:00
Will c1079d99ba fix(repolint): register the wire mirrors that had no guard
The registry declares 324 pairs now: the 202 it already had and 122 page
copies that agree field for field with the struct their handler writes. The
table moved to a second file, wireparity_table.go, rather than taking
wireparity.go past the 800-line ceiling; the checker still reads one list.

Left out on purpose, because the writer is not a named struct and a guard
over the lookalike struct would not see the response drift:
account.DeviceGrant (/account/login assembles a map), market.Page
(/market/packages assembles a map) and control's McpDraft (/mcp/parse
assembles a map). host.ts's Refusal and ViewRect are the Electron preload's
bridge, not kernel wire; workspace.ts's WorkspaceEntry has no writer.

Tests: the added half resolves, is exact today and is unique per reader; a
dropped field on createBackupRequest -> BackupCreateRequest names the field.
2026-10-28 13:55:00 +08:00
Will 6c1f68df48 fix(tui): a restored image-only turn is still a turn
The terminal's hand-written HistoryMessage dropped images, via and modelRef.
A turn that was only an image has empty text, so Restore saw nothing to draw
and dropped it from the transcript, and every reload lost the device and the
model of turns it did keep. Carry the fields that decide that a turn exists
and what it was, restore the image-only row with an honest line, and register
the pair in repolint so a dropped field fails make check.
2026-10-28 13:43:20 +08:00
Lancy 9e68ee8f25 fix(testenv): isolate config locks from the real user home 2026-10-28 11:43:21 +08:00
Lancy 74f0bf4dc9 fix(studio): use semantic typography in session rename dialog 2026-10-28 11:34:35 +08:00
Lancy 9ce320d1c2 fix(studio): keep project controls outside the scrolling tree 2026-10-28 11:34:06 +08:00
Lancy 8ccc09925f fix(studio): align settings header button heights 2026-10-28 11:34:06 +08:00
YHH da10d503d5 Merge branch 'studio' into codex/subagent-status-admission-14025 2026-10-27 20:32:52 -07:00
YHH 96cdd810da Merge branch 'studio' into codex/goal-usage-delegation-scope-14025 2026-10-27 20:32:17 -07:00
YHH d2f94a6bc3 Merge branch 'studio' into feat/14649-adjustable-viewport 2026-10-27 20:31:43 -07:00
YHH ccd0f0ca9f Merge branch 'studio' into fx/openwith-explains-inside-project 2026-10-27 20:31:09 -07:00
YHH a001b16563 Merge pull request #14685 from esengine/fx/xnet-0.62
fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6860
2026-10-27 20:30:52 -07:00
YHH cc08d5552b Merge pull request #14619 from SivanCola/fix/studio-worktree-labels
fix(studio): clarify worktree labels and directory choices / 简化工作树标识并保留目录模式选择
2026-10-27 20:09:28 -07:00
YHH bf42f9ad83 Merge pull request #14494 from SivanCola/fix/isolation-prepared-candidates-14025
feat(isolation): prepare Store-owned candidates / 由结果 Store 统一准备候选
2026-10-27 20:08:52 -07:00
YHH e511357b6d Merge pull request #14446 from SivanCola/codex/opaque-write-admission-14025
feat(mutation): coordinate opaque writes across roots / 未知范围写入跨根互斥
2026-10-27 20:08:14 -07:00
YHH 79b96679e0 Merge pull request #14459 from SivanCola/codex/process-root-wait-14025
feat(proc): expose root wait completion / 区分根等待完成与取消返回
2026-10-27 20:07:37 -07:00
YHH 6d29079a09 Merge pull request #14549 from chuiwenwei/fix/doctor-catalogs-stops-saying-ok
fix(doctor): a catalog that recorded a failure is not ok
2026-10-27 20:07:00 -07:00
Sivan 787886669f docs(ci): clarify carried proof limits and share Windows inventory checks 2026-10-28 11:04:54 +08:00
SivanCola 8378f0834c fix(delegation): identify busy ancestor continuations and share Windows checks 2026-10-28 11:03:31 +08:00
SivanCola 286106c0e7 docs(boot): disclose environment snapshot prefix correction
Problem: Release notes did not describe the snapshot ownership fix or its
possible one-time cache-prefix change for previously affected sessions.
Root cause: The impact was documented only in the PR and user guide.
Fix: Add Chinese and English unreleased entries preserving the first-request
cache-reuse caveat requested during review.
Verification: Release-note rendering/reference tests (12) and make check
pass; the staged diff contains only the two-line documentation fragment.
2026-10-28 11:00:51 +08:00
esengine 600bd55461 feat(desktop): drop the title block above each settings page
The navigation already shows the section and page, so the first card now sits
at the top of the page. The setting count and the section's value stay in the
navigation row.
2026-10-27 19:54:48 -07:00
esengine dc92a55618 fix(studio): the Execution row uses the same text size as the Thinking row 2026-10-27 19:43:13 -07:00
esengine 80ebd88bf7 fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6860 2026-10-27 19:33:39 -07:00
YHH 8ae73f9332 Merge pull request #14659 from esengine/fx/installer-closes-running-studio
feat(installer): close a running Studio thoroughly and say how when it cannot
2026-10-27 19:31:57 -07:00
SivanCola 7352385cc6 fix(delegation): reserve ancestor copy admission before publication
Problem: Competing continuations could create two copies of one ancestor in
the same parent session, leaving later continuation ambiguous.

Root cause: The source ref lock ended before a newly allocated copy published
metadata, and each preparation acquired a different destination ref lock.

Fix: Reserve the canonical nearest-source and target-parent pair before
searching for copies. Transfer the reservation to the returned run's existing
release lifetime, and release it on failed preparation. Preserve independent
parents, sources, current-reference locks and persisted formats.

Verification: Real TaskTool publication interleavings and Store regressions;
exact old-owner overlay fails 9 named events with 5 controls. Root 240-package
coverage, delegation/sessionstore race, vet, pinned lint, make check,
actionlint and Windows/Linux compile-only checks pass. Windows named-step
inventory validation rejects missing package completion and skipped cases.

Refs #14025
2026-10-28 10:29:45 +08:00
Lancy 6ade8b3238 feat(studio): add adjustable conversation viewport width 2026-10-28 09:16:34 +08:00
esengine 87fb22211d docs(release-notes): studio 2.54.0 2026-10-27 17:19:09 -07:00
YHH cbdc515ce7 Merge branch 'studio' into fx/installer-closes-running-studio 2026-10-27 16:51:12 -07:00
YHH fb3e8535e7 Merge branch 'studio' into fx/openwith-explains-inside-project 2026-10-27 16:50:39 -07:00
SivanCola 1117fb2eca fix(agent): bind carried proof to regular file state
Invalidate persisted Delivery proof when file mode changes without a byte edit.
Use the existing content-version reader and verify surrounding identity, mode,
size and mtime observations. Refuse links, directories and special files as
regular-file proof inputs.

Version fingerprint values so legacy byte-only proof conservatively requires
fresh evidence in both reader directions, while goals and transcripts remain
readable. Cover real Boot persistence/rebuild, compatibility and Unix kinds,
and add a required native Windows inventory with bilingual documentation.

Refs #14025
Refs #14133
2026-10-28 05:05:30 +08:00
SivanCola f181b32fcc fix(boot): preserve each data home's environment snapshot
Problem: A rebuild can render another data home's environment probe answer
instead of its own still-valid persisted prefix, invalidating compaction.

Root cause: Process-cache lookup preceded durable snapshot lookup, while
shared-result adoption left existing snapshots untouched and did not merge
transient failures against previous successful observations.

Fix: Prioritize fresh trusted snapshots and merge/persist shared refreshes
at the environment owner. Preserve the snapshot format and trust checks.
Keep this prefix change separate from the remote-project UI as requested
in the review of #14615.

Verification: All 219 root Go packages with tests passed on this independent
branch, including full boot; environment race, go vet, make lint, formatting
and diff checks passed. Public-probe and cross-process boot regressions fail
with the original production implementation and pass with this fix. The
split patches reconstruct the combined source with an identical Git tree.
2026-10-28 04:21:27 +08:00
SivanCola 430aea04a1 ci: run status admission inventory before broad Windows smoke
Preserve every workflow step, condition, timeout and assertion. Move the
status-owned pair away from concurrently added budget inventories so both
sets compose, and collect the named evidence before broad smoke can fail.

Refs #14656
2026-10-28 03:57:35 +08:00
SivanCola 7972a60da7 fix(event): preserve quote and scope capabilities together
Carry the accepted UsageQuoter event owner alongside Goal usage scope capture.
Retain both optional capabilities in wrappers and their shared inventory while
keeping scope-specific fixture additions independent of the quote additions.

Verify that nested bound usage reaches the occurrence-time quote owner once,
keeps the earlier Goal attribution, and preserves the emitted JSON. Document
the two owners and retain existing Windows required-test coverage.

Refs #14661
Refs #14671
2026-10-28 03:53:07 +08:00
YHH f00e8ac590 Merge pull request #14672 from esengine/fx/global-shortcut-page
fix(studio): put global-shortcut on a settings page and keep Composer under the size ceiling
2026-10-27 12:01:37 -07:00
YHH af176c59f3 Merge branch 'studio' into fx/global-shortcut-page 2026-10-27 11:34:12 -07:00
YHH cd9c8723de Merge pull request #14551 from chuiwenwei/fix/acp-inbox-refusals-carry-codes
fix(acp): an inbox refusal says which refusal it is
2026-10-27 11:22:28 -07:00
esengine 61bbcd5a16 feat(installer): close a running Studio thoroughly and say how when it cannot 2026-10-27 10:58:38 -07:00
YHH edefe37198 Merge pull request #14371 from SivanCola/codex/worktree-snapshot-content-14025
fix(worktree): capture current bytes and refuse stale apply / 修复快照旧内容与过期整合覆盖
2026-10-27 10:57:26 -07:00
YHH 7059b4ac45 Merge pull request #14631 from SivanCola/codex/skill-entry-root-budget-14025
fix(control): share root budget with explicit skills / 显式技能共享任务根预算
2026-10-27 10:56:48 -07:00
esengine e210f9be5e fix(studio): put global-shortcut on a settings page and keep Composer under the size ceiling 2026-10-27 10:47:07 -07:00
YHH bfbb153ab3 Merge pull request #14522 from chuiwenwei/fix/a-total-says-how-many-it-could-not-price
fix(stats): a total says how many calls it could not price
2026-10-27 10:26:13 -07:00
YHH ffa0911a56 Merge pull request #14671 from SivanCola/codex/authoritative-local-cost-budget-14025
fix: use authoritative local cost quotes / 本地费用门禁采用权威报价
2026-10-27 10:21:42 -07:00
YHH 6ff61b6c0e Merge pull request #14037 from imcreazy/fix/closing-the-last-page-keeps-the-workbench
fix(studio): closing the last page offers a new tab
2026-10-27 10:20:32 -07:00
YHH e85a2de9c4 Merge pull request #14578 from esengine/c5-hide-prefix
feat(providers): a provider can hide the prefix all its models share
2026-10-27 10:09:02 -07:00
esengine 4713b74f1d feat(openwith): say why an installed program is not offered 2026-10-27 10:07:45 -07:00
YHH 0f5e12064c Merge pull request #14670 from esengine/fx/usage-upload-bytes
feat(usage): show how much is uploaded to model providers
2026-10-27 09:53:33 -07:00
YHH a36dfec9e5 Merge pull request #14647 from esengine/fx/settings-pages-nav
feat(desktop): long settings sections split into pages in the navigation
2026-10-27 09:52:57 -07:00
YHH c112ecdfc9 Merge pull request #14592 from lancy69/feat/8290-global-window-shortcut
feat(studio): add an opt-in global show/hide shortcut
2026-10-27 09:52:40 -07:00
SivanCola 6279a638dc fix: use authoritative quotes for local task cost gates
Problem: local task cost gates could count a base rate while the accounting
event charged the vendor's occurrence-time peak rate, allowing extra tools.

Root cause: Agent budgeting recomputed spend with the legacy float adapter
instead of consuming the quote owned by the existing pricing sink.

Fix: forward an optional usage-quoter capability, capture one authoritative
quote for both delivery and budgeting, and accumulate fixed-point original
currency buckets. Preserve known subtotal and incomplete-usage semantics.
Keep display currency, provider requests and cancellation separate.

Verification: actual Agent and Boot peak/off-peak/relay regressions, preceding
owner negatives with controls, full root package inventory, relevant owner
and Boot race suites, static gates, cross-compilation and a pinned Windows
inventory with verifier mutants. Hosted native and independent review remain
separate. Shared root money and reservations are not implemented here.

Refs #14025.
2026-10-28 00:33:05 +08:00
esengine 1a208789c5 feat(usage): show how much is uploaded to model providers
Every model request carries the whole conversation, so upload grows with
context size times request count and nothing in the app showed it. Each send
attempt now reports its body size, a per-process meter keeps it per provider
and day beside the usage records, and Settings > Usage shows today, the last 30
days, the average request and the providers.
2026-10-27 08:41:11 -07:00
esengine d21e4bebbf feat(desktop): long settings sections split into pages in the navigation 2026-10-27 07:21:20 -07:00
lancy69 e0728253bb feat(studio): add an opt-in global show/hide shortcut
Own one disabled-by-default shortcut in the local Electron profile, with immediate authorized IPC and visible registration failures. Preserve old registration and saved state when rebind or persistence fails; restore at ready and unregister during quit.

Keep browser/network viewers unsupported and in-app bindings unchanged. Cover lifecycle, authorization, failure rollback, bounded native input and settings neighbours; add a real Linux/X11 OS-key smoke and transparent platform limits.

Fixes #8290. Refs #7623; stopped-app launching is not supplied.
2026-10-27 21:54:36 +08:00
YHH 75317a6fe3 Merge pull request #14666 from lancy69/feat/14610-remember-feedback-contact
feat(studio): remember last submitted feedback contact
2026-10-27 06:17:35 -07:00
YHH c7a869c9e9 Merge pull request #14662 from esengine/fx/session-tail-fork
fix(session): open the newest part of a history too large to replay
2026-10-27 06:09:34 -07:00
YHH 984b715f97 Merge pull request #14664 from esengine/fx/compact-notice-code
fix(compaction): word the dropped-turn notices in the window's language
2026-10-27 06:08:57 -07:00
YHH a4c2ebd5be Merge pull request #14571 from esengine/c5-vision-filter
feat(studio): the vision row lists only models declared to read images
2026-10-27 06:08:35 -07:00
YHH c4960bd263 Merge pull request #14589 from lancy69/feat/14388-theme-surface-opacity
feat(theme): allow per-scheme backdrop surface opacity
2026-10-27 05:50:13 -07:00
YHH 37a0cf23c6 Merge pull request #14591 from lancy69/fix/14497-queue-composer-layout
fix(studio): integrate pending queue into composer layout
2026-10-27 05:46:55 -07:00
YHH 281d9264cc Merge pull request #14590 from lancy69/fix/12019-soft-contrast-tier
fix(studio): give Soft contrast a lower AA palette
2026-10-27 05:43:45 -07:00
YHH 7bbc19bb1a Merge pull request #14588 from lancy69/fix/14470-wallpaper-settings
feat(studio): expose wallpaper settings and selected preview
2026-10-27 05:40:38 -07:00
esengine 6161a95f5c fix(session): open the newest part of a history too large to replay
A session whose message payload passes the replay bound could not be opened
and the window offered no way forward. The refusal now carries a code, and
the window offers to continue from the most recent part as a new session; the
original files are only read.
2026-10-27 05:33:53 -07:00
YHH d371619a9e Merge pull request #14657 from lancy69/fix/14632-session-delete
fix(studio): keep deleted legacy conversations out of the sidebar
2026-10-27 05:30:48 -07:00
YHH 3fef1b4ea1 Merge pull request #14602 from lancy69/fix/14231-editor-save-controls
fix(studio): keep file Save compact and readable
2026-10-27 05:30:31 -07:00
Lancy 180d3a71eb feat(studio): remember last submitted feedback contact 2026-10-27 20:03:17 +08:00
Lancy 336ca9cc30 fix(sessionstore): persist removal of legacy v4 conversations 2026-10-27 19:50:00 +08:00
esengine eeb0e27878 fix(compaction): word the dropped-turn notices in the window's language
The two notices a fold leaves when user turns survive only through the summary
carried no code, so a Chinese window showed the English fallback. They now
carry a code and a typed payload like the other coded notices.
2026-10-27 04:00:05 -07:00
SivanCola 57678cc06a fix: retain Goal usage ownership across delegated work
Problem: background children from an older chat or Goal could charge late
usage to a newly active Goal while still retaining their original root budget.

Root cause: the usage tee selected its current recorder at event delivery,
so recorder epoch checks were applied to the wrong owner.

Fix: capture an opaque process-local accounting scope at the shared tool-call
boundary. Forward the optional capability through sink wrappers, preserve it
for nested delegation, and select the captured Goal recorder at consumption.
Keep session tally, quoting, title exclusion, provider requests and wire data
unchanged. Document between-turn accounting and persistence limitations.

Verification: real Boot delayed-background and nested fixtures, same-Goal
controls, Goal lifecycle and JSON tests, exact preceding-owner negatives,
root package inventory, owner/Boot race tests, static gates, cross-compilation
and a pinned native Windows inventory with fail-closed verifier mutants.
Hosted native execution and independent review remain separate gates.

Refs #14025.
2026-10-27 18:24:18 +08:00
YHH e19aff7f26 Merge pull request #14655 from esengine/fx/blocked-calls-carry-identity
fix(agent): calls the host stops before they run carry a refusal identity the window words
2026-10-27 03:06:49 -07:00
YHH fed8d4e387 Merge pull request #14650 from esengine/fx/settings-storage-tally
fix(desktop): opening settings no longer walks the disk for the storage total
2026-10-27 03:02:54 -07:00
Will 03b0268479 docs(release-notes): record #14551 2026-10-27 18:01:53 +08:00
Will d7d0c8aeef fix(acp): an inbox refusal says which refusal it is
Every inbox handler answered a store refusal with -32600 and prose, so a client
could not tell "gone" from "wrong state" from "may already have run" — and one
that retried on any error re-sent work the kernel refuses precisely because the
turn may already have run. The HTTP frontend already names 14 conditions; ACP
now answers the same names, with -32020 for "the queue refused this call" and
-32021 for "the work may already have run", and leaves -32600 to a malformed
frame alone.

docs/ACP.md states the table and each refusal's class (terminal,
retry-after-repair, do not retry). repolint's family-vs-writer check now reads
both writers, so a sentinel added to the store cannot reach ACP unnamed.
2026-10-27 18:01:52 +08:00
Will c1321f9971 docs(release-notes): record #14522 2026-10-27 18:01:49 +08:00
Will dff7a9e7f3 fix(stats): a total says how many calls it could not price
A row already records why its money may be incomplete (`cost_estimated`,
`incomplete_reason`), but RangeStats kept only a bool, so a total could say
"this folded in an estimate" and never "of these calls, N were never priced,
because …". Both read paths now count those calls and name the distinct
reasons; the projection carries the reason as schema 8, which reindexes from
the daily files; an old or reason-less row still counts, it just adds no
reason to the vocabulary.
2026-10-27 18:01:49 +08:00
YHH 1fbb96530a Merge pull request #14648 from esengine/fx/summary-retry-connection-reset
fix(agent): retry a context summary once when its stream is reset
2026-10-27 02:59:18 -07:00
SivanCola 32c323a553 fix(delegation): reject unsupported persisted run states
Problem: Saved child records with missing or unknown status could reach
continuation and transcript-copy preparation.

Root cause: The shared metadata validator rejected three known bad states
but implicitly admitted every other string, including future enum values.

Fix: Permit only the existing completed/cancelled states before unchanged
identity checks. Keep metadata decoding and writers unchanged so refusal
preserves unknown fields and transcript bytes for diagnosis.

Verification: Real on-disk store/reopen matrix covers nine state shapes and
five preparation routes, artifact preservation and reference-lock release.
Exact preceding owner fails 25 named events with 31 passing controls.
Qualification results and compatibility limits are recorded in the PR body.
2026-10-27 17:43:47 +08:00
esengine 96cd01d6e2 fix(agent): calls the host stops before they run carry a refusal identity the window words 2026-10-27 02:37:11 -07:00
esengine f41b9d4131 fix(desktop): opening settings no longer walks the disk for the storage total 2026-10-27 02:10:00 -07:00
esengine 8094bcb3a8 fix(agent): retry a context summary once when its stream is reset 2026-10-27 01:40:28 -07:00
YHH d5ead8314c Merge pull request #14635 from esengine/fx/settings-type-roles
feat(desktop): settings text follows one set of type roles
2026-10-27 00:53:36 -07:00
esengine d2a2f9fe97 feat(desktop): settings text follows one set of type roles 2026-10-27 00:27:33 -07:00
YHH cf5f12ed55 Merge pull request #14620 from SivanCola/feature/provider-health-api
feat(provider): gate connection health on user opt-in / 连接检测默认关闭并限定用户授权
2026-10-26 23:57:52 -07:00
YHH 1d81d02a3f Merge pull request #14630 from esengine/fx/workbench-git-scroll
fix(desktop): the multi-repository Git panel scrolls instead of growing past the explorer
2026-10-26 22:54:49 -07:00
YHH 3c4982bc5c Merge pull request #14626 from esengine/fx/modal-focus-no-ring-after-mouse-open
fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring
2026-10-26 22:54:10 -07:00
SivanCola 6012926b9f fix(control): bind root budgets for explicit subagent skills
Problem: Explicit slash, composer and CLI profile children could exceed a
configured root token/wall budget because they bypassed the normal Agent turn.

Root cause: Those Controller entries invoked skill runners without preparing
the executor-owned scope; Goal skills installed observation without the spend
limit. Synchronous profile calls also lacked turn admission serialization.

Fix: Bind the existing Goal/root scope after blocking hooks for interactive
skills. Reuse synchronous Controller admission and root preparation for CLI
profile run/preview, retaining child pause and cancellation semantics.

Verification: Actual Boot HTTP/file-read and Controller lifecycle regressions;
exact old-owner overlay fails 13 named events with four passing controls.
Complete 239-package coverage includes the retained original Boot 15m alarm
and two disjoint remainder groups; four owner race suites and focused Boot
race pass. Root vet, full lint, check and actionlint pass. Windows/Linux
compile-only checks and the literal native gate contract pass locally.
2026-10-27 13:54:03 +08:00
esengine fd901eabed fix(desktop): the multi-repository Git panel scrolls instead of growing past the explorer 2026-10-26 22:36:58 -07:00
esengine b79621c47c fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring 2026-10-26 22:30:41 -07:00
esengine f193dfabe7 fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring 2026-10-26 22:30:30 -07:00
YHH 27b8e944ec Merge pull request #14621 from esengine/fx/phone-test-wait-for-delivery
test(notify): wait for the first phone delivery before attaching the link
2026-10-26 21:59:06 -07:00
esengine 5d973599fc test(notify): wait for the first phone delivery before attaching the link 2026-10-26 21:34:00 -07:00
YHH 37a74507d1 Merge pull request #14618 from esengine/fx/queue-hold-notice-clears
fix(desktop): clear the held-queue notice when the queue resumes
2026-10-26 21:32:07 -07:00
YHH b7fe9ca5f7 Merge pull request #14614 from SivanCola/codex/goal-resume-planner-budget-14025
fix(control): renew planner budget on Goal resume / 恢复 Goal 时续给规划预算
2026-10-26 21:30:02 -07:00
SivanCola 012bd085aa fix(provider): declare health consent as immediately effective
Problem: The setting catalogue described health consent as applying on session open.
Root cause: The declaration did not match the controller's per-request global read.
Fix: Mark the guarded setting Immediate; disable and authorization changes are
checked before cached evidence is returned.
Verification: Settings package tests and the registered HTTP opt-in/disable
regression pass. No payload or request logic changed.
2026-10-27 12:22:01 +08:00
SivanCola 590bc21470 feat(provider): add opt-in authorized connection health API
Problem: automatic picker probes need explicit user consent and must not
borrow credentials for project-defined destinations.
Root cause: the combined implementation used the merged configuration and
had no backend-enforced default-off setting.
Fix: use a strict user-global configuration snapshot, require the opt-in and
provider access before every cache read, refuse all redirects, and expose a
guarded settings API. Cache only fixed-size digests and status/timestamps;
never retain upstream error bodies or log credentials.
Verification: authorization/default-off/disable and malformed-config HTTP
regressions, previous-reader save overlay, desktop setting round trip,
provider-health race tests, and frontend route contracts passed. Full lint
and inventory-partitioned boot validation are tracked separately in the PR.

Refs #14617
2026-10-27 12:11:50 +08:00
SivanCola e031cd0cf6 fix(studio): clarify worktree labels and directory choices
Problem: Directory labels were inconsistent across the header and composer,
and the default green badge looked like a success status.

Root cause: Separate presentation strings described the same workspace
state, including an unnecessary badge for the ordinary directory case.

Fix: Show neutral labels only for isolated worktrees, retain explicit
current-folder/worktree choices with checkmarks, and share explanatory
tooltips while preserving full paths and branches. Update translations,
usage documentation, and the release note.

Verification: Frontend interaction, translation and style checks;
TypeScript; make check; gofmt; go vet; make lint; browser comparisons in
light/dark themes at wide and phone widths. Required Go package and
production build results are recorded in the pull request.
2026-10-27 12:06:27 +08:00
esengine 589bcab7a3 fix(desktop): clear the held-queue notice when the queue resumes 2026-10-26 21:03:37 -07:00
Will 87f7238ab7 chore: retrigger the suite after a flaky boot run
The boot package ran 726s on that job against 264s here, and this change does
not reach it: projectiondb.OpenReadOnly/Inspect have one production caller and
it is the doctor command. No code change.
2026-10-27 11:51:12 +08:00
Will 1bcded8b1b docs(release-notes): record #14549 2026-10-27 11:51:12 +08:00
Will e0f288b8ed fix(doctor): a catalog that recorded a failure is not ok
Problem: `reasonix doctor catalogs` printed the projection's integrity_check
string as the catalog's state, so a history catalog whose history_sources row
recorded health='corrupt' printed `ok`, and a future-schema database -- which
the runtime opens as StateDegraded -- printed `ok` too. The command a user is
told to run to check their data agreed with neither the catalog nor
`catalogs reindex --json`, which reports the same state as "failed": 1.

Root cause: the state column came from Inspection.Integrity and nothing else.
The command never asked the catalog what it recorded, and never compared the
recorded schema against the schema this build migrates to.

Fix: read the facts reindex already reads. refreshStatus and refresh now share
one readStatusCounts with the new read-only Inspect readers, so the
diagnostic's failed is the number reindex reports, and the reason is the one
the catalog recorded. A newer schema reports degraded against
projectiondb.SupportedVersion, which ApplyMigrations also uses. --json gains
state, failed, pending, corruptLines/lagBytes and failures only when there is
something to say, so a healthy catalog's output is unchanged. Text stays one
line per catalog. Exit stays 0: like `catalogs reindex --json`, this command
reports rather than repairs.

Verification: red-first. A reindexed undecodable transcript printed
`history  ok`; it now prints `history  failed ... failed=1 source=<path>:
<recorded reason>`. A schema-2 database printed `ok`; it now prints
`degraded ... supported=1`. Controls: healthy line and exit unchanged, missing
line unchanged, healthy JSON keys unchanged. Mutation: dropping the failure
read turns the corrupt-source test red, dropping the degraded read turns the
future-schema test red. projectiondb.OpenReadOnly reads a live WAL, which
immutable=1 skipped. No projection schema, history index or reindex path
changed. Refs docs/USAGE_CATALOG.md:25, docs/HISTORY_SEARCH_CATALOG.md:42-43.
2026-10-27 11:51:12 +08:00
YHH 61a6c0b18b Merge pull request #14604 from SivanCola/codex/tracked-command-job-owner-14025
fix(proc): retain published Job Object owner / 保留已发布的作业对象 owner
2026-10-26 20:49:37 -07:00
YHH 5831a67380 Merge pull request #14612 from esengine/fix/feedback-esc-lightbox
fix(studio): Escape closes only the innermost open layer
2026-10-26 20:47:14 -07:00
SivanCola b3ba6ee899 fix(control): renew the planner budget on Goal spend resume
Problem: A dual-model Goal resumed after a spend-budget pause can stop its
new planner research after one tool round despite the user renewing the slice.

Root cause: ResumeGoal resets only the executor's local budget. The tool
planner keeps its own Agent accumulator and stricter local gate, so renewing
the shared executor root does not clear that old planner spend.

Fix: On a successful budget-spend resume, also reset the existing companion
planner Agent through the runner's established accessor. Preserve manual
pauses, refused resumes, cumulative Goal statistics and delivery evidence.
No new API, default, cancellation authority, prompt or persisted format.

Verification: Actual Controller/Coordinator loop reproduces two resumed
planner requests instead of four on the preceding owner. Actual Boot plus
HTTP/SSE reads three fixture files per renewed slice; the exact old owner
overlay compiles and fails both regressions with four passing controls.
Full 239-package coverage reconciles 24061 unique test/subtest passes and
215 existing skips. The original Boot command hit its 15m package alarm;
all 846 top names close via 842 original terminals and disjoint 2/2-name
remainder groups, without raising timeouts. Complete control/coordinator
race1758 passes/4 existing skips, actual Boot race1 pass, static gates and
Windows/Linux compile-only checks pass. New-head hosted/native gates remain.
2026-10-27 11:36:01 +08:00
esengine fc68f914f8 fix(studio): Escape closes only the innermost open layer 2026-10-26 20:27:40 -07:00
SivanCola eafeac4ea4 test(proc): recognize the Windows recorder by executable path
Problem: The Windows fallback control missed its recorder marker and could
not remove the still-running taskkill test executable.

Root cause: exec.Command("taskkill", ...) preserves the bare command in
argv[0]. The recorder compared that argument with taskkill.exe, missed its
special entry point, and entered the complete package suite instead.

Fix: Identify the recorder with os.Executable, require LookPath to select
the fixture's same file, and fail closed on an unexpected recorder entry.
Add a bare-command alias probe whose fallback selector runs only the inert
helper. Run/Wait completes before temporary-directory cleanup. Keep the
production ownership repair unchanged and extend the native inventory to
seven pass events versus four expected failures and three passing controls.

Verification: Windows cross-compile, Windows vet and pinned Windows proc
lint pass; full root vet/lint and make check pass at unchanged baseline.
All 6268 selected Darwin inputs match the previous qualified commit; this
append changes only the Windows fixture, verifier inventory and docs.
The complete original Windows failure log is preserved. Fresh native
Windows execution on this new commit remains required, not inferred.
2026-10-27 11:25:55 +08:00
YHH 432c8927f2 Merge pull request #14606 from esengine/fix/share-endpoint-facts
fix(openai): an endpoint's refusal of stream_options is learned once per process
2026-10-26 20:16:15 -07:00
esengine 306aeecded fix(openai): an endpoint's refusal of stream_options is learned once per process 2026-10-26 19:58:28 -07:00
SivanCola 75a36959fd fix(proc): retain published command job ownership
Problem: Repeated cancellation or shell reaping can discard an established
Windows Job Object owner and perform a new PID-based stop attempt.

Root cause: TrackedCommand removes its one-shot TrackedJob on the first kill
and also starts a redundant PID tree observer after successful job assignment.

Fix: Keep the published owner across repeated and late cancellation, and let
an established Job Object own its tree. Preserve existing non-job fallbacks.
Add native Windows owned-child probes with a harmless taskkill recorder and
an exact preceding-owner counterfactual inventory gate.

Verification: Root inventory reconciles 239 packages, 24060 test/subtest
passes and 215 existing skips. Original Boot command hit its 15m package
alarm; all 845 top-level tests close through the recorded disjoint remainder
groups without a timeout increase. Complete proc and shell race suites pass.
Root vet, full lint, make check and actionlint pass. Windows new/old owners
compile and Windows vet passes; actual native regressions remain a hosted gate.
No full PID-identity, descendant-quiescence or physical-scope claim.
2026-10-27 10:48:46 +08:00
Lancy 03c406a5a4 fix(studio): keep file Save compact and readable 2026-10-27 09:33:58 +08:00
esengine 74805c102c docs(release-notes): studio 2.53.1 2026-10-26 18:01:26 -07:00
YHH e9268cc022 Merge pull request #14597 from esengine/fix/settings-header-no-drag
fix(studio): the full-window settings header's buttons respond to clicks
2026-10-26 18:01:04 -07:00
esengine d05faa37a7 fix(studio): the full-window settings header's buttons respond to clicks 2026-10-26 17:40:10 -07:00
YHH b0bb1588bc Merge pull request #14595 from SivanCola/codex/controller-root-routing-budget-14025
fix(control): include semantic routing in root budgets / 语义路由计入根预算
2026-10-26 17:35:56 -07:00
esengine e27fecef3c docs(release-notes): studio 2.53.0 2026-10-26 16:30:32 -07:00
SivanCola b19415a2a4 fix(control): account semantic routing in the root task budget
Problem: semantic routing runs before Agent/Coordinator root preparation, so its reported usage can permit an extra executor tool round.

Root cause: Controller binds no root before routing, and SemanticRouter does not report finalized usage to the accepted context observer.

Fix: share executor root preparation after admission/hooks and before routing in headless and interactive turns. Observe semantic terminal usage once, independent of audit/sink and answer success. Preserve cache hits, new-task/scope/reset lifetimes, normal accounting and cancellation authorities.

Verification: disjoint required/rest commands pass all 239 root packages; four complete owner race suites pass 3939 events with 14 existing skips; actual Boot race passes 12 events. Exact preceding owners compile and fail 15 regression events with six passing controls. Format, vet, full lint and make check pass at byte-identical baseline. Six Windows/Linux binaries compile; local literal Windows gate verifies 21 required events/two package terminals and rejects skip/missing-terminal controls, not native execution.
2026-10-27 07:23:30 +08:00
esengine d54c00e138 docs(release-notes): name the security hardening in 2.53.0 2026-10-26 15:35:03 -07:00
YHH 61f71af0c8 Merge pull request #14594 from SivanCola/codex/git-storage-environment-14025
fix(gitcmd): isolate host storage environment / 隔离宿主 Git 存储环境
2026-10-26 15:33:15 -07:00
esengine e8f3bfc7f2 test(peers): hold the turn open while the dispatch switch is turned off 2026-10-26 15:09:20 -07:00
SivanCola 33f4e9f642 fix(gitcmd): bind host storage to the resolved repository
Problem: ambient Git index/object variables can redirect host worktree preparation and object access.

Root cause: pinned Git/common/worktree directories do not override GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY or GIT_ALTERNATE_OBJECT_DIRECTORIES. The shared constructors retained them.

Fix: remove those three ambient overrides in Env and Detached. Keep deliberate host private-index/object overrides and repository-declared alternates. Cover actual delivery/candidate preparation and a Boot isolated-write/apply flow.

Verification: all 239 root packages pass across disjoint required/rest commands; complete gitcmd/worktree/isolation race 306 passes; Boot race four passes. Exact preceding owner compiles and fails 18 regression events while seven controls pass. Full lint, vet and make check pass with byte-identical baseline. Windows/Linux builds compile; local literal Windows inventory verifies 25 required events and three package terminals, not native execution.
2026-10-27 05:38:13 +08:00
YHH 827bc2cf4c Merge pull request #14593 from SivanCola/codex/coordinator-root-budget-14025
fix(coordinator): share root task budgets / 规划与执行共享根任务预算
2026-10-26 12:53:43 -07:00
esengine b8b7ac87f9 fix(serve): a paired device opens only folders the window already lists 2026-10-26 12:24:35 -07:00
esengine 3463385901 fix(config): a project's system_prompt_file waits for the folder to be trusted 2026-10-26 12:20:14 -07:00
SivanCola 2ae3cc8fa8 fix(coordinator): share planner and executor root token budgets
Problem: configured tasks used independent planner and executor root
counters, allowing an extra executor tool round after planning spend.

Root cause: Agent root binding began only inside each role's Run. A
plan-only turn never opened executor Run and could lose its accounting.

Fix: prepare the executor-owned root before routing, bind companion Agent
runs to it, and account legacy raw-planner usage at its logical terminal.
Track preparation by session object and host task scope. Adopt only the
preparing executor's binding after its own evidence reset; preserve clock,
immutable limit snapshots and captured old-job counters across continuation.
Keep local per-Agent gates/telemetry and all approval/cancellation boundaries.

Verification: required2 plus disjoint rest237 cover239 packages exactly
once,23980 test/subtest passes215 existing skips. Four full owner race
suites2786pass10skip; actualBoot shared and delegated token/wall16racepass.
Precise preceding Coordinator overlay compiles9fail4controls/2pkgfail.
LiteralWindows20named events/3pkg passes locally;6Win/Linux builds compile
only. Rootvet/full lint/makecheck/gofmt/diff pass,2663 baseline unchanged.

Refs #14025
2026-10-27 03:14:17 +08:00
esengine e19b45d39b fix(cli): trust --yes does not approve a provider endpoint unless --allow-providers is given 2026-10-26 12:07:12 -07:00
esengine 3653373671 fix(serve): refuse auth none on a reachable address unless --allow-open-network is given 2026-10-26 12:05:45 -07:00
YHH 97202ddd48 Merge pull request #14587 from lancy69/fix/14545-model-tooltip-aliases
fix(studio): show account and model aliases in composer tooltip
2026-10-26 11:54:48 -07:00
lancy69 c5334b70f3 fix(studio): integrate pending queue into composer layout 2026-10-27 02:40:28 +08:00
Lancy 47df9ff015 fix(studio): make soft contrast lower without crossing AA 2026-10-27 02:39:29 +08:00
Lancy 4a28c20aad feat(theme): add per-scheme backdrop surface opacity 2026-10-27 02:39:02 +08:00
Lancy 0aedda5da2 feat(studio): expose wallpaper settings and selected preview 2026-10-27 02:36:36 +08:00
Lancy 72e46ebbc8 fix(studio): show account and model aliases in composer tooltip 2026-10-27 02:33:39 +08:00
YHH 573c87ce81 Merge pull request #14585 from esengine/fix/feedback-sync-open-cap
fix(feedback-sync): evaluate every open row, not only the first 200
2026-10-26 10:58:39 -07:00
esengine 3321d690ea fix(feedback-sync): evaluate every open row, not only the first 200 2026-10-26 10:35:48 -07:00
YHH 897f9fc2dc Merge pull request #14583 from SivanCola/codex/anthropic-interrupted-usage-14025
fix(anthropic): retain interrupted usage / 保留中断流已知用量
2026-10-26 09:53:30 -07:00
esengine 6336f67634 style(hook): range over FieldsSeq 2026-10-26 09:35:58 -07:00
esengine 3e6cb52f6a fix(hook): keep an unquoted Windows path as written when listing a hook's programs 2026-10-26 09:35:14 -07:00
esengine 4227c20b83 fix(memory): a pinned memory is never written without confirmation 2026-10-26 09:34:09 -07:00
esengine a755eb601c fix(serve): cut a revoked device's streams under the registry lock 2026-10-26 09:33:06 -07:00
esengine 191d1e17a1 fix(permission): a git read in a repository that runs programs is put to the person 2026-10-26 09:32:51 -07:00
esengine 147d5365e8 fix(usecap): label catalog answers that quote MCP-written descriptions 2026-10-26 09:15:14 -07:00
esengine e8c22e7ce7 fix(config): a project folder cannot choose what enters the provider schema 2026-10-26 09:15:14 -07:00
esengine 4ca33493a6 fix(instructions): say where an untrusted folder's standing instructions came from 2026-10-26 08:58:00 -07:00
SivanCola 00a68b64b7 fix(anthropic): retain known usage when streaming is interrupted
Problem: Anthropic error, EOF and cancellation exits could discard usage
already reported by message_start or message_delta. Bounded consumers lost
the counts, while normal recovery reconstructed counts it already knew.

Root cause: the cumulative usage emitter was reachable only after
message_stop; consumers may stop immediately upon receiving ChunkError.

Fix: give each response one cumulative accumulator and delivery attempt.
Flush partial usage before explicit errors with existing Estimated identity,
retain cancellation-aware best-effort delivery on other exits, and compute
the original error classification before accounting changes sink state.
Keep exact committed usage, request construction and retry policy unchanged.

Verification: required builtin/boot plus disjoint rest cover all239 root
packages once,23966 test/subtest passes215 existing skips. Six full owner
race suites2615pass10skip; actualBoot4race events0skip. Exact preceding
decoder compiles and fails14events with4controls passing. Native-step
contract18events/2pkg passes locally;4Win/Linux binaries compile only.
Rootvet/full lint/makecheck/gofmt/diff pass; baseline2663 byte-identical.

Refs #14025
2026-10-26 23:54:59 +08:00
esengine 495a234781 fix(serve): keep install and enable routes to the window, not paired devices 2026-10-26 08:46:06 -07:00
esengine 167c01014a fix(serve): cap the login form body 2026-10-26 08:45:25 -07:00
esengine de21fb4880 fix(shellsafe): an awk program in the call is inline code 2026-10-26 08:45:05 -07:00
esengine ab144a4cd8 fix(installsource): vet an IP literal target before a proxy is used 2026-10-26 08:44:49 -07:00
esengine ed624e928b fix(net): judge the IPv4 an IPv6 transition address carries in the fetch guards 2026-10-26 08:43:53 -07:00
YHH 7934cbb25b Merge pull request #14575 from esengine/c5-persona-default
feat(studio): a personal agent preset can be the new task default
2026-10-26 08:35:19 -07:00
YHH 34c6199885 Merge pull request #14574 from SivanCola/codex/root-websearch-token-usage-14025
fix(search): count native root usage / 根预算计入原生搜索用量
2026-10-26 08:07:08 -07:00
esengine 51f2b1b5ec feat(providers): a provider can hide the prefix all its models share
The prefix is read off the entry's own model list up to its last slash and left
out of how models are drawn, in every listing; ids and refs never change, an
alias wins, and models that would read the same keep their prefix. Fixes #14573.
2026-10-26 07:46:44 -07:00
esengine 0fc3806db7 feat(studio): a personal agent preset can be the new task default
A deleted default is dropped on the refusal the kernel names and the task opens
without it. Fixes #14572.
2026-10-26 07:38:47 -07:00
SivanCola 2fa7ad62b1 fix(search): charge native search usage to the active root budget
Problem: an explicitly bounded Agent root omitted known native search usage,
allowing an extra normal tool round after a reported search answer.

Root cause: the search terminal finalized its independent request count and
reported usage without calling the accepted optional root observer.

Fix: observe the finalized record once before optional reporting. Preserve
query isolation, request counts, callback copies, unknown usage, native
completion, paid-backend consent and zero-limit behavior.

Verification: all 239 root packages pass through disjoint commands; four
complete owner race suites and real boot OpenAI/Anthropic HTTP regressions
pass. The compiled preceding owner fails seven events while nine controls
pass. Static gates, six cross-compiles and the literal 16-event Windows
contract pass locally; own native execution remains a hosted gate.
2026-10-26 22:38:38 +08:00
YHH c2a082f328 Merge pull request #14568 from esengine/c5-settings-bottom
fix(studio): an enlarged settings window reaches the bottom of the window
2026-10-26 07:36:10 -07:00
YHH e39ff6a8cf Merge pull request #14567 from esengine/c5-peer-unread
feat(peers): a session that received a message from another session is marked unread
2026-10-26 07:22:04 -07:00
esengine 5b9fedcafd feat(studio): an enlarged settings window fills the whole window and its button shows the state
The sheet now covers the title bar too, its header standing in for the bar
(drag region, macOS light clearance, Windows buttons); the enlarge button's
arrows turn inward while enlarged. Refs #14418.
2026-10-26 07:12:17 -07:00
esengine e062fcad00 feat(studio): the vision row lists only models declared to read images
A model chosen earlier stays visible, marked as not declared. Refs #14418.
2026-10-26 07:07:26 -07:00
YHH 90310e50bc Merge pull request #14556 from esengine/c5-dispatch-reply
feat(peers): a dispatched session answers the session that opened it on its own
2026-10-26 07:05:27 -07:00
YHH 9d1aafe7b6 Merge branch 'studio' into c5-peer-unread 2026-10-26 06:58:53 -07:00
YHH e07e0cb335 Merge pull request #14566 from SivanCola/codex/test-capability-refusal-evidence-14025
test(boot): assert refusal evidence / 用执行证据验证权限拒绝
2026-10-26 06:53:49 -07:00
YHH f87512d326 Merge pull request #14562 from SivanCola/codex/root-triage-token-usage-14025
fix(agent): count triage root usage / 根预算计入分类与筛查用量
2026-10-26 06:51:30 -07:00
esengine 2aff58cea3 fix(studio): an enlarged settings window reaches the bottom of the window
The centring border that dialogs share left a title-bar-high strip under a
settings sheet that is 100% tall. Refs #14418.
2026-10-26 06:49:03 -07:00
esengine 6e6186d123 feat(peers): a dispatched session answers the session that opened it on its own
When a turn that ran a task from the session that dispatched this one ends,
its outcome and final answer go back as a message to that session. Only the
opener is answered, so a reply never starts a reply. Refs #13341.
2026-10-26 06:45:39 -07:00
YHH 2ecca64075 Merge pull request #14548 from esengine/c5-vendor-icons
feat(providers): vendor marks on the preset list and the model menu
2026-10-26 06:45:09 -07:00
YHH 82d0a3e8c8 Merge pull request #14561 from EMEEEEMMMM/feat/wallpaper-scene-layers
feat(studio): render scene layers and per-track audio
2026-10-26 06:38:17 -07:00
esengine 7fb9b9c11f feat(peers): a session that received a message from another session is marked unread
A newly held message stamps the receiver through the existing unread fact and
announces itself with a quiet operator notice, which makes the window read the
session list again. Refs #13341.
2026-10-26 06:34:32 -07:00
SivanCola 6ba04e3621 test(boot): check refusal execution evidence instead of child timing
Problem: a Windows run rejected three correctly refused capability calls
because the child conversation took 2.138s against a 2s wall-time check.

Root cause: the metric spans provider rounds and persistence, so it does
not establish whether target resolution refused before execution.

Fix: assert error/refusal guidance, Executed=false and no shell receipt for
all three results. Remove unused timestamps and preserve the existing 60s
completion guard. Production behavior and permission policy are unchanged.

Verification: the single root command passes all 239 packages, selected
boundary race checks pass 17 events, and a compiled executed-receipt test
mutant is rejected. Formatting, vet, lint and repository checks pass;
Windows/Linux boot binaries compile, without a native-execution claim.
2026-10-26 21:32:20 +08:00
YHH eadf190dda Merge pull request #14560 from SivanCola/codex/bounded-root-token-usage-14025
fix(agent): count bounded root usage / 根预算计入有界辅助调用
2026-10-26 06:30:26 -07:00
EMEEEEMMMM b03f059771 fix(studio): bound scene layer numbers 2026-10-26 21:13:06 +08:00
SivanCola 00c8d9142e fix(agent): count triage usage in the root token budget
Problem: an explicitly bounded root omitted billed command classifications
and external-content screening, allowing extra normal tool rounds.

Root cause: the shared triage terminal emitted usage without charging the
existing context-bound root counter.

Fix: observe terminal usage through the accepted private root helper before
the optional display guard. Preserve verdicts, screening notices, event
conditions, missing usage, normal telemetry and cancellation authority.

Verification: all 239 root packages pass in disjoint commands; full Agent
race and real boot HTTP/SSE matrices pass. The compiled preceding owner
fails ten events while five controls pass. Static gates, four platform
cross-compiles and the literal 15-event Windows contract pass locally.
2026-10-26 21:09:21 +08:00
YHH 6c3982a6fa Merge pull request #14555 from esengine/c5-dispatch-ui
feat(peers): the model can ask to open folders as projects, behind one approval card
2026-10-26 06:08:16 -07:00
YHH 7e302f39c1 Merge pull request #14558 from esengine/c5-fix-14553
fix(studio): the icon rail no longer cuts through the macOS window controls
2026-10-26 05:51:30 -07:00
SivanCola 02653e31b9 fix(agent): count bounded auxiliary usage in root token scope
Problem: an explicitly bounded Agent root omitted reviewer/advisor usage
already emitted by the shared bounded-call terminal, allowing extra rounds.

Root cause: normal-round accounting does not own auxiliary request usage.

Fix: connect the bounded terminal to one copied, replaceable context
observer bound to the original root counter. Preserve job cancellation,
normal telemetry, missing usage and zero-limit behavior.

Verification: all 239 root packages pass in disjoint commands; nine complete
owner/caller race suites and real boot HTTP/SSE regressions pass. Exact old
owners reproduce the gap; selected accepted auxiliary composition passes.
Formatting, vet, lint, repository checks and eight cross-compiles pass.
The 20-event Windows contract is locally verified, not native execution.
2026-10-26 20:30:53 +08:00
YHH bf149af099 Merge pull request #14559 from xxoingr/fix/mux-pane-position-at-subscribe
fix(studio): a pane the shared stream attaches late re-reads what went by
2026-10-26 05:25:17 -07:00
esengine c12af38c28 fix(studio): the icon rail no longer cuts through the macOS window controls
The rail's edge and background start below the title bar where the shell
floats the controls. Fixes #14553.
2026-10-26 05:24:14 -07:00
xxoingr c83ba32982 fix(studio): a pane the shared stream attaches late re-reads what went by
A local pane rides the hub's multiplexed stream, which attaches it only
once that stream is open. Until then the subscription has no position,
and the attach's watermark became its baseline as if nothing before it
mattered. A pane that read its transcript mid-turn and was attached after
the turn ended never received the reply or turn_done, and went on showing
the person's message alone.

The subscription now reads where the stream stands when it subscribes,
with the attach read of /events/replay. An attach that finds the stream
has moved on since reports what went by as a gap, and the pane rebuilds
from /history as it does for any other hole.
2026-10-26 20:09:04 +08:00
YHH 63ef499a62 Merge pull request #14552 from SivanCola/codex/root-auxiliary-token-usage-14025
fix(agent): count auxiliary root tokens / 根预算计入压缩与诊断用量
2026-10-26 05:01:36 -07:00
EMEEEEMMMM 15fb25d923 docs(release-notes): name the scene layers change 2026-10-26 19:55:09 +08:00
EMEEEEMMMM 37a2f0a31a feat(studio): render scene layers and per-track audio 2026-10-26 19:53:33 +08:00
esengine 8394467359 feat(peers): the model can ask to open folders as projects, behind one approval card
open_projects raises a fresh card naming every folder; approving adds the
folders to the project list and trusts them, vetted per folder like an extra
workspace root. Off unless the user's dispatch switch is on. Refs #13341.
2026-10-26 04:47:37 -07:00
SivanCola 3baa8532b9 fix(agent): charge auxiliary usage to the active root token budget
Fold summary and failure-diagnosis terminal usage into the existing
context-bound root scope, including billed failures. Preserve local
normal-round telemetry and manual maintenance outside a root context.
2026-10-26 19:27:31 +08:00
YHH dba175d8f7 Merge pull request #14547 from SivanCola/codex/planner-local-task-budget-14025
fix(boot): honor planner task budgets / 规划器遵守既有任务预算
2026-10-26 03:57:22 -07:00
esengine 7974185006 feat(providers): a relay's mark stays on its service, model rows keep their own
Presets declare whether they relay other makers' models; the model menu
heading and the settings service list draw the service mark, while a relay's
model rows fall back to the letter chip. Refs #14538.
2026-10-26 03:53:28 -07:00
YHH 8c2f7c163c Merge pull request #14544 from par73e/fix/14527-project-root-validation
fix(cli): validate machine interface project roots
2026-10-26 03:44:58 -07:00
esengine e5a3a41e61 fix(providers): register the vendor filter action and its English label 2026-10-26 03:33:08 -07:00
esengine a50aac0022 feat(providers): vendor marks on the preset list and the model menu
Presets declare their vendor; the wire carries it per model as mark, and the
client draws artwork for declared vendors only. Refs #14538.
2026-10-26 03:33:07 -07:00
YHH 9b3241aecb Merge pull request #14546 from bercedev/fix/mcp-list-get-extra-args
fix(cli): reject extra arguments to mcp list and mcp get
2026-10-26 03:32:58 -07:00
par73e ede7df2c00 fix(cli): validate machine interface project roots 2026-10-26 18:19:54 +08:00
SivanCola f2752a056d fix(boot): honor configured task budgets in tool-enabled planner
Pass the existing TaskBudget conversion to planner Agent Options. Cover
real HTTP planning reads, context overrides, disabled and unknown-cost
cases, executor fallback and request-prefix stability.
2026-10-26 18:11:24 +08:00
berce 4ec65d610c fix(cli): reject extra arguments to mcp list and mcp get
both ignored everything after the subcommand (or the name), so a typo like --jsno printed the human output and exited 0. now they exit 2 like mcp browse does.
2026-10-26 13:06:57 +03:00
YHH f6f1734dc6 Merge pull request #14542 from esengine/c5-fix-14541
fix(studio): a default-preset change answered with 204 is success, not a JSON read
2026-10-26 03:05:40 -07:00
YHH eed84be6d6 Merge pull request #14537 from esengine/c5-plan-bar-design
feat(studio): the plan bar is its own card with a collapse button
2026-10-26 02:53:39 -07:00
YHH 93d7679db5 Merge pull request #14536 from SivanCola/codex/test-mcp-session-fixtures-14025
test(boot): retire MCP fixture sessions / MCP 漂移夹具先关闭旧会话
2026-10-26 02:43:12 -07:00
esengine cc90a23fac fix(studio): a default-preset change answered with 204 is success, not a JSON read
The kernel answers POST /agent-presets/default with 204 and no body, but the
port read the answer as JSON, so a change that had landed reported
'Unexpected end of JSON input'. The call is status-only and uses post().
2026-10-26 02:28:20 -07:00
YHH 190cbeb483 Merge pull request #14531 from SivanCola/codex/estimated-usage-quote-14025
fix(pricing): preserve usage uncertainty / 报价保留估算用量的不确定性
2026-10-26 02:19:53 -07:00
esengine 089133fe56 feat(studio): the plan bar is its own card with a collapse button
The plan bar sat inside the composer box and the open list had no obvious way
back out. It is now a card floating just above the composer; the open list has
a header (running and pending counts) and a collapse button at its top right,
and Esc or a press outside closes it through the shared dismiss path. The jump
buttons move up so the card does not cover them, and the conversation gets a
little bottom room while a plan exists.
2026-10-26 02:09:35 -07:00
YHH 378f65045c Merge pull request #14534 from lancy69/fix/14209-thinking-stream
fix(studio): keep replies streaming while guidance waits
2026-10-26 02:09:01 -07:00
SivanCola 3ebb4ebc25 test(boot): retire MCP fixtures before the next session
Problem: restart-style MCP tests keep prior controllers alive while changing
server definitions or constructing a later session. Their background handshakes
and schema writes are therefore outside the test's advertised phase boundary.
The macOS job on #14531 observed a missing followed-tools notice, although its
precise interleaving was not captured and local old/new stress both pass.

Fix: close the owning controller before each transition in follow/unfollow and
accepted-tool restart fixtures. Existing idempotent Close drains the fixture's
MCP host and queued schema writes. Retain every notice, held-tool, provider
surface, actual-call and refusal assertion without retry, sleep or weaker gate.

Verification: six MCP effect test/subtest events and four existing deterministic
Close barrier tests pass under race. Forty fresh compiled processes pass each
before and after; they do not reproduce the hosted nil notice. Full lint, vet,
make check and four Windows/Linux amd64 builds pass. Full builtin/boot plus
disjoint root inventory continue separately; no production source changes.

Refs #14025
Refs #14133
Refs #14531
2026-10-26 17:08:04 +08:00
Lancy 1bc745ea59 fix(studio): keep replies streaming while guidance waits 2026-10-26 16:35:46 +08:00
SivanCola da70bab265 test(pricing): align estimated usage consumer contracts
Problem: broad validation found three adjacent fixtures requiring selected
cost aliases or complete quotes even when their usage was reconstructed.
The shared quote repair correctly takes the existing incomplete-display path.

Fix: assert preserved original amounts, incomplete/partial coverage and absent
selected aliases for estimated wire, ACP status and persisted stats rows. Keep
reported-usage alias and legacy-row controls. Expand the existing Windows
inventory to all eighteen named events across seven packages.

Verification: affected consumer tests pass, with complete eventwire/ACP/stats
race suites at 351 passing events and no skips. The compiled preceding pricing
owner now fails fifteen regression events with three controls passing. Final
local Windows-step contract passes eighteen events; required-skip and missing
package-terminal controls fail. Full lint, vet and make check remain clean;
final original root validation continues against unchanged frozen Go sources.

Refs #14025
Refs #14133
2026-10-26 16:31:27 +08:00
SivanCola 9799eae998 fix(pricing): preserve incomplete estimated usage quotes
Interrupted provider streams can reconstruct positive usage without terminal
reported counts. The shared quote owner forwarded that provenance but ignored
it when certifying complete cost facts, allowing recovered usage to make a
fully covered ledger.

Use the existing incomplete-usage result while retaining fixed-point amounts
and occurrence-time valuations. Reported usage, including rate-card estimates,
keeps its completeness. Existing quote fields, reasons and ledger versions stay
unchanged; no provider-visible request, permission or budget default changes.

Cover shared event/config adapters, persisted ledger coverage and real boot SSE
recovery with an actual file read. Add a Windows fourteen-event inventory in the
existing job. This is a monetary-fact prerequisite of the full concurrency plan,
not shared root-money admission or hard reservation.

Verification: fourteen focused events and the real boot race pass; compiled
preceding pricing fails twelve regression events with two controls passing.
The prior reader accepts the incomplete quote fixture. Root vet, full lint and
make check pass with the baseline unchanged. Eight Windows/Linux amd64 owner
builds compile; full root inventory and complete owner race continue separately.

Refs #14025
Refs #14133
2026-10-26 16:23:39 +08:00
YHH d797d79374 Merge pull request #14526 from SivanCola/feature/root-task-wall-budget-14025
Share explicit wall budgets across Agent delegation / 委派 Agent 共享显式耗时预算
2026-10-26 00:53:58 -07:00
YHH f781d9e244 Merge pull request #14525 from imcreazy/fix/rail-run-opens-on-hover
Open a folded rail run on hover, and close it on the way out
2026-10-26 00:32:07 -07:00
SivanCola 1e17cad975 Share explicit wall budgets through Agent delegation
Problem: a host wall budget could land the root Agent while delegated Agents
and Manager-owned background jobs kept independent start times or no wall bound.

Root cause: the opaque inherited root scope carried only token accounting; the
existing task time gate read each Agent's own clock after its tool rounds.

Fix: attach an immutable root task start/limit pair to that existing scope.
Preserve the start across continuations and snapshot new root overrides without
rewriting old job bounds. New tasks/reset use a new clock. The existing task and
fleet projection helper also carries this sidecar while keeping Manager-owned
cancellation/deadlines. Use existing time soft landing and finalization; keep
defaults unlimited, with no hard deadline, new timer, schema or permission.

Verification: frozen6730Go/fourmodules; required2+rest237 cover239packages,
23754test/subtestpasses/215existingskips. Complete Agent/delegation/jobs race
2652passes/10existing skips; actual boot background task/capability/fleet4race
passes; accepted14523sixGo selectedoverlay54racepasses; prefixguards4passes.
Exact preceding owner compiles and fails9events/2controls across2packages.
Windows17-event step passes locally and rejects skip/missingterminal controls;
six Windows/Linuxamd64 test binaries compile, not native execution.
Rootvet/full lint/makecheck/format/diff pass; baseline byte-identical2663.

Refs #14025
Refs #14133
2026-10-26 15:15:08 +08:00
imcreazy a6bb1f97da Open a folded rail run on hover, and close it on the way out
A run stands for several questions, so a press on it could only guess.
Hovering it now shows the list, and leaving takes it away again.
2026-10-26 15:14:23 +08:00
YHH 829adcf220 Merge pull request #14523 from SivanCola/fix/task-budget-usage-completeness-14025
Keep incomplete usage out of certified task costs / 保持任务用量与成本完整性
2026-10-25 23:30:13 -07:00
SivanCola 2bd768738a Fix incomplete provider usage in task cost accounting
Problem: a priced Agent could report complete task costs after a round omitted
usage, and estimated usage could certify a cost threshold.

Root cause: the shared HTTP attempt helper synthesized request-only usage
without its existing Estimated identity; the budget observer also treated
missing or estimated token accounting as a fully priced reading.

Fix: preserve exact HTTP counts while marking synthesized usage incomplete.
Keep only reported priced costs in the Agent subtotal, retain uncertainty for
the task lifetime, and land when the known subtotal alone reaches the limit.
Keep defaults, schemas, cancellation and finalization unchanged. Add real
OpenAI HTTP boot coverage and an explicit Windows test inventory.

Verification: frozen 6729 Go files/four module files; required2 +rest237 close
239 packages, 23754 test/subtest passes and215 existing skips. Complete five
owner race2407passes10existing skips; real HTTP boot race4passes; cache guards4.
Exact preceding owners compile with13failing events/5controls; producer-only
rollback4fails/1control; deliberate unknown-erases-known mutation1fail.
Root vet, full lint, make check and formatting pass with baseline unchanged;
six Windows/Linux amd64 owner/boot binaries compile (not native execution).

Refs #14025
Refs #14133
2026-10-26 14:03:36 +08:00
YHH ebab571cdc Merge pull request #14518 from SivanCola/feature/root-task-token-budget-14025
Share token budgets through Agent delegation / 委派 Agent 共享 token 预算
2026-10-25 22:30:45 -07:00
SivanCola 181c8dac78 feat(agent): share reported token budgets through delegated runs
Problem: configured task token limits counted only each Agent's own provider
usage. Delegated Agents could continue spending outside the parent's reading.

Root cause: local runBudget instances never shared a root token total, while
background task/fleet closures start from Manager-owned contexts that omit
caller values.

Fix: bind a private atomic root counter to the existing task ledger and pass
its immutable per-run threshold through derived contexts. Project only this
scope into both background producers, preserving job cancellation authority.
Keep local telemetry, cost/wall gates, defaults, prefixes and soft landing.

Verification: compiling preceding owners9fail/2controls; preceding job-context
only4fail/2foregroundcontrols. Complete3owner race2639pass/10existing skip;
realboot5scenes+parent6racepass, explicit cacheguards4pass. Final239 package
inventory23710pass/215existing skip, required builtin/boot, rootvet, full lint,
makecheck, formatting and6Windows/Linux builds pass. New native inventory
requires20namedpasses/no skips; local jq and required-skip negative verified.

No hard reservation, root money aggregation, process kill, remote/durable
accounting or Coordinator cross-root scope is claimed. Refs #14025, #14133.
2026-10-26 12:54:21 +08:00
YHH 834b633c01 Merge pull request #14515 from SivanCola/fix/isolation-refusal-wording-14025
fix(studio): word isolation refusals on tool cards / 为隔离任务的拒绝原因补上说明
2026-10-25 21:49:32 -07:00
YHH e596cefc00 Merge pull request #14377 from SivanCola/codex/fileview-provenance-14025
fix(builtin): record only complete returned file views / 修复读取版本误认与过期覆盖
2026-10-25 21:48:19 -07:00
YHH 97d1b184a6 Merge pull request #14511 from SivanCola/test/serve-catalog-fixtures-14025
test: retire temporary serve catalogs / 清理临时 serve 夹具的共享 catalog
2026-10-25 21:30:07 -07:00
SivanCola 743c27f5a9 fix(studio): word the refusals isolated tasks already emit
Cause: internal/runtime/isolation and the isolated task tool refuse with
isolation.unknown_id, unavailable, not_git, apply_conflict and
unsupported_arg, but the kernel catalogue words none of them. A tool card
renders say(code), so these refusals showed no reason in either language.

Fix: word the five codes in kernel.ts and en_kernel.ts, in the terms of the
tool path that emits them.

Refs #14025
2026-10-25 21:28:04 -07:00
SivanCola 3d27a1a4e1 fix(builtin): bind file views to complete returned reads
Problem: whole-file rewrites can discard changes hidden by a later overlay
version, a paged read, or an oversized read that clears the previous guard.

Root cause: read_file renders one read and records another. Missing and
incomplete observations also collapse into the permissive never-seen path.

Fix: observe the renderer's decoded stream with bounded capture, refresh only
complete returned reads, retain older guards, and track first partial reads
explicitly. Keep never-seen compatibility and disabled protection behavior.

Verification: compiling old-owner controls fail five behavior regressions;
fixed focused race tests and real boot.Build checks pass. Full builtin,
root-package tests, go vet, make check and full lint are recorded in the PR.
Written bilingual security bounds and release notes accompany this slice.

A first partial read refuses replacement with the file's line count and the
read_file limit that certifies it. Without that, a default-limited read of a
file longer than 2000 lines can never be certified by rereading as told.

Refs #14025
Refs #14133
2026-10-25 21:21:51 -07:00
YHH a4cce4e7eb Merge pull request #14510 from esengine/c5-rail-focus-visible
fix(studio): only keyboard focus magnifies the question rail
2026-10-25 21:08:03 -07:00
YHH 632bb81057 Merge pull request #14267 from lancy69/fix/14223-workspace-alignment
fix(studio): align footer avatar and workspace text
2026-10-25 21:06:58 -07:00
YHH fb76a1c8a3 Merge pull request #14507 from imcreazy/perf/harness-serves-paged-history
perf(harness): serve the transcript in pages when `?paged=N` is set
2026-10-25 21:00:49 -07:00
YHH 2a72d4273d Merge pull request #14509 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-10-25 20:53:41 -07:00
Lancy eaf5fba77e fix(studio): align footer avatar and workspace text 2026-10-26 11:48:55 +08:00
SivanCola 2279f841db test(serve): retire shared catalogs with temporary runtime fixtures
Problem: the no-index history search test could inherit a process catalog from
a temporary runtime fixture, so its asserted precondition was not established.

Root cause: shared catalogs deliberately outlive controllers. Several actual
boot fixtures and the shared unread harness omitted their existing fixture
cleanup, allowing the process pointer to outlive the temporary home.

Fix: use the existing shared cleanup in those owners, establish no-index state
explicitly, and preserve coverage for real active-catalog failures. Production
code, workflow gates, timeouts, defaults and baseline remain unchanged.

Verification: preceding fixtures compile and fail the lifecycle regression;
the provider-editor sibling control passes. Complete serve1798pass/5existing
skip and complete disjoint race inventory1798pass/5skip; root239 package
inventory23696pass/215existing skip, required builtin/boot, rootvet, full lint,
makecheck, formatting and Windows/Linux amd64 compile pass. Original Linux500
and the local fullrace5m alarm remain separate evidence, not claimed fixed by
an external rerun. Refs #14025, #14133; followup to #14505.
2026-10-26 11:46:27 +08:00
imcreazy 57598edb62 perf(harness): serve the transcript in pages when ?paged=N is set
Every existing script keeps full history: without the parameter the port
answers 404, which the reader already treats as an unpageable kernel.
2026-10-26 11:44:54 +08:00
esengine fbc3c10d60 fix(studio): only keyboard focus magnifies the question rail
A mark that kept focus after a press set the rail's focus again whenever the
window handed focus back, so its neighbours stayed magnified and its peek stayed
open with the pointer elsewhere. Focus now moves the rail only when it is
focus-visible.
2026-10-25 20:44:04 -07:00
SivanCola 4ed7e0896c docs: update acknowledgments contributors 2026-10-26 03:39:47 +00:00
YHH c65f8be1b4 Merge pull request #14505 from SivanCola/codex/subagent-panic-outcome-14025
Fix delegated panic settlement before completion / 修复委派 panic 的终态结算
2026-10-25 20:23:17 -07:00
SivanCola beefbedc05 fix(delegation): classify panics before settling child outcomes
Foreground panic unwinding could publish a completed worker graph before its error was known, while legacy nil panics were swallowed by both foreground and background exits.

Classify return versus panic at one private delegation boundary, persist the failed transcript while owned, and release before terminal callbacks without letting old cleanup retire a reentrant replacement. Keep foreground panic propagation and accepted Manager finalizers. Cover real boot direct/capability dispatch, compiled nil-panic modes, error identity and ordinary outcome controls.
2026-10-26 10:53:48 +08:00
YHH ec56ac27a3 Merge pull request #14501 from esengine/c5-empty-state-new-session
fix(studio): with no folder, a session still opens, so settings, feedback and a new session work
2026-10-25 19:10:11 -07:00
YHH d95b0b112a Merge pull request #14430 from wanjinhao1/agent/fix-14427-report-id-notfound
Fix #14427: report show/delete with an unknown ID on an empty store exits 1 with a not-found error
2026-10-25 18:57:09 -07:00
esengine ce2ef197bd fix(studio): with no folder, a session still opens, so settings, feedback and a new session work
Settings and feedback refused with 'add a folder first' when no pane was open and
no folder was known, and the empty main area offered only Add folder. The kernel
already opens a session with no root in the app's own workspace, so the sheets
and the empty state now ask for exactly that.
2026-10-25 18:53:52 -07:00
YHH 438e5b71c4 Merge pull request #14453 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-c754d40a5b
build(deps-dev): bump electron-builder from 26.15.3 to 26.18.0 in /desktop/electron in the npm group
2026-10-25 18:19:47 -07:00
YHH 98570c95c0 Merge pull request #14498 from esengine/c5-notes-2.52.0
docs(release): studio 2.52.0 notes
2026-10-25 17:02:21 -07:00
esengine 5e222f332e docs(release): studio 2.52.0 notes 2026-10-25 16:44:45 -07:00
YHH 1cc6bf327a Merge pull request #14496 from SivanCola/fix/job-panic-result-14025
fix(jobs): keep recovered panics from success / 防止任务 panic 被误报成功
2026-10-25 13:29:13 -07:00
SivanCola c8fcbc138e fix(jobs): keep recovered panics from becoming successful results
Problem: legacy nil-panic recovery can publish a successful job after its
invocation or cleanup panics, and error-valued panics lose their cause identity.

Root cause: two boundaries treat a non-nil recover value as the only panic
signal and format panic errors without wrapping the original error.

Fix: share a private normal-return guard across invocation and finalization,
retain typed panic causes, preserve finalizer error classification and sibling
cleanup, and keep the existing invocation stack message without adding stacks
to finalizers. Move invocation recovery into its cohesive owning file.

Verification: compiling preceding owners fail six test/subtest events with two
controls passing. Actual compiled Manager child tests cover explicit panicnil
modes zero/one, invocation, cleanup and no-launch. Frozen required2/rest237
partitions close all239 packages with23656 passes/215existing skips; complete
jobs/delegation race549 passes and accepted14464 three-owner composition671
passes have zero skips. Independent required command has2349passes/66existing skips.
Vet, complete lint, make check and formatting pass at unchanged2664baseline.
Windows/Linuxamd64 owner builds are compilation only; exact-head hosted/native
and independent review remain separate gates. No Goexit, other-goroutine panic,
process-scope completion or crash recovery is claimed.
2026-10-26 03:40:51 +08:00
YHH abd3e3a62e Merge pull request #14464 from SivanCola/codex/checkpoint-writer-admission-14025
Cancel background writer admission and bind cleanup to registrations / 后台写入者可取消准入与身份清理
2026-10-25 12:29:20 -07:00
SivanCola 80baf7e0c5 Merge studio and preserve job-owned writer token finalization
Problem: accepted job finalization in #14469 conflicts with the pending
cancellable writer registration paths in task and fleet.

Root cause: both sides own background cleanup. Closure-only cleanup misses
proven no-launch, while ID-only cleanup cannot protect replacement writers.

Fix: retain Manager-owned terminal callbacks and pass each returned writer
registration token through the shared jobCleanup option. Remove the obsolete
ID-only helper. Preserve the foreground token lifetime, accepted transcript
and progress finalizers, and every other upstream change in this ordinary merge.

Verification: the original full 239-package root command passes with 23668
test/subtest passes and 215 existing skips. Complete checkpoint/delegation/jobs
race passes 663 events, zero skips (one unchanged package cached); real boot
handoff and no-launch race passes ten events. Independent required builtin/boot
passes 2354 events with 66 existing skips. Compiling preceding callers fail
four no-launch scenarios plus parent; the Manager/token generation regression
passes. Vet, full lint, make check, formatting and diff gates pass, baseline
2664 unchanged. Six Windows/Linux amd64 owner compilations are compilation
only. Exact new-head hosted/native validation and independent review remain.
2026-10-26 02:59:07 +08:00
YHH c1ffa46df2 Merge pull request #14469 from SivanCola/codex/job-resource-finalization-14025
Finalize background resources when job launch is declined / 修复后台任务未启动时的资源清理
2026-10-25 11:29:39 -07:00
YHH 36ecb1868a Merge pull request #14495 from xxoingr/test/workbench-menu-focus-after-effect
test(studio): wait for the reader menu to take focus before reading it
2026-10-25 11:29:27 -07:00
xxoingr 022c4db333 test(studio): wait for the reader menu to take focus before reading it
The reader menu opens from a promise callback, so its commit runs at
default priority and the effect that focuses the first item is a
separate Scheduler task (setImmediate). findByRole resolves from the
MutationObserver that sees the menu, then RTL drains with setTimeout(0);
when a millisecond passes before the next loop turn, the timer wins and
the assertion reads the reader, which still has focus.

Wait for the focus the way the other focus assertions in the suite do.
2026-10-26 02:11:39 +08:00
YHH 91858315ca Merge pull request #14490 from SivanCola/codex/settings-network-draft
Separate saved network settings from drafts / 区分网络草稿与已保存配置
2026-10-25 11:00:18 -07:00
YHH 14323ca29d Merge pull request #14491 from SivanCola/codex/settings-scroll-search
Reset settings category scrolling and await search targets / 修复设置滚动与异步搜索定位
2026-10-25 10:59:50 -07:00
YHH 6458adf7b6 Merge pull request #14492 from SivanCola/codex/settings-backup-route
Register file backups on machine settings routes / 修复本机配置备份接口 404
2026-10-25 10:53:14 -07:00
SivanCola 728a0ed165 feat(isolation): prepare Store-owned candidates from one baseline
Problem: candidate groups need a common baseline while preserving the result
Store's source, managed-root and identity ownership.

Root cause: ordinary Begin captures afresh, while arbitrary snapshot/path
adoption would bypass the owning Store and its existing settlement contract.

Fix: add an opaque process-local Preparation, recheck source/workspace/Git
directory identities, and create independent results from the captured
snapshot. Extract the existing Begin bodies into private shared helpers.
No best-of, runtime, default or persisted-format activation is included.

Verification: complete 239-package root inventory, full isolation race,
independent builtin/boot command, vet, full lint and make check pass with the
2664 baseline unchanged. Compiling unsafe controls distinguish baseline
refresh and replaced-directory admission; the preceding Store implementation
passes the new preparation tests. Linked-source overlay passes; Windows and
Linux amd64 compile only. Native/hosted validation and independent review
remain separate gates.
2026-10-26 01:50:29 +08:00
imcreazy 4f915fe504 ci(android): build the APK on a tag and attach it to the release 2026-10-26 01:39:33 +08:00
YHH 5a16e27b59 Merge pull request #14479 from SivanCola/fix/mutation-json-members-14025
fix(mutation): reject ambiguous record members / 拒绝事务记录歧义字段
2026-10-25 10:30:56 -07:00
YHH 70955832c3 Merge pull request #14482 from xxoingr/test/netclient-stream-dialer-proxy-boundary
test(netclient): pin the stream dialer's proxy boundary
2026-10-25 10:30:18 -07:00
SivanCola 6881c9748d fix(studio): register file backups on machine settings routes
Problem: local settings cannot load the file-backup catalog because both
machine settings URL forms return an untyped 404.

Root cause: file-backup handlers are registered only with account backup
routes, while the machine settings router registers a separate route set.

Fix: register the existing file-backup handlers with machine settings.
Normal runtime routes still register that same set once. Preserve host-only
checks, paired-device rejection, local-owner selection and mutation gates;
do not change payloads, encryption or persisted formats.

Verification: new regressions fail before the fix with plain 404 responses
and pass afterward for both machine prefixes. Focused machine settings,
file backup, account backup and mutation-gate tests pass. gofmt, go vet,
make lint, make check and the required builtin/boot tests pass.
2026-10-26 01:12:34 +08:00
SivanCola d11ca0472c fix(studio): reset category scrolling and wait for search targets
Problem: switching settings categories retains an unrelated scroll offset, while a searched setting that loads later never receives navigation feedback.

Root cause: categories share one scrolling element and landing resolves the anchor once before asynchronous content arrives.

Fix: key the panel by category, clear pending landing only when a guarded category change succeeds, and observe target arrival until its feedback lifetime begins. Keep existing categories, layouts, disclosures and unsaved-change guards.

Verification: five regressions fail on the unmodified baseline; all 28 settings test files and 215 tests pass with the fix. TypeScript/Vite and repository checks pass. Browser verification confirms a tools panel at scrollTop 5747 switches to network at 0 and search opens the existing advanced contrast disclosure.
2026-10-26 01:10:04 +08:00
SivanCola 57784a251c fix(studio): separate saved network settings from drafts
Problem: Network settings could be saved without edits, and pending reads,
saves or diagnostics could overwrite the state of a newly selected connection.
Connection tests also appeared to exercise unsaved form values.

Root cause: The form used one settings object for both saved state and edits,
with async completions that were not scoped to the owning connection.

Fix: Keep saved settings and the editable draft separately, disable no-op
saves, freeze submitted drafts, and identify diagnostics as testing saved
settings. Remount the form on connection changes and ignore completions from
unmounted owners. Preserve the existing expanded connection details layout.

Verification: The seven extracted regression cases failed on the baseline.
Network and i18n tests pass (10 files, 181 tests), including edit reversion
and loading/error coverage; tsc -b, make check and git diff --check pass.
2026-10-26 01:08:14 +08:00
YHH 4377886f88 Merge pull request #14478 from EMEEEEMMMM/feat/wallpaper-scene-audio-dxt
feat(studio): loop every scene soundtrack and decode DXT backdrops
2026-10-25 10:00:24 -07:00
YHH 28cab7d14b Merge pull request #14483 from esengine/c5-theme-syntax-tokens
feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables
2026-10-25 09:53:30 -07:00
YHH bd5ea6ddd3 Merge pull request #14485 from esengine/c5-fix-unreachable-port-flake
test(serve): take the unreachable address from a port that was seen to refuse
2026-10-25 09:53:20 -07:00
EMEEEEMMMM 6e2ba1056a fix(studio): bound DXT image and LZ4 sizes 2026-10-26 00:38:39 +08:00
YHH b458854377 Merge pull request #14472 from SivanCola/codex/isolation-runner-panic-14025
fix(isolation): retain work after runner panic / 保留 runner panic 后的隔离成果
2026-10-25 09:31:16 -07:00
esengine b29f8bf16b test(serve): take the unreachable address from a port that was seen to refuse 2026-10-25 09:30:56 -07:00
esengine d1f852953a test(theme): reach the syntax guard's hooks through the harness bundle, as the built page serves them 2026-10-25 09:19:28 -07:00
SivanCola 6287044a20 merge studio to retain the accepted feedback assertion fix
Problem: the ordinary test synchronization append conflicts with upstream's
independently accepted fix for the same passive draft-persistence boundary.

Root cause: both sides replace the immediate heldDraft assertion with waitFor;
upstream additionally explains why the effect may follow the thumbnail commit.

Fix: resolve the sole conflict to the exact accepted upstream test including
its comment. Preserve all incoming dependency/UI changes and every original
runner-panic implementation Go file. This merge is required by a verified
content conflict, not unrelated base drift; no rebase or force-push.

Verification: final inventory of 239 root packages is reconciled from disjoint
required2 and rest237 commands, both exit0, with frozen Go/module inputs.
Isolation race covers all 51 top-level tests through the original completed
39 trees plus the remaining12 under the same5m budget; original full owner
command hit its cumulative alarm and is retained as exit1, not called pass.
Five affected frontend files have99 tests pass; production typecheck/build,
formatting, root vet, full lint and make check pass without baseline widening.
Exact merge-head hosted/native checks and independent review remain pending.
2026-10-26 00:06:20 +08:00
YHH 52e9abe311 Merge pull request #14481 from esengine/c5-deps-vitest
build(deps): bump vitest to 5.0.5
2026-10-25 09:01:44 -07:00
esengine d0bff6bcf5 feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables 2026-10-25 08:58:37 -07:00
xxoingr 8c42340ea4 test(netclient): pin the stream dialer's proxy boundary
NewStreamDialer is how SSH reaches a remote host under the user's proxy
(remote/attach), and none of dialer.go was exercised: netclient sat at
47% against a 47.2% floor on a declared-sensitive path.

Four tests drive it against fake proxies on loopback:
- an http proxy gets CONNECT to the target with Proxy-Authorization, and
  the credentials never also travel as Authorization;
- a proxy that answers 407 yields an error, not an open stream;
- an https proxy sees a TLS handshake first, so CONNECT and credentials
  never cross in cleartext;
- a SOCKS5 proxy gets username/password auth and the target by name.

Each fails when the matching guard in dialer.go is removed. The
netclient floor rises from 47.2 to 65.8.
2026-10-25 23:54:17 +08:00
YHH f45fb56f62 Merge pull request #14476 from esengine/c5-fix-feedback-flake
test(studio): wait for the held feedback draft before asserting it
2026-10-25 08:44:36 -07:00
SivanCola 127ee9755b test(studio): await feedback draft persistence after paste
Problem: Studio job 121205496051 fails after the pasted image preview appears,
because the retained draft still contains no image.

Root cause: findByAltText observes the DOM commit, while holdDraft runs in a
passive effect. The test assumes those distinct boundaries complete together.
The unchanged merge passed a later external rerun, which does not repair the
scheduling assumption.

Fix: keep the exact single-image assertion for both heading and tab targets,
but await the draft condition with waitFor. Production UI and Go source stay
unchanged.

Verification: full Feedback interaction file 65 pass, restored focused cases
2 pass, typecheck pass. Two independent no-op-save controls each fail both
targets, proving persistence is still checked. Root vet, full lint, make check
and required builtin/boot pass at unchanged baseline 2664. The preceding
native/hosted evidence remains attached to af958f; the new head needs fresh CI.
2026-10-25 23:43:47 +08:00
esengine 0c9d5d1fd1 build(deps): bump vitest to 5.0.5 2026-10-25 08:40:03 -07:00
SivanCola 50a5186930 fix(mutation): reject ambiguous coordination record members
Problem: ambiguous private registry records can be admitted or scavenged as
known dead-owner state.

Root cause: struct JSON decoding merges repeated members and folds case;
unknown-field rejection alone does not preserve the original declarations.

Fix: validate unique decoded names and exact schema spelling for records and
capacity objects before typed decoding and liveness checks. Keep writer
encoding, legal spacing/order, escaped unique names and existing resources.
Join a killed process before closing cooperative input in death fixtures.

Verification: complete 239-package root inventory and required builtin/boot
commands exit 0; mutation race 88 pass, pending wait/opaque composition 116
pass. The compiling preceding decoder fails 22 behavior test/subtest events,
with two controls passing. Formatting, vet, full lint and make check pass
without changing the 2664 baseline. Windows/Linux amd64 compile only; decode
microbenchmark overhead is measured and disclosed separately from product
performance. No runtime or default activation.
2026-10-25 23:34:40 +08:00
YHH d51fb03f6b Merge pull request #14475 from lancy69/fix/13512-prompt-customization
docs: explain supported prompt customization
2026-10-25 08:33:23 -07:00
YHH 7d65acd714 Merge pull request #14474 from lancy69/fix/13919-project-persona-guidance
docs: explain project-specific persona guidance
2026-10-25 08:32:52 -07:00
esengine 0fd8d526e5 test(studio): wait for the held feedback draft before asserting it 2026-10-25 08:29:37 -07:00
YHH 751f0b13ca Merge pull request #14473 from esengine/c5-deps-npm
build(deps): bump mantine, lucide-react, maplibre-gl and vite
2026-10-25 08:27:21 -07:00
EMEEEEMMMM 7273f04d81 feat(studio): decode DXT scene textures 2026-10-25 23:18:33 +08:00
EMEEEEMMMM def94177cd feat(studio): play every scene soundtrack 2026-10-25 23:18:27 +08:00
Lancy e6337ef356 docs: explain supported prompt customization 2026-10-25 23:15:15 +08:00
Lancy d5e7c53401 docs: explain project-specific persona guidance 2026-10-25 23:14:21 +08:00
esengine 9609216f74 build(deps): bump mantine, lucide-react, maplibre-gl and vite to their latest patch and minor releases 2026-10-25 08:05:47 -07:00
YHH 8add01aacc Merge pull request #14468 from imcreazy/feat/rail-opens-a-folded-run
Let the rail say when history is unloaded, and open a folded run
2026-10-25 07:53:27 -07:00
YHH bdf3784f96 Merge pull request #14471 from esengine/c5-deps-go
build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19
2026-10-25 07:40:48 -07:00
imcreazy 186583996d Let the two new rail surfaces take the pointer
`.railhost` sets pointer-events: none, and only `.srail` opted back in. The
unloaded-history marker and the opened run inherited the none, so presses fell
through to the transcript. A style test now holds the rule.
2026-10-25 22:38:44 +08:00
SivanCola af958fdae8 fix(isolation): retain uninspected work after a runner panic
Problem: an isolated task could write files, then lose its discoverable result
identity when a host runner or provider callback panicked.

Root cause: Store.Execute called Finish only after an ordinary runner return;
panic unwind bypassed both completed-result and inspection publication.

Fix: recover at the runner invocation boundary, retain an error-valued panic
as a cause, and publish the existing inspection identity under the result
operation owner. Do not infer physical completion, inspect for emptiness, or
delete work on a panic. Preserve ordinary outcomes and truthful live-only
recovery when bookkeeping cannot be admitted or persisted.

Verification: final 239-package inventory closes using 238 unchanged broad
packages and the final isolation race package; 111 owner race and 3 real boot
race cases pass without skips. The compiling preceding owner fails 11 cases
with 3 ordinary-error controls passing. Required builtin/boot, root vet, full
lint and make check pass at unchanged baseline 2664. Linux/Windows amd64 are
compile-only; hosted/native and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 22:25:13 +08:00
esengine 148a3eabe7 build(deps): tidy the desktop module for the same bumps 2026-10-25 07:05:07 -07:00
YHH 82997a2d8f Merge pull request #14463 from esengine/c5-remove-reading-grips
feat(studio): remove the reading column's drag handles
2026-10-25 06:59:35 -07:00
esengine 682ecbe392 build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19 2026-10-25 06:56:32 -07:00
YHH 40f3156907 Merge pull request #14457 from esengine/dependabot/github_actions/actions-5a66b01c52
build(deps): bump the actions group with 3 updates
2026-10-25 06:40:28 -07:00
SivanCola 966f6ece9b test(ci): qualify background resource owners on native Windows
Problem: Windows PR smoke did not run jobs/delegation unit tests, and pure
delegation changes could skip native platform legs. Linux PR race coverage
also omitted the delegation owner tests.

Root cause: package routing predates the shared background resource lifecycle.
Compiling these owners as boot dependencies does not execute their tests.

Fix: run both complete packages on Windows with a retained JSON inventory,
require all15 portable lifecycle cases to pass, route pure delegation changes
to native legs and include delegation in the existing Linux race sweep.

Verification: platform-scope tests and pinned actionlint pass; the inventory
gate accepts actual local owner output and rejects missing-case, skipped-case
and missing-owner controls. Make check passes at unchanged baseline2664.
All6699 Go files match the qualified implementation commit; no product Go
source or deadline changed. Exact-head native/hosted qualification is pending.
2026-10-25 21:36:48 +08:00
YHH a3473882ce Merge pull request #14467 from esengine/c5-hub-auto-workspace
fix(serve): a new session with no project opens in the app's own workspace
2026-10-25 06:35:57 -07:00
SivanCola 9260e62870 fix(jobs): finalize resources when managed launch is declined
Problem: background task/fleet can strand checkpoint writers, transcript locks
and progress resources when the Manager never invokes the supplied closure.

Root cause: resource ownership moves to closure-only defers before the Manager
checks invalid identifiers or its closed root context. Those branches bypass
the closure and its cleanup, while the caller suppresses its own ending.

Fix: register optional host finalizers at the Manager's managed-execution
boundary. Run all callbacks once before completion after actual closure return
or proven no-launch, preserve cleanup failures/panic and transfer the actual
task/fleet resources and uninvoked terminal projections to that owner.

Verification: final239-package inventory uses238 broad packages plus the final
jobs package after a deterministic liveness-test-only change:23596 test/subtest
passes/215 existing skips. Full owner race648 and real boot race5 pass without
skips; required builtin/boot2346 passes/66 skips. Original public regressions
and compiling predecessor controls fail. Selected token-adapter composition
passes40 race cases. Vet/lint/check pass at unchanged baseline2664.

The liveness pair uses a controlled clock; production thresholds are unchanged.
Managed-closure completion is not descendant scope or durable crash recovery.
Refs #14025
2026-10-25 21:28:02 +08:00
esengine 8b4dea758d fix(serve): a new session with no project opens in the app's own workspace 2026-10-25 06:15:17 -07:00
SivanCola 3858f0d51f fix(checkpoint): separate writer admission from registry locking
Problem: cancelled background task/fleet calls can remain behind checkpoint
admission while the shared writer registry also blocks queries and cleanup.

Root cause: registration holds the registry mutex across the barrier, durable
undo retirement, notification and Store persistence. Callers discard their
admission context and defer cleanup by reusable string ID alone.

Fix: reserve identified pending registrations, prepare outside the registry
mutex and serialize latest snapshot publication separately. Pass context at
all task/fleet sites and transfer token-bound cleanup to admitted jobs. Preserve
legacy APIs, completed undo retirement and active work across parent turns.

Verification: original root239 passes with23590 test/subtest passes/215 existing
conditional skips; required builtin/boot2346 passes/66 skips. Complete owning
race487 passes/zero skips; real boot handoff race5 passes/zero skips. Compiling
preceding-owner/caller controls fail the intended assertions. Vet/lint/check
pass without raising baseline2664. Linux/Windows compilation is not native QA.

The jobs-owner no-launch finalization gap remains explicitly separate.
Refs #14025
2026-10-25 20:43:12 +08:00
esengine 1d723d2a28 feat(studio): remove the reading column's drag handles 2026-10-25 05:40:54 -07:00
YHH a43ba964fd Merge pull request #14458 from esengine/c5-grips-need-room
fix(studio): draw the reading column's handles only when there is a range to move in
2026-10-25 05:26:56 -07:00
YHH f7c2506cf2 Merge pull request #14450 from esengine/c5-drop-topbar-sandbox
fix(studio): drop the top bar's second route to Tools and permissions
2026-10-25 05:26:40 -07:00
SivanCola 5d484c4fe2 feat(proc): expose tracked root wait completion
Problem: A foreground command can return bounded cancellation while cmd.Wait
is still blocked, so call return cannot establish resource-lifetime completion.

Root cause: The tracked wait owner exposes its logical result and cleanup
diagnostics but no separate observation of the still-running root Wait.

Fix: Register a stable root-wait channel before the waiter starts and close it
after Wait returns, before result publication. Expose it with the explicit
RootWaitCompleted name. Preserve existing execution, cancellation, retry and
error behavior. No production lease consumer or whole-scope claim is added.

Verification: Deterministic cancellation/retry/publication tests, portable Go
child execution and a real blocked-output process/lease regression pass. Root
Wait remains distinct from descendant or external-scope completion; nil means
no registered observation. Full local/static and platform evidence is recorded
in the PR without inheriting previous heads' native results.
2026-10-25 20:00:34 +08:00
esengine 4db40e881c fix(studio): draw the reading column's handles only when there is a range to move in 2026-10-25 04:59:49 -07:00
dependabot[bot] 6c4f00ce42 build(deps): bump the actions group with 3 updates
Bumps the actions group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [actions/setup-python](https://github.com/actions/setup-python).


Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-25 11:56:24 +00:00
dependabot[bot] 5232ed58f5 build(deps-dev): bump electron-builder
Bumps the npm group in /desktop/electron with 1 update: [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder).


Updates `electron-builder` from 26.15.3 to 26.18.0
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/[email protected]/packages/electron-builder/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/[email protected]/packages/electron-builder)

---
updated-dependencies:
- dependency-name: electron-builder
  dependency-version: 26.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-25 11:53:19 +00:00
YHH a76cbec375 Merge pull request #14447 from esengine/c5-tool-blurbs
fix(studio): give the config-gated tools a blurb in the settings tool list
2026-10-25 04:53:06 -07:00
esengine 3a67b488a2 test(studio): the narrow-window guard no longer expects the removed top-bar button 2026-10-25 04:52:55 -07:00
esengine 553ed22a50 fix(studio): drop the top bar's second route to Tools and permissions 2026-10-25 04:39:26 -07:00
esengine 889211c785 fix(studio): give the config-gated tools a blurb in the settings tool list 2026-10-25 04:31:31 -07:00
YHH 540c14998b Merge pull request #14443 from esengine/c5-pane-wide-drop
feat(studio): a file dropped anywhere on the conversation pane is taken
2026-10-25 04:26:35 -07:00
SivanCola 25169a45f1 feat(mutation): admit explicitly opaque write effects
Problem: Directory-scoped coordination cannot exclude writers that address a
source file through a hardlink outside that directory.

Root cause: Path containment and named inode identity do not represent an
unresolved write effect; reinterpreting old directory records would silently
change their resource promise and nested admission could reuse a weaker hold.

Fix: Publish explicit version-3 opaque effects in the existing registry,
coordinate them symmetrically against all path-bearing transactions, and
require effect coverage when reusing a parent lease. Preserve ordinary named
parallelism, model capacity, previous directory semantics and final OS-locked
grant authority. No runtime or default requests the new effect here.

Verification: Full 239-package root inventory and independent builtin/boot
commands exit 0; mutation race 82 passes/zero skips; preceding-reader binary
preserves live/dead new records; weakened-owner control fails eight cases;
selected wait-snapshot composition has 36 race passes/zero skips. Root vet,
full lint, changed-file checks and formatting pass at unchanged baseline 2664.
Windows/Linux amd64 compile only; local execution is macOS.
2026-10-25 19:21:21 +08:00
YHH 9fce63aa5a Merge pull request #14442 from esengine/c5-price-currency-select
fix(studio): style the model-price currency select like the inputs beside it
2026-10-25 04:03:58 -07:00
esengine c41306961a feat(studio): a file dropped anywhere on the conversation pane is taken 2026-10-25 03:52:53 -07:00
esengine 0865bc1071 fix(studio): style the model-price currency select like the inputs beside it 2026-10-25 03:46:57 -07:00
YHH 46d63cf414 Merge pull request #14440 from SivanCola/codex/mutation-record-validation-14025
fix(mutation): retain unsupported registry records / 保留未知事务记录并拒绝准入
2026-10-25 03:30:33 -07:00
SivanCola 619c5b243d fix(mutation): retain unsupported coordination records
Problem: unfamiliar coordination records can be ignored during admission and
deleted after their OS owner exits.

Root cause: registry decoding discarded unknown fields, and version-1 resource
validation accepted every effect except capacity.

Fix: reject unfamiliar object fields and unsupported path effects before
liveness/scavenging, retain original bytes, and include record identity in the
typed protocol error. Preserve known version-1/2 records and omitted-effect
legacy semantics without changing writers or activating the runtime protocol.

Verification: complete 239-package root inventory, full mutation race,
compiling preceding-owner counterexamples, actual owner-process death,
known-record controls, selected waiting-observation composition, required
builtin/boot and static gates. Windows/Linux builds are compile-only local
evidence; exact-head hosted/native review remains separate.
2026-10-25 17:56:13 +08:00
YHH 1c330cbd9e Merge pull request #14438 from SivanCola/codex/isolation-settlement-fence-14025
fix: fence ordinary isolated-result writers / 隔离结果操作识别持久恢复屏障
2026-10-25 02:54:14 -07:00
YHH 6343cc7970 Merge pull request #14432 from xxoingr/fix/pane-tab-close-stays-inside
fix(studio): a pane tab's close button stays inside the tab
2026-10-25 02:53:50 -07:00
YHH ef0874a111 Merge pull request #14436 from esengine/c5-fix-14434-retention
fix(agent): cap verbatim retention by the compaction trigger
2026-10-25 02:21:47 -07:00
SivanCola adcc4b4d80 fix(isolation): fence ordinary result operations before recovery
Problem: A durable future settlement record must stop ordinary result
writers, including stale queued operations and destructive siblings.

Root cause: The per-ID mutex serializes operations but carries no durable
knowledge that a recovery owner has published a settlement fence.

Fix: Check host-rooted external fence metadata after operation ownership,
refuse any existing entry without interpreting its bytes, recheck context
and release only this attempt on refusal. Cover tools, Finish/Abort, empty
inspection, sweep, a real child process and actual boot/controller rebuild.
No journal writer, schema or complete recovery is introduced. Preceding
writers ignore this fence; future activation needs a cooperating protocol.

Verification: Full isolation race100 passes, zero skips; real boot race3
passes. Preceding owner compiles and fails15 test/subtests, including a
child process; predecessor boot owner actually writes source in both cases.
Final root inventory/required and static qualification recorded in PR.
Linux/Windows amd64 compilation is separate from native evidence.

Refs #14025
Refs #14133
2026-10-25 17:20:38 +08:00
xxoingr caf5c2d45e docs(release-notes): keep the pane tab note's English line within 200 columns 2026-10-25 17:19:50 +08:00
YHH bb273fec87 Merge branch 'studio' into fix/pane-tab-close-stays-inside 2026-10-25 01:56:24 -07:00
esengine e1c38d3036 fix(agent): cap verbatim retention by the compaction trigger, not only the window
User turns, retained failures and [[keep]] turns were budgeted as shares of
the window. On a 1M window under the 100K cost trigger those shares plus the
tail passed the trigger, so every fold was refused as still at or above it.
Each budget is now also held to a share of the trigger.
2026-10-25 01:55:42 -07:00
YHH 463e2f76f0 Merge pull request #14435 from esengine/c5-css-budget
style(studio): hold the stylesheet to its line budget
2026-10-25 01:55:32 -07:00
esengine 172ae8b80f style(studio): hold the stylesheet to its line budget after two merges landed together 2026-10-25 01:42:56 -07:00
YHH d03be98bcb Merge pull request #14425 from SivanCola/codex/worktree-literal-paths-14025
fix(worktree): keep integration paths literal / 修复特殊文件名导致隔离整合越范围写入
2026-10-25 01:22:31 -07:00
xxoingr decba9d9eb fix(studio): a pane tab's close button stays inside the tab
The folder and host labels on a pane tab were flex: none, so a long one
could not shrink: the title, the only part that could, went to zero width
and the close button was pushed past the tab's edge onto the next tab,
where a press activates that tab instead.

Both labels now shrink to an ellipsis and take at most 40% of the tab, and
the title keeps at least 4em. perf/tab-close-inside.mjs checks, in the
real stylesheet, that the close button stays inside its tab and is what a
press at its centre reaches, with a long title, a long folder, and both a
long folder and a long host.
2026-10-25 16:21:25 +08:00
YHH 3af39d9d0a Merge pull request #14407 from EMEEEEMMMM/feat/wallpaper-scene-mvp
Feat/wallpaper scene mvp
2026-10-25 01:00:15 -07:00
YHH 4775113397 Merge pull request #14415 from SivanCola/codex/isolation-native-ci-14025
ci: run complete mutation and isolation owners on Windows / 补齐事务与隔离机制原生及竞态验收
2026-10-25 00:57:35 -07:00
YHH 21a4569b39 Merge pull request #14406 from lancy69/feat/14139-thinking-preview
feat(studio): separate collapsed thinking previews from folding
2026-10-25 00:56:22 -07:00
SivanCola 479c8e7b16 merge: adopt reviewed studio owners for literal path review 2026-10-25 15:44:19 +08:00
SivanCola c79c5192ee test(mutation): pin legacy wait admission and adopt reviewed Windows fixes 2026-10-25 15:30:48 +08:00
Lancy d42cdbb434 feat(studio): separate collapsed thinking previews from folding 2026-10-25 15:26:36 +08:00
YHH 42c3c9f195 Merge pull request #14420 from esengine/c5-prefs-centered
fix(studio): centre the enlarged settings window's content column
2026-10-25 00:25:14 -07:00
YHH 526908630a Merge pull request #14429 from esengine/c5-serve-bind-first
fix(cli): serve binds its address before it reports anything
2026-10-25 00:24:55 -07:00
EMEEEEMMMM 7d9e9b7b6c fix(studio): bound scene image decoding 2026-10-25 15:14:56 +08:00
YHH 2dce532bf5 Merge pull request #14419 from chuiwenwei/fix/record-the-tokens-the-price-came-from
fix(stats): record the tokens the price came from
2026-10-25 00:14:02 -07:00
YHH 083d30bec8 Merge pull request #14416 from chuiwenwei/fix/lease-probe-asks-the-lock
fix(sessionstore): the lease probe asks the lock, not the sidecar
2026-10-25 00:13:21 -07:00
YHH d4d8876e92 Merge pull request #14423 from chuiwenwei/fix/history-keeps-the-arguments-it-restored
fix(tui): keep the arguments a restored tool call actually had
2026-10-25 00:13:01 -07:00
YHH 4af626e714 Merge pull request #14424 from esengine/c5-within-exact
fix(mutation): a prefix is exact only when its spelling agrees
2026-10-25 00:12:29 -07:00
YHH ffe06c2db8 Merge pull request #14422 from esengine/c5-gitin-stdout
test(worktree): gitIn returns standard output only
2026-10-25 00:12:07 -07:00
wanjinhao1 3154e9163d Fix #14427: report show/delete with unknown ID on empty store exits 1 with not-found error 2026-10-25 15:09:28 +08:00
SivanCola 5671e00f3f ci: require mutation owner coverage and probe capacity without a latency alarm 2026-10-25 15:08:12 +08:00
esengine c3d192dc4b fix(cli): serve binds its address before it reports anything
Only a serve with --port-file or --open listened first; a plain serve printed the
banner, share link, token path and the non-loopback warning and bound afterwards, so
an address that can never work still looked like a running server. Every command now
binds first, the report uses the bound address, and the now-unreachable
bind-inside-RunGraceful branch is gone.
2026-10-25 00:04:33 -07:00
esengine 5aa737bb95 fix(studio): centre the enlarged settings window's content column
.prefs-col had a max-width and no inline margin, so once the settings window was
enlarged the column hugged the navigation and every extra pixel piled up on its
right. In the enlarged mode the column is now centred in the room beside the nav.
2026-10-24 23:53:26 -07:00
SivanCola 24a775a94c fix(worktree): preserve literal paths through Git application
Problem: a candidate filename could act as a Git wildcard or magic pathspec,
writing an undeclared source file or dropping the actual literal result.
Newline-delimited hashing could also reinterpret a single filename.

Root cause: option separation was mistaken for literal path selection, and
ambient Git pathspec flags could override the host's intended selection.

Fix: centralize literal pathspecs for tree/index inspection and the shared
restore owner; C-quote hashing input by bytes; remove inherited selection
flags in the common host Git environment while preserving explicit flags.
Whole-workspace application reuses the shared writer. Keep the portable
stdout/stderr Git fixture repair aligned with the native CI follow-up.

Verification: complete 239-package root inventory, three-owner race, real boot
race, uncached required builtin/boot, compiling predecessor controls, six
Windows/Linux owner cross-compiles, formatting, root vet, lint and make check.
Native hosted checks and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 14:51:01 +08:00
esengine b26eddec18 fix(mutation): a prefix is exact only when its spelling agrees
withinExact used filepath.Rel, which ignores case on Windows, so a different
spelling of an absent file was reported as held there while heldWithin's comment
and TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings require the filesystem to
prove two spellings are one file. Require the prefix bytes to agree as well.
2026-10-24 23:43:39 -07:00
Will aeeff97700 fix(tui): keep the arguments a restored tool call actually had
The terminal's copy of /history's tool call had no argumentNormalization
field, so a restored transcript rebuilt the card from Arguments alone —
the proxy's effective text — and drew it as what the model wrote. The
original was not merely hidden; it never left the decoder.

Carry the host's normalization through HistoryToolCall into the
eventwire.Tool the restored transcript draws and inspects, following the
serve payload and the desktop copy that already do. The card still draws
only the effective arguments: showing the original is a UI decision for a
later change, and changing the card would also change a live call.

Register the pair as a declared subset of the serve historyToolCall in
wireparity, naming the six fields the terminal decides from. The exact
table is the desktop's and is read as a TypeScript interface, so a Go
reader belongs in the partial table, which compares both ways over the
declared names.
2026-10-25 14:41:21 +08:00
esengine 15f72be36f test(worktree): gitIn returns standard output only
The helper used CombinedOutput, so on Windows, where core.autocrlf is true, git's
'LF will be replaced by CRLF' warning on stderr was returned with the blob id and
TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type passed the pair to
update-index --cacheinfo. Standard output is what a caller reads; standard error
now only joins a failure message.
2026-10-24 23:38:00 -07:00
YHH 8bd18a5db4 Merge pull request #14233 from lancy69/fix/14220-execution-typography
fix(studio): execution typography follows body preferences
2026-10-24 23:36:49 -07:00
SivanCola eab502bd8e test(worktree): keep Git diagnostics out of object IDs
Problem: the newly executed native Windows owner inventory failed while
constructing its candidate-type fixture, before product application ran.

Root cause: gitIn returned CombinedOutput, so a successful hash-object CRLF
warning on stderr was concatenated with its object ID and fed to update-index.

Fix: keep stdout and stderr separate at the shared fixture owner, retaining
both in failure diagnostics. Pin autocrlf=true in the compatibility regression
so this contract is exercised on every host. No production or gate changes.

Verification: the preceding helper compiles and reproduces the same diagnostic
pollution locally; the complete worktree race, Windows compilation, required
builtin/boot, vet, lint and make check pass. Hosted native revalidation remains
separate from local and earlier full-root evidence.
2026-10-25 14:35:10 +08:00
YHH 1aa101e6a9 Merge pull request #14412 from esengine/c5-measure-forgets-drag
fix(studio): a line-width preset wins over a width the column was dragged to
2026-10-24 23:31:33 -07:00
Will 495f8ede4c fix(stats): record the tokens the price came from 2026-10-25 14:16:21 +08:00
Will de2256dfc4 fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its `.lease.json`
sidecar can be deleted by a person or a file scanner while the process still
owns the session. The probe answered free on a missing sidecar without looking
at the lock, so session list said idle, task list flipped a running item to
interrupted, and redact-sessions would rewrite a live transcript. It now asks
the lock whenever a lock file exists, and still creates nothing on the common
uncontended path.
2026-10-25 14:06:30 +08:00
Will 0e63e353fe fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its
`.lease.json` sidecar can be deleted by a person or a file scanner while the
process still owns the session. The probe answered free on a missing sidecar
without looking at the lock, so session list said idle, task list flipped a
running item to interrupted, and redact-sessions would rewrite a live
transcript. It now asks the lock whenever a lock file exists, and still
creates nothing on the common uncontended path.
2026-10-25 14:04:37 +08:00
SivanCola 3fa1ea460c ci: execute complete isolation owners on native Windows
Problem: green PR checks could omit complete isolation/worktree owner tests on
Windows and under Linux race; pure isolation changes could skip native legs.

Root cause: the smoke and race package allowlists lacked both owners, while
the platform classifier treated untagged isolation Go as neutral.

Fix: classify isolation paths as platform-relevant; execute complete Windows
owners with an uploaded JSON inventory, require both package terminals and
portable boundary tests, and include both packages in PR race. Replace the
redundant rollback subset. Preserve existing permissions, deadlines and cache
rules; do not alter product code.

Verification: compiling old-workflow/classifier regressions, exact inventory
positive/negative controls, final classifier/testenv suites, complete 238-package
root inventory, three-owner race, required builtin/boot, vet, lint and check.
Local execution is macOS; native hosted outcomes remain a separate gate.

Refs #14025
Refs #14133
2026-10-25 14:02:42 +08:00
YHH bf472862ca Merge pull request #14368 from SivanCola/codex/mutation-primitive-14025
feat(state): add mutation transaction primitive / 写入事务基础机制与测试
2026-10-24 22:59:58 -07:00
esengine d28a6089fc fix(studio): a line-width preset wins over a width the column was dragged to
The width a hand set is kept in --flow-w and outranks the stylesheet, so once the
reading column had been dragged, choosing the full-width preset changed --doc but
not the column and did nothing. Choosing a preset now drops the dragged width.
2026-10-24 22:54:22 -07:00
YHH 8c669ebe61 Merge pull request #14410 from SivanCola/codex/worktree-entry-modes-14025
fix(worktree): compare file modes during integration / 修复隔离整合丢失文件模式与类型
2026-10-24 22:35:06 -07:00
YHH df14e2f085 Merge pull request #14409 from SivanCola/codex/isolation-operation-leases-14025
fix(isolation): serialize result operations through cleanup / 隔离结果操作统一跨进程租约
2026-10-24 22:33:54 -07:00
SivanCola 600b4b8cb4 fix(worktree): compare entry modes during isolated application
Problem: executable-only changes and equal-byte regular/link changes could be
discarded as already applied, and source metadata edits could be overwritten.

Root cause: baseline, candidate and live file versions retained only blob IDs,
collapsing distinct Git modes and file kinds into one identity.

Fix: compare Git mode and blob together, follow the checkout's trusted/index
mode rules, hash real links and emulated links with their actual Git semantics,
and reject unsupported transitions before any source write. Keep the user's
index and configuration unchanged.

Verification: complete 238-package root inventory, worktree/isolation race,
real boot race, required builtin/boot, compiling predecessor controls and
operation-lease composition. Formatting, root vet, lint and make check pass.
Cross-compilation is recorded separately from pending hosted/native review.

Refs #14025
Refs #14133
2026-10-25 13:08:35 +08:00
EMEEEEMMMM e01aae97fd fix(studio): wrap wallpaper scene parse errors 2026-10-25 13:02:47 +08:00
SivanCola 36cb5e6c7b test(filelock): mirror relative-root rename admission
Problem: the rooted-lock rename regression failed on Windows before reaching
the lock operation.

Root cause: Go's direct os.OpenRoot uses a Windows handle without delete
sharing, while production opens the lock directory relative to its managed
Root using an Openat handle with delete sharing. The fixture conflated them.

Fix: open the test directory through its parent Root, matching the production
constructor. Retain the real rename/replacement and physical lock-location
assertions on every platform. Document the direct-root Windows limitation.
No production code, timeout or platform skip changes.

Verification: complete filelock race (25 passes), Windows amd64 compilation,
required builtin/boot, root vet, full lint and make check. The prior full root
qualification is retained separately; new hosted Windows evidence is pending.
2026-10-25 13:00:44 +08:00
EMEEEEMMMM aaaaf4d2ef fix(studio): address wallpaper scene review 2026-10-25 12:42:39 +08:00
SivanCola d1fe589bf8 fix(isolation): serialize result inspection and settlement
Problem: concurrent Stores could inspect one retained result and then write
or remove it while another operation was still using its candidate.

Root cause: reinspection checked authority without retaining a common owner.
Paths, first Finish, Abort and expiry cleanup could bypass Apply/Discard too.

Fix: add directory-handle OS file locks and one persistent per-result mutex
namespace. Hold admission through reinspection, mutation and synchronous
cleanup; preserve lock identity, active owners and live-only Finish retries.
Forward path-query cancellation and keep legacy writer limitations explicit.

Verification: complete 238-package root inventory, owner and real boot race,
compiled predecessor regressions, actual prior reader/sweeper compatibility,
Windows/Linux owner cross-compilation, formatting, vet, lint and make check.
No new native-platform or independent-review result is claimed here.

Refs #14025
Refs #14133
2026-10-25 12:33:34 +08:00
EMEEEEMMMM 89935ce452 fix(studio): keep scene controls out of the composer layout 2026-10-25 12:27:43 +08:00
EMEEEEMMMM db7a840a3f docs(release-notes): name the Wallpaper Engine scene import 2026-10-25 12:27:43 +08:00
EMEEEEMMMM b242c67680 feat(studio): import Wallpaper Engine scenes with sound and volume 2026-10-25 12:27:43 +08:00
YHH 8ebd327b12 Merge pull request #14408 from esengine/c5-actions-split
refactor(studio): move the feedback actions out of the full actions file
2026-10-24 21:21:18 -07:00
Lancy 04bdd5702b fix(studio): execution typography follows body preferences 2026-10-25 12:15:49 +08:00
esengine 811e335a89 refactor(studio): move the feedback actions out of the full actions file
actions.ts sat exactly at the 800-line ceiling, so any pull request that added a
single action id failed repolint. The 44 feedback.* ids are one concern and move
to actions_feedback.ts, the way the market, schedule and document groups already
live in their own files; the registry content is unchanged.
2026-10-24 20:55:34 -07:00
YHH 7a530b37ee Merge pull request #14405 from SivanCola/codex/checkpoint-cancellable-admission-14025
fix(checkpoint): cancel admission and preserve other writers / 取消检查点等待并保护其他写入者
2026-10-24 20:51:30 -07:00
SivanCola ee9f75e286 fix(checkpoint): cancel admission before invocation entry
Problem: cancelled calls remain parked behind checkpoint exclusivity, delay
their provisional resource cleanup, and may later enter hooks or execution.

Root cause: condition waits do not consume the invocation context. Observer
cleanup also exposes a shared writer-count decrement that can run twice.

Fix: centralize context-aware barrier admission, recheck cancellation after
durable preparation, return idempotent permits and bind the Agent caller.
Preserve completed undo retirement and every other active owner's admission.

Verification: deterministic cancellation and owner-preservation schedules,
compiling predecessor behavior failures, real boot direct/deferred coverage,
durable undo reloads, full root inventory, owner/boot race and required suites;
gofmt, vet, full lint and make check with unchanged gate baseline.
2026-10-25 11:14:07 +08:00
YHH e29812d38f Merge pull request #14404 from esengine/c5-edit-crlf
fix(edit): a literal edit match keeps the line ending of the line it lands in
2026-10-24 19:53:30 -07:00
YHH e4779b9f95 Merge pull request #14402 from SivanCola/codex/isolation-cached-result-14025
fix(isolation): reject settled result caches / 拒绝已处理成果的过期缓存
2026-10-24 19:53:13 -07:00
esengine 772b2f1de6 fix(edit): a literal edit match keeps the line ending of the line it lands in
The literal branches of applyOldStringEdit wrote the replacement verbatim, so a
multi-line new_string inserted bare LF into CRLF files while only the fuzzy paths
converted it. Each literal replacement now takes the ending of its own line, which
also keeps an LF region of a mixed file free of stray CR.
2026-10-24 19:28:14 -07:00
SivanCola 749e691863 fix(isolation): reinspect published results before settlement
Problem: a Store could apply an old cached Git tree after another Store had already applied or discarded that result, resurrecting its bytes in the source workspace.

Root cause: cache hits skipped durable publication and candidate admission; Git objects survive removal of the worktree. Refreshing without comparing the previous observation would also silently widen an earlier path declaration.

Fix: always reuse durable recovery/inspection for lookups, keep finished caches out of Pending, and refuse changed baseline/candidate/target trees until fresh path admission. Preserve first-Finish live-only and unsupported-version checks. No persisted, permission, tool-definition or default changes. Cross-process settlement locking and journals remain separate.

Verification: final complete 238-package root inventory passes (217 pass/21 no tests;23310 test/subtest passes,215 existing skips,194 valid cached packages). Full isolation race52 passes and real boot focus31 passes with no skips. The accepted predecessor Store compiles and fails9 behavior events. Gofmt, vet, full lint, make check and diff checks pass. Additional required builtin/boot command is running; new hosted/native evidence remains pending.
2026-10-25 10:20:29 +08:00
YHH e59262c519 Merge pull request #14401 from SivanCola/codex/isolation-runtime-options-14025
fix(isolation): honor task runtime options / 隔离任务遵守模型与步数参数
2026-10-24 19:06:13 -07:00
YHH 35003e6628 Merge pull request #14400 from SivanCola/codex/isolation-first-finish-14025
fix(isolation): retain first-Finish failures / 首次成果检查失败保留隔离结果
2026-10-24 19:05:34 -07:00
SivanCola cbac083536 fix(isolation): preserve task runtime selection and step caps
Problem: worktree task dispatch silently discarded effort, step limits and configured subagent models; parent model aliases also lost live session effort overrides.

Root cause: isolation branched before ordinary task runtime resolution, and alias resolution reloaded config instead of retaining the canonical parent runtime entry.

Fix: reuse shared task/model/effort/budget resolution, pass ephemeral options to the unattended Build, and canonicalize parent aliases to their runtime entry. Existing unsupported isolation restrictions remain refused; best-of keeps its prior policy. No persisted, wire, tool-definition or default changes.

Verification: complete 238-package root inventory passes (217 pass/21 no tests; 23289 test/subtest passes, 215 existing conditional skips, 198 cached packages). Owning delegation/isolation race has 400 passes; real boot focus has 39 passes, no skips. Compiling old owners fail 18 runtime scenarios plus parent, with two automatic-budget controls passing. First-Finish composition race has 32 passes. Gofmt, vet, full lint, make check and diff checks pass. Supplemental required builtin/boot is still running; hosted/native results remain pending.
2026-10-25 09:46:07 +08:00
SivanCola cf5fedae64 test(isolation): cover CRLF checkout during first-Finish recovery
Problem: Windows CI rejected successfully recovered isolated output because the new boot assertion required LF bytes.

Root cause: Git checkout may apply core.autocrlf conversion; recovery preserves the expected text with CRLF.

Fix: explicitly enable autocrlf in the disposable repository and normalize CRLF for the content assertion, retaining same-ID recovery and source-before-apply checks.

Verification: the old assertion compiles and deterministically fails with CRLF on macOS; the repaired four boot regressions pass under race (6 test/subtest events). Required builtin/boot pass (2291 passes, 66 existing conditional skips); vet, lint, make check and formatting pass. Native Windows replacement CI remains pending.
2026-10-25 09:36:50 +08:00
SivanCola d816d53347 fix(isolation): retain results after first finalization failure
Problem: isolated work could lose its recovery ID or be removed when the
first candidate inspection or completed-result publication failed.

Root cause: Finish treated inspector/persistence errors like successful
empty results, and Execute hid Finish errors behind a runner failure.

Fix: retain the candidate and original ID, publish a separate versioned
inspection marker without overwriting existing records, reuse rooted
admission and same-ID inspection, and report live-only retry explicitly
when recovery publication is unavailable. Join both errors. Keep normal
completed records unchanged and refuse unsupported inspection versions.

Verification: complete 238-package root inventory, isolation and real boot
race tests, compiling preceding-owner behavior failures, actual previous
reader/sweeper compatibility controls, formatting, vet, lint and check.
Local evidence is macOS only; hosted checks and independent review remain.
2026-10-25 09:01:57 +08:00
YHH 5b2b297e6e Merge pull request #14399 from SivanCola/codex/hook-checkpoint-admission-14025
fix(agent): admit reader hook writes through checkpoints / 只读调用 hook 纳入检查点写入准入
2026-10-24 17:49:06 -07:00
SivanCola ab68ff145f fix(agent): admit reader hook effects through checkpoint barrier
Problem: A reader's hooks can change files without participating in rewind
exclusion, retiring undo, or recording the checkpoint's coverage gap.

Root cause: The invocation caller selects checkpoint admission using only the
concrete tool's read-only flag, even though it already knows that its hook set
may write. The checkpoint owner is correct but this caller bypasses it.

Fix: Include possible tool-hook writes in the two existing admission/coverage
conditions. Reuse the existing writer permit and deferred cleanup. Keep empty
hook readers unchanged and keep tool execution, coverage, and effects separate.

Verification: Compiling original owners fail the actual callback/undo and four
boot regressions; fixed owner/checkpoint and boot race tests pass. Full root
inventory, required builtin/boot, formatting, vet, lint and make check are
qualified separately in the PR. Asynchronous hook process lifetime and the
barrier's existing cancellation limits remain explicit follow-up work.
2026-10-25 08:14:28 +08:00
YHH ffccf6877c Merge pull request #14397 from SivanCola/codex/provisional-write-admission-14025
fix(lease): release provisional write claims / 撤销未执行写入的临时占用
2026-10-24 17:03:54 -07:00
YHH b786e30b7a Merge pull request #14398 from SivanCola/codex/hook-workspace-invalidation-14025
fix(agent): preserve hook workspace invalidation / 保留 hook 工作区失效通知
2026-10-24 16:48:39 -07:00
SivanCola 72b725862f fix(agent): preserve workspace invalidation for hook effects
Problem: A hook can change files before refusing a tool or outside a reader's
declared paths without notifying an opted-in workspace sink. Nested writers
also omit publication until an outer writer happens to complete.

Root cause: Invalidation is gated by concrete tool execution and classification,
and publication belongs to the batch caller rather than the invocation owner.

Fix: Track possible tool-hook effects separately from Executed, publish through
the shared invocation exit after cleanup, and share result metadata projection
between batch and nested calls. Unknown hook scope invalidates content, tree and
Git metadata without claiming a confirmed write or widening authorization.

Verification: Full root 238-package inventory passed; complete agent/event race,
eight boot direct/capability hook cases under race, original-owner compiling
negative controls, and provisional-lease composition passed. Formatting, vet,
full lint and make check passed without changing the baseline. Local evidence
is macOS only; optional sink wiring and evidence accounting remain separate.
2026-10-25 07:29:49 +08:00
SivanCola bc330925c0 fix(lease): separate run and yielded-claim lifetimes
Problem:
Aborting provisional admission can expire an unrelated live user wait or
retake paths deliberately yielded to a user. A pending wait is also lost
when the last writer or an earlier restoration finishes afterward.

Root cause:
Physical release reset run state, and one retained extent represented both
active and parked ownership. Restoration dropped its temporary protection
before reholding the original caller. Expired queued grants and retention
callbacks could contribute resources to the next run.

Fix:
Separate active and parked retention, advance generations at the final
participating run end, and reconcile outstanding yields whenever writing
holds drain. Keep restoration protection through rehold, fence late
admission and retention, and clean expired grants without dropping current
writers. Actual in-flight writes stay protected after their run ends.

Verification:
The submitted owner compiles and fails all 14 selected test/subtest failure
events across the new public-API schedules. Corrected full workspacelease
and agent race pass, as do actual boot race, all 238 packages in disjoint
required/remaining partitions, required builtin/boot, vet, lint and check.
Local evidence is macOS only; hosted checks and review apply to the new head.

Refs #14025
Refs #14133
2026-10-25 06:50:34 +08:00
SivanCola affb71f9dd fix(lease): release provisional paths before mutation entry
Problem:
A refused write can pin a never-executed path through later model requests.
Repeated failed restoration can also report success to an expired caller.

Root cause:
Admission merges every request into the run-retained extent before local
preparation succeeds. Call cleanup only decrements in-flight accounting.
Resume keeps its once-only result in per-invocation locals, and refused
upgrades omit the terminal wait notice after registrar contention.

Fix:
Track identified provisional calls separately from retained extents and
publish their remaining union through the existing claim registrar. Retain
before possibly writing hooks or execution. Preserve earlier writes and
parallel claims, report failed cleanup conservatively, validate lifetimes
before and after reacquisition, and cache terminal callback outcomes.
Close registrar waits on refusal and clear completed cleanup's wait state.

Verification:
Complete 238-package inventory and required builtin/boot pass on the final
source; full workspacelease/agent race and focused real boot race pass.
Compiling original execution, resume and upgrade-wait owners fail the
selected deterministic regressions. Formatting, vet, lint and check pass.
Local platform evidence is macOS only; hosted checks and review are separate.

Refs #14025
Refs #14133
2026-10-25 05:36:29 +08:00
YHH 7adc244b94 Merge pull request #14396 from esengine/docs/release-studio-2.51.0
docs(release): studio 2.51.0 notes
2026-10-24 14:28:52 -07:00
esengine 453eee6b69 docs(release): studio 2.51.0 notes
Gathers the 38 fragments since 2.50.0 into one note: video wallpaper, find
case/regex switches, shortcut sheet, paged transcript loading, a narrower
reading column, dispatch trust and the delegation write-safety fixes.
2026-10-24 14:05:54 -07:00
YHH ce57da64cf Merge pull request #14366 from SivanCola/codex/transcript-paging-ui
feat(studio): load transcript pages incrementally / 正文渐进分页界面
2026-10-24 13:56:18 -07:00
YHH c0e99ad1a3 Merge pull request #14394 from SivanCola/codex/optimistic-call-admission-14025
fix(agent): coordinate optimistic child writes / 修复乐观子代理具名写入绕过占用
2026-10-24 13:31:41 -07:00
YHH 33ab226422 Merge branch 'studio' into codex/transcript-paging-ui 2026-10-24 13:30:24 -07:00
YHH 15652de96a Merge pull request #14392 from SivanCola/codex/isolation-record-boundary-14025
fix(isolation): verify recovered record identity / 校验隔离恢复记录的宿主身份
2026-10-24 13:30:17 -07:00
YHH ce7baf7e75 Merge pull request #14365 from SivanCola/codex/history-paging-api
feat(history): add cursor-based history API / 独立游标分页接口
2026-10-24 13:29:55 -07:00
SivanCola 11652b0146 test(studio): preserve API-first history route gate
Problem: standalone UI route checks require the paging API to have landed.

Maintainer guidance confirms that this check should remain a landing
dependency until #14365 is merged and the UI branch updates its studio base.
Restore the original route scanner and keep the HTTP fallback integration
tests, which prove runtime compatibility independently of landing order.

Verification: the original route test passes in the combined API/UI tree.
The standalone failure remains expected until the API base update. Vet,
complete lint, check and builtin/Boot validation pass without changing
production code, timeouts or coverage floors.
2026-10-25 04:06:37 +08:00
SivanCola ffc054a483 fix(agent): admit optimistic child writes through the shared scheduler
Problem: optimistic children can write paths held by live sibling tasks,
including through dynamically resolved local capabilities.

Root cause: whole-workspace optimistic grants bypass explicit-fence wrapping,
child options omit the shared scheduler, and parent admission skips children.

Fix: inherit the existing scheduler and admit actual named targets at execution,
using the actor grant's root and one complete resource set. Retain parent and
explicit-fence ownership, opaque tool availability and existing permissions.
Leave observation roots, provider prefixes and tool definitions unchanged.

Verification: compiling old owners write held files in four real boot task/fleet
direct/capability cases; fixed cases refuse overlap and preserve disjoint writes.
Full238-package root, required builtin/boot, complete three-owner race and focused
real assembly race pass. Vet/lint/check pass without raising the baseline.
Native hosted checks and independent review remain separate delivery gates.
2026-10-25 03:58:01 +08:00
SivanCola a60af4d12f fix(i18n): complete standalone paging refusal translations
Problem: the API-only PR failed the English refusal catalogue check.

Root cause: splitting the implementation retained the refusal codes but left
their English wording only in the UI branch.

Fix: include the same two English translations in the API branch, without
adding UI implementation.

Verification: reproduced the catalogue failure; all 170 i18n tests, typecheck,
vet, full lint, check and builtin/Boot suites pass. Existing catalogue
assertions and budgets are unchanged.

Place the shared refusal translations in EN_KERNEL on both branches so the
independent API and UI changes compose without a translation conflict. All
170 locale tests and typechecks pass on each branch.
2026-10-25 03:56:57 +08:00
SivanCola 7096a83db3 test(studio): validate optional history paging fallback
Problem: standalone paging UI failed the CI serve route contract.

Root cause: the scanner assumed every frontend endpoint was mandatory, while
paging explicitly supports older servers through full-history fallback.

Fix: declare only the exact paging call optional, require its fallback route,
and exercise the HTTP adapter and hook across unsupported and real failures.

Verification: reproduced the original failure; route tests, 19 frontend tests,
typecheck, vet, lint, check and builtin/Boot suites pass. Covergate tests pass;
unchanged computer coverage is 88.2% on macOS versus the 89.8% floor, also
reproduced on the sibling branch. Ubuntu coverage remains for CI.

Place the shared refusal translations in EN_KERNEL on both branches so the
independent API and UI changes compose without a translation conflict. All
170 locale tests and typechecks pass on each branch.
2026-10-25 03:56:57 +08:00
YHH dc7489570a Merge branch 'studio' into codex/history-paging-api 2026-10-24 12:29:21 -07:00
SivanCola aa694db5a3 fix(isolation): confine recovered-record queries to opened roots
Problem: CodeQL found two new uncontrolled-path stat alerts in record admission.
The first validator also read a claimed Git administrative backlink before
confining that path to the host-opened common directory.

Root cause: comparing embedded paths with global os.Stat did not itself provide
a filesystem namespace boundary, and marker discovery still used path globs.

Fix: enumerate and read slots through the opened managed root, perform identity
stats through root-relative handles, and constrain candidate/common metadata
reads before opening a backlink. Derive effective identities from checked host
roots, retain unknown fields unchanged, and treat brackets in root names literally.
Existing alias resolution remains metadata-only and grants no content-read scope.

Verification: final 238-package root inventory and required builtin/boot pass;
complete isolation race has 32 passes and real boot isolation race has five,
without skips. Six old-owner behavior groups compile and fail, namespace and
literal-directory regressions pass, and vet/lint/check retain the same baseline.
The replacement hosted CodeQL verdict and independent review remain pending.
2026-10-25 03:22:32 +08:00
SivanCola 2be21f5e53 merge: preserve first-save rail stars when syncing studio
Problem: the transcript paging UI conflicts with the upstream first-save bookmark fix. Root cause: both changes update the Pane-to-Transcript boundary, and retaining the runtime-id fallback loses unsaved stars on first save. Fix: merge studio f96ec2f869, retain session-path-only bookmark ownership alongside paging, and exercise both full and paged restoration. Verification: the upstream regression fails with the old scope and passes after resolution; 37 focused tests and 791 frontend files / 6343 tests pass, as do the production build, go vet, complete make lint and builtin/Boot suites. No lint baseline or deadline was changed.
2026-10-25 03:16:18 +08:00
YHH f96ec2f869 Merge pull request #14393 from xxoingr/fix/rail-star-before-first-save
fix(studio): a star placed before a conversation's first save stays with it
2026-10-24 12:04:48 -07:00
YHH cf077634d8 Merge pull request #14391 from xxoingr/test/final-observation-hang-budget
test(agent): wait out a held final observation, not a slow seal
2026-10-24 11:52:44 -07:00
SivanCola dcd09b8352 fix(isolation): bind recovered records to host-managed identities
Problem: a rebuilt store can trust a copied or mismatched durable record and
use its embedded candidate paths outside that record's actual managed slot.

Root cause: discovery checks the claimed workspace but does not bind snapshot
and candidate identity to the host-opened repository and observed marker slot.
Comparing Git markers only by inode also accepts cross-candidate hard links.

Fix: centralize record admission before every durable consumer; verify managed
directory membership, source identity, prefix roots and the linked candidate's
actual marker/backlink. Retain rejected records without rewriting metadata,
and resolve legacy identity through filesystem checks without launching Git.

Verification: five compiling old-owner negative-control groups fail as expected;
final 238-package root inventory and required builtin/boot pass, owning isolation
race has 30 passes and real boot isolation race has five, all without new skips.
Formatting, root vet, full lint and make check pass with the baseline unchanged.
Native hosted checks and independent review remain separate delivery gates.
2026-10-25 02:46:04 +08:00
xxoingr 48a1614e8c fix(studio): a star placed before a conversation's first save stays with it
An unsaved conversation scoped its rail stars by the pane's runtime id, and
the first save switched the scope to the session path, which reloaded from
an empty key: the star disappeared. The id is also a per-launch counter
(r1, r2, ...), so the key it left behind was read again by whichever unsaved
conversation pane r1 held after the next restart.

An unsaved conversation now keeps its stars in memory, and the first save
writes them under the session's path, merged with anything stored there.
The pane passes the session path alone as the scope.
2026-10-25 02:45:56 +08:00
YHH 65def98d70 Merge pull request #14387 from SivanCola/codex/role-fileview-ownership-14025
fix(agent): isolate review-role file views / 修复审查角色刷新执行代理读取记录
2026-10-24 11:29:07 -07:00
xxoingr 5230537980 test(agent): wait out a held final observation, not a slow seal
TestFinalObservationIsBoundedAfterACompletedAnswer gave Run one second
after cancel (three after the deadline) to come back. Run still seals the
contract and the bundle after its context ends, because the first lock
attempt does not wait; locally that takes about 18ms, but on a loaded
windows-latest runner it overran the window and the test reported a held
observation that was not there (studio runs 40110025629, 40064086851).

The observation the test holds never returns until it is released, so any
finite wait catches the regression; use testenv.Budget for both waits.
2026-10-25 02:16:00 +08:00
YHH e2a3342f8d Merge pull request #14390 from SivanCola/codex/isolation-inspection-retain-14025
fix(isolation): retain results on inspection errors / 修复恢复检查失败丢失隔离结果
2026-10-24 11:03:59 -07:00
SivanCola be47298166 fix(isolation): retain published results on recovery inspection errors
Recovery conflated a failed Git inspection with an empty result and erased
the durable marker before attempting worktree removal. Return inspection
errors without cleanup and report the existing unavailable refusal instead
of forgetting a valid result ID.

Exercise apply path discovery, Apply and Discard against real retained
worktrees after restarting the store and temporarily hiding Git. Preserve
marker bytes and work, then retry the same ID after Git returns. Keep age
expiration and pre-publication Finish recovery as separate lifecycle units.
2026-10-25 01:40:02 +08:00
YHH cf93de51be Merge pull request #14386 from SivanCola/codex/guardian-readonly-boundary-14025
fix(guardian): enforce resolved read-only execution / 修复审查角色动态写入越权
2026-10-24 10:29:13 -07:00
YHH 59bd6bc212 Merge pull request #14384 from esengine/c5-flow-narrow
feat(studio): the reading column can be dragged narrower than the readable measure
2026-10-24 10:29:04 -07:00
SivanCola efba49918c fix(agent): isolate planner and guardian file observations
Separate review-role FileViews from the executor and bind direct/deferred
local tools to one fresh record for each role. Install the planner frontend
before binding so its ledger/audit and view ownership survive boot wiring.
Keep the generic read-only filter a same-actor operation, and preserve the
existing nil-runtime planner adapter without changing its tool surface.

Add four real-boot stale-overwrite regressions, runtime/clone accounting
checks, a generic-filter compatibility case, and written security bounds.
The compiling old-owner overlay reproduces all four overwrites.
2026-10-25 01:12:51 +08:00
SivanCola 80c1b58c6e fix(guardian): enforce read-only execution after capability resolution
Guardian's visible registry was filtered, but its read-only capability proxy
could resolve an executor writer or a declared host mutation. Set the
permanent execution flag in the session constructor so actual targets are
checked before they run, without changing the review's executor approval.

Add local resolver and real boot regressions, with compiling old-owner
failures for both mutation classes and an actual file overwrite. Document
the authorization boundary and the separate file-view ownership concern.
2026-10-25 00:57:29 +08:00
YHH 0dd1a41153 Merge pull request #14382 from SivanCola/codex/fileview-ownership-14025
fix(agent): give each child its own file views / 修复父子代理文件读取记录共享
2026-10-24 09:52:40 -07:00
YHH 1c253bb218 Merge pull request #14379 from esengine/c5-first-run
feat(studio): a launch with no chosen or remembered project opens the app's own auto workspace, not home
2026-10-24 09:52:31 -07:00
esengine 4eb303b1ea docs(release-notes): reading column drag floor 2026-10-24 09:49:57 -07:00
esengine b0dac9152c feat(studio): the reading column can be dragged narrower than the readable measure
The drag floor was the readable measure itself (760), so the two presets were the
only widths a hand could reach on a typical window. A hand now reaches down to
640; the readable measure stays the untouched default and what Home returns to.
2026-10-24 09:47:32 -07:00
YHH 0357832547 Merge pull request #14348 from lancy69/fix/14326-workbench-context-menu
fix(studio): integrate workbench text context menus
2026-10-24 09:43:34 -07:00
YHH 55bbd301db Merge pull request #14381 from esengine/c5-dispatch-trust
fix(dispatch): trust an undecided project the user named in their own list
2026-10-24 09:33:27 -07:00
esengine 083a17ee02 fix(storage): list the auto workspace folder among the state root's entries
A first launch with nothing chosen creates <state>/workspaces/auto; the storage
panel and a state-root move must own it, or a move would strand it.
2026-10-24 09:32:30 -07:00
SivanCola 0e3fcb96ef fix(agent): give each child independent file observations
Problem: a child's read can refresh the parent's stale overwrite guard,
allowing the parent to discard an edit it has never read.

Root cause: copied file tools share FileViews, while deferred local tools
resolve through the root registry even inside nested children.

Fix: bind one fresh view per child to its direct and deferred file tools.
Keep disabled protection, tool identity, grants, read-only execution and
stable schemas intact. Install bindings on fresh capability frontends only.

Verification: original-owner controls reproduce the real boot overwrite;
a parent-pointer mapping control fails four nested cases, while the actor
binding passes all six shared-runtime/clone combinations. Root package,
owning race, composition, vet, check and lint evidence is recorded in the PR.

Refs #14025
Refs #14133
2026-10-25 00:13:36 +08:00
YHH e13e76e1c5 Merge pull request #14373 from esengine/c5-video-wallpaper
feat(studio): a wallpaper can be an MP4 or WebM video, played muted and looping
2026-10-24 09:06:31 -07:00
esengine d3880a6c83 fix(dispatch): trust an undecided project the user named in their own list
A dispatch target is resolved against the user's sidebar and the dispatch
switch is already the user's consent, so stopping on the folder's trust card
asked the same person twice. The folder is now recorded as trusted and the task
goes through; a folder that cannot be trusted whole still keeps its pane open on
the card with the task unsent.
2026-10-24 09:04:07 -07:00
YHH d561a40068 Merge pull request #14378 from esengine/c5-import-picker
fix(studio): the native session import card uses the app's button like the legacy one beside it
2026-10-24 08:54:44 -07:00
YHH fff33508f8 Merge pull request #14375 from esengine/c5-title-budget
fix(studio): automatic naming asks once more with room when the title model only thought
2026-10-24 08:42:12 -07:00
YHH 5e342dc0ec Merge pull request #14376 from imcreazy/fix/activity-line-one-type-scale
Set the activity line in one type scale
2026-10-24 08:41:00 -07:00
esengine 087ea05686 feat(studio): a launch with no chosen or remembered project opens the app's own auto workspace, not home 2026-10-24 08:39:37 -07:00
esengine 5f5a63afa6 fix(studio): the native session import card uses the app's button like the legacy one beside it 2026-10-24 08:38:22 -07:00
imcreazy 8aaa4af237 Set the activity line in one type scale
Its four spans were 11px/500, 11px/650, 9.5px/500 and 10px/450. The running
chip already has a wash behind it, so the heavier weight separates twice.
2026-10-24 23:19:36 +08:00
YHH 7c22c5340e Merge pull request #14372 from esengine/c5-arg-example
fix(tools): refusals of malformed calls, todo lists and diff evidence show what a valid one looks like
2026-10-24 08:19:03 -07:00
YHH 34e61e806d Merge pull request #14370 from esengine/c5-comp-fix
fix(studio): a conversation bound to an Agent preset continues when a built-in tool schema changed
2026-10-24 08:17:50 -07:00
esengine e401575036 fix(studio): automatic naming asks once more with room when the title model only thought 2026-10-24 08:14:50 -07:00
esengine 2b4ee07fd7 feat(studio): a wallpaper can be an MP4 or WebM video, played muted and looping 2026-10-24 08:11:52 -07:00
YHH 33abf2ef03 Merge pull request #14369 from esengine/c5-wallpaper-stream
feat(studio): wallpapers upload as a streamed body, have no size limit and are served in ranges
2026-10-24 07:59:52 -07:00
SivanCola ef34969818 Merge studio and drop obsolete design-document lint repair
Problem: Both extracted PRs became modify/delete conflicted while their local
qualification was running.

Root cause: Upstream removed FIRST_RUN_WORKSPACE.md in #14367 after the slices
had repaired its inherited lint metadata and paragraph violations.

Fix: Accept the upstream deletion and remove the now-obsolete prerequisite
repair. Retain every feature, regression and security-document change.

Verification: make check passes. The staged merge changes no Go file relative
to the independently qualified slice heads; their full package/race evidence
remains explicitly scoped to those source trees. Hosted checks must run on
the new merge heads. No unrelated production change or baseline relaxation.

Refs #14025
Refs #14133
2026-10-24 22:53:21 +08:00
SivanCola 7639c30135 Merge studio and drop obsolete design-document lint repair
Problem: Both extracted PRs became modify/delete conflicted while their local
qualification was running.

Root cause: Upstream removed FIRST_RUN_WORKSPACE.md in #14367 after the slices
had repaired its inherited lint metadata and paragraph violations.

Fix: Accept the upstream deletion and remove the now-obsolete prerequisite
repair. Retain every feature, regression and security-document change.

Verification: make check passes. The staged merge changes no Go file relative
to the independently qualified slice heads; their full package/race evidence
remains explicitly scoped to those source trees. Hosted checks must run on
the new merge heads. No unrelated production change or baseline relaxation.

Refs #14025
Refs #14133
2026-10-24 22:53:20 +08:00
esengine a5a7439b75 fix(tools): refusals of malformed calls, todo lists and diff evidence show what a valid one looks like 2026-10-24 07:50:23 -07:00
SivanCola fd8729692b test(worktree): reject stat-cache-hidden source overwrites
Problem: A snapshot-only regression does not establish that the shared tree
reader also protects the source workspace before Apply publishes a candidate.

Root cause: The old cached index can hide a user's intervening edit from the
whole-tree drift check, allowing Apply to return success and overwrite it.

Fix: Exercise actual snapshot/candidate/Apply assembly on disposable real Git
repositories. Require ErrWorkspaceMoved and unchanged user bytes. Document the
existing Git driver confinement and the additional rehashing cost explicitly.

Verification: A compiling overlay of the original production file fails for
both wrong success and actual overwrite; the repaired owner passes. Full final
worktree race passes (48.247s). Broader root and static qualification records
are retained separately; production code is unchanged by this test follow-up.

Refs #14025
Refs #14133
2026-10-24 22:48:54 +08:00
esengine 780b7fb1f9 fix(studio): a conversation bound to an Agent preset continues when a built-in tool schema changed 2026-10-24 07:38:15 -07:00
SivanCola 0f943c7f7a fix(worktree): rebuild private index before capturing content
Problem: A candidate snapshot can contain old bytes after the working file
changed while preserving its size and mtime.

Root cause: Copying the source index carries Git's trusted stat cache and
assume-unchanged state into StageAll, so staging can skip the actual bytes.

Fix: Preserve the copied index's staged tree, rebuild the private index from
that tree, then stage current content. Handle an unmerged private index first.
Keep staged ignored inputs, the real conflicted index, HEAD and refs intact.
Document concrete Git/path/authority boundaries and the unchanged need for
source coordination. Include the inherited documentation-lint prerequisite.

Verification: All three cache-policy variants fail against the original
function through a compiler overlay and pass with the repair. Full worktree
race passes, plus final expanded cache and conflict regressions. Check, lint
and root vet pass. Full builtin/boot exits zero (22.243s / 444.076s tests),
using a measured 15-minute package alarm with no product-deadline change.
The exact-commit full root sweep and hosted checks remain separate evidence.

Refs #14025
Refs #14133
2026-10-24 22:37:40 +08:00
esengine 3c3ba4395b feat(studio): wallpapers upload as a streamed body, have no size limit and are served in ranges 2026-10-24 07:35:07 -07:00
YHH 2a57596001 Merge pull request #14367 from esengine/c5-rm-firstrun
docs: remove the first-run workspace design (moved to #14363)
2026-10-24 07:34:40 -07:00
SivanCola db465ec26f feat(state): introduce independently reviewable mutation transactions
Problem: Task-length write claims cannot independently represent permission,
assignment and the physical mutation lifetime across cooperating runtimes.

Root cause: Shared admission, input preconditions, cancellation and release
have no common short-transaction owner that callers can adopt independently.

Fix: Extract the mutation resource/queue/capacity owner and content versions
from the preserved concurrency implementation. Add minimal non-blocking legacy
claim compatibility and exception-safe publication cleanup. Keep all runtime,
isolation, hook, controller and default activation outside this review slice.
Document authority, actual sinks, compatibility and unenforced boundaries,
and map the remaining source into dependent review units. Normalize an
inherited design-document lint prerequisite without relaxing its baseline.

Verification: make check, full lint and root vet pass. Owning-package race
passes, including real subprocess death and a strengthened cleanup regression
that fails without the cleanup via compiler overlay and passes with it.
Builtin passes. The full boot invocation reaches its 600-second package alarm;
all 811 top-level tests close in four inventory-verified groups (808 pass,
3 conditional skips, no missing/unexpected/failing selected tests). Linux and
Windows amd64 test binaries cross-compile; native platform and hosted CI
evidence are separate gates. The remaining root-package sweep is in progress.

Refs #14025
Refs #14133
2026-10-24 22:23:12 +08:00
esengine 4f896f2710 docs: remove the first-run workspace design (moved to #14363) 2026-10-24 07:20:48 -07:00
SivanCola 5846f3a226 fix(studio): continue history search after batched page commits
Problem: searching for a message outside the initial page could remain incomplete after one fast response. Root cause: the search effect depended only on busy/hasMore flags that React may batch back to their previous values. Fix: advance search on committed page revisions and release the completed single-flight request before publishing that revision. Verification: failing-before revision regression, 28 focused tests including scroll and anchor neighbors, and a browser replay fetching four pages and finding the first message.
2026-10-24 22:15:33 +08:00
SivanCola 591aac54e9 feat(history): expose bounded cursor paging independently of the UI
Problem: full transcript projection and transfer scale with conversation length. The session store now owns stable page snapshots and cross-page context, exposed through optional controller reads and an additive HTTP route. Existing full-history clients are unchanged. Validate through real Boot/resume, full-projection equivalence, cursor invalidation and append stability, request authority and foreground preemption. Focused history tests, race checks, Go lint and release-note tests pass; broad suites are recorded in the PR.
2026-10-24 22:13:10 +08:00
SivanCola 5d24185309 feat(studio): load transcript pages with stable reading anchors
Rebase the UI portion of #14087 onto current studio, preserving regex/case search and session-scoped navigation. Use optional paging with explicit unsupported-route fallback and retain mounted controls when prepending. Browser verification exposed zero-box display-contents wrappers; share rendered-box geometry with the landing writer and add a failing-before regression. Verification: 37 focused frontend tests, TypeScript checking, production build and wide/narrow light/dark browser renders; full-suite results and backend dependency are documented in the PR.
2026-10-24 22:13:10 +08:00
SivanCola 5810c1033b feat(history): define additive paging wire types
Define the optional history-page response and typed refusal translations shared by the API and UI splits. No caller switches to paging in this commit. Verification is performed on each independent split before publication.
2026-10-24 22:03:01 +08:00
YHH ef79a2215e Merge pull request #14335 from lancy69/fix/14324-remove-workbench-wide
fix(studio): remove redundant workbench widening mode
2026-10-24 06:58:22 -07:00
YHH 9614465f5b Merge pull request #14345 from 1012905/studio
feat(studio): cancel an update download from the Versions panel
2026-10-24 06:53:17 -07:00
YHH 80d90498c9 Merge pull request #14351 from Oliver-Rui/docs/first-run-workspace
feat(studio): first run without a picked workspace
2026-10-24 06:30:37 -07:00
YHH 1384c6db9d Merge pull request #14353 from esengine/c5-grip-quiet
fix(studio): the reading column's grips draw nothing until the pointer is on them
2026-10-24 06:29:58 -07:00
1012905 6aec414270 fix(desktop): register versions.cancel in the action registry 2026-10-24 21:18:43 +08:00
esengine 24df650056 fix(studio): the reading column's grips draw nothing until the pointer is on them 2026-10-24 06:11:25 -07:00
Oliver 50f7e2711b docs: add first-run workspace design proposal
Documents the last coupled first-run decision: with nothing remembered,
the first pane's workspace root falls back to the user's home directory
(pickLaunchWorkspace), so a session that was never given a chosen project
reads as temporary. Proposes a kernel-provisioned auto workspace instead,
modelled on MiMo Desktop's session-first behaviour (auto-created session
directories, global provider config, optional projects), and maps it onto
the existing launch chain, session store and workspace switch.
2026-10-24 21:08:48 +08:00
esengine 0d35e92bc4 docs(release-notes): keep the English item within the width budget 2026-10-24 06:05:11 -07:00
YHH 1862e06e2b Merge pull request #14344 from lancy69/fix/14323-skill-description-search
fix(completion): find slash skills by description
2026-10-24 06:04:32 -07:00
1012905 9bc6bfd45f Merge branch 'studio' of https://gh-proxy.com/https://github.com/1012905/DeepSeek-Reasonix into studio 2026-10-24 20:58:52 +08:00
1012905 b838d8d0ed Wait for cancelled install cleanup
Keep the install slot claimed until a cancelled download finishes cleanup, preventing it from cancelling a new install or deleting its files. Add regression tests and a release note.
2026-10-24 20:58:25 +08:00
1012905 6e3947fdfb Merge branch 'esengine:studio' into studio 2026-10-24 20:47:56 +08:00
YHH 336e4c71c4 Merge pull request #14347 from esengine/c5-feedback-ready
fix(studio): the feedback form takes a paste in the commit that draws it
2026-10-24 05:47:49 -07:00
Lancy a9dbff297d fix(studio): integrate workbench text context menus 2026-10-24 20:41:34 +08:00
esengine e1940bae40 fix(studio): the feedback form takes a paste in the commit that draws it 2026-10-24 05:29:25 -07:00
1012905 6d6990785e Add cancellation for version downloads
Expose download cancellation through the update API and Versions UI. Cancellation waits for the transfer to stop, removes its partial and cached artifacts, and reports a distinct non-failure state. Automatic updates do not retry a cancelled release but can proceed when a newer version is offered.
2026-10-24 19:59:33 +08:00
Lancy 9055a5cb63 fix(completion): find slash skills by description 2026-10-24 19:58:05 +08:00
YHH bcf61cf0bd Merge pull request #14342 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/undici-8.12.3
build(deps): bump undici from 8.12.1 to 8.12.3 in /desktop/frontend-next
2026-10-24 04:52:43 -07:00
dependabot[bot] af7b5fabe0 build(deps): bump undici from 8.12.1 to 8.12.3 in /desktop/frontend-next
Bumps [undici](https://github.com/nodejs/undici) from 8.12.1 to 8.12.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.12.1...v8.12.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 11:21:06 +00:00
YHH a5db484ac3 Merge pull request #14340 from esengine/dependabot/npm_and_yarn/tools/acp-conformance/fast-uri-3.1.9
build(deps-dev): bump fast-uri from 3.1.8 to 3.1.9 in /tools/acp-conformance
2026-10-24 03:29:08 -07:00
YHH 3a0daf2abd Merge pull request #14339 from esengine/dependabot/npm_and_yarn/workers/forum/zod-4.6.6
build(deps): bump zod from 3.25.76 to 4.6.6 in /workers/forum
2026-10-24 03:04:11 -07:00
dependabot[bot] c6ba3522b9 build(deps-dev): bump fast-uri in /tools/acp-conformance
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to 3.1.9.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 09:50:36 +00:00
dependabot[bot] 0da7d75b75 build(deps): bump zod from 3.25.76 to 4.6.6 in /workers/forum
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.6.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.6.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 09:50:24 +00:00
YHH da9fb7a9f9 Merge pull request #14213 from chuiwenwei/fix/import-completes-the-sidecar
fix(migrate): an imported session completes its sidecar
2026-10-24 02:04:06 -07:00
YHH a26dacdbb1 Merge pull request #14337 from esengine/c5-ask-draft
fix(studio): an unanswered ask card keeps its picks when its block unmounts
2026-10-24 01:29:36 -07:00
esengine 52dae508f7 fix(studio): an unanswered ask card keeps its picks when its block unmounts 2026-10-24 00:52:25 -07:00
Lancy 630b941e58 fix(studio): remove redundant workbench widening mode 2026-10-24 15:23:54 +08:00
YHH 42ec9546cf Merge pull request #14332 from jf200s/feat/browser-page-sleep
feat(browser): a page nobody drives sleeps, and wakes as the same target
2026-10-23 22:29:17 -07:00
F ee15d89fa2 fix(browser): the awake cap obeys the sleep switch too
Off means nothing sleeps, cap included: a person who never turned the
setting on kept losing the least recently used page's renderer when a
seventh page opened. The cap now runs only on a finite threshold, the
same one the sweep reads.
2026-10-24 13:06:41 +08:00
esengine 3621696b13 docs(release-notes): keep the English item within the width budget 2026-10-23 21:56:42 -07:00
YHH b19969e0b4 Merge pull request #14333 from lancy69/fix/14205-feedback-dialog-paste
fix(studio): route feedback image paste through the dialog
2026-10-23 21:52:54 -07:00
F e05325230c feat(settings): idle page sleep is off until the person turns it on
The rx-page-sleep desktop preference decides it, default off: prefs.js
owns the threshold, the shell reads it at boot and on every preference
save, and Settings -> Appearance carries the toggle. A woken page also
comes back fitted into its panel at the scale it had before the sleep.
2026-10-24 12:51:37 +08:00
F 8a3d9a6091 test(browser): a live probe that sleeps real pages and reports what it reclaimed
Five real Chromium pages through BrowserViews at a tiny threshold: the
working set they cost, the working set the sleep returns, a wake that
must answer against the page as it loaded, and a command the sleep
outlives that must fail rather than hang. Run with pnpm sleep-live.
2026-10-24 12:51:24 +08:00
Lancy 7bbbaf25dc fix(studio): route feedback image paste through the dialog 2026-10-24 12:21:29 +08:00
F 27b5dbd9a3 feat(browser): a page nobody drives sleeps, and wakes as the same target
An agent page's renderer is hundreds of megabytes the Studio pays for
whether anyone is watching. A page untouched for fifteen minutes and not
on screen is closed whole — address and a picture kept — under the same
targetId, so the kernel's handle survives; the next command, show, or
typed address rebuilds the view, replays the domains the kernel last
enabled (a domain switched off mid-session stays off), and answers only
once the page has loaded — a mid-load answer would describe the page it
was, and the kernel could not tell. A command the sleep outlives is
failed fast, for the session that dies never settles it and the kernel,
told nothing of a sleep, would wait on a page that will not speak. Six
awake pages at most — a wake yields no room: the page asked for
outranks the pages left alone — and eighteen asleep ones; past that the
oldest is buried and the kernel is told, the way a browser discards
abandoned tabs. REASONIX_NO_PAGE_SLEEP puts the feature away.
2026-10-24 11:52:23 +08:00
YHH 858a73d03f Merge pull request #14320 from imcreazy/fix/reading-column-grips
Let the reading column's grips, sidebars and composer move together
2026-10-23 16:04:11 -07:00
YHH f902caae16 Merge pull request #14322 from esengine/c5/compaction-notice
fix(compaction): a finished fold is announced with a code and a typed payload, so it follows the language
2026-10-23 15:54:22 -07:00
YHH 851f1dcf64 Merge pull request #14321 from imcreazy/fix/todo-panel-floats-over-the-column
Keep the plan panel from pushing the rail and the jump arrows
2026-10-23 15:53:19 -07:00
imcreazy 56b6f1a9b9 Leave the composer's own ceiling alone
It sized itself 42px inside the pane on purpose; pulling that to 34px to match
the text moved the mirror's wrapping off the caret's in composer-reflow.
2026-10-24 06:44:33 +08:00
YHH 2071971c8b Merge pull request #14300 from imcreazy/feat/keyboard-shortcuts-sheet
feat(studio): a shortcut sheet in the settings
2026-10-23 15:29:29 -07:00
esengine 769f4d1184 fix(compaction): a finished fold is announced with a code and a typed payload, so it follows the language 2026-10-23 15:18:40 -07:00
imcreazy 4a3ef66d9c Add the release note for the floating plan panel 2026-10-24 06:15:59 +08:00
imcreazy c3307775dc Keep the plan panel from pushing the rail and the jump arrows
Opening the todo list grew the composer, which shortened the scroller, which
re-measured the rail: every mark moved up and the two jump buttons went with
them. The panel now floats above the composer instead of taking its room.
2026-10-24 06:15:34 +08:00
imcreazy 9c7cb1aa70 Take the width away again when a first drag is cancelled
`App` owns `--flow-w` on `.app` only after a commit. Writing one back on
cancel would leave a pixel width nobody removes, pinning the column.
2026-10-24 06:08:30 +08:00
imcreazy 206d95dd8a Let the column's grips reach the sidebars and the composer
`--flow-w` went to `:root`, where a committed value on `.app` outranks it;
the sidebars only gave way on release, and the composer never followed at all.
2026-10-24 05:59:39 +08:00
imcreazy 237d63417a fix(studio): the key column holds the longest chord
`Enter / Shift+Enter` was wider than the shared minimum, so its description
sat 10px right of the others. 13em covers it.
2026-10-24 05:14:25 +08:00
YHH eeacfea88f Merge pull request #14296 from imcreazy/feat/conversation-rail-fold
feat(studio): fold a crowded conversation rail, and let a question be starred
2026-10-23 14:12:58 -07:00
imcreazy 6bf6b440ed fix(studio): the shortcut sheet lines its labels up
Each key cell was as wide as its own text, so the descriptions started at six different positions. A shared minimum on the key cell gives them one column.
2026-10-24 05:04:33 +08:00
YHH 27829cd3ff Merge pull request #14317 from esengine/c5/job-started-notice
fix(jobs): a started background job is announced with a code and its payload, so it follows the language
2026-10-23 14:04:16 -07:00
imcreazy 9068636c21 fix(studio): a rail fold never names a question a rewind removed
`groups` is a state slice; after a rewind shortens `marks`, a stale fold still pointed past its end and `marks[v.from]` was undefined. The fold is filtered to what exists.
2026-10-24 04:58:52 +08:00
YHH f08e2be652 Merge pull request #14294 from imcreazy/fix/run-analysis-way-in-and-out
fix(studio): make run analysis easy to enter and to leave
2026-10-23 13:55:58 -07:00
YHH 4975865891 Merge pull request #14299 from imcreazy/feat/session-export-markdown
feat(studio): export a conversation as Markdown
2026-10-23 13:53:01 -07:00
imcreazy 08158b6167 fix(studio): the rail's stars and folding on current studio
Rebased onto studio after #14301, which also touched Transcript.tsx.
2026-10-24 04:43:08 +08:00
imcreazy 6df4907583 fix(studio): a sidebar open reports its failure
`useSidebarOpen` dropped the promise with `void`, so `Workspaces` never saw the rejection and the row stayed on its old selection. It now returns the open's own promise.
2026-10-24 04:39:49 +08:00
esengine 935afed07c test(studio): saving a native archive as Markdown keeps its turns 2026-10-23 13:35:56 -07:00
YHH 660f2c9b8b Merge pull request #14301 from imcreazy/feat/find-regex-and-case
feat(studio): match case and regular expressions in the find bar
2026-10-23 13:35:22 -07:00
esengine 2184e43c60 fix(jobs): a started background job is announced with a code and its payload, so it follows the language 2026-10-23 13:32:42 -07:00
imcreazy 8032428bbf fix(studio): the back arrow names the view it returns to
It was a button with no declared action, which raised the undeclared input count. It sets the same view the sidebar uses.
2026-10-24 04:20:04 +08:00
imcreazy 453c9b7aeb fix(studio): 区分大小写 lives in one catalogue
`en_settings.ts` already defines it, and `i18n.test.ts` rejects a key that appears in two catalogue files.
2026-10-24 04:12:31 +08:00
imcreazy 51cb4202ba fix(studio): the export headings stay in the one catalogue
`en_export.ts` repeated 我 and 会话, and `i18n.test.ts` rejects a key that lives in two catalogue files. The headings now use the entries already there. The menu test follows the second export row.
2026-10-24 04:11:22 +08:00
imcreazy 3d5d965850 fix(studio): an empty match is not a hit
`^` and `a*` match the empty string, so the counter reported hits the painter had no range for. Both sides now skip them. Transcript.tsx keeps its 800-line ceiling by typing the mode inline instead of importing it.
2026-10-24 04:04:20 +08:00
imcreazy fe4b1d6d75 fix(studio): the count and the paint read the query the same way
The painter still searched the DOM for the literal text while the counter honoured the new modes, so a regex hit had no range to scroll to and a case-sensitive count did not match the painted one. Both now share one finder, a regex match advances past its own length, and a pathological pattern is cut short instead of freezing the window.
2026-10-24 03:56:58 +08:00
imcreazy 19bf655776 fix(studio): every shortcut row names the chord the window answers
The workbench chords are the platform modifier plus Shift, so a plain Shift+F types a letter. The zoom and modifier-Enter rows were missing.
2026-10-24 03:55:46 +08:00
YHH c8a7ab08c4 Merge pull request #14313 from jf200s/fix/cache-hit-rate
fix(stats): an endpoint's zero cache hit is a full miss, not an absence
2026-10-23 12:29:18 -07:00
esengine 9ad90c43bf docs(release): note for the cache hit rate fix 2026-10-23 12:05:51 -07:00
YHH 68e602fa97 Merge pull request #14316 from esengine/c5/fleet-test-race
test(delegation): observe the running fleet job before the children are released
2026-10-23 11:53:10 -07:00
esengine fee24df408 test(delegation): observe the running fleet job before the children are released 2026-10-23 11:38:24 -07:00
F 3f388550f7 fix(stats): an endpoint's zero cache hit is a full miss, not an absence
prompt_tokens_details with cached_tokens: 0 is the endpoint reporting the
split, so miss is derived from prompt - hit instead of staying zero and
vanishing into the unknown bucket; a hit above prompt_tokens is a broken
report and clamps to it. The overview and the meter floor the rate, so 100%
means every split token hit.
2026-10-24 01:43:05 +08:00
YHH 7bca7b7c7a Merge pull request #14306 from esengine/c5/release-2.50.0
docs(release): studio 2.50.0 notes
2026-10-23 09:53:00 -07:00
esengine e511c0beaa docs(release): studio 2.50.0 notes 2026-10-23 09:36:58 -07:00
YHH 0812e937c1 Merge pull request #14304 from esengine/c5/slash-typed-command
fix(studio): a command typed in full is still sent by Enter when longer commands share its name
2026-10-23 09:35:51 -07:00
YHH b4aa67ecb1 Merge pull request #14303 from esengine/c5/nav-label-center
fix(studio): a sidebar destination's label centres on its icon
2026-10-23 09:30:49 -07:00
imcreazy a55b816321 feat(studio): a shortcut sheet in the settings
A shortcut sheet in the settings.

Every chord the window answers to, on one page: find, search, the three views, stepping through
your questions, the workbench toggles, send, close, and the width keys. Chords are written the way
the platform writes them, so macOS shows ⌘ where Windows and Linux show Ctrl. It is a page of the
settings table of contents, so searching for "shortcut", "Esc" or "运行分析" lands on it.

The strings live in `en.ts` with every other catalogue entry: the i18n guard requires one
catalogue file, and an earlier revision of this branch split them out and failed it.
2026-10-24 00:17:25 +08:00
esengine ee4db04dd9 fix(studio): the sidebar's lower destination icons match the upper ones in size and weight 2026-10-23 09:12:20 -07:00
imcreazy 0948930529 fix(studio): make run analysis easy to enter and to leave
Make run analysis easy to enter and to leave.

- The entry in the window bar now carries its name. It needs an inline layout for that: the
  button is a 30px grid cell, so the icon and the label stacked and the text broke one character
  per line. `.chrome .thbtn.top-action` takes over the layout for this variant and keeps the
  label on one line; below 1180px the label steps aside so the other controls stay put.
- The analysis page has a back arrow beside its title.
- Picking a conversation that is already on screen returns its pane to the reading view instead
  of re-opening it. That rule now lives in `sidebaropen.ts` — partly because it is a rule about
  panes rather than window wiring, partly because `App.tsx` was 11 lines over its ceiling with
  it inline. It has its own test: open pane returns to reading, unopened opens a pane, a folder
  request opens a pane, and asking twice lands twice.

`Pane.tsx` differs from `studio` in one line (`onBack`), so the `useAgentRequest` wiring from
#14291 stays intact.
2026-10-24 00:09:14 +08:00
esengine c122a0c33a fix(studio): a command typed in full is still sent by Enter when longer commands share its name 2026-10-23 09:05:28 -07:00
imcreazy 70c77311c8 feat(studio): match case and regular expressions in the find bar
Match case and regular expressions in the find bar.

`usefind` now hands back `regex`, `caseSensitive` and `setMode` with the rest of its state —
the previous push declared them on `Finding` and never returned them, which is what
`frontend-next` caught. Changing either reading restarts from the first hit.
2026-10-23 23:59:55 +08:00
esengine aafd47647a fix(studio): a sidebar destination's label centres on its icon 2026-10-23 08:41:27 -07:00
imcreazy 4c9472e006 feat(studio): export a conversation as Markdown
Rewritten against the shape the kernel actually writes. A transcript line is a provider message
(`role`, `content`, `tool_calls`, `host_authored`), so the export keys on `role`: user and assistant
only, skipping host-authored user lines, tool results and system lines, and skipping assistant turns
that carry only tool calls. Headings go through `t()`. Native archives keep their `records` instead of
exporting an empty list. The test now uses real JSONL lines and covers the native-archive case.
2026-10-23 23:37:27 +08:00
YHH 80a3649883 Merge pull request #14293 from imcreazy/fix/reading-column-handles
fix(studio): make the reading column's handles visible and reachable
2026-10-23 08:31:34 -07:00
YHH 0586891b82 Merge pull request #14298 from xxoingr/docs/mcp-panel-not-mcp-add
docs(mcp): point a running chat at the /mcp panel, not /mcp add
2026-10-23 08:30:25 -07:00
YHH 498ac3a50f Merge pull request #14297 from xxoingr/test/two-asks-wait-for-the-yield
test(control): wait for the second ask to yield, not just to register
2026-10-23 08:12:40 -07:00
xxoingr d0baf85892 docs(mcp): point a running chat at the /mcp panel, not /mcp add
The controller runs only `/mcp connect` (#14167), so `/mcp add x` now
answers "unknown /mcp subcommand". Both guides and `reasonix mcp help`
still sent a running chat there to save and connect a server. They now name
`n` in the terminal /mcp panel, which installs through the same
POST /mcp/install as Desktop's Add and connect. The guides also name `p`,
the panel's project install, which InstallMCPServer writes to the
project's `reasonix.toml` (MCPConfigPathForEntry -> ProjectConfigPath).

TestTheDocsNameOnlyMCPAndSkillsSubcommandsThatRun reads docs/*.md and the
READMEs and holds every `/mcp <sub>` and `/skills <sub>` they write to the
subcommands completion offers, which are the ones the controller runs.
2026-10-23 22:57:57 +08:00
xxoingr bb7e1156f0 test(control): wait for the second ask to yield, not just to register
TestTwoAsksRetakeTheClaimOnlyAfterTheLastIsAnswered answered the first ask
as soon as two asks were registered. Ask registers before it yields the
workspace claim, and Yield reads the claim log from disk before it counts
the waiter, so on a slow Windows runner the second ask could still be short
of the yield when the first one resumed. The first resume then saw no other
waiter and took the claim back, and the test reported the claim retaken while
the second ask waited (studio 39941591112, windows-latest).

The test now waits for the second ask's prompt-queued notice. Ask reaches
lockPromptFor only after its yield, so the notice is the point at which the
second ask is waiting on the user.
2026-10-23 22:52:54 +08:00
imcreazy 9487d458ab fix(studio): make the reading column's handles visible and reachable
Rebuilt on current `studio`; the previous push was based on an older snapshot and carried unrelated reverts.
2026-10-23 22:51:53 +08:00
YHH 2639a5f526 Merge pull request #14292 from esengine/c5/billing-mode-selector
feat(providers): a connection says whether it is billed per use or by subscription
2026-10-23 07:31:55 -07:00
YHH 5345930379 Merge pull request #14291 from esengine/c5/agent-workbench-tab
feat(studio): a sub-agent opens beside the conversation as a workbench tab that reads its full record
2026-10-23 07:30:45 -07:00
YHH e4903bb43f Merge pull request #14063 from SivanCola/codex/present-files-settings
feat(studio): add opt-in file delivery settings / 新增文件交付卡片开关
2026-10-23 07:29:33 -07:00
YHH f22623f5d1 Merge pull request #14279 from tongsailong/fix/outside-observed-roots
fix(completion): report writes outside observed roots
2026-10-23 07:05:04 -07:00
esengine 1e4806ada6 feat(studio): a sub-agent opens beside the conversation as a workbench tab that reads its full record 2026-10-23 06:56:04 -07:00
YHH f07ec5db87 Merge pull request #14066 from SivanCola/codex/present-files-default-on
feat(tools): propose default-on file delivery / 提议默认启用文件交付卡片
2026-10-23 06:50:40 -07:00
esengine 66a992d41c feat(providers): a connection says whether it is billed per use or by subscription 2026-10-23 06:48:15 -07:00
YHH fbaa4595cd Merge pull request #14290 from esengine/c5/subagent-parent-call
feat(delegation): a sub-agent run names the tool call that spawned it
2026-10-23 06:35:31 -07:00
阿稳 62ea682420 test(completion): read the ledger's path identity in the out-of-sight tests
CI on Windows failed both new assertions: the detail named
`c:\outside-watch\reasonix cli.desktop` and the temp deliverable came
back lowercased, while the tests compared against the path spelled as
constructed. `evidence.normalizePath` lowercases on Windows, because the
ledger needs one identity per file and Windows hands back either case.

Compare against that identity instead of the constructed spelling: the
rule under test is about which paths are reported, not how the host
happens to capitalise them.
2026-10-23 21:32:36 +08:00
esengine 981d70747b Merge studio into present-files default-on 2026-10-23 06:29:20 -07:00
esengine 7eae730528 feat(delegation): a sub-agent run names the tool call that spawned it
Refs #13693
2026-10-23 06:15:55 -07:00
esengine 40a219f211 style(port): keep port.ts at the 800-line ceiling 2026-10-23 06:10:10 -07:00
阿稳 f3521711c7 test(completion): a granted root is not reported as out of sight
The gap names writes the host watches nothing of. A folder the person
granted as workspace is walked with the root and observed the same way,
so a write there is work product and must not carry the caveat.

This pins the rule at the boundary extraRoots decides, so a later change
cannot start reporting exactly the deliveries the grant exists to allow.
2026-10-23 21:06:11 +08:00
YHH 6e97bf257a Merge branch 'studio' into codex/present-files-settings 2026-10-23 05:39:43 -07:00
YHH 493736c685 Merge pull request #14152 from lancy69/fix/14126-minimal-image-capability
fix(composition): allow lightweight mode without image capability
2026-10-23 05:39:10 -07:00
阿稳 402eda1383 Merge remote-tracking branch 'origin/studio' into fix/outside-observed-roots 2026-10-23 20:37:13 +08:00
YHH 5e8d0d704a Merge pull request #14278 from tongsailong/fix/subagent-progress
fix(delegation): track background subagent progress
2026-10-23 05:12:26 -07:00
YHH e6ce624bf3 Merge pull request #14277 from tongsailong/fix/bounded-wait
fix(jobs): bound background wait timeout
2026-10-23 05:09:19 -07:00
阿稳 f2ff4b3425 Merge branch 'fix/outside-observed-roots' of https://github.com/tongsailong/DeepSeek-Reasonix into fix/outside-observed-roots 2026-10-23 20:06:57 +08:00
阿稳 dbcf4dc572 fix(hostwrite): read the home directory once for both lists
`Caches` read the process's home directory itself, which put the package two
reads over its `process-input` budget and the repo one over its ceiling. Both
lists answer from the same fact, so it is read in one place and passed to each.

The cache list also gained the two entries `Dirs` already had on the platforms
that have them: `$HOME/.cache` everywhere, and `Library/Caches` on macOS. A
cache filter that named only `go`, `.cargo` and `.npm` would report a Unix build
cache as out of sight on the one platform whose caches live under a different
directory name.
2026-10-23 20:06:21 +08:00
YHH edfc6b12b6 Merge pull request #14286 from esengine/feat/host-self-hosted-gateway
feat(remote): the desktop host and the phone page can use a gateway the person runs
2026-10-23 05:02:18 -07:00
YHH 061a1f69cc Merge branch 'studio' into fix/outside-observed-roots 2026-10-23 04:39:17 -07:00
YHH 30cc944d20 Merge branch 'studio' into fix/subagent-progress 2026-10-23 04:39:13 -07:00
YHH 9e60fd762a Merge branch 'studio' into fix/bounded-wait 2026-10-23 04:39:11 -07:00
esengine 24e6ccf64b feat(remote): the desktop host and the phone page can use a gateway the person runs 2026-10-23 04:38:13 -07:00
阿稳 f9de73aeda fix(completion): read build caches from their own source
Two defects, both found by CI on Windows and both in this PR's own new code.

`underScratchOrCache` asked `hostwrite.Dirs()` for the toolchain caches. That
function answers what a jail grants a command, and Windows jails nothing, so it
returns nil there: every build cache write was reported as out of sight on the
one platform where the list is empty. `hostwrite.Caches()` answers the question
actually being asked — where does a build write through — and is defined on every
platform. The two questions stay apart because conflating them is what made a
Windows build produce a caveat about its own module cache.

The one-off directory `sessiontemp` hands a turn is recognised by its position,
not by its name. It is a direct child of the host's temporary root, so that root
is read from the host and the first component below it is compared against the
prefix. The previous version walked every component, which exempted any folder
deeper in the tree that happened to spell the prefix, and it assumed the root was
named `/tmp`, which is not where Windows puts it.

The tests that pinned these edges set `HOME` and `TMPDIR` and then read the
result, which is why they agreed with the code on Linux and disagreed with it
everywhere else. `HOME` and `TMPDIR` are POSIX-only names: on Windows the
temporary root comes from `TEMP`, so `t.Setenv("TMPDIR", …)` changed nothing and
the assertions ran against the real root. They now call `disposable` with the
roots as arguments, and build paths from the host's own temp root, so each
assertion is about the rule rather than about an environment.

Tested: the thirteen out-of-scope tests, the packages this PR touches, and
`GOOS=windows go vet` on `internal/runtime/agent` and `internal/base/hostwrite`.
2026-10-23 19:21:36 +08:00
YHH 370b90716b Merge pull request #14281 from ShryukGrandhi/fix/issue-14163-studio-feedback-list-recovery-lineage-fo
fix(sessionstore): a NUL-filled branch-meta sidecar reads as absent
2026-10-23 04:20:28 -07:00
YHH 80e897cbf2 Merge pull request #14276 from esengine/fix/task-description-optimistic-default
fix(delegation): the task tool description says optimistic is the default for undeclared writes
2026-10-23 03:47:34 -07:00
YHH 30e7514754 Merge pull request #14275 from esengine/fix/coordination-hints-default-optimistic
fix(settings): the session-coordination hints say optimistic is the default
2026-10-23 03:45:01 -07:00
YHH 3ec18e6cb0 Merge pull request #14274 from esengine/fix/grants-test-waits-for-persists
test(approval): wait for every queued grant write before reading the sidecar
2026-10-23 03:44:24 -07:00
esengine 26d429e2b3 fix(delegation): the task tool description says optimistic is the default for undeclared writes 2026-10-23 03:35:17 -07:00
esengine 4f27fdd99f fix(settings): the session-coordination hints say optimistic is the default 2026-10-23 03:29:31 -07:00
阿稳 a440f83b0a fix(completion): report writes outside observed roots
A turn that wrote a file outside the workspace and then answered ended with a
`done` receipt and no gaps. The host saw the tool call succeed, but its
observation only covers the workspace and its granted extra roots, so the write
was dropped before the report was built: no mutation, nothing to verify, nothing
to say. `final_readiness` agreed — it judges a turn by whether it has a writer,
and a write it cannot see is not one — so the turn ended as a quiet success
while the work it claimed had landed nowhere the host could check.

Record those writes as a gap instead. `outsideObservedWrites` reads the turn's
own successful write receipts and keeps the paths that are outside the workspace
and outside what the host owns: the session scratch directory, toolchain caches.
Two exemptions carry the weight. A public temporary root is not exempt — a
person who asked for a file there asked for something to keep — and neither is
`bin/` under a cache, because `go install` puts the user's own executable in
`~/go/bin`. A scratch directory earns its exemption only as a direct child of a
temporary root, so a folder is not exempt for spelling the prefix.

Reported, never enforced. The verdict drops from done to partial and the receipt
names the gap; the turn ends exactly as it did before. A person who asked for a
file outside the workspace got what they asked for, and what they are owed is
the line saying that part is out of sight, not a turn that will not end. For the
same reason the gap is a notice, not a warning.

CompletionReceipt also stops discarding the report's own gaps when the verdict is
unknown. It returned only the claim-yield and mod gaps there, so a turn the host
could not judge — a plan with no criteria, writing only outside the workspace —
went back to saying nothing at all.

Blast radius: the completion report on turns that write outside the workspace,
and the receipt the host renders for them. The readiness gate, Delivery, Goal,
cancel and budget paths do not read the report, so none of them is affected.

Neighbouring behaviours tested: a turn that writes outside the workspace is not
reported as finished; one that stays inside keeps its quiet ending; a plain
answer and a read-only investigation end with no caveat and no continuation; a
scratch probe carries none; a deliverable in the public temp directory does; a
lookalike directory and an installed executable are not exempt; an unfinished
todo list still holds the turn; an unjudged turn keeps its gap. Desktop
`ReceiptCard` gained one case, with the three catalogue entries the i18n gate
requires; typecheck and the full vitest run pass.

Scope: this reports a write the host cannot see. It does not decide that a turn
which never wrote anything was unfinished — a read-only multi-step turn that
stops early still ends as it did.
2026-10-23 18:29:02 +08:00
阿稳 c9a6a3b4fc fix(delegation): track background subagent progress
A background sub-agent writes its work into its own transcript and its tool
calls, never into the job that started it. The job's activity stamp therefore
never moved, and the watchdog judged the run by bytes on streams that stay
empty: a busy sub-agent read as a job that had been silent since it started, and
a stalled notice went out about work that was progressing.

Bind the child's progress to its job at beginExecution — the one boundary every
delegated run crosses, foreground included, where a context with no job binds
nothing. A finished tool call is one reported step; token deltas refresh the
activity stamp at most once every two seconds, because the watchdog reads
minutes and the child emits far more often than that.

Reported steps are kept apart from output text rather than folded into it. They
answer different questions — whether the child advanced, and whether it wrote
here — and a caller decides from the pair. `wait` says both, and a stalled
notice says what the job had reported before going quiet, since a job that
worked and stopped needs a different next move than one that never got going.

Tested: internal/tools/jobs (a job that writes nothing stays alive while it
reports progress; wait reads steps separately from output), internal/tools/builtin
(progress wording per reading), internal/runtime/delegation (binding at
beginExecution, a finished tool call reports one step, deltas are paced, a
foreground run stays unbound), internal/contract/event, and desktop
frontend-next (typecheck; full vitest run, 6120 tests). The desktop stalled-notice
sentence had no English for its new wording and the i18n gate caught it; the
three entries are added here.
2026-10-23 18:28:55 +08:00
阿稳 162648e8e6 fix(jobs): bound background wait timeout
`wait` accepted any `timeout_seconds`, and omitting it meant blocking until
every job finished. A sub-agent that never exits then holds the turn open with
no way for the caller to tell a long wait from a hung session: the tool result
is the only thing that returns, and it returns only when the job does.

Cap one wait at 300 seconds and apply that cap when the timeout is omitted too,
so omitting it means "wait for the jobs" rather than "wait forever". The result
says when the ceiling was reached, and only then: a job that finishes first
returns before it, and claiming a 300s wait that never happened would be a
second false report in a message whose whole job is to be accurate.

The schema states the same bound the runtime applies. It previously promised an
unbounded wait, which the tool no longer performs.

Tested: internal/tools/builtin (wait ceiling applied and reported, a 1s wait
returns progress instead of holding), internal/tools/jobs, and the boot golden
recapture for the changed tool schema. internal/assembly/boot has one
pre-existing failure on this host (TestEffectSessionScratchIsReachableByItsSpelling)
that reproduces on the unmodified base: bubblewrap cannot create a namespace
here, so the sandboxed bash call the test relies on refuses to run.
2026-10-23 18:27:26 +08:00
esengine 71970278a5 test(approval): wait for every queued grant write before reading the sidecar 2026-10-23 03:23:46 -07:00
YHH 8fd97e1245 Merge pull request #14273 from esengine/fix/composer-toolbar-one-row
fix(studio): the buttons under the composer stay on one row as the conversation column narrows
2026-10-23 03:21:55 -07:00
YHH 2784587243 Merge pull request #14271 from esengine/feat/settings-sheet-expand
feat(studio): the settings window can be enlarged to fill the window, and remembers it
2026-10-23 03:18:51 -07:00
esengine 58c112c919 fix(studio): the buttons under the composer stay on one row as the conversation column narrows 2026-10-23 03:04:12 -07:00
esengine ccc7010c8f docs(release-notes): name the mirrored feedback issue 2026-10-23 02:57:35 -07:00
esengine a3880cf953 feat(studio): the settings window can be enlarged to fill the window, and remembers it 2026-10-23 02:56:29 -07:00
YHH 6ca52b40fb Merge pull request #14270 from esengine/fix/remember-note-says-where
fix(approval-card): the always-allow note says where paid search, egress and sign-in answers are kept
2026-10-23 02:51:00 -07:00
YHH 35a508c30a Merge pull request #14093 from lancy69/fix/14028-ask-unavailable
fix(studio): label Ask work mode as not implemented
2026-10-23 02:44:52 -07:00
esengine 728bab14d9 fix(approval-card): the always-allow note says where paid search, egress and sign-in answers are kept 2026-10-23 02:38:06 -07:00
YHH f14ae798be Merge pull request #14269 from esengine/fix/analysis-icon-activity
fix(studio): the run analysis icon is a pulse line that matches its neighbours
2026-10-23 02:28:43 -07:00
YHH f174ee55e4 Merge pull request #14268 from esengine/feat/nav-entries-customize
feat(studio): hide or reorder the navigation entries of the icon rail and the sidebar
2026-10-23 02:28:04 -07:00
YHH e8e03fde3a Merge pull request #14262 from esengine/fix/grants-persist-ordered
fix(approval): session grants persist in order, so a revoke is not undone by an earlier write
2026-10-23 02:13:16 -07:00
esengine 6fa3ab8dcf fix(studio): the run analysis icon is a pulse line that matches its neighbours 2026-10-23 02:12:52 -07:00
esengine 892d071d42 feat(studio): hide or reorder the navigation entries of the icon rail and the sidebar 2026-10-23 02:05:10 -07:00
YHH b6a321054e Merge pull request #14266 from esengine/feat/nav-parity
feat(studio): the sidebar offers the same destinations as the icon rail, from one list
2026-10-23 01:57:10 -07:00
YHH 741f40c7d4 Merge pull request #14265 from esengine/fix/effort-display-follows-choice
fix(effort): the status says the depth the person chose even when the request does not change
2026-10-23 01:48:19 -07:00
YHH de2f8b81a1 Merge pull request #14263 from esengine/fix/sidecar-not-executable-says-so
fix(sidecar): a runtime that is not a program says what the plugin has to ship
2026-10-23 01:44:41 -07:00
esengine 26a21fd177 feat(studio): the sidebar offers the same destinations as the icon rail, from one list 2026-10-23 01:44:18 -07:00
YHH 79ab732ae1 Merge pull request #14261 from esengine/fix/credential-heal-on-refusal
fix(desktop): check and restore the launch credential cookie after a refused request
2026-10-23 01:36:59 -07:00
esengine ecdf0fb140 fix(approval): session grants persist in order, so a revoke is not undone by an earlier write 2026-10-23 01:32:38 -07:00
YHH f702bf02ed Merge pull request #14259 from esengine/fix/run-skill-shows-name
fix(studio): a skill step names the skill it ran
2026-10-23 01:31:06 -07:00
YHH 08ac241c5e Merge pull request #14258 from esengine/fix/reveal-icon-folder
fix(studio): the reveal-in-file-manager icon is an open folder, not an arrow
2026-10-23 01:30:28 -07:00
YHH 9af6d78c1e Merge pull request #14257 from esengine/fix/subagent-entry-and-meaning
fix(delegation): tell the model a sub-agent is not a new session
2026-10-23 01:29:51 -07:00
YHH 384c0b8087 Merge pull request #14252 from esengine/fix/read-refusal-says-why
fix(tools): a refused read says which target, which folders may be read and why
2026-10-23 01:29:13 -07:00
esengine 9224c80c92 fix(effort): the status says the depth the person chose even when the request does not change 2026-10-23 01:28:14 -07:00
esengine 6e4681f33b fix(sidecar): a runtime that is not a program says what the plugin has to ship 2026-10-23 01:23:00 -07:00
esengine 907357fb33 fix(desktop): check and restore the launch credential cookie after a refused request 2026-10-23 01:13:06 -07:00
esengine 30b38c9063 fix(studio): a skill step names the skill it ran 2026-10-23 01:08:29 -07:00
YHH ab8e51c497 Merge pull request #14255 from esengine/fix/feedback-markdown-preview
feat(feedback): preview the report body as Markdown before sending
2026-10-23 01:06:05 -07:00
esengine c6930ad1b7 fix(studio): the reveal-in-file-manager icon is an open folder, not an arrow 2026-10-23 01:05:38 -07:00
esengine b4db2d6516 test(agent): compare the refused read's target by name, not by a path spelling Windows rewrites 2026-10-23 01:02:33 -07:00
YHH 180450189e Merge pull request #14254 from esengine/fix/feedback-mine-fast-and-list-first
fix(desktop): my feedback shows the list it has at once and keeps level, account and board behind one row
2026-10-23 01:01:37 -07:00
YHH 01f839c664 Merge pull request #14240 from esengine/feat/optimistic-claims-default-on
feat(config): optimistic claims are the default for sub-agent claims and the write lease
2026-10-23 01:01:03 -07:00
YHH 47cefa8fb2 Merge pull request #14239 from chuiwenwei/fix/an-unreadable-fact-is-not-absent
fix(memory): a fact that cannot be read is not absent
2026-10-23 01:00:26 -07:00
esengine 5cad66697b fix(delegation): tell the model a sub-agent is not a new session 2026-10-23 00:58:08 -07:00
esengine 1bbd409235 feat(feedback): preview the report body as Markdown before sending 2026-10-23 00:50:07 -07:00
YHH bdf86eb379 Merge pull request #14238 from chuiwenwei/fix/inbox-origin-is-not-the-persons
fix(tui): a runtime continuation is not the person's message
2026-10-23 00:40:25 -07:00
YHH 1261a1aade Merge pull request #14243 from esengine/feat/session-grants-persist
feat(approval): a conversation remembers the answers given for it, and resuming it does not ask again
2026-10-23 00:39:44 -07:00
YHH 66297898b9 Merge pull request #14244 from esengine/ci/browser-guards-timeout
ci(studio): give the browser guards job 25 minutes
2026-10-23 00:39:04 -07:00
esengine 0ba5b81993 docs(release): note the faster My feedback page 2026-10-23 00:35:57 -07:00
esengine 9a5fbb9e2e fix(desktop): my feedback shows the list it has at once and keeps level, account and board behind one row 2026-10-23 00:35:19 -07:00
esengine a375cbde45 docs(release): note that a refused read says why 2026-10-23 00:25:33 -07:00
esengine 691bc617a1 fix(tools): a refused read says which target, which folders may be read and why 2026-10-23 00:25:22 -07:00
esengine 3f0cf0bb10 ci(studio): give the browser guards job 25 minutes 2026-10-22 23:54:12 -07:00
YHH 994b9e05eb Merge pull request #14226 from esengine/fix/sidebar-rows-align-folders
fix(desktop): the sidebar's top and bottom rows sit on the project folders' icon line
2026-10-22 23:53:40 -07:00
esengine aff96b0800 docs(release): note that session grants are remembered with the conversation 2026-10-22 23:50:54 -07:00
esengine 0fc7a3c70a feat(approval): a conversation remembers the answers given for it, and resuming it does not ask again 2026-10-22 23:50:43 -07:00
Will a327256d5d fix(migrate): ask the existing helper whether the copy exists
absorbInterruptedCopy called os.Lstat itself, which CodeQL reports as a new
path sink in changed code. sessionArtifactExists is the store's pre-existing,
baselined reader for the same question: the copy exists when its transcript or
an owned sidecar does. It answers bool, so missing and unreadable look alike;
the import's callers already accept that, and a copy that is there but cannot
be read still reports its error where it is loaded.
2026-10-23 14:50:13 +08:00
Will a3172ed092 fix(migrate): keep the sidecar read where CodeQL already knows it
CodeQL flagged the two sidecar paths as new sinks once copyBranchMetaSidecar moved into migrate_copy.go: the os.ReadFile(BranchMetaPath(srcPath)) body was baselined in migrate.go, and a moved copy is not. The function moves back to its original position with only the error return changed, and completeBranchMetaSidecar now asks the pre-existing LoadBranchMeta reader whether the destination sidecar is there instead of calling os.Stat itself: ok means present and never rewritten, an error means unreadable and never clobbered, and only a clean absence proceeds. sameDirPath (a pure path comparison, no filesystem access) moves to migrate_passes.go so migrate.go stays inside its ratcheted file-size budget.
2026-10-23 14:50:13 +08:00
Will b92e5f0b50 fix(migrate): an imported session completes its sidecar
An import publishes the transcript first and the branch sidecar that carries
what it means second. A run stopped in that window left a transcript with no
sidecar, and the retry treated the transcript as completion: the flat import
healed only subagent artifacts and stamped the marker, and the v4 import only
healed the title. Both now finish the sidecar, refuse a failed sidecar write
as incomplete so the next run retries, and leave a finished copy untouched.
A v4 copy that is still a prefix of 1.x is brought up to the source; a copy
continued in 2.x keeps its own turns.
2026-10-23 14:50:13 +08:00
esengine 8def830791 docs(release): note that optimistic claims are on by default 2026-10-22 23:18:00 -07:00
esengine 5f7365e656 feat(config): optimistic claims are the default for sub-agent claims and the write lease 2026-10-22 23:16:04 -07:00
Will 9a986bc21a fix(desktop): import the unreadable-memory type the port re-exports
tsc caught it: port.ts re-exports UnreadableMemory from the local scope but
never imported it, so the panel's new type did not compile.
2026-10-23 13:52:11 +08:00
esengine 7633a44adb test(desktop): the session tab centring guard allows for the font the browser falls back to 2026-10-22 22:29:09 -07:00
ShryukGrandhi 8e88bd1665 fix(sessionstore): a NUL-filled branch-meta sidecar reads as absent
A power loss on NTFS can leave a .meta sidecar extended but never flushed, so it holds only NUL bytes. LoadBranchMeta reported that as a decode error, which made the recovery-lineage walk refuse to archive anything in the folder and kept UpdateBranchMeta from ever rewriting the file. The file records nothing, so it now loads as a missing sidecar and the next save replaces it.

Signed-off-by: ShryukGrandhi <[email protected]>
2026-10-22 22:19:01 -07:00
YHH e64f9f5507 Merge pull request #14225 from lancy69/fix/14219-composer-reflow
fix(studio): keep composer chip text aligned during reflow
2026-10-22 22:14:08 -07:00
YHH 66fb456651 Merge pull request #14229 from esengine/fix/workbench-wide-and-full-at-the-edges
fix(desktop): the workbench's wide toggle sits at the left edge of its bar and the full-size toggle at the right edge
2026-10-22 22:12:52 -07:00
esengine 630d4ce71c fix(desktop): the session tabs keep whole glyphs: shift the label down instead of trimming its box 2026-10-22 22:09:19 -07:00
esengine 3bb09ebc23 fix(desktop): the session pill sits on a whole pixel and its tab text stays centred across text sizes 2026-10-22 22:04:55 -07:00
esengine e812286ad7 fix(desktop): the session tabs centre their text between the pill's edges 2026-10-22 21:57:36 -07:00
esengine a9a451a565 fix(desktop): the sidebar account avatar is one rounded square 2026-10-22 21:49:13 -07:00
Will cc328cfda8 fix(memory): a fact that cannot be read is not absent
loadMemory returned (false, nil) for a read error, so every caller treated a
present-but-unreadable fact file as missing: it vanished from /memory, the
desktop panel, the memory tool and recall, and a save by name then minted a new
id at revision 1 over it with no snapshot, losing the old identity and history.

loadMemory now returns a typed FactReadError. ListActive reports unreadable
facts beside the readable ones, and the listing, read-by-name, panel, tool and
recall audit all name them; recall still serves the readable facts and keeps
its old sentence for a whole-directory failure. A save by that name refuses
before snapshotting or writing anything.
2026-10-23 12:46:33 +08:00
esengine def7a9733c fix(desktop): the sidebar logo's first letter starts where the row labels start 2026-10-22 21:45:38 -07:00
esengine da724fde38 fix(desktop): the sidebar logo lines up with the rows and has equal space above and below 2026-10-22 21:38:38 -07:00
esengine abf4643a1d fix(desktop): the sidebar's top rows drop their hint text and keys 2026-10-22 21:38:38 -07:00
esengine 734eedebdf fix(desktop): the sidebar's top and bottom rows sit on the project folders' icon line, and Files carries a hint like its neighbours 2026-10-22 21:38:38 -07:00
Lancy 0da4ecfd91 fix(studio): keep composer chip text aligned during reflow 2026-10-23 12:34:49 +08:00
esengine d34063a89b fix(desktop): the workbench's wide toggle sits at the left edge of its bar and the full-size toggle at the right edge 2026-10-22 21:33:34 -07:00
YHH 08428a8bd3 Merge pull request #14224 from esengine/fix/relocation-test-zero-quote
test(stats): the relocation fixture expects the zero-amount quote the projection now keeps
2026-10-22 21:08:43 -07:00
Will 216ea245fd fix(tui): a runtime continuation is not the person's message
The terminal counted and drew a host-authored inbox entry as the person's
own pending line, so `d d` on it reached DELETE /inbox/items/<id> and the
kernel dropped the runtime's continuation. The desktop has always filtered
these out; the terminal never decoded the field it filters on.

The mirror now carries origin, readonly and capacity, and a declared-subset
wire-parity check holds it (and the page's copy) to the fields a decision
reads, so dropping one is a `make check` failure instead of a silent zero.

A host continuation is out of the pending rows and out of the count, except
where it is blocked or uncertain and needs a decision only the person can
make; there it is drawn marked as the runtime's. A read-only snapshot offers
no mutation key and says why.
2026-10-23 11:59:16 +08:00
esengine a0934614fc test(stats): the relocation fixture expects the zero-amount quote the projection now keeps 2026-10-22 20:43:42 -07:00
YHH add2e45c05 Merge pull request #14221 from esengine/fix/sidebar-rows-one-color
fix(desktop): the sidebar rows read in one colour and New session has no fill at rest
2026-10-22 20:28:52 -07:00
YHH 3649924679 Merge pull request #14214 from chuiwenwei/fix/usage-projection-forgets-old-roots
fix(stats): a relocated stats root does not count twice
2026-10-22 20:15:23 -07:00
YHH cd3c673973 Merge pull request #14215 from chuiwenwei/fix/usage-projection-keeps-zero-money
fix(stats): the projection keeps a zero-amount quote
2026-10-22 20:04:01 -07:00
YHH 1dae038558 Merge pull request #14212 from chuiwenwei/fix/import-marker-needs-every-session
fix(migrate): a failed copy must not stamp the import marker
2026-10-22 20:03:19 -07:00
YHH 839f2a2deb Merge pull request #14104 from chuiwenwei/fix/active-goal-retraction
fix(control): retract the goal contract when a goal stops
2026-10-22 20:02:38 -07:00
YHH 61c2633834 Merge pull request #14216 from esengine/fix/schedule-page-trust-current
fix(schedule): offer the open project for trust on the scheduled runs page
2026-10-22 20:01:57 -07:00
esengine 930808cb6d fix(desktop): the sidebar rows read in one colour and New session has no fill at rest 2026-10-22 19:55:08 -07:00
YHH aff13c0dc3 Merge pull request #14211 from esengine/fix/settings-fit-names-memory-icon
fix(desktop): the settings sheet shows its whole menu, the memory icon is centred, and the run analysis view keeps one name
2026-10-22 19:36:36 -07:00
YHH 63cefcd0fb Merge pull request #14210 from esengine/fix/feedback-paste-anywhere
fix(desktop): a paste into the feedback form reaches it after a click on a blank part
2026-10-22 19:25:51 -07:00
esengine 872031925d fix(schedule): offer the open project for trust on the scheduled runs page 2026-10-22 19:24:56 -07:00
Will 2023dfe047 chore: retrigger the check suite
The force-push to the restacked head did not start the test/build workflows
(closing and reopening only re-ran the labeler), so this empty commit emits a
synchronize event. No code change.
2026-10-23 10:13:18 +08:00
esengine fb3f5e4e57 fix(desktop): the settings sheet shows its whole menu, the memory icon is centred, and the run analysis view keeps one name 2026-10-22 19:00:49 -07:00
YHH 94b51af86d Merge pull request #14208 from esengine/fix/tool-blurbs-complete
fix(desktop): describe every built-in tool in the settings list in the interface language
2026-10-22 18:56:19 -07:00
YHH 9f1ad0873d Merge pull request #14207 from esengine/fix/sidebar-rows-align
fix(desktop): line up the sidebar rows' icons and labels with the rail hidden
2026-10-22 18:55:39 -07:00
esengine 74212941ae fix(desktop): a paste into the feedback form reaches it after a click on a blank part, and takes an image offered only as a clipboard item 2026-10-22 18:53:27 -07:00
Will 269bc394d1 fix(stats): the projection keeps a zero-amount quote
A line that carried a currency and a quote of zero was priced by the exact
JSONL read but discarded by the projection, which treated amount 0 as "no
price". A single-day range reads JSONL while a multi-day range reads the
projection, so one session showed [{CNY 3},{USD 0}] on 今日 and [{CNY 3}] on
30 天, lost costEstimated on a day whose only quote was zero, and could emit
the false "usage but no cost record" note.

A currency is the whole test for "priced"; zero is a genuine free quote. Both
build paths now keep it: projectRecord no longer drops amount 0, the rederive
view no longer filters cost<>0, and the rollup read no longer skips amount 0,
while a row with no currency still carries no money. No schema column was
needed, so SchemaVersion is unchanged; every launch reconciles the day files
through rederive, which repairs a projection built by the old filters.

Tests cover the fixed two-day history through the exact read, the incremental
append projection and the rebuilt projection, the zero-amount-only day's
costEstimated in both, and controls for a currency-less row and a non-zero
amount.
2026-10-23 09:51:05 +08:00
Will 4f25e4e555 fix(stats): a relocated stats root does not count twice 2026-10-23 09:50:40 +08:00
Will 6065e522c0 fix(migrate): a failed copy must not stamp the import marker 2026-10-23 09:42:21 +08:00
YHH b10f8bfd6f Merge pull request #14204 from esengine/fix/receipt-command-shown-short
fix(desktop): show only the start of a long command in the delivery receipt
2026-10-22 18:38:36 -07:00
esengine cc49a64de4 fix(desktop): describe every built-in tool in the settings list in the interface language 2026-10-22 18:27:26 -07:00
Will b572d96811 fix(control): retract the goal contract when a goal stops
<active-goal contract="full"> carries the whole goal-mode contract into the
model-visible history, and nothing retired it when the goal stopped: the next
request carried no goal block while the contract still said to keep pursuing
it, and active-goal being a superseded tag made a fold keep that stale copy.

A stop the model did not report now renders a tombstone under the same tag:
reason paused, cleared, budget, or host (the completion check could not run).
A model-reported block and a completed goal compose nothing, because the model
already knows. Delivery is asked of the visible history through the delivery
PR's projectionDebt: the tombstone is owed while the newest active-goal block
in view is a contract or reminder, and not once it is the tombstone itself, so
a rewind or fold that drops it owes it again with no remembered state.
SupersededUserBlockTags is unchanged, so the tombstone is the newest copy a
fold keeps.

The boot harness's blockOf now matches a tag prefix, since a variant block
opens with attributes after its tag.
2026-10-23 09:23:32 +08:00
esengine d968c3e329 fix(desktop): line up the sidebar rows' icons and labels with the rail hidden 2026-10-22 18:19:53 -07:00
esengine 3821d42d8a docs(release-notes): keep the receipt fragment within the line width 2026-10-22 18:03:51 -07:00
esengine d1858ef240 docs(release-notes): receipt command shown short fragment 2026-10-22 18:03:30 -07:00
esengine 36571f34ae fix(desktop): show only the start of a long command in the delivery receipt 2026-10-22 18:03:14 -07:00
YHH a08a4d1c8a Merge pull request #14202 from esengine/fix/kernel-refuses-retry-of-maybe-run-item
fix(inbox): the queue refuses to retry an entry that may already have run
2026-10-22 17:28:37 -07:00
esengine 85e1227ec6 docs(release-notes): queue refuses retry of maybe-run fragment 2026-10-22 16:58:48 -07:00
esengine 14cc437ffe fix(inbox): the queue refuses to retry an entry that may already have run 2026-10-22 16:58:34 -07:00
YHH de91470edc Merge pull request #14201 from esengine/docs/fragment-14179
docs(release-notes): fragment for #14179
2026-10-22 16:28:40 -07:00
YHH 6a59451d7b Merge pull request #14200 from esengine/feat/meter-session-total
fix(desktop): label the meter's running total as the session's and add the turn's own amount
2026-10-22 16:03:40 -07:00
esengine 17f98c8d2a docs(release-notes): fragment for #14179 2026-10-22 15:53:16 -07:00
YHH 519288c24c Merge pull request #14179 from chuiwenwei/fix/schedule-missed-one-shot-expires
fix(schedule): a missed one-shot stops being active
2026-10-22 15:53:02 -07:00
YHH 2e3cffd9b8 Merge pull request #14199 from esengine/docs/fragments-for-merged-prs
docs(release-notes): fragments for #14178, #14180, #14181, #14195 and #14196
2026-10-22 15:52:22 -07:00
esengine d56044f47e docs(release-notes): meter session and turn money fragment 2026-10-22 15:39:59 -07:00
esengine fe71ea4d71 fix(desktop): label the meter's running total as the session's and add the turn's own amount 2026-10-22 15:39:43 -07:00
esengine a5f083449a docs(release-notes): fragments for #14178, #14180, #14181, #14195 and #14196 2026-10-22 15:33:04 -07:00
YHH 4fa25f9a04 Merge pull request #14195 from lancy69/feat/sidebar-expansion-memory
feat(studio): propose remembered sidebar expansion states
2026-10-22 15:32:02 -07:00
YHH 62b96c7737 Merge pull request #14196 from lancy69/feat/settings-flat-scrim
feat(studio): separate Settings with a feedback-style flat scrim
2026-10-22 15:31:22 -07:00
YHH cd1241e3a8 Merge pull request #14180 from chuiwenwei/fix/tui-inbox-retry-knows-what-may-have-run
fix(tui): refuse a retry that may already have run
2026-10-22 15:30:41 -07:00
YHH 0355e62b93 Merge pull request #14178 from chuiwenwei/fix/desktop-pane-currency-fold
fix(desktop): keep a session's money per currency
2026-10-22 15:29:56 -07:00
YHH dd6d27e9bf Merge pull request #14181 from chuiwenwei/fix/migration-respects-session-lease
fix(migrate): leave a leased session where it is
2026-10-22 15:29:16 -07:00
YHH 1441c85e27 Merge pull request #14198 from imcreazy/feat/drag-the-reading-column
feat(studio): drag the edges of the reading column
2026-10-22 14:31:42 -07:00
imcreazy ab2878f33c feat(studio): drag the edges of the reading column 2026-10-23 04:57:44 +08:00
Lancy c85d97648f feat(studio): separate Settings with a flat feedback-style scrim 2026-10-23 01:53:16 +08:00
Lancy 7ca1d60d4f feat(studio): remember sidebar project expansion choices 2026-10-23 01:52:40 +08:00
YHH 2ddc6f646e Merge pull request #14193 from esengine/docs/release-notes-2.49.0
docs(release): studio 2.49.0 notes
2026-10-22 10:16:16 -07:00
esengine ec980105a2 docs(release): studio 2.49.0 notes 2026-10-22 10:05:14 -07:00
YHH 00bcf2610a Merge pull request #14191 from esengine/fix/sidebar-rhythm-and-separators
fix(desktop): even out the sidebar's shortcut rows and use one hint separator
2026-10-22 10:04:20 -07:00
YHH 01c8881a5f Merge pull request #14188 from esengine/fix/title-thought-only-says-so
fix(serve): say when the title model spent its budget thinking
2026-10-22 09:52:34 -07:00
esengine cc499a99b9 fix(desktop): pull the shortcut rows up without adding a stylesheet line 2026-10-22 09:29:36 -07:00
esengine 2a670f9419 docs(release-notes): sidebar rhythm and separators fragment 2026-10-22 09:21:03 -07:00
esengine 9309ce419a fix(desktop): even out the sidebar's shortcut rows and use one hint separator 2026-10-22 09:20:44 -07:00
YHH b771548207 Merge pull request #14187 from esengine/fix/sidebar-drop-duplicate-remote-row
fix(desktop): drop the sidebar's second link to the remote settings when the icon rail is hidden
2026-10-22 09:18:09 -07:00
esengine 26c4aec7d3 docs(release-notes): title thought-only fragment 2026-10-22 09:14:26 -07:00
esengine 2c4b9c29a9 fix(serve): say when the title model spent its budget thinking 2026-10-22 09:14:12 -07:00
YHH 05419f3569 Merge pull request #14176 from EMEEEEMMMM/feat/usage-today-hourly
feat(usage): show hourly usage for a single-day window
2026-10-22 09:04:14 -07:00
esengine c505806479 docs(release-notes): keep the sidebar fragment within the line width 2026-10-22 08:47:49 -07:00
YHH 9f8478b578 Merge pull request #14186 from esengine/ci/race-full-suite-package-timeout
ci(race): give the full-suite package timeout headroom over assembly/boot
2026-10-22 08:47:13 -07:00
EMEEEEMMMM 70c8f4c18d feat(usage): show hourly usage for a single-day window 2026-10-22 23:46:07 +08:00
esengine 6301f4cb46 docs(release-notes): sidebar single remote entry fragment 2026-10-22 08:26:30 -07:00
esengine cda7945c58 fix(desktop): drop the sidebar's second link to the remote settings when the icon rail is hidden 2026-10-22 08:26:16 -07:00
YHH b0e69e04bb Merge pull request #14184 from esengine/fix/rail-hidden-sidebar-rows-one-type-scale
fix(desktop): give the sidebar's shortcut rows one type scale when the icon rail is hidden
2026-10-22 08:20:27 -07:00
esengine 09e10c5a3c ci(race): give the full-suite package timeout headroom over assembly/boot 2026-10-22 08:13:44 -07:00
YHH 62228e6f5d Merge pull request #14136 from FuchaZ/studio
feat(theme): add Patina as a plain light/dark palette
2026-10-22 08:04:23 -07:00
YHH 249248678d Merge pull request #14182 from esengine/fix/dag-redaction-test-counts-reads
test(sessionstore): count the bytes a redaction reads instead of process-wide allocation
2026-10-22 07:53:55 -07:00
YHH 12c3d7e0cc Merge pull request #14112 from chuiwenwei/fix/unpriced-calls-visible
fix(stats): count a call with no cost fact as incomplete
2026-10-22 07:53:18 -07:00
esengine c7260eacf1 docs(release-notes): sidebar rows type scale fragment 2026-10-22 07:39:25 -07:00
esengine 1199ca4356 fix(desktop): give the sidebar's shortcut rows one type scale when the icon rail is hidden 2026-10-22 07:39:06 -07:00
YHH 76020b84cb Merge pull request #14170 from imcreazy/fix/continuation-prompt-names-the-host
fix(control): keep the host's continuation prompt out of the user's turns
2026-10-22 07:30:34 -07:00
YHH 2ef6093fba Merge pull request #14175 from esengine/fix/tool-list-blurbs-in-ui-language
fix(desktop): describe each tool in the settings list in the interface language
2026-10-22 07:29:55 -07:00
esengine dc426fc539 test(sessionstore): count the bytes a redaction reads instead of process-wide allocation 2026-10-22 07:26:56 -07:00
Fucha f84861bea2 fix(theme): lighten Patina dark fgFaint to clear AA on floatHi 2026-10-22 22:25:58 +08:00
Fucha f5c5b79a47 docs(release): note the Patina theme in #14136 2026-10-22 22:21:22 +08:00
Fucha 006c369d09 fix(theme): raise Patina light synNumber to clear AA on codeBg 2026-10-22 22:21:14 +08:00
imcreazy 57da2aa319 fix(control): restore the source changes and register the older opening 2026-10-22 21:59:26 +08:00
esengine accf15760b docs(release-notes): tool list description fragment 2026-10-22 06:37:24 -07:00
imcreazy 71f434721b test(serve): regenerate the trace fixture after the prompt change 2026-10-22 21:37:17 +08:00
esengine 6b717476f8 fix(desktop): describe each tool in the settings list in the interface language 2026-10-22 06:37:10 -07:00
imcreazy c6afee53d8 fix(control): keep the host's continuation prompt out of the user's turns 2026-10-22 20:49:34 +08:00
YHH 42f1cd739c Merge pull request #14045 from imcreazy/fix/unprefixed-request-remote-pane
fix(serve): carry an unprefixed request to a remote pane
2026-10-22 05:43:28 -07:00
YHH 95d86f1700 Merge pull request #14044 from imcreazy/fix/dns-tcp-reply-framing
fix(netdns): read a TCP reply past its length prefix
2026-10-22 05:42:49 -07:00
YHH 6b56a1fb0e Merge pull request #14161 from lancy69/fix/14116-bounded-session-replay
fix(sessionstore): bound history replay memory
2026-10-22 05:28:30 -07:00
YHH c7b90c0676 Merge pull request #14162 from lancy69/fix/14145-side-progress
fix(studio): stream progress for side questions
2026-10-22 05:27:40 -07:00
imcreazy c6586b9ec9 docs(release-notes): name the unprefixed request fix 2026-10-22 20:21:37 +08:00
imcreazy 8560defcf8 docs(release-notes): name the DNS TCP reply framing fix 2026-10-22 20:21:30 +08:00
Lancy 788cf7e18f fix(studio): stream progress for side questions 2026-10-22 19:58:32 +08:00
Lancy 93e5cf97de fix(sessionstore): bound history replay memory
Retain legacy DAG payload locations instead of discarded message bodies, preflight JSON without copying large strings, and refuse oversized records or retained histories without modifying source data or falling back to stale checkpoints.

Refs #14116
2026-10-22 19:58:06 +08:00
YHH a18234d724 Merge pull request #14167 from esengine/fix/mcp-skills-offer-only-what-runs
fix(control): offer only the /mcp and /skills subcommands the controller runs
2026-10-22 04:29:19 -07:00
YHH 240a24ca41 Merge pull request #14165 from esengine/fix/memory-lock-test-no-wallclock
test(memory): drop the wall-clock bound on uncontended saves
2026-10-22 03:48:37 -07:00
esengine 4ec14ba1fb docs(release-notes): mcp and skills completion fragment 2026-10-22 03:48:26 -07:00
esengine f9d0c934f6 fix(control): offer only the /mcp and /skills subcommands the controller runs 2026-10-22 03:48:14 -07:00
esengine e986ba01fb test(memory): drop the wall-clock bound on uncontended saves 2026-10-22 03:35:37 -07:00
YHH bbd7a01725 Merge pull request #14154 from esengine/fix/host-verification-off-by-default
feat(config): the host no longer holds a changed turn to its checks unless asked
2026-10-22 02:24:16 -07:00
YHH d337b7d8b0 Merge pull request #14150 from KHG420/codex/studio-wechat-paste-attachments
fix(studio): copy pasted files into managed attachments
2026-10-22 02:19:16 -07:00
YHH c163b58980 Merge pull request #14157 from esengine/fix/side-panel-renders-markdown
fix(desktop): draw the side conversation's answer as markdown
2026-10-22 01:53:00 -07:00
YHH c5d0df2120 Merge pull request #14158 from esengine/feat/loopmetrics-inputs
feat(loopmetrics): how to feed a real session, plus usage and M4 definition fixes
2026-10-22 01:52:36 -07:00
xiexian 9c12321906 fix(studio): copy pasted files into managed attachments 2026-10-22 16:26:22 +08:00
esengine 42c398aa6e feat(loopmetrics): session inputs note, truncation sidecar, and usage dedupe that survives resumed sessions 2026-10-22 01:19:37 -07:00
YHH 9ef691ebc9 Merge pull request #14155 from esengine/feat/loopmetrics-phase1
feat(loopmetrics): phase 1 harness and first results on long-session conditions
2026-10-22 01:19:07 -07:00
esengine f0c704db90 docs(release-notes): side panel markdown fragment 2026-10-22 01:14:18 -07:00
esengine 8e498b3f8c fix(desktop): draw the side conversation's answer as markdown 2026-10-22 01:13:45 -07:00
YHH 9a27d9a967 Merge pull request #14140 from SivanCola/fix/studio-turn-completion-state
fix(agent): bound post-answer finalization / 修复回答结束后的收尾阻塞
2026-10-22 01:05:17 -07:00
Will 95b8aa15a0 docs(release-notes): record unpriced calls counting as a floor
The reviewer asked for a bilingual fragment and supplied its outline: a
pre-schema row and a title call now count toward a total labelled a floor.
2026-10-22 16:00:47 +08:00
Will 485e2506eb fix(stats): count a call with no cost fact as incomplete
A call that spent tokens but recorded no money was counted in the token
and request totals and contributed 0 to Cost, with nothing in the payload
saying its money was unknown. Two paths produced that shape.

Title generation wrote such a row live: the serve title model builds its
own stats.Recorder instead of emitting through the assembled sink, so no
CostQuoteSink sits in front of it. The title event carried a rate card but
no quote, and the recorder writes cost fields only for a non-nil quote.
It now prices the call at emit time with event.EnsureCostQuote, using the
same QuoteContext construction the assembled sink uses
(boot.QuoteContextFor), so it is quoted like any other call.

Rows written before the cost fields existed (and any row that simply
lacks them) had the same effect on reads. The incompleteness trigger is
now "no cost fact": an explicit cost_complete=false, or tokens counted
with no cost_amount. One predicate (usagecatalog.CostIncomplete) is
applied on all three read surfaces, so they agree.

No price is invented and no legacy row is backfilled.
2026-10-22 16:00:37 +08:00
esengine b98bad7509 fix(loopmetrics): drive.py refuses a foreign reasonix.toml, and the report states analysed and excluded blocks 2026-10-22 00:46:57 -07:00
esengine d5916445b2 feat(loopmetrics): phase 1 harness, scripted turns and first results 2026-10-22 00:44:44 -07:00
esengine 17bb7dd92c feat(loopmetrics): a run may be several legs of one session 2026-10-22 00:41:29 -07:00
YHH 0b48f5f3b7 Merge pull request #14149 from esengine/feat/loopmetrics
feat(loopmetrics): read-only loop metrics for trajectories, with negative controls
2026-10-22 00:35:56 -07:00
esengine 2df3595ac5 docs(release): note the host verification default 2026-10-22 00:33:25 -07:00
esengine 09d43155a1 feat(config): the host no longer holds a changed turn to its checks unless asked 2026-10-22 00:23:59 -07:00
Lancy 46f6adebbb fix(composition): make lightweight image capability optional 2026-10-22 15:09:42 +08:00
esengine f4fe395407 test(loopmetrics): a duplicated tool_result event counts one call 2026-10-21 23:49:43 -07:00
esengine eebf779b7c docs(loopmetrics): add the metadata header and split the long paragraphs 2026-10-21 23:47:17 -07:00
esengine 9d3a8958e1 feat(loopmetrics): read the flat wire-log envelope and fall back to message-level reasoning 2026-10-21 23:46:40 -07:00
esengine 1184b33233 feat(loopmetrics): read-only analyzer for repeated tool calls and reasoning, with negative controls 2026-10-21 23:45:22 -07:00
esengine 664d75d1d2 docs(loopmetrics): pre-register the loop metrics and their thresholds 2026-10-21 23:43:50 -07:00
YHH c9b435284b Merge pull request #14099 from chuiwenwei/fix/projection-delivery-from-visible-history
fix(control): derive projection delivery from the visible history
2026-10-21 23:42:28 -07:00
YHH 7a139fe8fa Merge pull request #14110 from chuiwenwei/fix/guardian-review-cost-accounted
fix(stats): quote a guardian review's usage like any other call
2026-10-21 23:37:52 -07:00
YHH 8029860f15 Merge pull request #14144 from SivanCola/fix/studio-summary-terminal-race
fix(agent): reject interrupted summaries at EOF / 修复压缩流结束与取消竞态
2026-10-21 23:37:06 -07:00
Will 6b56aade96 fix(desktop): keep a session's money per currency 2026-10-22 14:31:46 +08:00
Will 54a1d436a8 fix(migrate): leave a leased session where it is
The flat-import re-home took the save locks but never asked the session
lease, so it could move a WAL-backed transcript out of a live session root
and delete the old path while another runtime (or this one) still owned it.

Gate every move/remove in the migrate family on the same predicate
renameOverlongSession uses: sessionLeaseHeldLocally OR
SessionLeaseHeldByOtherRuntime. A deferred base is not counted as imported
and leaves the one-time marker unwritten, so a later run settles it.

Tests: audit red/green pair, unleased control, same-process lease control.
2026-10-22 14:28:57 +08:00
Will b3ff8de554 fix(tui): refuse a retry that may already have run 2026-10-22 14:20:33 +08:00
Will 7ded08677e fix(schedule): a missed one-shot stops being active 2026-10-22 14:14:01 +08:00
SivanCola b6b1b3da08 docs(studio): publish PR 14140 release-note fragment
Problem: the post-answer finalization fix note would be missed by the current release fragment collection.

Root cause: it used the retired shared unreleased.md path and did not name the fixing PR.

Fix: move the bilingual entry into unreleased/14140-bound-post-answer-finalization.md, preserving #13953 and adding #14140 to both bullets.

Verification: release-note and Markdown-reference tests pass (12 tests); make check, fragment discovery, bilingual references, release rendering, and whitespace checks pass. Only release-note documentation changes.
2026-10-22 14:09:17 +08:00
SivanCola 3698fb6dad docs(studio): publish PR 14144 release-note fragment
Problem: the compaction fix note was outside the release fragment glob and cited the related finalization PR instead of its own fix.

Root cause: the note used the retired shared unreleased.md layout.

Fix: move the bilingual entry into unreleased/14144-compaction-eof-vs-cancellation.md and reference #14144 in both bullets.

Verification: release-note and Markdown-reference tests pass (12 tests); make check, fragment discovery, exact bilingual references, release rendering, and whitespace checks pass. Only release-note documentation changes.
2026-10-22 14:08:04 +08:00
YHH 9264b58ba4 Merge pull request #14143 from esengine/fix/browser-eval-timeout
fix(browser): stop an eval script whose promise never settles
2026-10-21 22:28:49 -07:00
SivanCola 8380c9b67c fix(agent): reject interrupted summaries at EOF
Problem: compaction could report success and accept a partial digest after
the summary deadline or caller cancellation.

Root cause: providers close their channel when context ends. Selecting EOF
instead of the simultaneously ready cancellation accepted nonempty output;
the deferred ceiling classifier only corrected non-nil errors.

Fix: keep EOF validation at one result boundary and check the request context
before accepting a digest or classifying truncation. Preserve typed ceiling
and cancellation failures, usage accounting, and stable provider requests.

Verification: force EOF/cancellation overlap with a progress barrier and fake
clock, cover normal completion/truncation and charged usage, and exercise
the real Build-to-notice path. Baseline production source fails on macOS
and Linux; fixed Linux cases pass 20 repetitions. Focused race/cache tests,
full root tests, required tool/boot tests, vet, lint, make check, and release
script checks pass. A separate combined tree also validates this fix with
the post-answer finalization change from PR #14140.
2026-10-22 13:19:02 +08:00
esengine 869c4dc6e3 fix(browser): stop an eval script whose promise never settles 2026-10-21 22:01:28 -07:00
Will 2a8bdbce0b fix(control): resolve the projection seer on the current executor
The seer controller.New bound was the executor's method value, and that value
reads the session in place: a SetSession from /new, resume, a branch switch, an
extension system prompt or a fold is already visible to it. Those sites answer
from the current session. A model switch is different — it builds a new
controller, and ApplyRuntimeMigration adopts the old conversation into it. The
carried turns still hold the standing blocks the old model was sent, so the
derived rule reported them delivered and the new model's first request carried
no listing.

The seer now resolves through the controller and answers "nothing is visible"
for a generation that has delivered nothing yet. ApplyRuntimeMigration marks
that state when the replacement's model ref differs from the outgoing one; the
hand-off to the runner clears it, so exactly one request restates the standing
blocks and the visible history answers after that. A rebuild that keeps the
model keeps the derived answer.
2026-10-22 12:41:12 +08:00
SivanCola 859b3d3382 fix(agent): bound post-answer observation and honor turn cancellation
Problem: A completed model answer could leave the controller running while
workspace observation or an evidence stream lock kept finalization alive.

Root cause: Post-answer observation and sealing detached their deadlines
from the turn context. Observation called filesystem operations synchronously,
so its existing timeout did not actually bound the caller's wait.

Fix: Carry the run context through every finalization entry point. Bound
observation with a buffered result that captures no mutable turn state,
record missing observations as gaps, and cancel unused initial snapshots.
Keep optional finalization cancellation from discarding completed answers.

Verification: Deterministic cancellation, deadline, observation-budget,
held-seal-lock and real boot/controller completion regressions fail before
the fix and pass on macOS and Linux arm64 after it. Related race checks,
121 frontend tests, formatting, vet, lint and changed-file checks pass.
The required builtin-tool and full boot package suites also pass.
2026-10-22 11:59:03 +08:00
Fucha b4e6be051e docs(theme): list Patina with the plain palettes 2026-10-22 11:29:53 +08:00
Fucha 72e85d0634 Merge branch 'esengine:studio' into studio 2026-10-22 11:29:18 +08:00
Fucha 68b68a58e0 revert: restore upstream theme author guide 2026-10-22 11:22:31 +08:00
Fucha 0db2d1d0d7 docs(theme): list Patina with the plain palettes 2026-10-22 11:20:30 +08:00
Fucha 1551bf6254 feat(theme): add Patina as a plain light/dark palette 2026-10-22 11:20:15 +08:00
Will 8f48aa844f docs(release-notes): record the guardian cost fix
The reviewer asked for a bilingual fragment: the paid total now includes
the guardian's reviews, and an unpriced row marks the total as a floor.
2026-10-22 09:54:27 +08:00
Will cbc094ebbf fix(stats): quote a guardian review's usage like any other call 2026-10-21 20:18:55 +08:00
Will 87965d1863 test(control): pin a removal announced after a fold kept the block
An audit found that a fold which keeps the old block used to leave a
removal unannounced (the tombstone was gated on a bookkeeping flag the
fold cleared). Deriving delivery from the visible history fixes it; this
pins that a removed instruction is still announced while the model can
read the old block.
2026-10-21 17:51:05 +08:00
Will b110b945f0 docs(release-notes): record derived delivery; rename the red test
Reviewer: state the constraint that holds now, and drop the red marker from
the file name; add a bilingual fragment for the user-visible changes.
2026-10-21 17:46:57 +08:00
Will 85ff4dc202 test(control): pin rewind and branch-switch re-delivery
An audit found the same hole through two more entry points: /rewind
truncates the turn that carried the block, and /switch replaces the
transcript with another branch's prefix. Deriving delivery from the
visible history fixes both, so the audit's two providers-boundary tests
now guard that they stay fixed.
2026-10-21 17:46:41 +08:00
Will 1772a7bbba fix(control): derive projection delivery from the visible history
A standing projection was settled as delivered when a turn was handed to the
runner, so anything that refused the turn before the provider (an extension
block, a cancel, a failed send) or a /new left the debt settled although no
request had carried the block.

Delivery is now a fact about the model-visible history: owed compares the
newest block of its tag in the visible user records, so a LocalOnly record or
compaction summary never answers for it. A refusal, cancel, fold, rewind or
session rotation changes the answer by itself; nothing is recorded at the
hand-off.

Removed: projectionDebt.{pending,delivered,settle,forget,sent,inherit},
promptFailureDebt.{composed,settle}, settleTurnProjections and runSettled, the
CompactionDone→forget handler, InheritScratchFrom and the per-owner forget
hooks. One-time failure/refusal notes are filtered by visibility instead of
settled, so a turn refused before the provider still owes them.
2026-10-21 17:46:41 +08:00
Lancy b57079c75a fix(studio): label Ask work mode as not implemented 2026-10-21 16:45:07 +08:00
SivanCola 9f08f957f3 feat(tools): propose enabling file delivery cards by default
Problem: File delivery requires explicit opt-in even when cards are useful.
Root cause: The user-only configuration key defaults to false.
Fix: Default it on while preserving explicit off choices, read boundaries
and tool exclusions. Keep this policy separate from the settings UI PR.
Verification: Config round trips, complete config/settings/builtin/boot
suites, exact request goldens, schema stability, vet and lint.

The default request fixture gains 143 schema tokens. A newly opened session
may have one cold prefix; this does not mutate the live session's schemas.
2026-10-21 12:27:21 +08:00
SivanCola eba966d685 refactor(studio): separate file delivery UI from the default policy
Problem: The settings PR also changed the default tool surface.
Root cause: UI discoverability and default-on policy were bundled together.
Fix: Restore the existing default, registration fixtures and configuration
rendering, retaining the searchable user-only switch below Tool approval.
Keep UI wording and route tests independent of a future default decision.
Verification: Settings and transport tests, browser captures, Go suites,
frontend build, vet, lint and repository policy checks.
2026-10-21 12:27:21 +08:00
SivanCola f095d8e9f8 feat(studio): expose file delivery settings and enable cards by default
Problem: AI file delivery cards required a manual, default-off config edit.

Root cause: present_files had no settings surface or read/write endpoint, and
the config delta renderer treated false as the default.

Fix: Add a searchable user-only switch immediately below Tool approval.
Default the tool on, preserve explicit off choices, and keep registration
changes at session opening. Update wire contracts, documentation and the
provider-visible tool fixtures for the additional 143 schema tokens.

Verification: Config round trips, route grants, UI persistence/search/order,
real boot tool-surface effects, frontend tests/build, Go tests/vet/lint and
browser checks in light/dark wide and phone layouts.
2026-10-21 12:04:27 +08:00
imcreazy 640ec25699 fix(serve): carry an unprefixed request to a remote pane 2026-10-21 10:52:07 +08:00
imcreazy d79787b973 fix(netdns): read a TCP reply past its length prefix 2026-10-21 10:52:00 +08:00
imcreazy 602c9a3e1d fix(studio): closing the last page offers a new tab 2026-10-21 07:13:30 +08:00
1607 changed files with 92804 additions and 8329 deletions

No files matched your search

@@ -0,0 +1,24 @@
[
{
"package": "reasonix/internal/runtime/delegation",
"tests": [
"TestAncestorCopyAdmissionCoversUnpublishedRun",
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue",
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy",
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue",
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy",
"TestAncestorCopyAdmissionAllowsDifferentParents",
"TestAncestorCopyAdmissionReleasesAbandonedPreparation",
"TestAncestorCopyAdmissionReleasesAfterInvalidCopy",
"TestAncestorCopyAdmissionAllowsDifferentSources",
"TestAncestorCopyAdmissionUsesNearestSourceIdentity",
"TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from",
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/continue_from",
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/fork_from"
]
}
]
@@ -0,0 +1,26 @@
[
{
"package": "reasonix/internal/runtime/agent",
"tests": [
"TestCarriedProofInvalidatesPermissionChange",
"TestCarriedProofFileStateCompatibility",
"TestCarriedProofFileStateCompatibility/legacy_file",
"TestCarriedProofFileStateCompatibility/legacy_absent",
"TestCarriedProofFileStateCompatibility/future",
"TestCarriedProofFileStateCompatibility/current_file",
"TestCarriedProofFileStateCompatibility/current_absent",
"TestCarriedProofRejectsDirectoryState",
"TestCarriedProofStandsOnlyForUnchangedContent",
"TestCarriedProofSeesADeletion"
]
},
{
"package": "reasonix/internal/assembly/boot",
"tests": [
"TestEffectCarriedProofTracksFileState",
"TestEffectCarriedProofTracksFileState/unchanged",
"TestEffectCarriedProofTracksFileState/content",
"TestEffectCarriedProofTracksFileState/mode"
]
}
]
+293
View File
@@ -0,0 +1,293 @@
name: Android
# The Android shell under `android/` ships from the same tag the desktop line
# publishes, and lands on the same release page.
#
# It is a separate workflow rather than a job inside release-studio.yml for two
# reasons. The toolchains share nothing — Go, the Android SDK, a Gradle wrapper —
# so a runner carrying both would install three toolchains to build one artifact.
# And `signing-contract` pins which workflows may reach the SignPath credentials;
# this one never touches them, and keeping it out of that graph is what keeps the
# fingerprint stable.
#
# A failure here does not hold back the desktop release: the two are independent
# by construction, and an APK is worth less than the desktop builds it sits
# beside.
on:
push:
tags: ['studio-v*']
workflow_dispatch:
inputs:
tag:
description: "Existing studio tag to build for and attach to (for example studio-v2.51.0)"
required: false
type: string
ref:
description: "A branch to build as a trial run of this workflow; nothing is attached to any release"
required: false
type: string
permissions:
contents: write # upload the APK to the release the tag belongs to
concurrency:
group: android-${{ inputs.tag || inputs.ref || github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
name: resolve the tag
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
candidate: ${{ steps.release.outputs.candidate }}
publish: ${{ steps.release.outputs.publish }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
# The same tag grammar release-studio.yml enforces, spelled the same way:
# two workflows that disagree about which tags name a release would publish
# one artifact for a tag the other refused.
- name: Resolve tag and version
id: release
env:
IN_TAG: ${{ inputs.tag }}
IN_REF: ${{ inputs.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
# A trial run builds a branch as a throwaway version and publishes nothing.
if [ -n "$IN_REF" ] && [ -z "$IN_TAG" ]; then
sha="$(git rev-parse --verify "origin/$IN_REF^{commit}")"
{ echo "tag="; echo "version=v0.0.0-dev.${sha:0:7}"; echo "sha=$sha"
echo "candidate=false"; echo "publish=false"; } >> "$GITHUB_OUTPUT"
echo "trial build of $IN_REF at $sha"
exit 0
fi
tag="${IN_TAG:-$REF_NAME}"
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
exit 1
fi
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
if ! git merge-base --is-ancestor "$sha" origin/studio; then
echo "::error::$tag is not on studio history"
exit 1
fi
version="${tag#studio-}"
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
fi
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
echo "candidate=$candidate"; echo "publish=true"; } >> "$GITHUB_OUTPUT"
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
build:
name: build the APK
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: read
# `publish` decides whether to attach anything, and the only honest answer to
# "will this APK install" is whether a key was restored.
outputs:
signed: ${{ steps.key.outputs.signed }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
# pnpm first: setup-node's pnpm cache needs the executable to exist.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
with:
version: 10
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: "21"
cache: gradle
# The SDK is the one thing nothing sets for you, per android/README.md. The
# runner image carries one; this asserts the two pieces the build names
# rather than re-installing them, so a missing package fails here by name
# instead of surfacing later as an unresolved compileSdk.
- name: Provide the Android SDK
run: |
if [ -z "${ANDROID_HOME:-}" ]; then
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
exit 1
fi
for piece in "platforms/android-35" "build-tools/35.0.0"; do
if [ ! -d "$ANDROID_HOME/$piece" ]; then
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
exit 1
fi
done
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
# A key that exists signs; a key that does not leaves the artifact
# unsigned, which is what a release with no key has always produced. The
# decision is the repository's, not this workflow's: it reads the four
# secrets android/README.md already names.
- name: Restore the signing key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "no signing key configured: the APK will be unsigned"
exit 0
fi
mkdir -p android/keystore
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
{
echo "storeFile=keystore/release.jks"
echo "storePassword=${STORE_PASSWORD}"
echo "keyAlias=${KEY_ALIAS}"
# PKCS#12 keystores use one password for both; only write the second
# when it is genuinely a different fact.
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
} > android/keystore/keystore.properties
chmod 600 android/keystore/*
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "signing key restored"
- name: Build the kernel and the page
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
export REASONIX_APP_VERSION="${VERSION#v}"
bash ./scripts/build-kernel.sh
bash ./scripts/sync-page.sh
# The one end-to-end check that needs no phone: the kernel the shell starts,
# serving the page the APK carries. Running it here means an APK that would
# open blank fails the build instead of the install.
- name: Verify the packaged page
working-directory: android
run: |
(cd .. && go build -o reasonix-verify ./cmd/reasonix)
KERNEL_BIN="$PWD/../reasonix-verify" bash ./scripts/verify-page.sh
- name: Assemble
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
# The version comes from the tag, so the About screen and the update
# check name this release. versionCode keeps its existing shape, which
# is why the build digit stays 1: it is a single decimal place, and a
# number that grows past it would collide with the next patch.
./gradlew assembleRelease --no-daemon "-PreasonixVersion=${VERSION#v}"
- name: Name the artifact
id: apk
working-directory: android/app/build/outputs/apk/release
run: |
src="$(ls ./*.apk | head -n 1)"
if [ -z "$src" ]; then
echo "::error::assembleRelease produced no APK"
exit 1
fi
case "$src" in
*-unsigned.apk) name="ReasonixMobile-${{ needs.resolve.outputs.version }}-unsigned.apk" ;;
*) name="ReasonixMobile-${{ needs.resolve.outputs.version }}.apk" ;;
esac
mv "$src" "$name"
sha256sum "$name" > "$name.sha256"
{
echo "path=$PWD/$name"
echo "name=$name"
} >> "$GITHUB_OUTPUT"
ls -l "$name" "$name.sha256"
# An unsigned package is not a lesser APK, it is an unusable one: Android's
# package manager rejects it outright rather than reporting a signature
# mismatch. Say so where the run summary can be read, instead of letting a
# green build imply a shippable artifact.
- name: Say what an unsigned build means
if: steps.key.outputs.signed != 'true'
run: |
echo "::warning::no signing key configured, so this APK is unsigned. Android refuses to install an unsigned package, so the release will carry no APK; the file stays a workflow artifact for whoever signs it."
{
echo "### No APK on this release"
echo ""
echo "The build produced an unsigned APK, which Android refuses to install."
echo "It is attached to this run as a workflow artifact only. Add the four"
echo "signing secrets to publish one."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v8
with:
name: android-apk
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/apk/release/*.sha256
if-no-files-found: error
publish:
name: attach the APK
needs: [resolve, build]
# Nothing to attach unless the build was signed. Skipping is the honest
# outcome: a release page with an APK nobody can install is worse than a
# release page without one.
if: needs.build.outputs.signed == 'true' && needs.resolve.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: android-apk
path: dist
# The release itself belongs to release-studio.yml, which creates it and
# owns its notes. This waits for it rather than creating a second one: two
# jobs racing to define a release is how a page ends up with the wrong body.
- name: Attach to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
REPO: ${{ github.repository }}
run: |
# 90 minutes: on the last Studio release the page appeared about 40
# minutes after the tag run started, and the APK build spends part of
# that before this wait begins.
for _ in $(seq 1 360); do
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
gh release upload "$TAG" dist/*.apk dist/*.sha256 --repo "$REPO" --clobber
echo "attached to $TAG"
exit 0
fi
sleep 15
done
echo "::error::release $TAG did not appear within 90 minutes; the APK was built but not published"
exit 1
+779 -13
View File
@@ -170,6 +170,23 @@ jobs:
shell: pwsh
run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
- name: test (Windows concurrency contracts)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
node --test scripts/windows-concurrency-tests.test.mjs
node scripts/windows-concurrency-tests.mjs "$RUNNER_TEMP/concurrency-windows.jsonl"
- name: upload Windows concurrency inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: concurrency-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/concurrency-windows.jsonl
if-no-files-found: error
retention-days: 7
- name: test
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
env:
@@ -179,6 +196,35 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test (Windows subagent status admission)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^TestSubagent(PersistedStatusAdmission|UnknownStatusStillReadableForDiagnosis)$' ./internal/runtime/delegation | tee "$RUNNER_TEMP/subagent-status-admission.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
["future-paused", "missing", "empty", "COMPLETED", "completed", "cancelled", "failed", "interrupted", "running"] as $states |
["continue", "copy", "ancestor", "legacy_ancestor", "current_copy"] as $routes |
(["TestSubagentPersistedStatusAdmission", "TestSubagentUnknownStatusStillReadableForDiagnosis"] +
[$states[] | "TestSubagentPersistedStatusAdmission/" + .] +
[$states[] as $state | $routes[] | "TestSubagentPersistedStatusAdmission/" + $state + "/" + .]) as $required |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
($required - $tests | length) == 0
' "$RUNNER_TEMP/subagent-status-admission.jsonl"
- name: upload Windows subagent status admission inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: subagent-status-admission-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/subagent-status-admission.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows composition links)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
@@ -198,7 +244,8 @@ jobs:
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, the startup/checkpoint packages with portable
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
# filesystem contracts, plus cmd/. Isolation/worktree/mutation have a
# complete JSON inventory below. The full ./... sweep stays on pushes
# to main-v2; the Unix legs always run the full suite. Keep the job-level
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
@@ -222,11 +269,728 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
- name: test (Windows published command job owner)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
shell: bash
run: |
set -euo pipefail
evidence="$RUNNER_TEMP/published-job-owner"
mkdir -p "$evidence"
go test -count=1 -timeout=3m -json -run '^TestPublishedJob' ./internal/base/proc | tee "$evidence/positive.jsonl"
node scripts/published-job-evidence.mjs positive "$evidence/positive.jsonl"
node scripts/published-job-evidence.mjs overlay "$evidence"
set +e
go test -count=1 -timeout=3m -json -overlay="$evidence/overlay.json" -run '^TestPublishedJob' ./internal/base/proc > "$evidence/negative.jsonl"
old_result=$?
set -e
test "$old_result" -eq 1
node scripts/published-job-evidence.mjs negative "$evidence/negative.jsonl"
- name: upload Windows published command job owner evidence
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: published-job-owner-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/published-job-owner/
if-no-files-found: error
retention-days: 7
- name: test (Windows isolation owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
(["TestResultOperationUsesCrossProcessOwnership",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/content",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type",
"TestApplyPathsEmulatedLinksUseSnapshotFilters",
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
' "$RUNNER_TEMP/isolation-owners.jsonl"
jq -se '
. as $events |
{"reasonix/internal/platform/worktree": [
"TestApplyPathsRejectsRedirectedAncestors",
"TestApplyPathsRejectsRedirectedAncestors/outside_delete",
"TestApplyPathsRejectsRedirectedAncestors/inside_delete",
"TestApplyPathsRejectsRedirectedAncestors/dangling_delete",
"TestApplyPathsRejectsRedirectedAncestors/outside_write",
"TestApplyPathsRejectsRedirectedAncestors/inside_write",
"TestApplyPathsRejectsRedirectedAncestors/regular_parent",
"TestApplyPathsRejectsRedirectedAncestors/unchanged",
"TestApplyPathsRejectsRedirectedAncestors/missing_creation",
"TestWriteChangesRejectsRedirectedDeletion",
"TestWriteChangesUnlinksLeafDirectoryLink",
"TestWholeWorkspaceApplyPreservesFileToDirectoryTransition"],
"reasonix/internal/runtime/isolation": [
"TestApplyToolRetainsResultAfterAncestorReplacement"]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass") and
all(.value[]; . as $test |
[ $events[] | select(.Package == $package.key and .Test == $test and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass"))
' "$RUNNER_TEMP/isolation-owners.jsonl"
- name: upload Windows isolation test inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: isolation-owners-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/isolation-owners.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows background resource owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 2 -timeout=18m -json ./internal/tools/jobs/... ./internal/runtime/delegation/... | tee "$RUNNER_TEMP/background-resources.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/tools/jobs", "reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
(["TestJobFinalizationCoversInvokedAndUninvoked/success",
"TestJobFinalizationCoversInvokedAndUninvoked/error",
"TestJobFinalizationCoversInvokedAndUninvoked/panic",
"TestJobFinalizationCoversInvokedAndUninvoked/closed",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_session",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_kind",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/invoked",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/uninvoked",
"TestJobFinalizationPrecedesCompletionPublication",
"TestJobFinalizationCloseBetweenPublicationAndLaunch",
"TestJobFinalizationWaitsForClosureAfterLogicalCancellation",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/invalid_session",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/invalid_session"] - $tests | length) == 0
' "$RUNNER_TEMP/background-resources.jsonl"
- name: upload Windows background resource inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: background-resources-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/background-resources.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows delegated Goal usage scope)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*|DelegatedUsageKeepsGoalOwner|EffectDelegatedUsageKeepsGoalOwner)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
jq -se '
["TestUsageScopePreservesCapturedOwnerAndWire", "TestUsageScopeKeepsEarlierEventOwner",
"TestUsageScopeWithoutCapability", "TestDelegatedUsageKeepsGoalOwner",
"TestDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestDelegatedUsageKeepsGoalOwner/replaced_goal",
"TestDelegatedUsageKeepsGoalOwner/cleared_goal", "TestDelegatedUsageKeepsGoalOwner/resumed_goal",
"TestDelegatedUsageKeepsGoalOwner/same_goal_next_turn", "TestDelegatedUsageKeepsGoalOwner/between_turns",
"TestDelegatedUsageKeepsGoalOwner/nested_after_replacement", "TestEffectDelegatedUsageKeepsGoalOwner",
"TestEffectDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/replaced_goal",
"TestEffectDelegatedUsageKeepsGoalOwner/nested_chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/same_goal",
"TestEffectDelegatedUsageKeepsGoalOwner/completed_before_goal"] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/contract/event", "reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/goal-usage-scope.jsonl"
- name: upload Windows delegated Goal usage scope inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: goal-usage-scope-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/goal-usage-scope.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows planner local budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^TestEffectPlannerConfiguredTaskBudget$' ./internal/assembly/boot | tee "$RUNNER_TEMP/planner-local-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestEffectPlannerConfiguredTaskBudget", "TestEffectPlannerConfiguredTaskBudget/tokens",
"TestEffectPlannerConfiguredTaskBudget/cost", "TestEffectPlannerConfiguredTaskBudget/wall",
"TestEffectPlannerConfiguredTaskBudget/unconfigured", "TestEffectPlannerConfiguredTaskBudget/explicit_zero",
"TestEffectPlannerConfiguredTaskBudget/unpriced_cost", "TestEffectPlannerConfiguredTaskBudget/missing_usage_cost",
"TestEffectPlannerConfiguredTaskBudget/context_bound", "TestEffectPlannerConfiguredTaskBudget/context_disables",
"TestEffectPlannerConfiguredTaskBudget/executor_only"] - $tests | length) == 0
' "$RUNNER_TEMP/planner-local-budget.jsonl"
- name: upload Windows planner local budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: planner-local-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/planner-local-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Controller routing root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage|EffectRoutingRootBudget)$' ./internal/runtime/capability ./internal/assembly/boot | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/capability", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
([
"TestEffectRoutingRootBudget",
"TestEffectRoutingRootBudget/balanced",
"TestEffectRoutingRootBudget/cached_continuation",
"TestEffectRoutingRootBudget/cached_new_scope",
"TestEffectRoutingRootBudget/cached_new_task",
"TestEffectRoutingRootBudget/cached_reset",
"TestEffectRoutingRootBudget/context_zero",
"TestEffectRoutingRootBudget/dual",
"TestEffectRoutingRootBudget/headless",
"TestEffectRoutingRootBudget/interactive",
"TestEffectRoutingRootBudget/missing",
"TestEffectRoutingRootBudget/unconfigured",
"TestSemanticRootUsage",
"TestSemanticRootUsage/billed_error",
"TestSemanticRootUsage/cache",
"TestSemanticRootUsage/estimated",
"TestSemanticRootUsage/invalid_answer",
"TestSemanticRootUsage/missing",
"TestSemanticRootUsage/nil_sink",
"TestSemanticRootUsage/no_observer",
"TestSemanticRootUsage/reported"
] - $tests | length) == 0
' "$RUNNER_TEMP/controller-routing-budget.jsonl"
- name: upload Windows Controller routing root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: controller-routing-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/controller-routing-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Coordinator root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(PreparedRootBudget|CoordinatorRootBudget|EffectCoordinatorRoot)' ./internal/runtime/agent ./internal/runtime/coordinator ./internal/assembly/boot | tee "$RUNNER_TEMP/coordinator-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/runtime/coordinator", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestPreparedRootBudgetLifetime", "TestPreparedRootBudgetLifetime/continuation",
"TestPreparedRootBudgetLifetime/new_scope", "TestPreparedRootBudgetLifetime/new_task",
"TestPreparedRootBudgetLifetime/reset", "TestPreparedRootBudgetLifetime/new_session",
"TestPreparedRootBudgetProjectionAndPreservedTurn", "TestCoordinatorRootBudgetRoutes",
"TestCoordinatorRootBudgetRoutes/raw", "TestCoordinatorRootBudgetRoutes/tool_planner",
"TestCoordinatorRootBudgetRoutes/failure_fallback", "TestCoordinatorRootBudgetRoutes/plan_only_continuation",
"TestCoordinatorRootBudgetRoutes/approval_declined_continuation", "TestCoordinatorRootBudgetRoutes/approval_allowed",
"TestEffectCoordinatorRootTokenBudget", "TestEffectCoordinatorRootTokenBudget/shared",
"TestEffectCoordinatorRootTokenBudget/unconfigured", "TestEffectCoordinatorRootTokenBudget/context_disables",
"TestEffectCoordinatorRootTokenBudget/missing_planner", "TestEffectCoordinatorRootTokenBudget/executor_only"] - $tests | length) == 0
' "$RUNNER_TEMP/coordinator-root-budget.jsonl"
- name: upload Windows Coordinator root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coordinator-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/coordinator-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows resumed Goal planner budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(GoalResumeRenewsPlannerBudget|GoalNonBudgetResumeKeepsPlannerBudget|GoalTokenBudgetPausesAndResumes|EffectGoalResumeRenewsPlannerBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-planner-resume.jsonl"
jq -se '
["TestGoalResumeRenewsPlannerBudget",
"TestGoalNonBudgetResumeKeepsPlannerBudget",
"TestGoalNonBudgetResumeKeepsPlannerBudget/manual_pause",
"TestGoalNonBudgetResumeKeepsPlannerBudget/not_resumable",
"TestGoalTokenBudgetPausesAndResumes",
"TestEffectGoalResumeRenewsPlannerBudget"] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/goal-planner-resume.jsonl"
- name: upload Windows resumed Goal planner budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: goal-planner-resume-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/goal-planner-resume.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows root wall budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootWall|EffectRootWall)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-wall-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootWallBudgetReachesDelegatedAgents", "TestRootWallBudgetReachesDelegatedAgents/foreground",
"TestRootWallBudgetReachesDelegatedAgents/nested", "TestRootWallBudgetReachesDelegatedAgents/siblings",
"TestRootWallBudgetReachesDelegatedAgents/context", "TestRootWallBudgetReachesDelegatedAgents/unconfigured",
"TestRootWallBudgetReachesDelegatedAgents/explicit_zero", "TestRootWallBudgetContinuationAndReset",
"TestRootWallBudgetContinuationAndReset/continuation", "TestRootWallBudgetContinuationAndReset/new_task",
"TestRootWallBudgetContinuationAndReset/reset", "TestRootWallBudgetContinuationAndReset/override",
"TestRootWallBudgetProjectionKeepsJobDeadline", "TestEffectRootWallBudgetReachesManagedJobs",
"TestEffectRootWallBudgetReachesManagedJobs/task", "TestEffectRootWallBudgetReachesManagedJobs/capability",
"TestEffectRootWallBudgetReachesManagedJobs/fleet"] - $tests | length) == 0
' "$RUNNER_TEMP/root-wall-budget.jsonl"
- name: upload Windows root wall budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-wall-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-wall-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root token budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken|EffectReportedTotalTokenBudget)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootTokenBudgetIncludesChildProviderUsage", "TestRootTokenBudgetIncludesChildProviderUsage/configured",
"TestRootTokenBudgetIncludesChildProviderUsage/context", "TestRootTokenBudgetIncludesChildProviderUsage/unconfigured",
"TestRootTokenBudgetIncludesChildProviderUsage/explicit_zero", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren",
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/nested", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/siblings",
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/child_override", "TestRootTokenBudgetContinuationAndReset",
"TestRootTokenBudgetContinuationAndReset/continuation", "TestRootTokenBudgetContinuationAndReset/new_task",
"TestRootTokenBudgetContinuationAndReset/reset", "TestRootTokenScopeAtomicAccountingAndJobCancellation",
"TestEffectRootTokenBudgetReachesDelegatedJobs", "TestEffectRootTokenBudgetReachesDelegatedJobs/foreground",
"TestEffectRootTokenBudgetReachesDelegatedJobs/foreground_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background",
"TestEffectRootTokenBudgetReachesDelegatedJobs/background_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background_fleet"] - $tests | length) == 0
' "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
. as $events |
{"reasonix/internal/runtime/agent": [
"TestBudgetCountsKnownReportedTokens",
"TestBudgetCountsKnownReportedTokens/split",
"TestBudgetCountsKnownReportedTokens/total_only",
"TestBudgetCountsKnownReportedTokens/partial_split",
"TestBudgetCountsKnownReportedTokens/lower_total",
"TestBudgetCountsKnownReportedTokens/missing_total",
"TestBudgetCountsKnownReportedTokens/negative_split",
"TestBudgetCountsKnownReportedTokens/zero",
"TestBudgetCountsKnownReportedTokens/negative",
"TestReportedTokensEnforceLocalCapOnInheritedRoot",
"TestReportedTokensFollowOriginalAuxiliaryRoot",
"TestReportedTokenCountersSaturate"],
"reasonix/internal/assembly/boot": [
"TestEffectReportedTotalTokenBudget",
"TestEffectReportedTotalTokenBudget/split",
"TestEffectReportedTotalTokenBudget/total_only",
"TestEffectReportedTotalTokenBudget/partial_split",
"TestEffectReportedTotalTokenBudget/lower_total",
"TestEffectReportedTotalTokenBudget/missing_total",
"TestEffectReportedTotalTokenBudget/disabled"]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass") and
all(.value[]; . as $test |
[ $events[] | select(.Package == $package.key and .Test == $test and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass"))
' "$RUNNER_TEMP/root-token-budget.jsonl"
- name: upload Windows root token budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-token-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-token-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows budget usage completeness)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RequestOnlyUsage|RunBudgetMissingUsage|TaskBudgetKnownCost|RunBudgetExplicitZero|TaskBudgetUsageCompleteness|TaskBudgetCostUses|EffectBudgetCompleteness|TaskBudgetGateIgnoresCostWhenUnpriced)' ./internal/contract/provider ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/budget-usage.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/contract/provider", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRequestOnlyUsageDoesNotCertifyTokenAccounting", "TestRunBudgetMissingUsageKeepsCostIncomplete",
"TestRunBudgetMissingUsageKeepsCostIncomplete/complete", "TestRunBudgetMissingUsageKeepsCostIncomplete/missing_first",
"TestRunBudgetMissingUsageKeepsCostIncomplete/missing_last", "TestRunBudgetMissingUsageKeepsCostIncomplete/all_missing",
"TestTaskBudgetKnownCostStillLandsAfterMissingUsage", "TestRunBudgetExplicitZeroUsageIsPriced",
"TestTaskBudgetUsageCompletenessFollowsItsLifetime", "TestTaskBudgetUsageCompletenessFollowsItsLifetime/continuation",
"TestTaskBudgetUsageCompletenessFollowsItsLifetime/new_task", "TestTaskBudgetUsageCompletenessFollowsItsLifetime/explicit_reset",
"TestTaskBudgetCostUsesOnlyTheReportedSubtotal", "TestTaskBudgetGateIgnoresCostWhenUnpriced",
"TestEffectBudgetCompletenessFromOpenAIStream", "TestEffectBudgetCompletenessFromOpenAIStream/missing_0",
"TestEffectBudgetCompletenessFromOpenAIStream/missing_1", "TestEffectBudgetCompletenessFromOpenAIStream/missing_2"] - $tests | length) == 0
' "$RUNNER_TEMP/budget-usage.jsonl"
- name: upload Windows budget usage inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: budget-usage-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/budget-usage.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Git storage environment)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(HostStorageEnvironment|HostObjectStorage|HostExplicitStorageControls|DetachedStorageEnvironment|WorktreePreparationOwnsItsIndex|EffectIsolatedTaskOwnsGitStorage)$' ./internal/platform/gitcmd ./internal/platform/worktree ./internal/assembly/boot | tee "$RUNNER_TEMP/git-storage-environment.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/platform/gitcmd", "reasonix/internal/platform/worktree", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
([
"TestDetachedStorageEnvironment",
"TestDetachedStorageEnvironment/explicit_env",
"TestDetachedStorageEnvironment/inherited_env",
"TestEffectIsolatedTaskOwnsGitStorage",
"TestEffectIsolatedTaskOwnsGitStorage/ambient_existing",
"TestEffectIsolatedTaskOwnsGitStorage/ambient_missing",
"TestEffectIsolatedTaskOwnsGitStorage/control",
"TestHostExplicitStorageControls",
"TestHostExplicitStorageControls/explicit_object_directory",
"TestHostExplicitStorageControls/private_index",
"TestHostExplicitStorageControls/repository_declared_alternate",
"TestHostObjectStorage",
"TestHostObjectStorage/ambient_alternate_not_read",
"TestHostObjectStorage/writes_stay_in_repository",
"TestHostStorageEnvironment",
"TestHostStorageEnvironment/GIT_ALTERNATE_OBJECT_DIRECTORIES",
"TestHostStorageEnvironment/GIT_INDEX_FILE",
"TestHostStorageEnvironment/GIT_OBJECT_DIRECTORY",
"TestWorktreePreparationOwnsItsIndex",
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_existing",
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_missing",
"TestWorktreePreparationOwnsItsIndex/candidate_control",
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_existing",
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_missing",
"TestWorktreePreparationOwnsItsIndex/delivery_control"
] - $tests | length) == 0
' "$RUNNER_TEMP/git-storage-environment.jsonl"
- name: upload Windows Git storage environment inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: git-storage-environment-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/git-storage-environment.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Anthropic interrupted usage)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(InterruptedUsage|EffectAnthropicPartialUsageRecovery)' ./internal/model/anthropic ./internal/assembly/boot | tee "$RUNNER_TEMP/anthropic-interrupted-usage.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/model/anthropic", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestInterruptedUsageTerminal", "TestInterruptedUsageTerminal/upstream_error",
"TestInterruptedUsageTerminal/payload_error", "TestInterruptedUsageTerminal/premature_eof",
"TestInterruptedUsageTerminal/decode_error", "TestInterruptedUsageTerminal/reported_zero",
"TestInterruptedUsageTerminal/missing_control", "TestInterruptedUsageTerminal/complete_control",
"TestInterruptedUsageBoundedConsumer", "TestInterruptedUsageReadErrorAndCancellation",
"TestInterruptedUsageReadErrorAndCancellation/read_error", "TestInterruptedUsageReadErrorAndCancellation/cancelled_buffered_reader",
"TestInterruptedUsageAbandonedConsumer", "TestInterruptedUsageIdleTimeout",
"TestEffectAnthropicPartialUsageRecovery", "TestEffectAnthropicPartialUsageRecovery/upstream_error",
"TestEffectAnthropicPartialUsageRecovery/premature_eof", "TestEffectAnthropicPartialUsageRecovery/complete_control"] - $tests | length) == 0
' "$RUNNER_TEMP/anthropic-interrupted-usage.jsonl"
- name: upload Windows Anthropic interrupted usage inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: anthropic-interrupted-usage-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/anthropic-interrupted-usage.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root websearch budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootWebSearch|EffectRootWebSearch)' ./internal/tools/websearch ./internal/assembly/boot | tee "$RUNNER_TEMP/root-websearch-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/tools/websearch", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootWebSearchUsageTerminal", "TestRootWebSearchUsageTerminal/reported",
"TestRootWebSearchUsageTerminal/no_reporter", "TestRootWebSearchUsageTerminal/missing",
"TestRootWebSearchUsageTerminal/no_observer", "TestRootWebSearchUsageTerminal/input_refused",
"TestRootWebSearchUsageTerminal/no_factory", "TestRootWebSearchUsageTerminal/factory_error",
"TestRootWebSearchUsageTerminal/billed_failure", "TestRootWebSearchUsageTerminal/cancel_after_usage",
"TestRootWebSearchUsageTerminal/cancel_before_start", "TestRootWebSearchUsageTerminal/stream_error",
"TestEffectRootWebSearchTokenBudget", "TestEffectRootWebSearchTokenBudget/reported",
"TestEffectRootWebSearchTokenBudget/disabled", "TestEffectRootWebSearchTokenBudget/missing"] - $tests | length) == 0
' "$RUNNER_TEMP/root-websearch-budget.jsonl"
- name: upload Windows root websearch budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-websearch-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-websearch-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows explicit skill root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task",
"TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope",
"TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session",
"TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero",
"TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget",
"TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview",
"TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips",
"TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero",
"TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
- name: upload Windows explicit skill root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: explicit-skill-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root triage budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootTriage|EffectRootTriage)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-triage-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootTriageTokenBudget", "TestRootTriageTokenBudget/classifier", "TestRootTriageTokenBudget/screen",
"TestRootTriageTokenBudget/billed_failure", "TestRootTriageTokenBudget/missing", "TestRootTriageTokenBudget/disabled",
"TestRootTriageTokenBudget/no_sink", "TestRootTriageTokenBudget/counts_without_total",
"TestRootTriageOutsideRunDoesNotCreateScope", "TestEffectRootTriageScreenBudget",
"TestEffectRootTriageScreenBudget/reported", "TestEffectRootTriageScreenBudget/flagged",
"TestEffectRootTriageScreenBudget/disabled", "TestEffectRootTriageScreenBudget/missing",
"TestEffectRootTriageScreenBudget/screen_disabled"] - $tests | length) == 0
' "$RUNNER_TEMP/root-triage-budget.jsonl"
- name: upload Windows root triage budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-triage-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-triage-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root auxiliary budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootAuxiliary|EffectRootAuxiliary)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootAuxiliaryAutomaticCompaction", "TestRootAuxiliaryTokenBudget",
"TestRootAuxiliaryTokenBudget/summary", "TestRootAuxiliaryTokenBudget/summary_failed",
"TestRootAuxiliaryTokenBudget/summary_missing", "TestRootAuxiliaryTokenBudget/summary_disabled",
"TestRootAuxiliaryTokenBudget/diagnosis", "TestRootAuxiliaryTokenBudget/diagnosis_failed",
"TestRootAuxiliaryTokenBudget/diagnosis_missing", "TestRootAuxiliaryTokenBudget/diagnosis_disabled",
"TestRootAuxiliaryManualCompactionOutsideRun", "TestRootAuxiliaryBudgetProjectionKeepsOldTask",
"TestEffectRootAuxiliarySummaryBudget", "TestEffectRootAuxiliarySummaryBudget/configured",
"TestEffectRootAuxiliarySummaryBudget/disabled", "TestEffectRootAuxiliarySummaryBudget/missing_usage"] - $tests | length) == 0
' "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
- name: upload Windows root auxiliary budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-auxiliary-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-auxiliary-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows local cost quote)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*|EffectLocalCostUsesOccurrenceQuote)$' ./internal/contract/event ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
jq -se '
["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak",
"TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown",
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks",
"TestEffectLocalCostUsesOccurrenceQuote", "TestEffectLocalCostUsesOccurrenceQuote/peak",
"TestEffectLocalCostUsesOccurrenceQuote/offpeak", "TestEffectLocalCostUsesOccurrenceQuote/relay"] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/local-cost-quote.jsonl"
- name: upload Windows local cost quote inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: local-cost-quote-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/local-cost-quote.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows estimated usage quotes)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(EstimatedUsage|EffectEstimatedUsage|ToWireUsagePayloadJSON$|StatusExtensionTracksMultipleSessionsAndUsage$|CacheSplitProvenanceIsRecorded$|RecordedPlainCallIsUnchanged$)' ./internal/contract/pricing ./internal/contract/event ./internal/contract/config ./internal/assembly/boot ./internal/contract/eventwire ./internal/frontend/acp ./internal/state/stats | tee "$RUNNER_TEMP/estimated-quote.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/contract/pricing", "reasonix/internal/contract/event", "reasonix/internal/contract/config", "reasonix/internal/assembly/boot",
"reasonix/internal/contract/eventwire", "reasonix/internal/frontend/acp", "reasonix/internal/state/stats"] - $packages | length) == 0 and
(["TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts", "TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts/CNY",
"TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts/USD", "TestEstimatedUsageLedgerRetainsPartialCoverage",
"TestEstimatedUsageLedgerRetainsPartialCoverage/separate_keys", "TestEstimatedUsageLedgerRetainsPartialCoverage/same_key",
"TestEstimatedUsageSavedQuoteReader", "TestEstimatedUsageQuoteSinkCoversTurnAndGuardian",
"TestEstimatedUsageQuoteSinkCoversTurnAndGuardian/turn", "TestEstimatedUsageQuoteSinkCoversTurnAndGuardian/guardian",
"TestEstimatedUsageConfiguredQuote", "TestEffectEstimatedUsageQuoteAfterStreamRecovery",
"TestEffectEstimatedUsageQuoteAfterStreamRecovery/reported_control", "TestEffectEstimatedUsageQuoteAfterStreamRecovery/interrupted",
"TestToWireUsagePayloadJSON", "TestStatusExtensionTracksMultipleSessionsAndUsage", "TestCacheSplitProvenanceIsRecorded", "TestRecordedPlainCallIsUnchanged"] - $tests | length) == 0
' "$RUNNER_TEMP/estimated-quote.jsonl"
- name: upload Windows estimated quote inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: estimated-quote-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/estimated-quote.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows bounded root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(AuxiliaryUsageObserver|BoundedUsageObserver|RootBounded|EffectRootBounded)' ./internal/contract/provider ./internal/model/boundedllm ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/bounded-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/contract/provider", "reasonix/internal/model/boundedllm",
"reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestAuxiliaryUsageObserverReplacesAndCopies",
"TestBoundedUsageObserverOwnsOneTerminal", "TestBoundedUsageObserverOwnsOneTerminal/reported",
"TestBoundedUsageObserverOwnsOneTerminal/billed_failure", "TestBoundedUsageObserverOwnsOneTerminal/without_sink",
"TestBoundedUsageObserverOwnsOneTerminal/missing", "TestBoundedUsageObserverOwnsOneTerminal/input_refused",
"TestRootBoundedReviewerBudget", "TestRootBoundedReviewerBudget/reported",
"TestRootBoundedReviewerBudget/billed_failure", "TestRootBoundedReviewerBudget/missing_usage",
"TestRootBoundedReviewerBudget/disabled", "TestRootBoundedReviewerBudget/no_emission",
"TestRootBoundedReviewerBudget/projected_job_context", "TestRootBoundedNestedBudgetChargesOnce",
"TestRootBoundedLateProjectionStaysWithOldTask", "TestEffectRootBoundedAdvisorBudget",
"TestEffectRootBoundedAdvisorBudget/reported", "TestEffectRootBoundedAdvisorBudget/disabled",
"TestEffectRootBoundedAdvisorBudget/missing_usage"] - $tests | length) == 0
' "$RUNNER_TEMP/bounded-root-budget.jsonl"
- name: upload Windows bounded root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: bounded-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/bounded-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -245,12 +1009,12 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite uses the same 18m package timeout as Windows smoke and a
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
# the failed tests' output and the 15 slowest boot tests.
# Full push suite: a 36m package timeout inside a 45m step budget. -count=1
# as at the smoke step. A failure prints the failed tests' output and the 15
# slowest boot tests.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 35
timeout-minutes: 45
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -261,9 +1025,9 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
shell: bash
run: |
# internal/assembly/boot alone takes 14-15 minutes on a Windows runner;
# internal/assembly/boot alone can take over 25 minutes on a Windows runner;
# the package limit leaves headroom inside the step limit above.
go test -count=1 -p 4 -timeout=28m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
go test -count=1 -p 4 -timeout=36m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (full) failure detail
@@ -315,13 +1079,15 @@ jobs:
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
# internal/assembly/boot under -race runs past ten minutes on a shared
# runner; the package limit leaves headroom over the default.
run: go test -race -timeout=25m ./...
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
# Its DTOs are generated from internal/ext/extension/protocol, and the
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.12'
+10 -1
View File
@@ -87,7 +87,7 @@ jobs:
browser-guards:
name: browser guards
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 25
defaults:
run:
working-directory: desktop/frontend-next
@@ -155,9 +155,14 @@ jobs:
node perf/browser-full.mjs
node perf/mod-ui.mjs
node perf/codeeditor-size.mjs
node perf/composer-reflow.mjs
node perf/composer-toolbar-row.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/prefs-wide-centered.mjs
node perf/tab-pip.mjs
node perf/syntax-tokens.mjs
node perf/tab-close-inside.mjs
node perf/context-card-hover.mjs
node perf/sessquick.mjs
node perf/sessionrows.mjs
@@ -183,8 +188,12 @@ jobs:
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/output-cap.mjs
node perf/agent-run-tab.mjs
node perf/changes-cap.mjs
node perf/session-menu-fit.mjs
node perf/rail-rows-typography.mjs
node perf/settings-nav-fit.mjs
node perf/rail-rows-align.mjs
node perf/progress-tail.mjs
node perf/trust-wait.mjs
node perf/nest-nodes.mjs
+16
View File
@@ -117,6 +117,22 @@ go test ./internal/runtime/agent/ -v # verbose, one package
go test ./internal/tools/builtin/ -run TestGrep # one test
```
`testenv.IsolateUserState` installs a disposable user home and config-edit lock
scope. Default-path tests can clear `REASONIX_HOME` without losing the test
registry, while explicitly isolated Reasonix homes retain their own locks.
`REASONIX_TEST_HOME` carries the scope to helper subprocesses. A helper that
skips the isolation guard must install the inherited home with
`testscope.InstallHome` before running tests.
The environment variable alone cannot activate a test scope. Production
executables still use the OS account's home for shared config locks.
The guard removes its scratch home on normal completion. Scratch homes are
created under the original home, so an interrupted run can still leave
`.reasonix-test-home-*` directories; this is separate from persistent
config-edit locks in the real `.reasonix/locks` directory.
### Code style
- `gofmt` is enforced by CI — format before committing
+18 -5
View File
@@ -169,6 +169,19 @@ Every package ships with a `.minisig` signature, and the release carries
The same release also carries the 2.x `reasonix` CLI as archives for
`darwin|linux|windows × amd64|arm64`.
The Android build ships on that release as `ReasonixMobile-<version>.apk`
(`arm64`, Android 8.0 or newer). The kernel and the page run on the phone, and
the agent reaches your servers over SSH; pairing with a computer is done by
scanning the code it shows.
The APK is signed with the Android release key rather than minisign, so it
carries a `.sha256` and no `.minisig`.
When that key is absent the build is named
`ReasonixMobile-<version>-unsigned.apk`. Android refuses to install an unsigned
package, so those builds stay a workflow artifact for whoever wants to sign one,
and the release carries no APK at all.
### Reasonix 1.x (stable)
The 1.x CLI installs through npm on any supported platform, or Homebrew on
@@ -288,11 +301,11 @@ A small list of folks whose work has shaped Reasonix the most — the current to
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+14 -5
View File
@@ -156,6 +156,15 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
安卓版在同一个发布页上,文件名是 `ReasonixMobile-<版本>.apk`(`arm64`,Android 8.0 及以上)。
这个 APK 用 Android 发布密钥签名,不走 minisign,因此只有一个 `.sha256`,没有 `.minisig`。
该密钥缺失时产物名为 `ReasonixMobile-<版本>-unsigned.apk`。Android 不接受未签名的包,
这类构建只留作 workflow 产物供自行签名,发布页上不会挂 APK。
内核与界面都跑在手机上,Agent 通过 SSH 连接你自己的服务器;与电脑配对时扫它显示的二维码。
### Reasonix 1.x(稳定版)
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
@@ -269,11 +278,11 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+21
View File
@@ -20,10 +20,25 @@ plugins {
}
// One version, read by the About screen and the update check.
//
// A published build takes its version from the tag it was cut for, so the About
// screen and the update check name that release rather than the number this file
// was last edited to. Without the property — a local build — the numbers below
// are the answer.
def publishedVersion = findProperty("reasonixVersion") as String
def versionMajor = 2
def versionMinor = 0
def versionPatch = 0
def versionBuild = 1
if (publishedVersion) {
def parsed = publishedVersion =~ /^(\d+)\.(\d+)\.(\d+)/
if (!parsed.find()) {
throw new GradleException("reasonixVersion must start with MAJOR.MINOR.PATCH, got: ${publishedVersion}")
}
versionMajor = parsed.group(1) as int
versionMinor = parsed.group(2) as int
versionPatch = parsed.group(3) as int
}
// Where the two build inputs are expected. Both are produced from the checkout
// beside this module, by the scripts named in each message.
@@ -53,6 +68,12 @@ android {
// what `MainActivity` feeds real inset values for, since a WebView does
// not implement `env(safe-area-inset-*)`.
targetSdk 35
// versionCode must strictly increase, because Android refuses an update
// whose versionCode is not higher. The build digit is a single decimal
// place, so the scheme collides once a patch reaches 10: 2.55.10 and
// 2.56.0 both come out as 25601. Patch releases above 9 are not expected
// on Studio, and widening the digit now would renumber every build, so
// the constraint is recorded rather than fixed.
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -47,7 +47,7 @@ app_gradle="$android/app/build.gradle"
version_field() {
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
}
app_version="$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)"
app_version="${REASONIX_APP_VERSION:-$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)}"
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ldflags="-s -w"
ldflags="$ldflags -X main.version=v$app_version"
+2 -1
View File
@@ -32,7 +32,8 @@ fi
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
printf '%s\n' "$token" > "$work/home/remote/launch.token"
# The kernel refuses a token file other users can read.
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
export REASONIX_HOME="$work/home"
export HOME="$work/home"
+59 -3
View File
@@ -420,7 +420,7 @@ own outcome).
| Flag | Default | Purpose |
| --- | --- | --- |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens). |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens; `audit` checks a batch's published figures against its retained rows). |
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
@@ -433,6 +433,7 @@ own outcome).
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`, `upstream` and `recall-search`; `none` \| `all`. |
| `-out` | *(stdout)* | Write the markdown report here. |
| `-json` | *(none)* | Write the JSON report here (optional). |
| `-batch` | *(none)* | Suite mode: write `results.jsonl` (one row per attempt) and `batch.json` (manifest v1) here, so the report's figures can be audited later. Audit mode: read the batch from here. See [Auditing a batch](#auditing-a-batch). |
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
@@ -455,10 +456,65 @@ Diff-mode flags:
| `-timeout` | `1200` | Agent timeout in seconds (diff mode). |
| `-attempts` | `1` | Diff mode: retry up to N times until a run passes (stochastic agent). |
## Auditing a batch
`-batch DIR` writes two files from a suite run: `results.jsonl` (one row per
attempt — the raw record every figure is computed from) and `batch.json`
(manifest v1). `-mode audit -batch DIR` re-derives the report's headline figures
from `results.jsonl`, prints the manifest's claim beside the recomputed value,
and exits non-zero naming any figure that moved. It also refuses a batch whose
manifest is missing, records no commit, or whose rows are truncated.
This is an **aggregation audit, not a replay**: it proves the published figures
follow from the retained rows. It does not re-execute the model, tools or
environment, and cannot show the experiment would reproduce. A real replay would
need the commit, a corpus fingerprint, the model and provider configuration, the
seed, the flags, the raw results and the runtime version; it is not implemented.
`schema_version` is top level. The rest is three groups:
| Group | Fields | Answers |
| --- | --- | --- |
| `over` | `command.mode` and `command.axes` (every flag that changes what a figure means; paths omitted); `tasks.count` and `tasks.digest` (sha256 over sorted, NUL-separated task ids — the corpus identity, never the task text); `filters`; `denominator` (`unit`, `definition`, `value`) | what the figures are over |
| `producer` | `commit` (or an explicit `unknown`, never a guess), `commit_source`, `dirty`, `model`, `provider`, `seed`, `started_at`, `finished_at`, `harness_version`, `go_version` | which run produced them |
| `trust` | `results_sha256`, `claimed` (the headline figures), `ledger`, `retention` | why they can still be checked |
The ledger accounts for every planned run, and the audit checks that the counts
form a funnel (`scored ≤ completed ≤ attempted`, `failed ≤ scored`,
`scored = pass1 + failed`, `rows = attempted + skipped`). A row count is never a
denominator: `denominator.value` is a count of scored tasks, and `rows` is
published only to show what was retained.
| Count | Meaning |
| --- | --- |
| `planned` | graded runs the batch set out to attempt: filtered tasks × trials |
| `attempted` | retained rows for runs that started (skipped rows are not attempts) |
| `completed` | attempts that ended and left a row; this harness writes a row only after an attempt ends, so it equals `attempted` and the equality is checked, not assumed |
| `scored` | completed first attempts of solvable, non-skipped tasks — the pass@1 denominator |
| `failed` | scored attempts the grader did not pass |
| `unmeasured` | attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals, never counted as zero |
| `excluded` | rows kept out of the accuracy denominator: skipped, `no_solution`, and retry entries |
Spend and cache fields are omitted from `claimed` when nothing was measured, so
an absent figure reads as unknown rather than as a measured zero. `seed` is
omitted for the same reason: the harness does not set a sampling seed.
`results_sha256` covers the values and the presence of every row; line endings,
indentation and key order are normalised away because none of them is evidence,
while number spelling is kept (a rewritten `1` is not `1.0`).
A successful audit records the verifier version (`audit.verifier_version`) and
the digest it checked in `batch.json`, so a re-verification can tell which code
checked the batch.
`results.jsonl` is retained unredacted beside the manifest — task prompts and
agent notes included — and is not public. The manifest itself carries a task-id
digest, no prompt text, no absolute paths, no endpoints or keys and no model
output. A claim whose rows are gone cannot be audited: there is nothing to
recompute from, and this tool will not certify it.
## Dataset retention
Keep every `-json` report and `-trajectories` directory from real runs: they
are the accumulating corpus — per-task contracts-to-be, full event
Keep every `-json` report, `-trajectories` directory and `-batch` directory from
real runs: they are the accumulating corpus — per-task contracts-to-be, full event
trajectories, checkpoint oracle verdicts, stop curves and phase traces — that
any future offline learning (routing, stop policies, budgets) would train
and evaluate on. The control plane stays deterministic and interpretable
+428
View File
@@ -0,0 +1,428 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// runAuditMode prints both sides of the audit before returning the failure, so
// a moved figure is visible even when the exit code is non-zero.
func runAuditMode(dir, outMD string) error {
report, err := auditBatch(dir)
if report != "" {
emit(report, outMD, "")
}
return err
}
// auditBatch re-derives the published figures from results.jsonl and checks
// them against batch.json. It reports both sides and never re-runs anything.
func auditBatch(dir string) (string, error) {
if strings.TrimSpace(dir) == "" {
return "", errors.New("audit needs -batch <directory>: the directory a suite run wrote")
}
manifestPath := filepath.Join(dir, batchManifestName)
data, err := os.ReadFile(manifestPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("no %s under %s: a batch that kept no manifest cannot be audited", batchManifestName, dir)
}
return "", fmt.Errorf("read %s: %w", batchManifestName, err)
}
var m batchManifest
if err := json.Unmarshal(data, &m); err != nil {
return "", fmt.Errorf("%s is not valid JSON: %w", batchManifestName, err)
}
if m.SchemaVersion != batchSchemaVersion {
return "", fmt.Errorf("%s declares schema version %d; this audit implements v%d", batchManifestName, m.SchemaVersion, batchSchemaVersion)
}
if commit := strings.TrimSpace(m.Producer.Commit); commit == "" || commit == "unknown" {
return "", fmt.Errorf("%s records no commit (%q): the figures cannot be tied to a version, so this audit stops", batchManifestName, m.Producer.Commit)
}
rows, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
if err != nil {
return "", err
}
hash := hashOf(canonical)
stats := gatherSuiteStats(rows)
fromRows := ledgerFromRows(rows)
figures, failures := compareClaims(m.Trust.Claimed, stats)
ledger, ledgerFailures := compareLedger(m.Trust.Ledger, fromRows)
failures = append(failures, ledgerFailures...)
failures = append(failures, ledgerInvariants(m.Trust.Ledger)...)
failures = append(failures, denominatorChecks(m, fromRows)...)
if hash != m.Trust.ResultsSHA256 {
failures = append([]string{fmt.Sprintf("results_sha256: manifest %s, rows %s", textOrUnknown(m.Trust.ResultsSHA256), hash)}, failures...)
}
report := renderAudit(dir, m, rows, hash, figures, ledger, failures)
if len(failures) > 0 {
return report, fmt.Errorf("%d mismatch(es): the published figures do not follow from the retained rows", len(failures))
}
if err := recordAuditReceipt(manifestPath, m, hash); err != nil {
return report, err
}
return report, nil
}
// readBatch parses results.jsonl once: the typed rows the figures come from,
// and the canonical bytes results_sha256 covers.
func readBatch(path string) ([]result, []byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, fmt.Errorf("no %s: the manifest describes rows that are not beside it", batchResultsName)
}
return nil, nil, fmt.Errorf("read %s: %w", batchResultsName, err)
}
if len(data) > 0 && data[len(data)-1] != '\n' {
return nil, nil, fmt.Errorf("%s does not end in a newline: its last row is truncated", batchResultsName)
}
var rows []result
var canonical bytes.Buffer
line := 0
for raw := range bytes.SplitSeq(data, []byte("\n")) {
if len(bytes.TrimSpace(raw)) == 0 {
continue
}
line++
var r result
if err := json.Unmarshal(raw, &r); err != nil {
return nil, nil, fmt.Errorf("%s line %d is not a complete row (truncated or corrupt): %w", batchResultsName, line, err)
}
rows = append(rows, r)
canon, err := canonicalJSON(raw)
if err != nil {
return nil, nil, fmt.Errorf("%s line %d: %w", batchResultsName, line, err)
}
canonical.Write(canon)
canonical.WriteByte('\n')
}
return rows, canonical.Bytes(), nil
}
// auditFigure is one number on both sides: what the manifest claimed and what
// the retained rows give. Unknown is printed as unknown, never as zero.
type auditFigure struct {
name string
claimed string
actual string
note string
moved bool
}
func compareClaims(c batchClaims, s suiteStats) ([]auditFigure, []string) {
rows := []struct {
name string
claimed string
actual string
required bool
}{
{"solved", intText(c.Solved), strconv.Itoa(s.passed), true},
{"pass1", intText(c.Pass1), strconv.Itoa(s.pass1), true},
{"prompt_tokens", intText(c.PromptTokens), strconv.Itoa(s.pTok), false},
{"completion_tokens", intText(c.CompletionTokens), strconv.Itoa(s.cTok), false},
{"cache_hit_tokens", intText(c.CacheHitTokens), strconv.Itoa(s.hit), false},
{"cache_miss_tokens", intText(c.CacheMissTokens), strconv.Itoa(s.miss), false},
{"cost", floatText(c.Cost), floatText(new(s.cost)), false},
{"currency", c.Currency, s.currency, false},
{"accounted", intText(c.Accounted), strconv.Itoa(s.accounted), true},
{"unaccounted", intText(c.Unaccounted), strconv.Itoa(s.unaccounted), true},
{"partial", intText(c.Partial), strconv.Itoa(s.partial), true},
{"tcts_median_ms", int64Text(c.TCTSMedianMs), strconv.FormatInt(median(s.ttcs), 10), false},
}
var figures []auditFigure
var failures []string
for _, row := range rows {
f := auditFigure{name: row.name, claimed: row.claimed, actual: row.actual}
switch {
case row.claimed == "":
f.claimed = "unknown"
f.note = "not claimed"
if row.required {
f.note = "required, absent"
failures = append(failures, row.name+": the manifest claims no "+row.name+"; an absent figure is required here")
}
case row.claimed != row.actual:
f.moved = true
failures = append(failures, fmt.Sprintf("%s: manifest %s, rows %s", row.name, row.claimed, row.actual))
}
figures = append(figures, f)
}
return figures, failures
}
// compareLedger checks the counts the rows can answer. Planned cannot be
// recomputed from rows — it is what the batch set out to do — so it is only
// held to the consistency checks in ledgerInvariants.
func compareLedger(declared, actual batchLedger) ([]auditFigure, []string) {
type entry struct {
name string
claimed *int
replayed *int
}
entries := []entry{
{"planned", declared.Planned, nil},
{"attempted", declared.Attempted, actual.Attempted},
{"completed", declared.Completed, actual.Completed},
{"scored", declared.Scored, actual.Scored},
{"failed", declared.Failed, actual.Failed},
{"unmeasured", declared.Unmeasured, actual.Unmeasured},
{"excluded", declared.Excluded, actual.Excluded},
{"rows", declared.Rows, actual.Rows},
{"skipped", declared.Skipped, actual.Skipped},
}
required := map[string]bool{"planned": true, "attempted": true, "completed": true, "scored": true, "failed": true, "unmeasured": true, "excluded": true}
var figures []auditFigure
var failures []string
for _, e := range entries {
f := auditFigure{name: e.name}
switch {
case e.claimed != nil:
f.claimed = strconv.Itoa(*e.claimed)
default:
f.claimed = "unknown"
f.note = "not claimed"
if required[e.name] {
f.note = "required, absent"
failures = append(failures, "ledger."+e.name+": absent; schema v1 requires this count, and absent is unknown, never zero")
}
}
if e.replayed == nil {
f.actual = "n/a"
f.note = joinNote(f.note, "not recomputable from rows")
} else {
f.actual = strconv.Itoa(*e.replayed)
}
if e.claimed != nil && e.replayed != nil && *e.claimed != *e.replayed {
f.moved = true
failures = append(failures, fmt.Sprintf("ledger.%s: manifest %d, rows %d", e.name, *e.claimed, *e.replayed))
}
figures = append(figures, f)
}
return figures, failures
}
// ledgerInvariants is the denominator guard: the counts must describe a
// funnel, so a manifest cannot publish a numerator larger than its base.
func ledgerInvariants(l batchLedger) []string {
var out []string
check := func(lower, upper *int, lowerName, upperName string) {
if lower == nil || upper == nil || *lower <= *upper {
return
}
out = append(out, fmt.Sprintf("ledger: %s %d exceeds %s %d", lowerName, *lower, upperName, *upper))
}
check(l.Scored, l.Planned, "scored", "planned")
check(l.Completed, l.Attempted, "completed", "attempted")
check(l.Scored, l.Completed, "scored", "completed")
check(l.Failed, l.Scored, "failed", "scored")
check(l.Unmeasured, l.Completed, "unmeasured", "completed")
check(l.Excluded, l.Rows, "excluded", "rows")
check(l.Unmeasured, l.Attempted, "unmeasured", "attempted")
if l.Rows != nil && l.Attempted != nil && l.Skipped != nil && *l.Rows != *l.Attempted+*l.Skipped {
out = append(out, fmt.Sprintf("ledger: rows %d is not attempted %d + skipped %d", *l.Rows, *l.Attempted, *l.Skipped))
}
return out
}
// denominatorChecks ties the published denominator to the ledger and to the
// outcome split, so passes and failures have to add up to it.
func denominatorChecks(m batchManifest, fromRows batchLedger) []string {
var out []string
d := m.Over.Denominator
if d.Unit != denominatorUnit || d.Definition != denominatorDefinition {
out = append(out, "denominator: the manifest defines it as "+strconv.Quote(d.Unit+" / "+d.Definition)+", which this audit does not implement")
}
if fromRows.Scored != nil && d.Value != *fromRows.Scored {
out = append(out, fmt.Sprintf("denominator: manifest %d, rows %d", d.Value, *fromRows.Scored))
}
if m.Trust.Ledger.Scored != nil && d.Value != *m.Trust.Ledger.Scored {
out = append(out, fmt.Sprintf("denominator: manifest %d, ledger.scored %d", d.Value, *m.Trust.Ledger.Scored))
}
if m.Trust.Claimed.Pass1 != nil && m.Trust.Ledger.Failed != nil && m.Trust.Ledger.Scored != nil {
if sum := *m.Trust.Claimed.Pass1 + *m.Trust.Ledger.Failed; sum != *m.Trust.Ledger.Scored {
out = append(out, fmt.Sprintf("ledger: scored %d is not pass1 %d + failed %d", *m.Trust.Ledger.Scored, *m.Trust.Claimed.Pass1, *m.Trust.Ledger.Failed))
}
}
return out
}
// recordAuditReceipt keeps the verifier identity in the manifest, so a later
// re-verification can tell which code checked it.
func recordAuditReceipt(manifestPath string, m batchManifest, hash string) error {
m.Audit = &batchAuditReceipt{
VerifierVersion: auditToolVersion,
VerifiedAt: time.Now().UTC().Format(time.RFC3339),
ResultsSHA256: hash,
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("encode audit receipt: %w", err)
}
if err := os.WriteFile(manifestPath, append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("record audit receipt: %w", err)
}
return nil
}
func renderAudit(dir string, m batchManifest, rows []result, hash string, figures, ledger []auditFigure, failures []string) string {
var b strings.Builder
fmt.Fprintf(&b, "## e2ebench batch audit — `%s`\n\n", filepath.Base(strings.TrimSpace(dir)))
b.WriteString("**What the figures are over**\n\n")
fmt.Fprintf(&b, "- schema: manifest v%d, audited by `%s/%d`\n", m.SchemaVersion, auditToolName, auditToolVersion)
fmt.Fprintf(&b, "- command: `%s` · axes: %s\n", m.Over.Command.Mode, axisText(m.Over.Command.Axes))
fmt.Fprintf(&b, "- task ids: `%s` (%d ids; task text is represented by this digest, not stored)\n", textOrUnknown(m.Over.Tasks.Digest), m.Over.Tasks.Count)
fmt.Fprintf(&b, "- filters: %s\n", filterText(m.Over.Filters))
fmt.Fprintf(&b, "- denominator: **%d %ss** — %s\n", m.Over.Denominator.Value, m.Over.Denominator.Unit, m.Over.Denominator.Definition)
b.WriteString("\n**Which run produced them**\n\n")
fmt.Fprintf(&b, "- commit: `%s` (%s, %s)\n", m.Producer.Commit, sourceText(m.Producer.CommitSource), dirtyText(m.Producer.Dirty))
fmt.Fprintf(&b, "- model: %s · provider: %s\n", nameText(m.Producer.Model), nameText(m.Producer.Provider))
fmt.Fprintf(&b, "- seed: %s\n", seedText(m.Producer.Seed))
fmt.Fprintf(&b, "- started: %s · finished: %s\n", m.Producer.StartedAt, m.Producer.FinishedAt)
fmt.Fprintf(&b, "- harness: %s · runtime %s\n", m.Producer.HarnessVersion, m.Producer.GoVersion)
b.WriteString("\n**Why they can still be trusted**\n\n")
fmt.Fprintf(&b, "- rows retained: %d in `%s`\n", len(rows), batchResultsName)
fmt.Fprintf(&b, "- results_sha256: %s\n", hashText(m.Trust.ResultsSHA256, hash))
if m.Audit != nil {
fmt.Fprintf(&b, "- last verified by `%s/%d` at %s over `%s`\n", auditToolName, m.Audit.VerifierVersion, m.Audit.VerifiedAt, m.Audit.ResultsSHA256)
}
fmt.Fprintf(&b, "- retention: %s\n", textOrUnknown(m.Trust.Retention))
b.WriteString("\n| claimed figure | manifest | rows | |\n|---|---:|---:|---|\n")
writeFigures(&b, figures)
b.WriteString("\n| ledger | manifest | rows | |\n|---|---:|---:|---|\n")
writeFigures(&b, ledger)
b.WriteString("\n")
if len(failures) > 0 {
b.WriteString("**Mismatches**\n\n")
for _, f := range failures {
fmt.Fprintf(&b, "- %s\n", f)
}
b.WriteString("\n")
}
if len(failures) > 0 {
fmt.Fprintf(&b, "**Verdict:** the published figures do **not** follow from the retained rows (%d mismatch(es)).\n\n", len(failures))
} else {
b.WriteString("**Verdict:** the published figures follow from the retained rows.\n\n")
}
b.WriteString("<sub>An aggregation audit, not a reproduction: the model, tools and environment are not re-executed, so this cannot show the experiment would reproduce. A real replay would need the commit, corpus fingerprint, model and provider configuration, seed, flags, raw results and runtime version; it is not implemented.</sub>\n")
return b.String()
}
func writeFigures(b *strings.Builder, figures []auditFigure) {
for _, f := range figures {
note := f.note
if f.moved {
note = joinNote(note, "**moved**")
}
fmt.Fprintf(b, "| %s | %s | %s | %s |\n", f.name, f.claimed, f.actual, note)
}
}
func axisText(axes []batchFlag) string {
if len(axes) == 0 {
return "none recorded"
}
parts := make([]string, len(axes))
for i, a := range axes {
parts[i] = a.Name + "=" + a.Value
}
return "`" + strings.Join(parts, "` · `") + "`"
}
func filterText(f batchFilters) string {
if strings.TrimSpace(f.Task) == "" {
return "none (`-task` unset: every task in the suite)"
}
return "`-task " + f.Task + "`"
}
func sourceText(source string) string {
if source == "" {
return "source unknown"
}
return "source " + source
}
func dirtyText(dirty *bool) string {
if dirty == nil {
return "dirty state unknown"
}
if *dirty {
return "dirty tree: the commit does not identify the code that ran"
}
return "clean tree"
}
// nameText prints an identity name, and says unknown when the field is absent
// rather than printing an empty name that reads as "none".
func nameText(name string) string {
if strings.TrimSpace(name) == "" {
return "unknown (absent from the manifest; absent is not zero)"
}
return "`" + name + "`"
}
func seedText(seed *int64) string {
if seed == nil {
return "unknown (absent from the manifest; an absent seed is not seed 0)"
}
return strconv.FormatInt(*seed, 10)
}
func hashText(claimed, actual string) string {
if claimed == "" {
return "unknown (the manifest claims no digest)"
}
if claimed != actual {
return "**MISMATCH** — manifest `" + claimed + "`, rows `" + actual + "`"
}
return "ok (`" + actual + "`)"
}
func textOrUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "unknown"
}
return s
}
func joinNote(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
default:
return a + "; " + b
}
}
func intText(p *int) string {
if p == nil {
return ""
}
return strconv.Itoa(*p)
}
func int64Text(p *int64) string {
if p == nil {
return ""
}
return strconv.FormatInt(*p, 10)
}
func floatText(p *float64) string {
if p == nil {
return ""
}
return strconv.FormatFloat(*p, 'f', -1, 64)
}
+458
View File
@@ -0,0 +1,458 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"sort"
"strconv"
"strings"
"time"
"reasonix/internal/contract/config"
)
const (
batchSchemaVersion = 1
batchResultsName = "results.jsonl"
batchManifestName = "batch.json"
// auditToolVersion is the version of the code below; every successful
// audit stamps it into the manifest, so a later reader can tell which
// verifier checked the batch.
auditToolVersion = 1
auditToolName = "e2ebench-audit"
// batchScope names the claim: an aggregation audit, not a replay.
batchScope = "aggregation audit: the claimed figures are recomputed from the retained rows; the model, tools and environment are not re-executed, so this is not a reproduction of the experiment"
// denominatorUnit and denominatorDefinition say what pass@1 divides by.
denominatorUnit = "task"
denominatorDefinition = "first attempts of solvable, non-skipped tasks: pass@1 divides passes by this count, never by the retained row count"
// batchRetention is the one line a reader needs before sharing the batch.
batchRetention = "results.jsonl is retained unredacted beside this manifest (task prompts and agent notes included) and is not public; the manifest carries a task-id digest, no prompt text, no absolute paths, no endpoints or keys and no model output"
)
// batchOver is what the figures are over: the command, the task set identity,
// the filters, and the denominator the accuracy is divided by.
type batchOver struct {
Command batchCommand `json:"command"`
Tasks batchTaskSet `json:"tasks"`
Filters batchFilters `json:"filters"`
Denominator batchDenominator `json:"denominator"`
}
// batchCommand records the mode and every flag that changes what a figure
// means. Output paths are deliberately absent: they are not evidence, and an
// absolute path is the operator's, not the batch's.
type batchCommand struct {
Mode string `json:"mode"`
Axes []batchFlag `json:"axes"`
}
type batchFlag struct {
Name string `json:"name"`
Value string `json:"value"`
}
// batchTaskSet identifies the corpus without carrying its text: a stable
// digest over the sorted task ids and how many there were.
type batchTaskSet struct {
Count int `json:"count"`
Digest string `json:"digest"`
}
type batchFilters struct {
// Task is -task as given; empty means no filter.
Task string `json:"task,omitempty"`
}
// batchDenominator names the unit pass@1 divides by, so a row count can never
// stand in for it.
type batchDenominator struct {
Unit string `json:"unit"`
Definition string `json:"definition"`
Value int `json:"value"`
}
// batchProducer is which run produced the figures. Absent optional fields are
// unknown, never zero: a missing seed is not seed 0.
type batchProducer struct {
Commit string `json:"commit"`
CommitSource string `json:"commit_source,omitempty"`
Dirty *bool `json:"dirty,omitempty"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
Seed *int64 `json:"seed,omitempty"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
HarnessVersion string `json:"harness_version"`
GoVersion string `json:"go_version"`
}
// batchTrust is why the figures can still be checked: the digest of the rows,
// the headline figures as published, and the ledger every planned run is
// accounted for in.
type batchTrust struct {
ResultsSHA256 string `json:"results_sha256"`
Claimed batchClaims `json:"claimed"`
Ledger batchLedger `json:"ledger"`
Retention string `json:"retention"`
}
// batchClaims are the headline figures the report publishes. A figure the run
// could not measure is absent, not zero.
type batchClaims struct {
Solved *int `json:"solved"`
Pass1 *int `json:"pass1"`
PromptTokens *int `json:"prompt_tokens,omitempty"`
CompletionTokens *int `json:"completion_tokens,omitempty"`
CacheHitTokens *int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens *int `json:"cache_miss_tokens,omitempty"`
Cost *float64 `json:"cost,omitempty"`
Currency string `json:"currency,omitempty"`
Accounted *int `json:"accounted"`
Unaccounted *int `json:"unaccounted"`
Partial *int `json:"partial"`
TCTSMedianMs *int64 `json:"tcts_median_ms,omitempty"`
}
// batchLedger accounts for every planned run. Rows is the retained record, not
// a denominator; Scored is the denominator.
type batchLedger struct {
Planned *int `json:"planned"`
Attempted *int `json:"attempted"`
Completed *int `json:"completed"`
Scored *int `json:"scored"`
Failed *int `json:"failed"`
Unmeasured *int `json:"unmeasured"`
Excluded *int `json:"excluded"`
Rows *int `json:"rows,omitempty"`
Skipped *int `json:"skipped,omitempty"`
Definitions map[string]string `json:"definitions,omitempty"`
}
// batchAuditReceipt is what a successful audit leaves behind.
type batchAuditReceipt struct {
VerifierVersion int `json:"verifier_version"`
VerifiedAt string `json:"verified_at"`
ResultsSHA256 string `json:"results_sha256"`
}
// batchManifest is written beside results.jsonl so the report never has to be
// reconstructed from a terminal scrollback.
type batchManifest struct {
SchemaVersion int `json:"schema_version"`
Scope string `json:"scope"`
Over batchOver `json:"over"`
Producer batchProducer `json:"producer"`
Trust batchTrust `json:"trust"`
Audit *batchAuditReceipt `json:"audit,omitempty"`
}
// ledgerDefinitions explains each count in the manifest itself, so the
// denominator cannot be reinterpreted after the fact.
var ledgerDefinitions = map[string]string{
"planned": "graded runs the batch set out to attempt: filtered tasks x trials",
"attempted": "retained rows for runs that started (skipped rows are not attempts)",
"completed": "attempts that ended and left a row; this harness writes a row only after an attempt ends",
"scored": "completed first attempts of solvable, non-skipped tasks: the pass@1 denominator",
"failed": "scored attempts the grader did not pass",
"unmeasured": "attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals",
"excluded": "rows kept out of the accuracy denominator: skipped, no_solution, and retry entries",
"rows": "rows retained in results.jsonl; a record of what was kept, never the denominator",
"skipped": "planned runs deliberately not started (anchor has no seed, or the token budget ran out)",
}
// batchProvenance is what the batch knows before any run happens. Everything
// else in the manifest is derived from the rows that were written.
type batchProvenance struct {
dir string
started time.Time
planned int
axes []batchFlag
tasks batchTaskSet
filters batchFilters
commit string
dirty *bool
model string
provider string
}
// captureBatchProvenance reads the checkout and model identity before the
// batch runs: git cannot describe a tree the agent has since changed.
func captureBatchProvenance(dir string, cfg suiteConfig, tasks []task, taskFilter string) batchProvenance {
commit, dirty := checkoutCommit(".")
model, provider := modelIdentity(cfg.model)
count, digest := taskSetDigest(tasks)
return batchProvenance{
dir: dir, started: time.Now().UTC(), planned: count * max(cfg.trials, 1),
axes: batchAxes(cfg), tasks: batchTaskSet{Count: count, Digest: digest},
filters: batchFilters{Task: taskFilter},
commit: commit, dirty: dirty, model: model, provider: provider,
}
}
// writeBatch writes the rows first and the manifest second, so a manifest
// never describes rows that are not beside it.
func writeBatch(p batchProvenance, results []result) error {
if err := os.MkdirAll(p.dir, 0o750); err != nil {
return fmt.Errorf("create batch directory: %w", err)
}
var raw, canonical bytes.Buffer
for _, r := range results {
line, err := json.Marshal(r)
if err != nil {
return fmt.Errorf("encode row: %w", err)
}
raw.Write(line)
raw.WriteByte('\n')
canon, err := canonicalJSON(line)
if err != nil {
return err
}
canonical.Write(canon)
canonical.WriteByte('\n')
}
// Rows carry task prompts and agent notes, so they stay the operator's.
if err := os.WriteFile(filepath.Join(p.dir, batchResultsName), raw.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", batchResultsName, err)
}
manifest := buildBatchManifest(p, results, canonical.Bytes())
b, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encode manifest: %w", err)
}
if err := os.WriteFile(filepath.Join(p.dir, batchManifestName), append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("write %s: %w", batchManifestName, err)
}
return nil
}
func buildBatchManifest(p batchProvenance, results []result, canonical []byte) batchManifest {
stats := gatherSuiteStats(results)
harness, goVersion := harnessIdentity()
source := ""
if p.commit != "unknown" {
source = "checkout"
}
return batchManifest{
SchemaVersion: batchSchemaVersion,
Scope: batchScope,
Over: batchOver{
Command: batchCommand{Mode: "suite", Axes: p.axes},
Tasks: p.tasks,
Filters: p.filters,
Denominator: batchDenominator{Unit: denominatorUnit, Definition: denominatorDefinition, Value: stats.ran},
},
Producer: batchProducer{
Commit: p.commit, CommitSource: source, Dirty: p.dirty,
Model: p.model, Provider: p.provider,
StartedAt: p.started.Format(time.RFC3339), FinishedAt: time.Now().UTC().Format(time.RFC3339),
HarnessVersion: harness, GoVersion: goVersion,
},
Trust: batchTrust{
ResultsSHA256: hashOf(canonical),
Claimed: claimsFor(stats),
Ledger: ledgerFor(results, p.planned),
Retention: batchRetention,
},
}
}
// claimsFor publishes the report's headline figures. Without a single
// accounted run there is no measured spend: those fields stay absent.
func claimsFor(s suiteStats) batchClaims {
c := batchClaims{
Solved: new(s.passed), Pass1: new(s.pass1),
Accounted: new(s.accounted), Unaccounted: new(s.unaccounted), Partial: new(s.partial),
}
if s.accounted > 0 {
c.PromptTokens, c.CompletionTokens = new(s.pTok), new(s.cTok)
c.Cost, c.Currency = new(s.cost), s.currency
if s.hit+s.miss > 0 {
c.CacheHitTokens, c.CacheMissTokens = new(s.hit), new(s.miss)
}
}
if s.passed > 0 {
c.TCTSMedianMs = new(median(s.ttcs))
}
return c
}
// ledgerFor accounts for every planned run. A retry is a row, not a planned
// unit: the plan is tasks x trials, which is why Scored and Attempted differ.
func ledgerFor(results []result, planned int) batchLedger {
l := ledgerFromRows(results)
l.Planned = new(planned)
l.Definitions = ledgerDefinitions
return l
}
func ledgerFromRows(results []result) batchLedger {
var attempted, completed, scored, failed, unmeasured, excluded, skipped int
for _, r := range results {
inDenominator := !r.Skipped && !r.NoSolution && r.Attempt <= 1
if r.Skipped {
skipped++
excluded++
continue
}
attempted++
completed++
if r.Unaccounted {
unmeasured++
}
if !inDenominator {
excluded++
continue
}
scored++
if !r.Passed {
failed++
}
}
return batchLedger{
Attempted: new(attempted), Completed: new(completed), Scored: new(scored),
Failed: new(failed), Unmeasured: new(unmeasured), Excluded: new(excluded),
Rows: new(len(results)), Skipped: new(skipped),
}
}
// canonicalJSON is what results_sha256 covers: line endings, indentation and
// key order are dropped because none of them is evidence, while a changed value
// or a missing row still changes the digest. Number spelling is kept, so a
// rewritten 1 is not silently equal to 1.0.
func canonicalJSON(line []byte) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(line))
dec.UseNumber()
var row any
if err := dec.Decode(&row); err != nil {
return nil, fmt.Errorf("row is not valid JSON: %w", err)
}
return json.Marshal(row)
}
func hashOf(data []byte) string {
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// checkoutCommit reports the checkout the batch runs from. Unknown is written
// explicitly rather than guessed: a directory git cannot describe is not a
// commit, and a dirty tree is not the commit it names.
func checkoutCommit(dir string) (string, *bool) {
commit := "unknown"
out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()
if err == nil {
if sha := strings.TrimSpace(string(out)); sha != "" {
commit = sha
}
}
var dirty *bool
out, err = exec.Command("git", "-C", dir, "status", "--porcelain").Output()
if err == nil {
state := strings.TrimSpace(string(out)) != ""
dirty = &state
}
return commit, dirty
}
// modelIdentity resolves the model and provider names the agent will run
// under. Names only: no endpoint, key or credential reaches the manifest.
func modelIdentity(flagModel string) (model, provider string) {
ref := strings.TrimSpace(flagModel)
cfg, err := config.Load()
if err == nil && cfg != nil {
if resolved, _, ok := cfg.ResolveModelWithFallback(ref); ok {
if name, rest, found := strings.Cut(resolved, "/"); found {
return rest, name
}
return resolved, ""
}
if ref == "" {
ref = strings.TrimSpace(cfg.DefaultModel)
}
}
if name, rest, found := strings.Cut(ref, "/"); found {
return rest, name
}
return ref, ""
}
func harnessIdentity() (string, string) {
version := "(devel)"
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" {
version = bi.Main.Version
}
return version, runtime.Version()
}
// taskSetDigest identifies the corpus by sorted id, not by name order: a
// reordered -task list is the same set. Ids are NUL-separated because a path
// name cannot contain NUL, so no two id sets share a digest string.
func taskSetDigest(tasks []task) (int, string) {
ids := make([]string, len(tasks))
for i, t := range tasks {
ids[i] = t.ID
}
sort.Strings(ids)
sum := sha256.Sum256([]byte(strings.Join(ids, "\x00")))
return len(ids), "sha256:" + hex.EncodeToString(sum[:])
}
// batchAxes lists the flags that change what a figure means, with their
// effective values. Paths and free text stay out: -steer records its count.
func batchAxes(cfg suiteConfig) []batchFlag {
tape := "off"
switch cfg.tape.mode {
case tapeRecord:
tape = "record"
case tapeReplay:
tape = "replay"
}
return []batchFlag{
{"profile", cfg.profile},
{"ablate", cfg.arm.Arm()},
{"cache", cfg.cacheArm},
{"anchor", cfg.anchor},
{"effort", valueOr(cfg.effort, "default")},
{"policy", valueOr(cfg.policy, "baseline")},
{"force-planner", formatBool(cfg.forcePlanner)},
{"checkpoints", formatBool(cfg.checkpoints)},
{"attempts", strconv.Itoa(max(cfg.attempts, 1))},
{"trials", strconv.Itoa(max(cfg.trials, 1))},
{"budget", strconv.Itoa(cfg.budget)},
{"segments", strconv.Itoa(max(cfg.segments, 1))},
{"steers", strconv.Itoa(len(cfg.steers))},
{"meter", onOff(cfg.meterConfig != "")},
{"faults", onOff(!cfg.meterFaults.empty())},
{"tape", tape},
{"reply-language", valueOr(cfg.replyLanguage, "auto")},
}
}
func valueOr(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return v
}
func formatBool(v bool) string {
if v {
return "true"
}
return "false"
}
func onOff(v bool) string {
if v {
return "on"
}
return "off"
}
+519
View File
@@ -0,0 +1,519 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
// a retry. Numbers are chosen so every headline figure is non-zero and the
// ledger has one of each interesting category.
func fixtureRows() []result {
return []result{
{
task: task{ID: "task-a", Prompt: "fixture prompt a"},
runMetrics: runMetrics{PromptTokens: 1000, CompletionTokens: 100, CacheHitTokens: 600, CacheMissTokens: 400, Cost: 0.01, Currency: "USD"},
Attempt: 1, Passed: true, WallMs: 1200, TTCSMs: 1200,
},
{
task: task{ID: "task-b", Prompt: "fixture prompt b"},
runMetrics: runMetrics{PromptTokens: 2000, CompletionTokens: 200, CacheHitTokens: 1500, CacheMissTokens: 500, Cost: 0.02, Currency: "USD"},
Attempt: 1, Passed: true, WallMs: 800, TTCSMs: 800,
},
{
task: task{ID: "task-c", Prompt: "fixture prompt c"},
runMetrics: runMetrics{PromptTokens: 3000, CompletionTokens: 300, CacheHitTokens: 0, CacheMissTokens: 3000, Cost: 0.03, Currency: "USD"},
Attempt: 1, Passed: false, WallMs: 500,
},
{
task: task{ID: "task-c"},
runMetrics: runMetrics{PromptTokens: 500, CompletionTokens: 50, Cost: 0.005, Currency: "USD"},
Attempt: 2, Passed: true, WallMs: 400, TTCSMs: 900,
},
}
}
func fixtureProvenance(dir string) batchProvenance {
count, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
return batchProvenance{
dir: dir,
started: timeAt("2026-10-28T00:00:00Z"),
planned: count,
axes: []batchFlag{{"profile", "baseline"}, {"trials", "1"}},
tasks: batchTaskSet{Count: count, Digest: digest},
commit: "0123456789abcdef0123456789abcdef01234567",
dirty: new(false),
model: "deepseek-flash",
}
}
func timeAt(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
t.Helper()
dir := t.TempDir()
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
t.Fatalf("write batch: %v", err)
}
return dir, readManifest(t, dir)
}
func readManifest(t *testing.T, dir string) batchManifest {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, batchManifestName))
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var m batchManifest
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("decode manifest: %v", err)
}
return m
}
func writeManifest(t *testing.T, dir string, m batchManifest) {
t.Helper()
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
t.Fatalf("encode manifest: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, batchManifestName), append(data, '\n'), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
}
// writeRows rewrites results.jsonl without touching the manifest, which is what
// a tampered batch looks like.
func writeRows(t *testing.T, dir string, rows []result) {
t.Helper()
var b bytes.Buffer
for _, r := range rows {
line, err := json.Marshal(r)
if err != nil {
t.Fatalf("encode row: %v", err)
}
b.Write(line)
b.WriteByte('\n')
}
if err := os.WriteFile(filepath.Join(dir, batchResultsName), b.Bytes(), 0o600); err != nil {
t.Fatalf("write rows: %v", err)
}
}
// A batch written by the producer audits clean: the figures the manifest
// published are the figures the retained rows give.
func TestBatchAuditRoundTrip(t *testing.T) {
dir, m := writeFixtureBatch(t)
if m.SchemaVersion != batchSchemaVersion || m.Scope == "" {
t.Fatalf("manifest schema = %d scope = %q, want v%d with a named claim", m.SchemaVersion, m.Scope, batchSchemaVersion)
}
if m.Over.Denominator.Value != 3 || m.Over.Denominator.Unit != denominatorUnit || m.Over.Denominator.Definition != denominatorDefinition {
t.Fatalf("denominator = %+v, want 3 tasks under the harness definition", m.Over.Denominator)
}
if m.Producer.Commit != "0123456789abcdef0123456789abcdef01234567" || m.Producer.CommitSource != "checkout" {
t.Fatalf("producer = %+v, want the checkout commit recorded", m.Producer)
}
if m.Trust.Ledger.Planned == nil || *m.Trust.Ledger.Planned != 3 {
t.Fatalf("planned = %v, want 3", m.Trust.Ledger.Planned)
}
if m.Trust.Ledger.Scored == nil || *m.Trust.Ledger.Scored != 3 {
t.Fatalf("scored = %v, want 3", m.Trust.Ledger.Scored)
}
if m.Trust.Ledger.Attempted == nil || *m.Trust.Ledger.Attempted != 4 || m.Trust.Ledger.Excluded == nil || *m.Trust.Ledger.Excluded != 1 {
t.Fatalf("ledger attempted/excluded = %v/%v, want 4/1", m.Trust.Ledger.Attempted, m.Trust.Ledger.Excluded)
}
// The manifest must not carry the raw task text the rows legitimately do.
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("fixture prompt")) {
t.Fatalf("the manifest carries raw task text:\n%s", raw)
}
out, err := auditBatch(dir)
if err != nil {
t.Fatalf("a producer-written batch did not audit: %v\n%s", err, out)
}
for _, want := range []string{
"| solved | 3 | 3 |",
"| pass1 | 2 | 2 |",
"| prompt_tokens | 6500 | 6500 |",
"| cost | 0.065 | 0.065 |",
"| scored | 3 | 3 |",
"| failed | 1 | 1 |",
"| planned | 3 | n/a |",
"results_sha256: ok",
"the published figures follow from the retained rows.",
"aggregation audit, not a reproduction",
} {
if !strings.Contains(out, want) {
t.Fatalf("audit output is missing %q:\n%s", want, out)
}
}
receipt := readManifest(t, dir).Audit
if receipt == nil || receipt.VerifierVersion != auditToolVersion || receipt.ResultsSHA256 != m.Trust.ResultsSHA256 {
t.Fatalf("audit receipt = %+v, want verifier v%d over the same digest", receipt, auditToolVersion)
}
}
// The axes carry what changes a figure's meaning, never the operator's machine:
// meter/tape/trajectory paths and steer text stay out of the manifest.
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
dir := t.TempDir()
prov := fixtureProvenance(dir)
prov.axes = batchAxes(suiteConfig{
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
attempts: 1, trials: 2, budget: 0, segments: 3,
meterConfig: "/Users/someone/.reasonix/config.toml",
meterFaults: faultScript{at: map[int]int{3: 429}},
tape: tapeConfig{mode: tapeRecord, root: "/tmp/tapes"},
steers: map[int]string{2: "secret steer text"},
trajDir: "/tmp/traj",
})
if err := writeBatch(prov, fixtureRows()); err != nil {
t.Fatalf("write batch: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"/Users", "/tmp", "secret steer text", ".reasonix"} {
if bytes.Contains(raw, []byte(forbidden)) {
t.Fatalf("the manifest carries %q:\n%s", forbidden, raw)
}
}
axes := map[string]string{}
for _, a := range readManifest(t, dir).Over.Command.Axes {
axes[a.Name] = a.Value
}
want := map[string]string{
"profile": "delivery", "effort": "high", "trials": "2", "segments": "3",
"meter": "on", "faults": "on", "tape": "record", "steers": "1", "ablate": "full",
}
for name, value := range want {
if axes[name] != value {
t.Fatalf("axis %s = %q, want %q (axes: %v)", name, axes[name], value, axes)
}
}
}
// The corpus digest is a set digest: order must not move it, and ids that only
// differ by a separator must not collide.
func TestTaskSetDigestIsSeparatedAndOrderIndependent(t *testing.T) {
n1, d1 := taskSetDigest([]task{{ID: "b"}, {ID: "a"}})
n2, d2 := taskSetDigest([]task{{ID: "a"}, {ID: "b"}})
if n1 != 2 || n2 != 2 || d1 != d2 {
t.Fatalf("order moved the corpus digest: %d/%s vs %d/%s", n1, d1, n2, d2)
}
_, joined := taskSetDigest([]task{{ID: "a\nb"}})
if joined == d1 {
t.Fatal("a newline inside one id collides with two ids")
}
}
// One token count moved after publication: the audit must fail and name the
// figure that moved, not just say the digest differs.
func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
dir, m := writeFixtureBatch(t)
tampered := fixtureRows()
tampered[0].PromptTokens += 1000
writeRows(t, dir, tampered)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a tampered row audited clean:\n%s", out)
}
for _, want := range []string{
"prompt_tokens: manifest 6500, rows 7500",
"| prompt_tokens | 6500 | 7500 | **moved** |",
"MISMATCH",
"the published figures do **not** follow from the retained rows",
} {
if !strings.Contains(out, want) {
t.Fatalf("audit output is missing %q:\n%s", want, out)
}
}
if readManifest(t, dir).Audit != nil {
t.Fatal("a failed audit recorded a verification receipt")
}
if m.Trust.ResultsSHA256 == "" {
t.Fatal("fixture manifest carries no digest")
}
}
// The three ways a batch can be unauditable are three different messages: no
// manifest, no commit to tie the figures to, and rows cut short.
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
t.Run("missing manifest", func(t *testing.T) {
dir := t.TempDir()
writeRows(t, dir, fixtureRows())
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("rows without a manifest audited clean:\n%s", out)
}
if !strings.Contains(err.Error(), "no batch.json") || !strings.Contains(err.Error(), "kept no manifest") {
t.Fatalf("missing-manifest error = %v", err)
}
})
t.Run("unknown commit", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Producer.Commit = "unknown"
m.Producer.CommitSource = ""
writeManifest(t, dir, m)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a manifest with an unknown commit audited clean:\n%s", out)
}
if !strings.Contains(err.Error(), "records no commit") {
t.Fatalf("no-commit error = %v", err)
}
})
t.Run("absent commit", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Producer.Commit = ""
writeManifest(t, dir, m)
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "records no commit") {
t.Fatalf("an absent commit answered %v, want the no-commit refusal", err)
}
})
t.Run("truncated rows", func(t *testing.T) {
dir, _ := writeFixtureBatch(t)
path := filepath.Join(dir, batchResultsName)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data[:len(data)-40], 0o600); err != nil {
t.Fatal(err)
}
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("truncated rows audited clean:\n%s", out)
}
if !strings.Contains(err.Error(), "truncated") {
t.Fatalf("truncated-rows error = %v", err)
}
})
t.Run("half a row with a newline", func(t *testing.T) {
dir, _ := writeFixtureBatch(t)
path := filepath.Join(dir, batchResultsName)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
cut := bytes.LastIndexByte(data[:len(data)-1], '\n')
if cut < 0 {
t.Fatal("fixture has fewer than two rows")
}
broken := append(append([]byte{}, data[:cut+1]...), data[cut+1:len(data)-20]...)
broken = append(broken, '\n')
if err := os.WriteFile(path, broken, 0o600); err != nil {
t.Fatal(err)
}
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "not a complete row") {
t.Fatalf("a half-written row answered %v, want a truncation refusal", err)
}
})
}
// manifestWithoutOptionals is pinned JSON, not a re-marshal of the writer's
// struct: seed, provider, dirty and cost are absent, and the audit must read
// each as unknown rather than as zero. This is the record the contract is
// about, so it is written out in full.
const manifestWithoutOptionals = `{
"schema_version": 1,
"scope": %q,
"over": {
"command": {"mode": "suite", "axes": [{"name": "profile", "value": "baseline"}, {"name": "trials", "value": "1"}]},
"tasks": {"count": 3, "digest": %q},
"denominator": {"unit": "task", "definition": %q, "value": 3}
},
"producer": {
"commit": "0123456789abcdef0123456789abcdef01234567",
"commit_source": "checkout",
"model": "deepseek-flash",
"started_at": "2026-10-28T00:00:00Z",
"finished_at": "2026-10-28T00:10:00Z",
"harness_version": "(devel)",
"go_version": "go1.26.5"
},
"trust": {
"results_sha256": %q,
"claimed": {
"solved": 3,
"pass1": 2,
"prompt_tokens": 6500,
"completion_tokens": 650,
"cache_hit_tokens": 2100,
"cache_miss_tokens": 3900,
"accounted": 4,
"unaccounted": 0,
"partial": 0,
"tcts_median_ms": 900
},
"ledger": {
"planned": 3,
"attempted": 4,
"completed": 4,
"scored": 3,
"failed": 1,
"unmeasured": 0,
"excluded": 1,
"rows": 4,
"skipped": 0
},
"retention": %q
}
}
`
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
dir := t.TempDir()
writeRows(t, dir, fixtureRows())
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
if err != nil {
t.Fatal(err)
}
_, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
manifest := fmt.Sprintf(manifestWithoutOptionals, batchScope, digest, denominatorDefinition, hashOf(canonical), batchRetention)
if err := os.WriteFile(filepath.Join(dir, batchManifestName), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
out, err := auditBatch(dir)
if err != nil {
t.Fatalf("a manifest with only optional fields absent did not audit: %v\n%s", err, out)
}
for _, want := range []string{
"seed: unknown (absent from the manifest; an absent seed is not seed 0)",
"provider: unknown (absent from the manifest; absent is not zero)",
"| cost | unknown | 0.065 | not claimed |",
} {
if !strings.Contains(out, want) {
t.Fatalf("audit output is missing %q:\n%s", want, out)
}
}
for _, forbidden := range []string{"seed: 0", "| cost | 0 |", "provider ``"} {
if strings.Contains(out, forbidden) {
t.Fatalf("an absent field read as %q:\n%s", forbidden, out)
}
}
}
// The digest covers values, not spelling: CRLF line endings and reordered keys
// are the same rows, so the same manifest still audits.
func TestBatchDigestIgnoresLineEndingsAndKeyOrder(t *testing.T) {
dir, m := writeFixtureBatch(t)
path := filepath.Join(dir, batchResultsName)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var rewritten bytes.Buffer
for line := range bytes.SplitSeq(data, []byte("\n")) {
if len(line) == 0 {
continue
}
rewritten.Write(reorderKeys(t, line))
}
if !bytes.Contains(rewritten.Bytes(), []byte("\r\n")) {
t.Fatal("fixture rewrite did not use CRLF")
}
if bytes.Equal(rewritten.Bytes(), data) {
t.Fatal("fixture rewrite left the bytes unchanged, so it proves nothing")
}
if err := os.WriteFile(path, rewritten.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
_, canonical, err := readBatch(path)
if err != nil {
t.Fatalf("re-spelled rows did not parse: %v", err)
}
if got := hashOf(canonical); got != m.Trust.ResultsSHA256 {
t.Fatalf("digest moved with spelling: %s, want %s", got, m.Trust.ResultsSHA256)
}
if out, err := auditBatch(dir); err != nil {
t.Fatalf("re-spelled rows did not audit: %v\n%s", err, out)
}
}
// reorderKeys emits the row with its keys in reverse order and a CRLF ending:
// both differences are outside the values results_sha256 covers.
func reorderKeys(t *testing.T, line []byte) []byte {
t.Helper()
var obj map[string]json.RawMessage
if err := json.Unmarshal(line, &obj); err != nil {
t.Fatalf("decode row: %v", err)
}
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Sort(sort.Reverse(sort.StringSlice(keys)))
var b bytes.Buffer
b.WriteByte('{')
for i, k := range keys {
if i > 0 {
b.WriteByte(',')
}
key, err := json.Marshal(k)
if err != nil {
t.Fatal(err)
}
b.Write(key)
b.WriteByte(':')
b.Write(obj[k])
}
b.WriteString("}\r\n")
return b.Bytes()
}
// A ledger is a funnel. Counts that cannot all be true together are rejected,
// so the published denominator cannot be larger than the runs behind it.
func TestBatchAuditRejectsALedgerThatDoesNotAddUp(t *testing.T) {
t.Run("scored above completed", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Trust.Ledger.Scored = new(5)
writeManifest(t, dir, m)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a ledger with scored > completed audited clean:\n%s", out)
}
if !strings.Contains(out, "ledger: scored 5 exceeds completed 4") {
t.Fatalf("audit output does not name the broken invariant:\n%s", out)
}
})
t.Run("denominator is not passes plus failures", func(t *testing.T) {
dir, m := writeFixtureBatch(t)
m.Trust.Ledger.Failed = new(0)
writeManifest(t, dir, m)
out, err := auditBatch(dir)
if err == nil {
t.Fatalf("a ledger whose denominator is not pass1+failed audited clean:\n%s", out)
}
if !strings.Contains(out, "ledger: scored 3 is not pass1 2 + failed 0") {
t.Fatalf("audit output does not name the denominator split:\n%s", out)
}
})
}
+22 -15
View File
@@ -233,20 +233,9 @@ func (r result) class() string {
const defaultSuiteTokenBudget = 800_000
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
flag.Usage = printUsage
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve")
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve | audit")
addr := flag.String("addr", "127.0.0.1:7480", "serve mode: live dashboard listen address")
subset := flag.String("subset", "benchmarks/swebench/subset.json", "swebench mode: instance subset file")
reportIn := flag.String("report", "", "sft mode: the run report (-json output) whose grader verdicts decide which runs are exported")
@@ -276,6 +265,7 @@ func main() {
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
batchDir := flag.String("batch", "", "suite mode: write results.jsonl (one row per run) and batch.json (manifest v1) here, so a later -mode audit can re-derive the report's figures; audit mode: read the batch from here")
budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
// diff-mode flags
repo := flag.String("repo", ".", "repo root (diff mode)")
@@ -298,6 +288,10 @@ func main() {
os.Exit(2)
}
profile, arm, cache, anchor := axes.profile, axes.arm, axes.cache, axes.anchor
if *batchDir != "" && *mode != "suite" && *mode != "audit" {
fmt.Fprintln(os.Stderr, "e2ebench: -batch is written by -mode suite and read by -mode audit; -mode "+*mode+" does neither")
os.Exit(2)
}
if *mode == "swebench" {
if _, err := permissionFlag(*permission); err != nil {
@@ -321,6 +315,7 @@ func main() {
if dispatchOfflineMode(*mode, offlineModeArgs{
trajDir: *trajDir, suite: *suite, reportIn: *reportIn, outMD: *outMD, outJSON: *outJSON, addr: *addr,
batchDir: *batchDir,
}) {
return
}
@@ -342,10 +337,10 @@ func main() {
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
replyLanguage: agentReplyLanguage(),
}, *suite, *taskFilter, *outMD, *outJSON)
}, *suite, *taskFilter, *outMD, *outJSON, *batchDir)
}
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON, batchDir string) {
tasks, err := loadTasks(suite)
if err != nil {
fmt.Fprintln(os.Stderr, "load suite:", err)
@@ -363,7 +358,19 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
os.Exit(2)
}
// The provenance is captured before the first run: the commit a run ends
// on is not necessarily the commit it started from.
var batch batchProvenance
if batchDir != "" {
batch = captureBatchProvenance(batchDir, cfg, tasks, taskFilter)
}
results := runSuite(cfg, tasks)
if batchDir != "" {
if err := writeBatch(batch, results); err != nil {
fmt.Fprintln(os.Stderr, "write batch:", err)
os.Exit(1)
}
}
report := render(results)
if outMD != "" {
+3
View File
@@ -58,6 +58,7 @@ type offlineModeArgs struct {
outMD string
outJSON string
addr string
batchDir string
}
// dispatchOfflineMode runs mode if it is one of the offline ones and reports
@@ -82,6 +83,8 @@ func dispatchOfflineMode(mode string, a offlineModeArgs) bool {
emit(report, a.outMD, "")
case "sft":
fail("sft", runSFTMode(a.trajDir, a.suite, a.reportIn, a.outMD))
case "audit":
fail("audit", runAuditMode(a.batchDir, a.outMD))
case "serve":
fail("serve", runServeMode(a.trajDir, a.suite, a.addr))
default:
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"flag"
"fmt"
"strings"
)
// printUsage is the harness's help text. It names one thing -mode audit does
// not do — replay the experiment — and what a replay would need instead.
func printUsage() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Keep a batch's rows and manifest, then audit its figures against them:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -batch runs/today\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode audit -batch runs/today\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "Auditing is not replaying: -mode audit re-derives the report's headline figures\n"+
"from results.jsonl and checks them against batch.json, naming every figure that moved.\n"+
"It never re-executes the model, tools or environment, so it cannot show the experiment\n"+
"reproduces. A real replay would need the commit, corpus fingerprint, model and provider\n"+
"configuration, seed, flags, raw results and runtime version; it is not implemented.\n")
}
+8 -7
View File
@@ -6,19 +6,20 @@ import (
"strings"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/config"
"reasonix/internal/frontend/serve"
)
// launchWorkspace is the folder the first pane opens. The working directory
// counts only when someone chose it: a Start-menu or Finder launch inherits the
// launchWorkspace is the folder the first pane opens: a chosen directory, then
// a remembered project, then the app's own auto workspace, never the home
// directory. The working directory counts only when someone chose it: a Start-menu or Finder launch inherits the
// install directory or the filesystem root, and taking that as a project puts
// the app's own folder in the sidebar again after every removal.
func launchWorkspace(appExe string) string {
home, _ := os.UserHomeDir()
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), config.AutoWorkspaceDir())
}
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
func pickLaunchWorkspace(cwd, appExe string, remembered []string, auto string) string {
if userChoseDir(cwd, appExe) {
return cwd
}
@@ -27,8 +28,8 @@ func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) s
return dir
}
}
if home != "" {
return home
if auto != "" && os.MkdirAll(auto, 0o755) == nil {
return auto
}
return cwd
}
+59 -4
View File
@@ -4,7 +4,10 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/config"
)
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
@@ -40,7 +43,8 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
}
return d
}
a, b, home := mk("a"), mk("b"), mk("home")
a, b := mk("a"), mk("b")
auto := filepath.Join(base, "auto")
gone := filepath.Join(base, "gone")
proj := mk("proj")
@@ -53,11 +57,11 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
{"chosen directory wins over the list", proj, []string{a}, proj},
{"list head", app, []string{a, b}, a},
{"stale head skipped", app, []string{gone, b}, b},
{"all stale falls to home", app, []string{gone}, home},
{"empty list falls to home", app, nil, home},
{"all stale falls to the auto workspace", app, []string{gone}, auto},
{"empty list falls to the auto workspace", app, nil, auto},
}
for _, c := range cases {
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, auto); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
@@ -90,3 +94,54 @@ func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
}
}
func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *testing.T) {
base := t.TempDir()
state := filepath.Join(base, "state")
home := filepath.Join(base, "home")
app := filepath.Join(base, "app")
for _, dir := range []string{state, home, app} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("REASONIX_HOME", state)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Chdir(app)
exe := filepath.Join(app, "Reasonix Studio")
got := launchWorkspace(exe)
if got == home {
t.Fatalf("a first launch opened the home directory %q", got)
}
if want := config.AutoWorkspaceDir(); got != want {
t.Fatalf("launchWorkspace = %q, want the auto workspace %q", got, want)
}
if st, err := os.Stat(got); err != nil || !st.IsDir() {
t.Fatalf("the auto workspace was not created: %v", err)
}
if rel, err := filepath.Rel(state, got); err != nil || strings.HasPrefix(rel, "..") {
t.Fatalf("the auto workspace %q is not under the state root %q", got, state)
}
}
func TestPickLaunchWorkspaceCreatesTheAutoWorkspaceLast(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "app")
exe := filepath.Join(app, "Reasonix Studio")
auto := filepath.Join(base, "state", "workspaces", "auto")
if got := pickLaunchWorkspace(app, exe, nil, auto); got != auto {
t.Fatalf("got %q, want %q", got, auto)
}
if st, err := os.Stat(auto); err != nil || !st.IsDir() {
t.Fatalf("not created: %v", err)
}
chosen := filepath.Join(base, "proj")
if err := os.MkdirAll(chosen, 0o755); err != nil {
t.Fatal(err)
}
if got := pickLaunchWorkspace(chosen, exe, nil, auto); got != chosen {
t.Fatalf("a chosen directory must win over the auto workspace: %q", got)
}
}
+1
View File
@@ -516,6 +516,7 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
dialer.NetDialContext = stream.DialContext
client := account.New(os.Getenv("REASONIX_ACCOUNTS_URL"), "reasonix-studio/"+version, httpClient)
host := remotecloud.New(client, &dialer, os.Getenv("REASONIX_REMOTE_URL"), version, tasks)
host.SetGateway(os.Getenv("REASONIX_REMOTE_GATEWAY_ID"))
if registrar, ok := tasks.(interface{ SetCloudControllerDisconnect(func(string) error) }); ok {
registrar.SetCloudControllerDisconnect(host.DisconnectController)
}
+66
View File
@@ -72,6 +72,57 @@ go run ./cmd/reasonix serve # from the repository root
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
```
## Messages sent during a run
**Settings → Appearance → Keyboard shortcuts → Messages sent while running** chooses the default:
- **Steer at the next tool boundary** (default) adds guidance to the current run.
- **Send after the whole run** queues a new turn until the active run finishes.
The choice is local to this device's browser profile, applies across projects,
and survives reloads. It does not change idle sends or messages already queued.
Skill invocations continue to use follow-up turns.
The queue still supports hold/resume, editing, and taking a message back into
the composer. **Send now** overrides the wait for a selected message by stopping
the current response and starting that message as a new turn; it is unavailable
while the queue is held.
Guidance not delivered before a run ends still becomes a follow-up through the
kernel's existing queue.
## Global show/hide shortcut
In the desktop shell, **Settings → Appearance → Global show / hide shortcut**
offers one opt-in binding, disabled by default.
1. Click **Record shortcut**, then press a modifier and key combination.
Escape cancels recording.
2. Review the platform-native label, then **Apply shortcut** to register it.
3. **Clear shortcut** disables it immediately.
You can also edit the Electron accelerator directly (up to 128 characters).
The shell requires Control, Command, Alt or Super, even through IPC or when
restored from disk. Shift may accompany one of them but cannot qualify alone.
The shortcut hides a focused Studio window; otherwise it restores, shows and
focuses the existing window. It does not change in-app shortcuts or tray settings.
The primary Electron instance owns the binding and stores it in its local shell
profile. Changes apply immediately; invalid or unavailable bindings leave the
previous saved and registered binding intact.
Startup registration failures are shown in settings rather than reported as
success. An unavailable binding may be occupied, reserved by the OS, denied
permission, or unsupported by the desktop.
Native Wayland environments may not support registration with this Electron
backend; no portal authorization or cross-platform success is implied.
The binding exists only while Studio is running. It cannot launch a stopped
application and no external daemon is installed. Browser and network viewers
cannot configure a remote machine's global shortcut.
## Building and testing
```sh
@@ -79,6 +130,21 @@ make studio-test # go test ./... for this modul
cd frontend-next && pnpm typecheck && pnpm test && pnpm build
```
The Linux/X11 and macOS shortcut smoke drives the real Studio window, chord
capture, and OS key events. After building the host and frontend, run
`pnpm shortcut-live` in `electron/`. macOS uses System Events and requires
Accessibility/Automation permission for the launching process.
Use isolated `REASONIX_HOME` and `XDG_CONFIG_HOME` directories; the smoke seeds
a test provider at an unreachable loopback port.
- On Linux/X11, `REASONIX_XDOTOOL` names `xdotool`.
- On Linux/X11, `REASONIX_XMESSAGE` optionally names `xmessage` for the separate-app focus check.
- `REASONIX_SCREENSHOTS` optionally names the screenshot output directory.
This test requires a working display. Windows and Wayland are not covered by
this native smoke; successful builds do not establish native shortcut behavior.
Packaging runs through electron-builder; `release-studio.yml` drives it on all
three platforms. Linux ships a `.deb` because that is what makes Studio
self-updating there. See `docs/STUDIO_RELEASE.md`.
+74
View File
@@ -121,3 +121,77 @@
${EndIf}
Pop $R9
!macroend
# Closing a running copy before an install, an update or an uninstall. The
# uninstaller runs from inside the install folder, so the process counting skips
# this process by id: matching on the folder alone would find the uninstaller
# itself and end it.
!macro rxScript
FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w
FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n"
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n"
FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n"
FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n"
FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
FileClose $0
!macroend
!macro rxFind
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" find $R2`
Pop $R0
!macroend
!macro rxKill
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" kill $R2`
Pop $0
!macroend
!macro rxKillElevated
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -C "Start-Process -FilePath powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$PLUGINSDIR\rxclose.ps1','$INSTDIR','kill','$R2' -Verb RunAs -Wait -WindowStyle Hidden"`
Pop $0
!macroend
!macro customCheckAppRunning
InitPluginsDir
System::Call 'kernel32::GetCurrentProcessId() i .R2'
!insertmacro rxScript
!insertmacro rxFind
${if} $R0 == 0
${ifNot} ${isUpdated}
${andIfNot} ${Silent}
DetailPrint "$(appClosing)"
${endIf}
StrCpy $R1 0
rxCloseLoop:
IntOp $R1 $R1 + 1
!insertmacro rxKill
Sleep 1000
!insertmacro rxFind
${if} $R0 != 0
Goto rxClosed
${endIf}
${if} $R1 == 4
${andIfNot} ${Silent}
DetailPrint "Asking Windows for permission to close the elevated copy of ${PRODUCT_NAME}..."
!insertmacro rxKillElevated
Sleep 1000
!insertmacro rxFind
${if} $R0 != 0
Goto rxClosed
${endIf}
${endIf}
${if} $R1 < 6
Goto rxCloseLoop
${endIf}
StrCmp $LANGUAGE 2052 rxZh
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} is still running and the installer could not close it.$\r$\n$\r$\nRight-click its icon in the system tray (click the ^ if it is hidden) and choose Quit, or press Ctrl+Shift+Esc, end every process named $\"${PRODUCT_NAME}$\" (run Task Manager as administrator if the app was started as administrator), then click Retry." /SD IDCANCEL IDRETRY rxRetry
Quit
rxZh:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} 仍在运行,安装程序无法自动关闭它。$\r$\n$\r$\n请在任务栏右下角的托盘图标(看不到就点 ^)上右键,选择“退出”;或按 Ctrl+Shift+Esc 打开任务管理器,结束所有名为“${PRODUCT_NAME}”的进程(如果它是以管理员身份启动的,请用管理员身份打开任务管理器),然后点“重试”。" /SD IDCANCEL IDRETRY rxRetry
Quit
rxRetry:
StrCpy $R1 0
Goto rxCloseLoop
rxClosed:
${endIf}
!macroend
+6 -2
View File
@@ -10,14 +10,18 @@
"start": "electron .",
"build:host": "node packaging/build-host.js",
"build:linux-payload": "node packaging/linux-payload.js",
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs",
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs test/globalshortcut.test.mjs",
"build:app": "electron-builder --dir",
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"shortcut-live": "electron test/globalshortcut_live.js",
"theme-live": "electron test/theme_live.js",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_live.js",
"pick-live": "electron test/elementpick_live.js",
"browser-fit-pages": "electron test/browser_fit_pages.js",
"sleep-live": "electron test/sleep_live.js",
"image-live": "electron test/image_live.js",
"image-pane-live": "electron test/image_pane_live.js",
"sidepanel-live": "electron test/sidepanel_live.js",
@@ -30,7 +34,7 @@
},
"devDependencies": {
"electron": "44.7.2",
"electron-builder": "^26.15.3",
"electron-builder": "^26.18.0",
"playwright": "1.64.1"
},
"pnpm": {
+43 -43
View File
@@ -12,8 +12,8 @@ importers:
specifier: 44.7.2
version: 44.7.2([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected]5.3)([email protected])
specifier: ^26.18.0
version: 26.18.0([email protected]8.0)([email protected])
playwright:
specifier: 1.64.1
version: 1.64.1
@@ -50,8 +50,8 @@ packages:
engines: {node: '>=12.0.0'}
hasBin: true
'@electron/[email protected].0':
resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==}
'@electron/[email protected].1':
resolution: {integrity: sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==}
engines: {node: '>=22.12.0'}
hasBin: true
@@ -80,9 +80,9 @@ packages:
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
engines: {node: '>= 16'}
'@noble/hashes@2.4.0':
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@peculiar/[email protected]':
resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==}
@@ -154,12 +154,12 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
[email protected]5.3:
resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==}
[email protected]8.0:
resolution: {integrity: sha512-lx+KkuCSZHcbECf4R3IAcC9VOFk0+cMUwljO63s5y8yqS1Lw0sRGmjPaWsPwP3zv3ohZYbMhUvjXeiQXXZhJXA==}
engines: {node: '>=14.0.0'}
peerDependencies:
dmg-builder: 26.15.3
electron-builder-squirrel-windows: 26.15.3
dmg-builder: 26.18.0
electron-builder-squirrel-windows: 26.18.0
[email protected]:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -219,8 +219,8 @@ packages:
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
engines: {node: '>=12.0.0'}
[email protected]5.3:
resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==}
[email protected]6.0:
resolution: {integrity: sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==}
engines: {node: '>=14.0.0'}
[email protected]:
@@ -336,8 +336,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
[email protected]5.3:
resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==}
[email protected]8.0:
resolution: {integrity: sha512-ozCGC54wi2C1RkS2v69y9lQxvoQ1Coh+iT8CmWiyY8y1ZuOKVvuNson2Hd+HEOs9+EZNNsOBbzKgQZ4SNqWd0w==}
[email protected]:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
@@ -359,16 +359,16 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
[email protected]5.3:
resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==}
[email protected]8.0:
resolution: {integrity: sha512-a2elgTipboXNIsxqS1HOmHMGp4Pd3LJgIZOdIFTOskBSawvxKyZWYWQ1d7KKA4CtINSchYCYEKzhxRqiKZev9Q==}
[email protected]5.3:
resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==}
[email protected]8.0:
resolution: {integrity: sha512-ASgIuQnOq4RuD8jMhS9hI0LS3EMRT1I/M5R6utqifB/BHN80IcCjBdt+DED+NmNPvmBgEtrlExXZitNdA7kXOg==}
engines: {node: '>=14.0.0'}
hasBin: true
[email protected]5.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
[email protected]8.0:
resolution: {integrity: sha512-MagnqaZSALSWjcdKu5HEVhYUZD/2BDL3q7XzrqWwub6gf/UfD3ItMgmthVDv9w5y9E0gH1C3JWqKaXwf6f4ROg==}
[email protected]:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
@@ -1111,7 +1111,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron/[email protected].0([email protected])':
'@electron/[email protected].1([email protected])':
dependencies:
'@malept/cross-spawn-promise': 2.0.0
debug: 4.4.3([email protected])
@@ -1164,7 +1164,7 @@ snapshots:
'@noble/[email protected]': {}
'@noble/hashes@2.4.0': {}
'@noble/hashes@1.8.0': {}
'@peculiar/[email protected]':
dependencies:
@@ -1244,33 +1244,33 @@ snapshots:
dependencies:
color-convert: 2.0.1
[email protected]5.3([email protected]5.3)([email protected]5.3)([email protected]):
[email protected]8.0([email protected]8.0)([email protected]8.0)([email protected]):
dependencies:
'@electron/asar': 3.4.1
'@electron/fuses': 1.8.0
'@electron/get': 3.1.0([email protected])
'@electron/notarize': 2.5.0([email protected])
'@electron/osx-sign': 1.3.3([email protected])
'@electron/rebuild': 4.2.0([email protected])
'@electron/rebuild': 4.2.1([email protected])
'@electron/universal': 2.0.3([email protected])
'@malept/flatpak-bundler': 0.4.0([email protected])
'@noble/hashes': 2.4.0
'@noble/hashes': 1.8.0
'@peculiar/webcrypto': 1.7.1
'@types/fs-extra': 9.0.13
ajv: 8.20.0
asn1js: 3.0.10
async-exit-hook: 2.0.1
builder-util: 26.15.3([email protected])
builder-util: 26.16.0([email protected])
builder-util-runtime: 9.7.0([email protected])
chromium-pickle-js: 0.2.0
ci-info: 4.3.1
debug: 4.4.3([email protected])
dmg-builder: 26.15.3([email protected]5.3)([email protected])
dmg-builder: 26.18.0([email protected]8.0)([email protected])
dotenv: 16.6.1
dotenv-expand: 11.0.7
ejs: 3.1.10
electron-builder-squirrel-windows: 26.15.3([email protected]5.3)([email protected])
electron-publish: 26.15.3([email protected])
electron-builder-squirrel-windows: 26.18.0([email protected]8.0)([email protected])
electron-publish: 26.18.0([email protected])
fs-extra: 10.1.0
hosted-git-info: 4.1.0
isbinaryfile: 5.0.7
@@ -1343,7 +1343,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]5.3([email protected]):
[email protected]6.0([email protected]):
dependencies:
'@types/debug': 4.1.13
builder-util-runtime: 9.7.0([email protected])
@@ -1470,10 +1470,10 @@ snapshots:
minimatch: 3.1.5
p-limit: 3.1.0
[email protected]5.3([email protected]5.3)([email protected]):
[email protected]8.0([email protected]8.0)([email protected]):
dependencies:
app-builder-lib: 26.15.3([email protected]5.3)([email protected]5.3)([email protected])
builder-util: 26.15.3([email protected])
app-builder-lib: 26.18.0([email protected]8.0)([email protected]8.0)([email protected])
builder-util: 26.16.0([email protected])
fs-extra: 10.1.0
js-yaml: 4.3.2
transitivePeerDependencies:
@@ -1500,23 +1500,23 @@ snapshots:
dependencies:
jake: 10.9.4
[email protected]5.3([email protected]5.3)([email protected]):
[email protected]8.0([email protected]8.0)([email protected]):
dependencies:
app-builder-lib: 26.15.3([email protected]5.3)([email protected]5.3)([email protected])
builder-util: 26.15.3([email protected])
app-builder-lib: 26.18.0([email protected]8.0)([email protected]8.0)([email protected])
builder-util: 26.16.0([email protected])
electron-winstaller: 5.4.0([email protected])
transitivePeerDependencies:
- dmg-builder
- supports-color
[email protected]5.3([email protected]5.3)([email protected]):
[email protected]8.0([email protected]8.0)([email protected]):
dependencies:
app-builder-lib: 26.15.3([email protected]5.3)([email protected]5.3)([email protected])
builder-util: 26.15.3([email protected])
app-builder-lib: 26.18.0([email protected]8.0)([email protected]8.0)([email protected])
builder-util: 26.16.0([email protected])
builder-util-runtime: 9.7.0([email protected])
chalk: 4.1.2
ci-info: 4.4.0
dmg-builder: 26.15.3([email protected]5.3)([email protected])
dmg-builder: 26.18.0([email protected]8.0)([email protected])
fs-extra: 10.1.0
lazy-val: 1.0.5
simple-update-notifier: 2.0.0
@@ -1525,11 +1525,11 @@ snapshots:
- electron-builder-squirrel-windows
- supports-color
[email protected]5.3([email protected]):
[email protected]8.0([email protected]):
dependencies:
'@types/fs-extra': 9.0.13
aws4: 1.13.2
builder-util: 26.15.3([email protected])
builder-util: 26.16.0([email protected])
builder-util-runtime: 9.7.0([email protected])
chalk: 4.1.2
form-data: 4.0.6
+576 -79
View File
@@ -3,6 +3,7 @@ const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typed } = require("./browserguard");
const { ARM, STOP } = require("./elementpick");
const { InputWake } = require("./inputwake");
const { KeyboardKeeper } = require("./keyboard");
@@ -11,24 +12,48 @@ const { KeyboardKeeper } = require("./keyboard");
// a phone layout or not at all.
const AGENT_VIEWPORT = { width: 1280, height: 900 };
// How long a page may be held out of the window while its navigation runs. A
// server that accepts the connection and never answers would otherwise keep the
// panel blank, with nothing turning, for as long as it likes; past this the page
// is shown again, loaded or not.
// How long a page may be held out of the window while its navigation runs; past
// this it is shown again loaded or not, or a server that accepts the connection
// and never answers would keep the panel blank for as long as it likes.
const LEAVE_LIMIT_MS = 4000;
// CSS cannot clip a native view, so the page's corners are rounded here; the
// value is .bview's border-radius in studio.css, whose rect the view fills.
const PAGE_RADIUS = 8;
// BrowserViews owns the pages the agent's browser opens in this window. Each is
// a view on its own partition — never the session the window's credential
// lives in. A page nobody is watching is not hidden and not moved wholly off
// screen: Chromium lays out nothing outside the window, and a page it created
// while its view was hidden takes no input until shown. It is kept overlapping
// the window by one pixel in the bottom-left corner instead.
// How many pages may stay asleep at once; past this the oldest is buried.
const SLEPT_LIMIT = 18;
// How long a pick waits for the person. Past it the page is left as it was
// found: a picker left armed swallows every click on the page, so the wait is
// a minute and a half, not an afternoon.
const PICK_LIMIT_MS = 90_000;
// A page's own scripts live in world 0; the picker answers from its own.
const PICK_WORLD = 999;
// The page wrote the answer; these repeat the page script's own limits because
// the shell cannot trust it to have run them.
const TEXT_CLIP = 4000;
const NEARBY_CLIP = 300;
const MARKUP_CLIP = 6000;
const VALUE_CLIP = 500;
const ATTR_LIMIT = 64;
const STYLE_CLIP = 200;
const ELEMENT_STRINGS = ["pageUrl", "pageTitle", "tagName", "role", "accessibleName", "selector", "xpath", "text", "nearbyText", "htmlExcerpt"];
const STYLE_KEYS = ["backgroundColor", "color", "display", "fontFamily", "fontSize", "fontWeight"];
const SELECTOR_BASIS = ["testid", "aria-label", "id", "class"];
const CANCEL_REASONS = ["escape", "dismissed", "timeout", "hidden", "navigation", "gone"];
const clip = (value, limit) => (value.length > limit ? value.slice(0, limit) : value);
// A command that switches a CDP domain on. The kernel sends these once at
// attach, and a sleep throws away the session they were said on, so they are
// kept per page and said again to every session a wake builds.
const ENABLE_SHAPE = /^[A-Za-z]+\.enable$/;
// BrowserViews owns the pages the agent's browser opens in this window, each a
// view on its own partition, never the session the window's credential lives in.
// An unwatched page is parked at a one-pixel corner; an idle one sleeps whole.
class BrowserViews {
constructor({ win, kernelOrigin }) {
constructor({ win, kernelOrigin, sleepAfterMs = 15 * 60_000, sweepMs = 60_000, maxAwake = 6 }) {
this.win = win;
this.kernelOrigin = kernelOrigin;
this.entries = new Map();
@@ -39,12 +64,23 @@ class BrowserViews {
this.trusted = new Set();
this.shown = "";
this.keyboard = new KeyboardKeeper({ win });
this.wake = new InputWake({ win });
this.inputWake = new InputWake({ win });
// A page whose renderer has sat untouched for sleepAfterMs is slept by the
// sweep: a slept page keeps its entry and its targetId, so the kernel's
// handle outlives the sleep.
this.sleepAfterMs = sleepAfterMs;
this.maxAwake = maxAwake;
this.sweeper = setInterval(() => this.sweep(), sweepMs);
// The timer holds no process open by itself: the Studio's loop is kept by
// its windows, and a test run full of these must still be able to end.
if (typeof this.sweeper.unref === "function") this.sweeper.unref();
// Moves with every show and hide, so a freeze that finishes after one does
// not put away a page shown since.
this.placed = 0;
win.on("resize", () => {
for (const [id, entry] of this.entries) if (id !== this.shown) this.putAway(entry.view);
for (const [id, entry] of this.entries) {
if (id !== this.shown && entry.view) this.putAway(entry.view);
}
});
// A page in a minimized window keeps its widget hidden, and a hidden widget
// drops the input the agent sends it — the protocol call does not even
@@ -72,7 +108,7 @@ class BrowserViews {
ses.on("will-download", (event, item, contents) => {
event.preventDefault();
for (const entry of this.entries.values()) {
if (entry.view.webContents === contents) {
if (entry.view && entry.view.webContents === contents) {
entry.onDownload({ url: item.getURL(), suggestedFilename: item.getFilename() });
}
}
@@ -82,9 +118,93 @@ class BrowserViews {
create({ partition, url, onEvent, onClosed, onPopup, onDownload }) {
this.guard(partition);
const targetId = crypto.randomUUID();
// The cap on awake pages obeys the same switch as the sweep — off means
// nothing sleeps, cap included: the least recently touched page is slept to
// make room, never the one the person is looking at. A wake yields no room.
let awake = 0;
let victim = null;
for (const [id, other] of this.entries) {
if (other.slept) continue;
awake++;
if (id !== this.shown && !other.sleeping && !other.returning && (!victim || other.lastTouch < victim.lastTouch)) victim = other;
}
if (Number.isFinite(this.sleepAfterMs) && awake >= this.maxAwake && victim) this.sleep(victim).catch(() => {});
const entry = {
targetId,
partition,
url,
onEvent,
onPopup,
onDownload,
view: null,
mainFrame: "",
enables: [],
typed: 0,
synthUntil: 0,
attached: false,
panel: null,
override: null,
emulated: null,
scale: 1,
lastTouch: Date.now(),
slept: null,
sleeping: false,
rising: null,
pending: new Set(),
showing: false,
dialog: false,
closed: false,
};
// The burial of a target is the one thing the kernel is told of, so it
// lives on the entry rather than on the view: a page that sleeps and wakes
// grows a new view, and it is buried by its close, not by a view's death.
const close = () => {
if (entry.closed) return;
entry.closed = true;
this.entries.delete(targetId);
clearTimeout(entry.returning);
if (entry.attached && !this.win.isDestroyed() && entry.view) this.win.contentView.removeChildView(entry.view);
entry.attached = false;
const contents = entry.view && entry.view.webContents;
if (contents && !contents.isDestroyed()) contents.close();
onClosed();
};
entry.close = close;
this.entries.set(targetId, entry);
try {
this.spawn(entry);
} catch (err) {
// A spawn that fails half-built leaves no entry behind: the target never
// happened, so nothing is closed and the kernel is told nothing of it.
entry.closed = true;
this.entries.delete(targetId);
const contents = entry.view && entry.view.webContents;
if (contents && !contents.isDestroyed()) contents.close();
throw err;
}
return {
targetId,
send: (method, params) => {
entry.lastTouch = Date.now();
// A slept page is woken first and the call dispatched to what it comes
// back as; a page still rising takes the call when it has landed, since
// mid-load answers the page it was.
const rising = entry.slept ? this.wake(entry) : entry.rising;
if (rising) return rising.then(() => this.dispatch(entry, method, params));
return this.dispatch(entry, method, params);
},
close,
mainFrame: () => (entry.slept ? "" : entry.mainFrame),
};
}
// spawn builds the view a page lives in and loads its address. It runs for
// create() and again for every wake, so a page that slept comes back as
// exactly what a new page is, under the handle it never lost.
spawn(entry) {
const view = new WebContentsView({
webPreferences: {
partition: "persist:" + partition,
partition: "persist:" + entry.partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
@@ -94,8 +214,9 @@ class BrowserViews {
},
});
view.setBorderRadius(PAGE_RADIUS);
entry.view = view;
entry.mainFrame = "";
const contents = view.webContents;
let mainFrame = "";
const allowed = (to) => guestNavigationAllowed(to, this.kernelOrigin);
contents.on("will-navigate", (event, to) => {
if (!allowed(to)) event.preventDefault();
@@ -104,58 +225,218 @@ class BrowserViews {
if (!allowed(to)) event.preventDefault();
});
contents.setWindowOpenHandler(({ url: to }) => {
if (allowed(to)) onPopup(to);
if (allowed(to)) entry.onPopup(to);
return { action: "deny" };
});
installPaneReload(contents);
contents.debugger.attach("1.3");
contents.debugger.on("message", (_event, method, params, sessionId) => {
entry.lastTouch = Date.now();
if (sessionId) return;
if (method === "Page.frameNavigated" && params && params.frame && !params.frame.parentId) {
mainFrame = params.frame.id;
entry.mainFrame = params.frame.id;
}
onEvent(method, params);
// The kernel enables Page at attach, so the dialog events reach the
// shell: a page waiting on its answer is not slept under the question.
if (method === "Page.javascriptDialogOpening") entry.dialog = true;
else if (method === "Page.javascriptDialogClosed") entry.dialog = false;
entry.onEvent(method, params);
});
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
this.watchLoads(targetId, entry);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
this.entries.delete(targetId);
clearTimeout(entry.returning);
if (entry.attached && !this.win.isDestroyed()) this.win.contentView.removeChildView(view);
entry.attached = false;
if (!contents.isDestroyed()) contents.close();
onClosed();
};
contents.once("destroyed", close);
contents.on("render-process-gone", close);
this.entries.set(targetId, entry);
// The page loads before the window holds it: a page that finishes loading in a
// view already attached is given the keyboard by the platform, and the
// person's composition in the app page ends with it.
void contents.loadURL(url).catch(() => {});
return {
targetId,
send: (method, params) => {
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
try {
params = scaleInput(method, params, entry.scale);
} catch (e) {
return Promise.reject(e);
}
// A capture puts the page's emulation back without its scale.
if (method === "Page.captureScreenshot") return contents.debugger.sendCommand(method, params).finally(() => void this.fit(entry, true));
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
entry.synthUntil = Date.now() + 250;
return this.wake.run(() => contents.debugger.sendCommand(method, params));
},
close,
mainFrame: () => mainFrame,
// The picker's key belongs to the page while the page holds the keyboard:
// a press that went there never reaches the window's own page, so it is
// taken here and the picker is started where the person is looking.
contents.on("before-input-event", (event, input) => {
if (!input || input.type !== "keyDown" || input.isAutoRepeat || !input.key || !input.shift) return;
if (String(input.key).toLowerCase() !== "c") return;
if (process.platform === "darwin" ? !input.meta : !input.control) return;
if (entry.targetId !== this.shown) return;
event.preventDefault();
this.send("browser:pick-shortcut", { targetId: entry.targetId });
});
this.watchLoads(entry.targetId, entry);
const gone = () => {
// A sleep closes the contents itself, so a death under it is the sleep
// landing, not the target dying: the entry stays a tombstone for its next
// send() to raise or its close() to bury.
if (entry.sleeping) {
entry.sleeping = false;
return;
}
if (entry.slept || entry.view?.webContents !== contents) return;
entry.close();
};
contents.once("destroyed", gone);
contents.on("render-process-gone", gone);
// A woken page must come back laid out at the viewport the agent set, not
// at whatever its view happens to be while it is parked.
if (entry.emulated) contents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", entry.emulated).catch(() => {});
// The page loads before the window holds it, or the platform hands it the
// keyboard and ends the person's composition. The load's promise is what a
// wake waits out; a create leaves the waiting to the kernel.
return contents.loadURL(entry.url).catch(() => {});
}
// dispatch carries one protocol call to the page's debugger. It runs on a
// live view only; every caller has seen to the page being awake first.
dispatch(entry, method, params) {
// An enable-shape command is remembered as it is sent — latest parameters,
// first-seen order — and a domain switched off leaves the set, so the next
// wake does not switch it back on.
if (ENABLE_SHAPE.test(method) || method === "Page.setLifecycleEventsEnabled") {
const known = entry.enables.find((one) => one[0] === method);
if (known) known[1] = params;
else entry.enables.push([method, params]);
} else if (ENABLE_SHAPE.test(method.replace(/\.disable$/, ".enable"))) {
const enable = method.slice(0, -".disable".length) + ".enable";
entry.enables = entry.enables.filter(([one]) => one !== enable);
}
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
try {
params = scaleInput(method, params, entry.scale);
} catch (e) {
return Promise.reject(e);
}
// A capture puts the page's emulation back without its scale.
if (method === "Page.captureScreenshot") return this.command(entry, method, params).finally(() => void this.fit(entry, true));
if (!method.startsWith("Input.")) return this.command(entry, method, params);
entry.synthUntil = Date.now() + 250;
return this.inputWake.run(() => this.command(entry, method, params));
}
// command is one debugger call the kernel is waiting on. A session that dies
// never settles its calls on its own, so a sleep fails them here: the kernel
// reads an error it can retry, not a timeout it cannot tell from a vanishing.
command(entry, method, params) {
return new Promise((resolve, reject) => {
entry.pending.add(reject);
entry.view.webContents.debugger.sendCommand(method, params).then(
(value) => {
entry.pending.delete(reject);
resolve(value);
},
(err) => {
entry.pending.delete(reject);
reject(err);
},
);
});
}
// sleep puts a page away whole: its renderer is closed, and what is kept is
// the address it was on and a picture to draw while it is gone. Nothing the
// kernel holds changes, so its handle survives and the next call wakes it.
async sleep(entry) {
if (entry.slept || entry.closed || !this.maySleep(entry)) return;
const contents = entry.view && entry.view.webContents;
if (!contents) return;
// The picture is what the window draws while the page is gone, taken the
// way freeze() takes its own; a page that cannot be photographed is slept
// all the same.
let url = entry.url;
let picture = "";
try {
url = contents.getURL() || entry.url;
const image = await contents.capturePage();
if (!image.isEmpty()) picture = "data:image/jpeg;base64," + image.toJPEG(85).toString("base64");
} catch {
picture = "";
}
// The page may have been shown, woken, closed, or begun a navigation while
// the picture was taken; a sleep that lost that race must not tear anything
// down, and a navigation killed mid-flight hangs the kernel waiting on it.
if (entry.slept || entry.closed || !entry.view || !this.maySleep(entry)) return;
entry.slept = { url, picture };
// The calls still open on the session that is about to die are closed
// first, as errors: the kernel hears nothing of a sleep, so no one else
// will tell them.
for (const fail of entry.pending) fail(new Error("the page went to sleep"));
entry.pending.clear();
entry.sleeping = true;
if (entry.attached && !this.win.isDestroyed()) this.win.contentView.removeChildView(entry.view);
entry.attached = false;
if (!contents.isDestroyed()) contents.close();
entry.view = null;
entry.at = null;
entry.panel = null;
entry.override = null;
entry.scale = 1;
// A settle timer left running would put a view back that no longer
// exists, or worse, the one a wake builds next.
clearTimeout(entry.returning);
entry.returning = null;
// The ceiling on the dead: a tombstone is only a picture and an address,
// but a driver that never comes back leaves one for every page it opened.
// Past this the oldest-touched is buried whole and the kernel is told.
let tombs = 0;
let oldest = null;
for (const other of this.entries.values()) {
if (!other.slept) continue;
tombs++;
if (!oldest || other.lastTouch < oldest.lastTouch) oldest = other;
}
if (tombs > SLEPT_LIMIT && oldest) oldest.close();
}
// wake raises a slept page: the view is built again on the address it dozed
// off on, its viewport and the kernel's enabled domains are said again to the
// new session, and the promise answers only once the page has loaded.
wake(entry) {
if (!entry.slept) return entry.rising || Promise.resolve();
entry.url = entry.slept.url;
entry.slept = null;
// The sleep has landed, so whatever death its old contents still reports
// is nothing for this one to answer for.
entry.sleeping = false;
const load = this.spawn(entry);
// The recorded emulation is the view-fitting state a sleep threw away;
// restoring it lets show() fit the panel the way it did before the sleep.
entry.override = entry.emulated ? { ...entry.emulated } : null;
for (const [method, params] of entry.enables) {
entry.view.webContents.debugger.sendCommand(method, params).catch(() => {});
}
entry.rising = load.finally(() => {
entry.rising = null;
});
return entry.rising;
}
// maySleep says whether a page may be put away: not while the person is looking
// at it, a navigation or a settle is pending, a certificate or a dialog waits,
// it is sounding, or DevTools is open. Downloads need no guard here.
maySleep(entry) {
if (entry.targetId === this.shown || entry.picking || entry.returning || entry.certificate || entry.dialog) return false;
const contents = entry.view && entry.view.webContents;
if (typeof contents?.isCurrentlyAudible === "function" && contents.isCurrentlyAudible()) return false;
if (typeof contents?.isDevToolsOpened === "function" && contents.isDevToolsOpened()) return false;
return true;
}
// The sweep puts pages nobody has touched to sleep. One page's failure must
// not take the timer down with it: the pages after it are still paying.
sweep() {
const now = Date.now();
for (const entry of this.entries.values()) {
try {
if (entry.slept || entry.sleeping) continue;
if (now - entry.lastTouch <= this.sleepAfterMs) continue;
if (!this.maySleep(entry)) continue;
this.sleep(entry).catch(() => {});
} catch {}
}
}
// A preference saved while the app runs takes effect on the next sweep.
setSleepAfterMs(ms) {
this.sleepAfterMs = ms;
}
// What a test may read, and nothing more: whether a target is asleep, and
// the address it is asleep on.
asleep(targetId) {
const entry = this.entries.get(targetId);
return entry && entry.slept ? { url: entry.slept.url } : null;
}
// show draws one page over the rectangle the page reserved for it, in window
@@ -163,6 +444,25 @@ class BrowserViews {
show(targetId, rect) {
const entry = this.entries.get(targetId);
if (!entry || !validRect(rect)) return this.hide();
entry.lastTouch = Date.now();
// A slept page has no view to place, and the panel does not send its
// rectangle twice: the show is retried once the wake has built the view,
// and only once — two shows racing would both think they placed it.
if (entry.slept) {
if (!entry.showing) {
entry.showing = true;
this.wake(entry).then(
() => {
entry.showing = false;
this.show(targetId, rect);
},
() => {
entry.showing = false;
},
);
}
return;
}
const at = {
x: Math.round(rect.x),
y: Math.round(rect.y),
@@ -170,13 +470,12 @@ class BrowserViews {
height: Math.round(rect.height),
};
// The panel reports its rectangle whenever anything in the window changes,
// which during a turn is constantly, and nearly always the same rectangle:
// re-adding the view and setting the bounds it already has is work for
// every one of those.
// nearly always the same one: re-adding the view and setting the bounds it
// already has is work for every one of those.
if (this.shown === targetId && sameRect(entry.panel, at)) return;
this.placed++;
for (const [id, other] of this.entries) {
if (id !== targetId) this.putAway(other.view, other);
if (id !== targetId && other.view) this.putAway(other.view, other);
}
this.shown = targetId;
// A page that was parked comes back as a view that is attached but whose
@@ -213,8 +512,10 @@ class BrowserViews {
// emulate records the viewport a page was told to lay out in, which the view
// is then fitted to; null is the page going back to the size of its view.
// The recording is what survives a sleep and comes back with the page.
async emulate(entry, params) {
entry.override = sized(params) ? { ...params } : null;
entry.emulated = sized(params) ? { ...params } : null;
if (!entry.override) {
entry.scale = 1;
if (entry.panel) this.fit(entry);
@@ -239,7 +540,7 @@ class BrowserViews {
reattach() {
for (const [id, entry] of this.entries) {
if (this.win.isDestroyed() || entry.view.webContents.isDestroyed() || !entry.attached) continue;
if (this.win.isDestroyed() || !entry.view || entry.view.webContents.isDestroyed() || !entry.attached) continue;
this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
if (id !== this.shown) this.putAway(entry.view, entry);
@@ -248,10 +549,8 @@ class BrowserViews {
}
// A navigation that starts in an attached view takes the keyboard from the app
// page when it finishes, and a composition in progress there is committed as
// the raw letters. While the app page holds the keyboard the view is held out of
// the window for the length of the navigation, and put back when it ends; a
// person who is in the page themselves keeps it where it is.
// page and would commit a composition there as raw letters. While the app page
// holds the keyboard the view is held out of the window until it ends.
leave(entry) {
if (!entry.attached || this.win.isDestroyed() || this.keyboard.owner !== "app") return;
this.win.contentView.removeChildView(entry.view);
@@ -263,7 +562,7 @@ class BrowserViews {
settle(entry) {
clearTimeout(entry.returning);
entry.returning = undefined;
if (entry.attached || this.win.isDestroyed() || entry.view.webContents.isDestroyed()) return;
if (entry.attached || !entry.view || this.win.isDestroyed() || entry.view.webContents.isDestroyed()) return;
this.win.contentView.addChildView(entry.view);
entry.attached = true;
if (entry.at) entry.view.setBounds(entry.at);
@@ -271,12 +570,15 @@ class BrowserViews {
}
// freeze answers with a picture of the page on screen, taken before it is put
// away, for the window to draw in its place while something of its own is
// over it. Only the page already shown to the person is ever taken, and the
// picture goes to this window's page and nowhere else.
// away. Only the page already shown is ever taken, and the picture goes to
// this window's page and nowhere else.
async freeze() {
const id = this.shown;
const entry = this.entries.get(id);
// A slept page was photographed when it went to sleep and has no view to
// take another with: its picture is the answer, and there is nothing to
// put away.
if (entry && entry.slept) return entry.slept.picture;
if (!entry || entry.view.webContents.isDestroyed()) {
this.hide();
return "";
@@ -296,7 +598,16 @@ class BrowserViews {
hide() {
this.placed++;
this.shown = "";
for (const entry of this.entries.values()) this.putAway(entry.view, entry);
for (const entry of this.entries.values()) {
if (entry.view) this.putAway(entry.view, entry);
}
// A pick behind a page nobody can see cannot be used: the person is looking
// at a picture, so the pick is settled instead of swallowing clicks unseen.
for (const entry of this.entries.values()) {
if (!entry.picking) continue;
entry.picking.reason = "hidden";
void this.cancelPick(entry.targetId);
}
}
putAway(view, entry) {
@@ -310,8 +621,15 @@ class BrowserViews {
}
control(targetId, action) {
const contents = this.entries.get(targetId)?.view.webContents;
if (!contents) return;
const entry = this.entries.get(targetId);
const view = entry?.view;
if (!view) {
// A slept page has no contents to steer; waking it first is what makes
// the action land on the page it comes back as, instead of nowhere.
if (entry?.slept) this.wake(entry).then(() => this.control(targetId, action)).catch(() => {});
return;
}
const contents = view.webContents;
const history = contents.navigationHistory;
switch (action) {
case "back":
@@ -329,6 +647,170 @@ class BrowserViews {
}
}
// pick arms one page's element picker and answers what the person pointed
// at, or { cancelled: true } when they pressed Escape, navigated away, or
// left it longer than a pick may wait. The highlight and the hover card are
// drawn inside the page: a native view sits above anything this window's own
// page could draw over it.
// The page wrote the answer, so the shell decides what an answer is: fields
// from an allow-list, strings clipped to the page script's own limits, and a
// shape it cannot read treated as gone.
clean(answer) {
const gone = { cancelled: true, reason: "gone" };
if (!answer || typeof answer !== "object") return gone;
if (answer.cancelled) {
const reason = CANCEL_REASONS.includes(answer.reason) ? answer.reason : "gone";
return { cancelled: true, reason };
}
const el = answer.element;
if (!el || typeof el !== "object") return gone;
const out = {};
for (const field of ELEMENT_STRINGS) {
const value = el[field];
if (typeof value !== "string") continue;
out[field] = clip(value, field === "htmlExcerpt" ? MARKUP_CLIP : field === "nearbyText" ? NEARBY_CLIP : TEXT_CLIP);
}
if (SELECTOR_BASIS.includes(el.selectorBasis)) out.selectorBasis = el.selectorBasis;
if (el.attributes && typeof el.attributes === "object") {
const attributes = {};
for (const [key, value] of Object.entries(el.attributes)) {
if (Object.keys(attributes).length >= ATTR_LIMIT) break;
if (typeof value === "string") attributes[clip(key, VALUE_CLIP)] = clip(value, VALUE_CLIP);
}
out.attributes = attributes;
}
if (Number.isFinite(el.capturedAt)) out.capturedAt = el.capturedAt;
if (el.rect && typeof el.rect === "object") {
const { x, y, width, height } = el.rect;
if ([x, y, width, height].every(Number.isFinite)) out.rect = { x, y, width, height };
}
if (el.style && typeof el.style === "object") {
out.style = {};
for (const key of STYLE_KEYS) if (typeof el.style[key] === "string") out.style[key] = clip(el.style[key], STYLE_CLIP);
}
if (!out.selector || !out.tagName) return gone;
return { cancelled: false, element: out };
}
async pick(targetId, labels) {
const entry = this.entries.get(targetId);
if (!entry) return { cancelled: true, reason: "gone" };
// A pick draws inside the page that is on screen; a page that is not shown
// would pick behind the one the person is looking at.
if (entry.targetId !== this.shown) return { cancelled: true, reason: "hidden" };
if (entry.slept) await this.wake(entry).catch(() => {});
const contents = entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed()) return { cancelled: true, reason: "gone" };
// A second pick on the same page replaces the first: one picker, one
// overlay, and the older call answers as cancelled.
if (entry.picking) {
entry.picking.reason = "dismissed";
await this.cancelPick(targetId).catch(() => {});
}
// Why the pick ends when it ends without an element: Escape and a dismissal
// come from the page, the other three from here. The person is told which,
// so "nothing happened" is never the answer.
const picking = (entry.picking = { reason: "" });
const timer = setTimeout(() => {
picking.reason = "timeout";
void this.cancelPick(targetId);
}, PICK_LIMIT_MS);
if (typeof timer.unref === "function") timer.unref();
try {
const answer = this.clean(await contents.executeJavaScript(ARM(labels), false));
if (answer.cancelled) return picking.reason ? { cancelled: true, reason: picking.reason } : answer;
// The page cannot invent an element: the shell re-finds the selector in
// its own world before the answer is trusted.
if (!(await this.lands(contents, answer.element))) return { cancelled: true, reason: "gone" };
// The picture is what the person saw: a complaint about how something
// looks is answered by the element, not only by its markup.
const shot = await this.shot(entry, answer.element.rect).catch(() => "");
if (shot) answer.shot = shot;
return answer;
} catch {
// The page navigated, reloaded, slept, or was closed under the pick.
return { cancelled: true, reason: picking.reason || "navigation" };
} finally {
if (entry.picking === picking) entry.picking = null;
clearTimeout(timer);
}
}
// lands asks the DOM from the shell's own world whether a selector still
// names an element of the tag the pick reported. A page script cannot answer
// this one in the page's place.
async lands(contents, element) {
if (!element || !element.selector || !element.tagName) return false;
const tag = await contents
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
{
code: `(() => {
const el = document.querySelector(${JSON.stringify(element.selector)});
return el ? el.tagName.toLowerCase() : "";
})()`,
},
])
.catch(() => "");
return tag === String(element.tagName).toLowerCase();
}
// shot captures the element as the view drew it. The page's rect is in CSS
// pixels and the view may be fitted, so the scale is applied and the result
// is kept inside the view; an element scrolled out of sight has no picture.
async shot(entry, rect) {
const contents = entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed() || !rect) return "";
const scale = entry.scale || 1;
const bounds = entry.view.getBounds();
const at = {
x: Math.max(0, Math.round(rect.x * scale)),
y: Math.max(0, Math.round(rect.y * scale)),
width: Math.round(rect.width * scale),
height: Math.round(rect.height * scale),
};
at.width = Math.min(at.width, bounds.width - at.x);
at.height = Math.min(at.height, bounds.height - at.y);
if (at.width < 1 || at.height < 1) return "";
const image = await contents.capturePage(at);
return image.isEmpty() ? "" : image.toDataURL();
}
// verify re-finds a selector after the page changed, so a person can see
// whether an edit landed where the pick pointed.
async verify(targetId, selector) {
const entry = this.entries.get(targetId);
if (!entry || !selector) return { found: false };
const contents = entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed()) return { found: false };
const at = await contents
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
{
code: `(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return { found: false };
const r = el.getBoundingClientRect();
return { found: true, tag: el.tagName.toLowerCase(), rect: { x: r.x, y: r.y, width: r.width, height: r.height } };
})()`,
},
])
.catch(() => ({ found: false }));
if (!at || !at.found || !at.rect) return { found: false };
const shot = await this.shot(entry, at.rect).catch(() => "");
return { found: true, tag: at.tag, rect: at.rect, shot };
}
// cancelPick disarms the picker, which settles a pick still waiting on the
// page. A page that has gone answers nothing, which reads as cancelled.
cancelPick(targetId) {
const entry = this.entries.get(targetId);
const contents = entry && entry.view && entry.view.webContents;
if (!contents || contents.isDestroyed()) return Promise.resolve(false);
return contents.executeJavaScript(STOP, false).then(
() => true,
() => false,
);
}
// navigate starts loading what the person typed and answers "", or the code
// of why the window will not: "scheme" or "network_file".
navigate(targetId, address) {
@@ -336,6 +818,11 @@ class BrowserViews {
const { url, fallback, refusal } = typed(address, this.kernelOrigin);
if (!entry) return "scheme";
if (refusal) return refusal;
// An address typed at a slept page is for the page it wakes up as.
if (entry.slept) {
this.wake(entry).then(() => this.loadTyped(targetId, entry, url, fallback)).catch(() => {});
return "";
}
void this.loadTyped(targetId, entry, url, fallback);
return "";
}
@@ -435,6 +922,11 @@ class BrowserViews {
if (!cert) return false;
this.trusted.add(trustKey(cert.host, cert.fingerprint));
entry.certificate = null;
// The page the certificate was refused for may be asleep by now.
if (entry.slept) {
this.wake(entry).then(() => this.loadTyped(targetId, entry, cert.url, "")).catch(() => {});
return true;
}
void this.loadTyped(targetId, entry, cert.url, "");
return true;
}
@@ -444,8 +936,15 @@ class BrowserViews {
}
closeAll() {
// The sweep's timer must not outlive the pages it sweeps: the pages go,
// and a timer left holding the process open keeps a test run from ever
// ending.
if (this.sweeper) {
clearInterval(this.sweeper);
this.sweeper = null;
}
for (const id of [...this.logins.keys()]) this.answerLogin(id);
for (const entry of [...this.entries.values()]) entry.view.webContents.close();
for (const entry of [...this.entries.values()]) entry.close();
}
}
@@ -488,11 +987,9 @@ function placement(panel, emulated) {
return { at: { x, y, width, height }, scale };
}
// Input that carries a point. The page reads it in the viewport it was told it
// has, the view draws that viewport scaled, and a point meant for the page lands
// where it is drawn only after the same scaling. The mouse is measured to; the
// rest are not, and a point that silently lands elsewhere is worse than a
// refusal, so they are refused while the page is scaled.
// Input that carries a point lands where it is drawn only after the same scaling
// the view was drawn at. The mouse is measured to; a point that lands elsewhere
// silently is worse than a refusal, so the rest are refused while scaled.
const POINTED_INPUT = new Set([
"Input.dispatchTouchEvent",
"Input.dispatchDragEvent",
+12 -1
View File
@@ -19,4 +19,15 @@ function keepCredential(ses, cookie, log) {
return () => ses.cookies.removeListener("changed", onChanged);
}
module.exports = { keepCredential };
// The change event is not the only way the jar loses the cookie: a network
// service restart drops session cookies without announcing a removal. A request
// the kernel refused is the moment that shows, so the shell reads the jar then.
async function restoreIfStale(ses, cookie, log) {
const held = await ses.cookies.get({ url: cookie.url, name: cookie.name });
const found = held.find((c) => c.name === cookie.name);
if (found && found.value === cookie.value) return;
log(`credential cookie ${found ? "differs from this launch's" : "missing"} after a refused request; restoring`);
await ses.cookies.set(cookie).catch((err) => log(`credential cookie restore failed: ${err.message}`));
}
module.exports = { keepCredential, restoreIfStale };
+5 -2
View File
@@ -13,6 +13,7 @@ const WORDS = {
paste: "粘贴",
selectAll: "全选",
pasteAndMatchStyle: "粘贴并匹配样式",
quote: "加入对话",
delete: "删除",
substitutions: "替换",
showSubstitutions: "显示替换",
@@ -33,6 +34,7 @@ const WORDS = {
selectAll: "Select All",
pasteAndMatchStyle: "Paste and Match Style",
delete: "Delete",
quote: "Add to conversation",
substitutions: "Substitutions",
showSubstitutions: "Show Substitutions",
smartQuotes: "Smart Quotes",
@@ -70,7 +72,8 @@ function editItems(lang, can) {
// click: this only decides what to offer.
function contextTemplate(params, lang) {
if (!params.isEditable && !params.selectionText) return [];
return editItems(lang, params.editFlags ?? {});
const items = editItems(lang, params.editFlags ?? {});
return params.isEditable ? items : items.filter((item) => item.role === "copy" || item.role === "selectAll");
}
// Everything the platform's own Edit menu carries, with the same roles, so
@@ -128,4 +131,4 @@ function menuInstaller(Menu, platform = process.platform) {
};
}
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
module.exports = { contextTemplate, editItems, wordsFor, editMenuTemplate, applicationMenuTemplate, menuInstaller };
+572
View File
@@ -0,0 +1,572 @@
"use strict";
// The page half of the element picker, kept as a function so it can be
// stringified into the page. The highlight and the hover card are drawn there
// because a native view sits above anything this window's own page draws.
//
// Nothing here may reach out of the function: the page runs it alone, with no
// module and no binding of this process to refer to.
function pagePicker(labels = {}) {
// The window owns the words and sends them in: the page only draws them.
const L = {
color: "Color",
background: "Background",
font: "Font",
frame: "Element inside an embedded frame — not supported",
shadow: "Element inside a shadow root — not supported",
...labels,
};
const KEY = "__reasonixElementPicker";
const CLIP = 4000;
// Any words of its own already say what the element is, in any language;
// only one that cannot speak — an icon, an image — is given the words
// around it. A Chinese heading is a sentence in a dozen characters, so the
// test is whether it has text at all, not how much.
const NEARBY_OWN = 2;
const NEARBY_LIMIT = 300;
let settlePick = null;
// The promise is the pick: it settles on the click, on Escape, or when the
// page takes the picker away from under it.
const answer = new Promise((done) => {
settlePick = done;
});
const previous = window[KEY];
if (previous) previous.stop();
const TAG_ROLES = {
A: "link",
BUTTON: "button",
IMG: "img",
NAV: "navigation",
MAIN: "main",
FORM: "form",
SELECT: "combobox",
TEXTAREA: "textbox",
H1: "heading",
H2: "heading",
H3: "heading",
H4: "heading",
H5: "heading",
H6: "heading",
};
const INPUT_ROLES = {
checkbox: "checkbox",
radio: "radio",
range: "slider",
button: "button",
submit: "button",
reset: "button",
};
const KEPT = new Set(["id", "class", "href", "src", "alt", "title", "name", "type", "placeholder", "data-testid"]);
// The roles an element can have while saying nothing to a reader by itself:
// an icon button, an image, a field. Context is for these. A decorative
// span has no role, and the page around it does not give it one.
const SPEAKING = new Set(["button", "link", "textbox", "checkbox", "radio", "slider", "combobox", "img"]);
const clip = (text, limit) => (text.length > limit ? text.slice(0, limit) : text);
const textOf = (node) => (node.innerText || node.textContent || "").trim();
// rgb()/rgba() as the page computed them, written the way a person reads a
// colour; a translucent one is left as computed so its alpha is not lost.
const hex = (css) => {
const match = /^rgba?\(([^)]+)\)$/.exec(css || "");
if (!match) return css || "";
const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number);
if (parts.length > 3 && parts[3] < 1) return css;
const [r, g, b] = parts;
return ("#" + [r, g, b].map((one) => Math.round(one).toString(16).padStart(2, "0")).join("")).toUpperCase();
};
const roleOf = (el) => {
const stated = el.getAttribute("role");
if (stated) return stated;
if (el.tagName === "INPUT") return INPUT_ROLES[el.type] || "textbox";
return TAG_ROLES[el.tagName] || "";
};
const nameOf = (el) => {
const ids = (el.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
if (ids.length) {
const text = ids
.map((id) => {
const node = document.getElementById(id);
return node ? textOf(node) : "";
})
.join(" ")
.trim();
if (text) return clip(text, 500);
}
for (const attr of ["aria-label", "alt", "title", "placeholder"]) {
const value = el.getAttribute(attr);
if (value && value.trim()) return clip(value.trim(), 500);
}
return clip(textOf(el), 500);
};
// What the page declares as an identity outlives what it builds for styling:
// a hash class is gone after the next deploy, a data-testid is not.
const attrValue = (value) => '"' + value.replace(/["\\]/g, "\\$&") + '"';
// A CSS path a person can paste, preferring the page's own identity over its
// styling. Falls back to tag.class with :nth-of-type where a sibling would
// answer the same, up to eight levels of parent.
const selectorOf = (el) => {
const testId = el.getAttribute("data-testid");
if (testId) return { selector: `[data-testid=${attrValue(testId)}]`, basis: "testid" };
const label = el.getAttribute("aria-label");
if (label) return { selector: `${el.tagName.toLowerCase()}[aria-label=${attrValue(label)}]`, basis: "aria-label" };
const own = el.getAttribute("id");
if (own) return { selector: "#" + CSS.escape(own), basis: "id" };
const parts = [];
let node = el;
for (let depth = 0; node && node.nodeType === 1 && depth < 8; depth++) {
const id = node.getAttribute("id");
if (id) {
parts.unshift("#" + CSS.escape(id));
break;
}
let part = node.tagName.toLowerCase();
for (const name of [...node.classList].filter(Boolean).slice(0, 3)) part += "." + CSS.escape(name);
const parent = node.parentElement;
if (parent) {
const same = [...parent.children].filter((child) => child.tagName === node.tagName);
if (same.length > 1) part += `:nth-of-type(${same.indexOf(node) + 1})`;
}
parts.unshift(part);
node = node.parentElement;
}
return { selector: parts.join(" > "), basis: "class" };
};
const xpathOf = (el) => {
const parts = [];
let node = el;
for (let depth = 0; node && node.nodeType === 1 && depth < 12; depth++) {
let index = 1;
for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
if (sib.tagName === node.tagName) index++;
}
parts.unshift(`${node.tagName.toLowerCase()}[${index}]`);
node = node.parentElement;
}
return "/" + parts.join("/");
};
// What the element says. A password field never hands over what was typed in
// it: the pick leaves with the fact that it was one, and nothing else.
const textFor = (el) => {
// An editable region holds what the person is writing right now.
if (el.isContentEditable) return "[masked editable text]";
if (el.tagName === "INPUT" && el.type === "password") return "[masked password input]";
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
return clip([el.getAttribute("aria-label"), el.getAttribute("placeholder"), el.getAttribute("name"), el.type].filter(Boolean).join(" "), CLIP);
}
return clip(textOf(el), CLIP);
};
// What surrounds an element, for the ones that cannot speak for themselves.
// An element with text of its own asks for nothing: the nearest article
// around a title is the whole page, which is not context but noise.
// What surrounds an element, for the ones that cannot speak for themselves.
// An element with text of its own asks for nothing, and neither does one
// with no role at all: a decorative mark is not made meaningful by the page
// around it, so the words there would be noise rather than context.
const nearbyFor = (el) => {
if (textOf(el).trim().length >= NEARBY_OWN) return "";
if (!SPEAKING.has(roleOf(el))) return "";
const near = el.closest("article, section, main, form, li, tr, dialog") || el.parentElement;
return near ? clip(textOf(near), NEARBY_LIMIT) : "";
};
// The element's own markup with its scripts and the values a person typed
// taken out, so what goes to the chat is structure, not their input.
const excerptFor = (el) => {
const clone = el.cloneNode(true);
for (const drop of clone.querySelectorAll("script, style, noscript, template")) drop.remove();
for (const field of clone.querySelectorAll("input")) field.removeAttribute("value");
if (clone.tagName === "INPUT") clone.removeAttribute("value");
for (const area of clone.querySelectorAll("textarea")) area.textContent = "";
if (clone.isContentEditable) clone.textContent = "";
return clip(clone.outerHTML, 6000);
};
const describe = (el) => {
const rect = el.getBoundingClientRect();
const computed = getComputedStyle(el);
const attributes = {};
for (const attr of el.attributes) {
const name = attr.name.toLowerCase();
if (name === "value") continue;
if (!KEPT.has(name) && !name.startsWith("aria-")) continue;
attributes[name] = clip(attr.value, 500);
}
const style = {
color: hex(computed.color),
display: computed.display,
fontFamily: computed.fontFamily,
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
};
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
style.backgroundColor = hex(computed.backgroundColor);
}
const path = selectorOf(el);
const info = {
pageUrl: location.href,
pageTitle: document.title,
tagName: el.tagName.toLowerCase(),
selector: path.selector,
xpath: xpathOf(el),
htmlExcerpt: excerptFor(el),
attributes,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style,
capturedAt: Date.now(),
};
// Which of the four the selector came from, so a reader can tell a stable
// identity from one that a deploy will change.
info.selectorBasis = path.basis;
// A field with no answer is left out rather than sent empty: "there is no
// role" and "the role is an empty string" are different answers, and the
// second is not one this page can give.
const role = roleOf(el);
if (role) info.role = role;
const name = nameOf(el);
if (name) info.accessibleName = name;
const own = textFor(el);
if (own) info.text = own;
const nearby = nearbyFor(el);
if (nearby) info.nearbyText = nearby;
return info;
};
const box = document.createElement("div");
box.dataset.reasonixPick = "box";
box.setAttribute("aria-hidden", "true");
box.style.cssText =
"position:fixed;z-index:2147483646;pointer-events:none;border:2px solid #3b82f6;" +
"border-radius:3px;background:rgba(59,130,246,.12);box-sizing:border-box";
const card = document.createElement("div");
card.dataset.reasonixPick = "card";
card.setAttribute("aria-hidden", "true");
card.style.cssText =
"position:fixed;z-index:2147483647;pointer-events:none;background:#1f2328;color:#e6edf3;" +
"border-radius:8px;padding:9px 12px;font:12px/1.6 ui-sans-serif,system-ui,sans-serif;" +
"box-shadow:0 8px 24px rgba(0,0,0,.28);min-width:190px;max-width:340px";
// One card line. The label is the quiet side, the value the loud one; both
// are set as text, never as markup, because the page chose both.
const line = (label, value, loud) => {
const row = document.createElement("div");
row.style.cssText = "display:flex;gap:12px;justify-content:space-between;align-items:baseline";
const key = document.createElement("span");
key.textContent = label;
key.style.cssText = `flex:none;color:${loud ? "#e6edf3" : "#8b949e"};font-weight:${loud ? 600 : 400}`;
const val = document.createElement("span");
val.textContent = value;
val.style.cssText =
"color:#e6edf3;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden;" +
"text-overflow:ellipsis;white-space:nowrap" + (loud ? ";font-weight:600" : "");
row.append(key, val);
card.append(row);
};
let target = null;
// The chain of children walked up from, so the down keys can walk back.
let descended = [];
// Shift-click keeps a set: the person is naming a region, not one node.
const chosen = [];
const marks = [];
let armed = true;
let listeners = [];
const clear = () => {
for (const [type, handler, capture] of listeners) document.removeEventListener(type, handler, capture);
listeners = [];
for (const { cover } of frameCovers) cover.remove();
frameCovers.length = 0;
for (const at of marks) at.remove();
marks.length = 0;
box.remove();
card.remove();
if (window[KEY] && window[KEY].stop === stop) delete window[KEY];
};
const settle = (result) => {
if (!armed) return;
armed = false;
clear();
settlePick(result);
};
const stop = (reason = "dismissed") => settle({ cancelled: true, reason });
// Where the card sits: under the element when the viewport has room for it,
// above otherwise, and never past either edge.
// A frame's own document takes the pointer, so the top document never hears a
// move over it. A cover over each frame keeps the picker's events coming and
// says why that area is not pickable instead of going quiet.
const frameCovers = [];
const positionCovers = () => {
for (const { cover, frame } of frameCovers) {
const rect = frame.getBoundingClientRect();
cover.style.left = rect.left + "px";
cover.style.top = rect.top + "px";
cover.style.width = rect.width + "px";
cover.style.height = rect.height + "px";
cover.style.display = rect.width > 0 && rect.height > 0 ? "block" : "none";
}
};
const coverFrames = () => {
for (const { cover } of frameCovers) cover.remove();
frameCovers.length = 0;
for (const frame of document.querySelectorAll("iframe, frame")) {
const cover = document.createElement("div");
cover.dataset.reasonixFrame = "1";
cover.style.cssText =
"position:fixed;z-index:2147483646;pointer-events:auto;background:rgba(59,130,246,0.08);border:1px dashed rgba(59,130,246,0.55);box-sizing:border-box";
cover.addEventListener("pointermove", () => show(frame), true);
cover.addEventListener("click", (event) => {
swallow(event);
show(frame);
}, true);
document.documentElement.append(cover);
frameCovers.push({ cover, frame });
}
positionCovers();
};
const place = () => {
if (!target) return;
const rect = target.getBoundingClientRect();
const width = Math.max(0, rect.width);
const height = Math.max(0, rect.height);
box.style.left = rect.left + "px";
box.style.top = rect.top + "px";
box.style.width = width + "px";
box.style.height = height + "px";
card.style.visibility = "hidden";
card.style.left = "0px";
card.style.top = "0px";
const size = card.getBoundingClientRect();
const below = rect.bottom + 8;
const top = below + size.height <= innerHeight ? below : Math.max(8, rect.top - size.height - 8);
const left = Math.min(Math.max(8, rect.left), Math.max(8, innerWidth - size.width - 8));
card.style.left = left + "px";
card.style.top = top + "px";
card.style.visibility = "visible";
};
// The chain from the document down to the element, short enough to read in
// the card: a person walking up with the arrow keys needs to see where they
// are, not the whole tree.
const ancestorsOf = (el) => {
const chain = [];
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
chain.unshift(node);
if (chain.length > 8) break;
}
return chain;
};
const crumbLabel = (el) => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "");
// A frame or a shadow host cannot be read from here: the picker only ever
// sees the host element, so it says so instead of handing over a useless
// answer that looks like the real thing.
const blockedOf = (el) => {
if (!el) return "";
if (el.tagName === "IFRAME" || el.tagName === "FRAME") return "iframe";
if (el.shadowRoot) return "shadow";
return "";
};
const show = (el) => {
target = el;
const rect = el.getBoundingClientRect();
const computed = getComputedStyle(el);
card.textContent = "";
const blocked = blockedOf(el);
if (blocked) {
const note = document.createElement("div");
note.textContent = blocked === "iframe" ? L.frame : L.shadow;
note.style.cssText = "max-width:260px;color:#e6edf3;font-size:12px;line-height:1.5";
card.append(note);
box.style.display = "block";
card.style.display = "block";
place();
return;
}
const crumb = document.createElement("div");
crumb.style.cssText = "display:flex;gap:4px;overflow:hidden;white-space:nowrap;margin-bottom:4px;color:#8b949e;font-size:11px";
const chain = ancestorsOf(el).slice(-5);
chain.forEach((node, index) => {
if (index) {
const sep = document.createElement("span");
sep.textContent = "›";
sep.style.opacity = "0.6";
crumb.append(sep);
}
const part = document.createElement("span");
part.textContent = crumbLabel(node);
if (node === el) {
part.style.color = "#e6edf3";
part.style.fontWeight = "600";
}
crumb.append(part);
});
card.append(crumb);
line(el.tagName.toLowerCase(), `${Math.round(rect.width)}×${Math.round(rect.height)}`, true);
line(L.color, hex(computed.color), false);
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
line(L.background, hex(computed.backgroundColor), false);
}
line(L.font, `${computed.fontSize} ${computed.fontFamily}`, false);
box.style.display = "block";
card.style.display = "block";
place();
};
document.documentElement.append(box, card);
coverFrames();
box.style.display = "none";
card.style.display = "none";
// Every gesture is taken from the page while the pick is armed: a click
// chooses an element instead of pressing a button, and no page handler hears
// it either. Escape is the way out that changes nothing.
const swallow = (event) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
let moveAt = null;
let moveFrame = 0;
const onMove = (event) => {
// One hit test per frame: a page with a heavy layout is not asked to answer
// a move it will not draw.
moveAt = event;
if (moveFrame) return;
moveFrame = requestAnimationFrame(() => {
moveFrame = 0;
if (!armed) return;
const at = moveAt;
moveAt = null;
if (!at) return;
const el = document.elementFromPoint(at.clientX, at.clientY);
if (!el || el === box || el === card) return;
if (el.dataset && el.dataset.reasonixFrame) return;
if (el === target) return;
descended = [];
show(el);
});
};
// A picked element keeps a mark with its order, so a set stays visible while
// the person keeps adding to it.
const mark = (el) => {
const rect = el.getBoundingClientRect();
const at = document.createElement("div");
at.dataset.reasonixPicked = "1";
at.setAttribute("aria-hidden", "true");
at.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;border:1px solid #22c55e;background:rgba(34,197,94,0.12);box-sizing:border-box;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px`;
const order = document.createElement("span");
order.textContent = String(marks.length + 1);
order.style.cssText = "position:absolute;top:-17px;left:-1px;background:#22c55e;color:#04170a;font:600 10px ui-sans-serif,system-ui,sans-serif;padding:1px 4px;border-radius:3px";
at.append(order);
document.documentElement.append(at);
marks.push(at);
};
const onPick = (event) => {
if (!armed) return;
swallow(event);
// The pointer may have climbed with the arrow keys: what is picked is the
// level the person is looking at, not what happens to be under the cursor.
try {
// A frame or shadow host is not an answer: the click leaves the pick
// armed while the card already says why.
if (target) {
if (blockedOf(target)) return;
if (event.shiftKey) {
chosen.push(target);
mark(target);
return;
}
return settle(chosen.length ? { cancelled: false, elements: [...chosen, target].map(describe) } : { cancelled: false, element: describe(target) });
}
const el = document.elementFromPoint(event.clientX, event.clientY);
if (!el) return settle({ cancelled: true });
if (blockedOf(el)) return;
if (event.shiftKey) {
chosen.push(el);
mark(el);
return;
}
settle(chosen.length ? { cancelled: false, elements: [...chosen, el].map(describe) } : { cancelled: false, element: describe(el) });
} catch (error) {
console.error("reasonix element pick failed", error);
settle({ cancelled: true, reason: "gone" });
}
};
const onKey = (event) => {
if (event.key === "Escape") {
swallow(event);
stop("escape");
return;
}
// Up walks to the parent and down back to the child it left, with the chain
// drawn in the card so neither move is a guess.
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
swallow(event);
const parent = target && target.parentElement;
if (parent && parent !== document.documentElement) {
descended.push(target);
show(parent);
}
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
swallow(event);
const child = descended.pop();
if (child && target && child.parentElement === target) show(child);
return;
}
// Every other key belongs to the picker as well: Enter on a focused button
// would run the page action a click is not allowed to run, and the pick is
// not using the page.
swallow(event);
};
const onScroll = () => {
place();
positionCovers();
};
for (const [type, handler, capture] of [
["pointerdown", swallow, true],
["pointerup", swallow, true],
["mousedown", swallow, true],
["mouseup", swallow, true],
["dblclick", swallow, true],
["contextmenu", swallow, true],
["click", onPick, true],
["pointermove", onMove, true],
["mousemove", onMove, true],
["keydown", onKey, true],
["scroll", onScroll, true],
["resize", onScroll, false],
]) {
document.addEventListener(type, handler, capture);
listeners.push([type, handler, capture]);
}
window[KEY] = { stop };
return answer;
}
// ARM runs the picker in the page's own world; the promise it answers with is
// what executeJavaScript returns, so a pick takes as long as the person does.
const ARM = (labels) => `(${pagePicker.toString()})(${JSON.stringify(labels || {})})`;
// STOP disarms whatever picker is armed, settling that pick as cancelled.
const STOP = `(() => { const picker = window.__reasonixElementPicker; if (picker) picker.stop(); })()`;
module.exports = { ARM, STOP };
+65
View File
@@ -0,0 +1,65 @@
"use strict";
const PREF_KEY = "shell-global-shortcut";
const MAX_BINDING_LENGTH = 128;
function createGlobalShortcut({ globalShortcut, binding = "", save, windowOf, showWindow }) {
let state = { binding, status: binding ? "unavailable" : "disabled" };
let closed = false;
const press = () => {
if (closed) return;
const win = windowOf();
if (win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused()) win.hide();
else showWindow();
};
function register(value) {
try {
return globalShortcut.register(value, press) ? "" : "unavailable";
} catch {
return "invalid";
}
}
function apply(value, persist) {
if (closed) return { ...state, error: "quitting" };
if (typeof value !== "string" || value.length > MAX_BINDING_LENGTH) return { ...state, error: "invalid" };
const next = value.trim();
if (next && !next.split("+").slice(0, -1).some(part => /^(CommandOrControl|CmdOrCtrl|Command|Cmd|Control|Ctrl|Alt|Option|AltGr|Super|Meta)$/i.test(part))) {
return { ...state, error: "invalid" };
}
if (next === state.binding && state.status === "registered") return { ...state };
if (next) {
const error = register(next);
if (error) return { ...state, error };
}
try {
if (persist) save(next);
} catch {
if (next) globalShortcut.unregister(next);
return { ...state, error: "save" };
}
if (state.status === "registered" && state.binding !== next) globalShortcut.unregister(state.binding);
state = { binding: next, status: next ? "registered" : "disabled" };
return { ...state };
}
return {
start() {
const result = apply(binding, false);
if (result.error) state = { binding, status: result.error };
return { ...state };
},
read: () => ({ ...state }),
apply: value => apply(value, true),
close() {
closed = true;
if (state.status === "registered") globalShortcut.unregister(state.binding);
},
};
}
function registerGlobalShortcutIPC(ipcMain, fromWindow, shortcutOf) {
const authorized = event => fromWindow(event) && event.senderFrame === event.sender.mainFrame;
ipcMain.handle("shell:global-shortcut", event => authorized(event) ? shortcutOf()?.read() ?? { binding: "", status: "unavailable" } : null);
ipcMain.handle("shell:set-global-shortcut", (event, value) => authorized(event) ? shortcutOf()?.apply(value) ?? { binding: "", status: "unavailable" } : null);
}
module.exports = { PREF_KEY, MAX_BINDING_LENGTH, createGlobalShortcut, registerGlobalShortcutIPC };
+9 -6
View File
@@ -5,17 +5,20 @@
// compositor leaves uncovered at a fractional scale matches the app.
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
// "auto" and anything unrecognised follow the system, as the page does.
function groundFor(prefs, systemDark) {
const theme = prefs?.["rx-theme"];
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
function groundFor(prefs, systemDark, painted) {
const source = themeSourceFor(prefs, painted);
const dark = source === "system" ? systemDark : source === "dark";
return dark ? GROUND.dark : GROUND.light;
}
// The scheme the window frame takes. Windows draws a frameless window's border
// from it, so a dark page on a light system needs it set to stay borderless.
function themeSourceFor(prefs) {
const theme = prefs?.["rx-theme"];
function themeSourceFor(prefs, painted) {
const theme = painted?.mode ?? prefs?.["rx-theme"];
if (theme === "scheduled") {
const scheme = painted?.scheme ?? prefs?.["rx-theme-resolved"];
return scheme === "dark" || scheme === "light" ? scheme : "system";
}
return theme === "dark" || theme === "light" ? theme : "system";
}
+49 -10
View File
@@ -1,5 +1,5 @@
"use strict";
const { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
const { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
const { relaunchForOzonePlatform } = require("./ozone");
// Before the instance lock: this process must hold nothing its relaunch needs.
@@ -15,6 +15,7 @@ const { installTray } = require("./tray");
const { instanceID, profileFor } = require("./instance");
const { watchLauncher, consoleEcho } = require("./launcher");
const { installApplicationMenu, installContextMenu } = require("./menu");
const { textContextMenuHandler } = require("./textcontextmenu");
const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
@@ -30,7 +31,8 @@ const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor, themeSourceFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { loadPrefs, savePrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs");
const { PREF_KEY: SHORTCUT_PREF, createGlobalShortcut, registerGlobalShortcutIPC } = require("./globalshortcut");
const { stateFile, openWindow } = require("./windowstate");
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { processMemoryHandler } = require("./procmem");
@@ -38,7 +40,7 @@ const { captureHandler } = require("./capture");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
const { raise } = require("./raise");
const { keepCredential } = require("./credential");
const { keepCredential, restoreIfStale } = require("./credential");
const { actHandler } = require("./acts");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
@@ -89,6 +91,7 @@ let browserRelay = null;
let reload = null;
let powerGuard = null;
let logs = null;
let windowShortcut = null;
let handshaken = false;
let starting = null;
@@ -198,8 +201,9 @@ cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text))
powerGuard.begin();
// The poll alone leaves a turn unprotected for up to its period; the page's
// own submit is the moment one starts, and this process sees it complete.
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*submit`] }, (details) => {
if (details.method === "POST" && details.statusCode < 300) void powerGuard?.refresh();
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*`] }, (details) => {
if (details.statusCode === 403) void restoreIfStale(session.defaultSession, credentialCookie, (line) => logs.shell.line(line));
if (details.method === "POST" && details.statusCode < 300 && details.url.endsWith("submit")) void powerGuard?.refresh();
});
followTheme();
win = createWindow();
@@ -230,8 +234,10 @@ cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text))
// The agent's browser draws its pages as views in this window: the kernel
// drives them through the relay, and the page decides where one is shown.
// Idle pages sleep only once the person turns rx-page-sleep on; prefs.js owns
// the threshold, and a save reaches the running window through onSave below.
function hostAgentBrowser() {
browserViews = new BrowserViews({ win, kernelOrigin: origin });
browserViews = new BrowserViews({ win, kernelOrigin: origin, sleepAfterMs: pageSleepAfterMs(storedPrefs()) });
const protocol = new BrowserProtocol({
createView: (spec) => browserViews.create(spec),
post: (frame) => browserRelay?.post(frame),
@@ -280,6 +286,8 @@ function cleanUpLegacyInstalls() {
});
}
let credentialCookie;
// Set before anything is loaded, or the first request answers 403 and the
// window opens on a refusal. HttpOnly because nothing in the page ever reads
// it, Strict because no cross-site navigation ever needs to carry it, and no
@@ -294,6 +302,7 @@ async function armCredential(ready) {
secure: false,
sameSite: "strict",
};
credentialCookie = cookie;
await session.defaultSession.cookies.set(cookie);
keepCredential(session.defaultSession, cookie, (line) => logs.shell.line(line));
}
@@ -328,9 +337,12 @@ function fitted() {
}
const storedPrefs = () => loadPrefs(prefsFile(app.getPath("userData")));
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors);
let paintedTheme = null;
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors, paintedTheme);
const followTheme = () => {
nativeTheme.themeSource = themeSourceFor(storedPrefs());
const source = themeSourceFor(storedPrefs(), paintedTheme);
if (nativeTheme.themeSource !== source) nativeTheme.themeSource = source;
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
};
function createWindow() {
@@ -391,14 +403,20 @@ const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), a
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
installApplicationMenu(uiLang);
followTheme();
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
void powerGuard?.refresh();
browserViews?.setSleepAfterMs(pageSleepAfterMs(storedPrefs()));
}, paint => {
paintedTheme = paint;
followTheme();
});
nativeTheme.on("updated", () => {
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
});
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
registerGlobalShortcutIPC(ipcMain, fromWindow, () => windowShortcut);
const showTextContextMenu = textContextMenuHandler(Menu, fromWindow, () => origin, uiLang);
ipcMain.handle("text:context-menu", showTextContextMenu);
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
ipcMain.handle("window:unread-badge", (event, unread) => {
@@ -433,6 +451,15 @@ ipcMain.handle("browser:control", (event, targetId, action) => {
ipcMain.handle("browser:navigate", (event, targetId, address) =>
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? "scheme") : "scheme",
);
ipcMain.handle("browser:pick", (event, targetId, labels) =>
fromWindow(event) ? (browserViews?.pick(String(targetId), labels) ?? { cancelled: true }) : { cancelled: true },
);
ipcMain.handle("browser:pick-cancel", (event, targetId) =>
fromWindow(event) ? (browserViews?.cancelPick(String(targetId)) ?? false) : false,
);
ipcMain.handle("browser:verify", (event, targetId, selector) =>
fromWindow(event) ? (browserViews?.verify(String(targetId), String(selector)) ?? { found: false }) : { found: false },
);
ipcMain.handle("browser:trust-certificate", (event, targetId) =>
fromWindow(event) ? (browserViews?.trustCertificate(String(targetId)) ?? false) : false,
);
@@ -544,7 +571,18 @@ if (!primary) {
}
app.whenReady().then(() => {
if (!primary) return;
if (!primary || quitting) return;
windowShortcut = createGlobalShortcut({
globalShortcut,
binding: savedPrefs[SHORTCUT_PREF] ?? "",
save: binding => {
const file = prefsFile(app.getPath("userData"));
savePrefs(file, { ...loadPrefs(file), [SHORTCUT_PREF]: binding });
},
windowOf: () => win,
showWindow,
});
windowShortcut.start();
installApplicationMenu(uiLang);
boot().catch((err) => {
console.error("reasonix-studio:", err.message);
@@ -591,6 +629,7 @@ module.exports = { current: () => ({ win, tray, client, origin }) };
const drainCua = createQuitGate(() => cua, () => app.quit(), error => logs.shell.line(`CUA cleanup: ${error.message}`));
app.on("before-quit", event => {
quitting = true;
windowShortcut?.close();
logs.shell.line("shell: quitting");
drainCua(event);
browserRelay?.stop();
+33 -7
View File
@@ -1,6 +1,7 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { PREF_KEY: SHORTCUT_PREF } = require("./globalshortcut");
// The page's own preferences, kept past the origin that stored them. The kernel
// listens on a fresh loopback port each launch and localStorage is keyed by
@@ -12,13 +13,20 @@ function prefsFile(userData) {
return path.join(userData, "window-prefs.json");
}
// Anything that is not one of the page's own string preferences is dropped, in
// both directions: a hand-edited file or a page bug cannot widen what is kept.
// How long a page the agent's browser holds may sit undriven before the shell
// puts it to sleep. Off until the person turns it on; "on" waits 15 minutes.
const PAGE_SLEEP_MS = 15 * 60_000;
function pageSleepAfterMs(prefs) {
return prefs && prefs["rx-page-sleep"] === "on" ? PAGE_SLEEP_MS : Infinity;
}
// Only page string preferences and the shell-owned shortcut survive a disk read.
function pick(value) {
const out = {};
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
for (const [key, v] of Object.entries(value)) {
if (key.startsWith(PREFIX) && typeof v === "string") out[key] = v;
if ((key.startsWith(PREFIX) || key === SHORTCUT_PREF) && typeof v === "string") out[key] = v;
}
return out;
}
@@ -44,14 +52,17 @@ function savePrefs(file, prefs) {
// in the same process and must not reach this file.
// fileOf is asked on each use: the profile directory is settled during boot,
// after these handlers exist.
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}, onTheme = () => {}) {
ipcMain.on("prefs:load", (event) => {
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {};
});
ipcMain.on("prefs:save", (event, prefs) => {
if (fromWindow(event)) {
try {
savePrefs(fileOf(), prefs);
const current = loadPrefs(fileOf());
const next = pickPagePrefs(prefs);
if (current[SHORTCUT_PREF] !== undefined) next[SHORTCUT_PREF] = current[SHORTCUT_PREF];
savePrefs(fileOf(), next);
onSave();
} catch {
// A preference that did not save is one relaunch of lost state, not a
@@ -60,6 +71,21 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
}
event.returnValue = true;
});
ipcMain.on("prefs:theme", (event, paint) => {
if (!fromWindow(event) || !event.senderFrame || event.senderFrame !== event.sender.mainFrame) return;
if (!paint || typeof paint !== "object" || Array.isArray(paint)) return;
const { mode, scheme } = paint;
if (mode !== "auto" && mode !== "scheduled" && mode !== "light" && mode !== "dark") return;
if (scheme !== "light" && scheme !== "dark") return;
if ((mode === "light" || mode === "dark") && mode !== scheme) return;
onTheme({ mode, scheme });
});
}
module.exports = { PREFIX, prefsFile, pick, loadPrefs, savePrefs, registerPrefs };
function pickPagePrefs(prefs) {
const page = pick(prefs);
delete page[SHORTCUT_PREF];
return page;
}
module.exports = { PREFIX, PAGE_SLEEP_MS, pageSleepAfterMs, prefsFile, pick, loadPrefs, savePrefs, registerPrefs };
+11
View File
@@ -22,14 +22,18 @@ contextBridge.exposeInMainWorld("reasonixHost", {
platform: process.platform,
titleBar,
setUnreadBadge: (unread) => ipcRenderer.invoke("window:unread-badge", unread),
syncTheme: (mode, scheme) => ipcRenderer.send("prefs:theme", { mode, scheme }),
minimiseWindow: () => ipcRenderer.invoke("window:minimise"),
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
closeWindow: () => ipcRenderer.invoke("window:close"),
// A PNG of this window's own page, taking nothing: there is no way to name another.
captureWindow: () => ipcRenderer.invoke("window:capture"),
showTextContextMenu: (request) => ipcRenderer.invoke("text:context-menu", request),
// What the shell's graphics are running as this launch.
graphics: () => ipcRenderer.invoke("shell:graphics"),
globalShortcut: () => ipcRenderer.invoke("shell:global-shortcut"),
setGlobalShortcut: (binding) => ipcRenderer.invoke("shell:set-global-shortcut", binding),
// The window's processes as type and two sizes, for the memory a report may carry.
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
@@ -61,10 +65,17 @@ contextBridge.exposeInMainWorld("reasonixHost", {
freezeBrowserView: () => ipcRenderer.invoke("browser:freeze"),
controlBrowserView: (targetId, action) => ipcRenderer.invoke("browser:control", String(targetId), String(action)),
navigateBrowserView: (targetId, address) => ipcRenderer.invoke("browser:navigate", String(targetId), String(address)),
// Arm this page's element picker, and let a person put it away again. The
// answer is the element they pointed at, or that they picked nothing.
pickBrowserElement: (targetId, labels) => ipcRenderer.invoke("browser:pick", String(targetId), labels),
cancelBrowserPick: (targetId) => ipcRenderer.invoke("browser:pick-cancel", String(targetId)),
verifyBrowserElement: (targetId, selector) => ipcRenderer.invoke("browser:verify", String(targetId), String(selector)),
// Why a page did not load, and a login a page or proxy asks for. The password
// goes back to this window's main process only; the kernel never sees it.
onBrowserLoadState: (listener) => subscribe("browser:load-state", listener),
onBrowserLogin: (listener) => subscribe("browser:login", listener),
// The picker's key, pressed in the page rather than in this one.
onBrowserPickShortcut: (listener) => subscribe("browser:pick-shortcut", listener),
trustBrowserCertificate: (targetId) => ipcRenderer.invoke("browser:trust-certificate", String(targetId)),
answerBrowserLogin: (id, username, password) =>
ipcRenderer.invoke("browser:login-answer", String(id), String(username || ""), String(password || "")),
+86
View File
@@ -0,0 +1,86 @@
"use strict";
const { editItems, wordsFor } = require("./editmenu");
const FLAGS = ["editable", "selected", "canUndo", "canRedo", "quote"];
function validRequest(request) {
return request !== null && typeof request === "object" && !Array.isArray(request)
&& Object.keys(request).length === FLAGS.length + 2
&& FLAGS.every((key) => Object.hasOwn(request, key) && typeof request[key] === "boolean")
&& Number.isFinite(request.x) && Number.isFinite(request.y);
}
function textContextTemplate(request, lang, choose) {
if (!request.editable && !request.selected) return [];
let items = editItems(lang, {
canUndo: request.canUndo, canRedo: request.canRedo,
canCut: request.selected, canCopy: request.selected, canPaste: request.editable, canSelectAll: true,
});
if (!request.editable) items = items.filter((item) => item.role === "copy" || item.role === "selectAll");
const accelerators = {
undo: "CommandOrControl+Z",
redo: process.platform === "darwin" ? "CommandOrControl+Shift+Z" : "CommandOrControl+Y",
selectAll: "CommandOrControl+A",
};
items = items.map((item) => {
const accelerator = accelerators[item.role];
if (!accelerator) return item;
return { id: item.role, label: item.label, enabled: item.enabled, accelerator,
click: () => choose(item.role) };
});
if (request.quote && request.selected) {
items.push({ type: "separator" }, { label: wordsFor(lang).quote, click: () => choose("quote") });
}
return items;
}
function textContextMenuHandler(Menu, fromWindow, originOf, languageOf) {
const pending = new WeakMap();
return function showTextContextMenu(event, request) {
const window = fromWindow(event);
if (!window || !validRequest(request)) return "";
const contents = window.webContents;
if (!event.senderFrame || event.senderFrame !== contents.mainFrame) return "";
try {
if (new URL(event.senderFrame.url).origin !== new URL(originOf()).origin) return "";
} catch {
return "";
}
pending.get(window)?.();
const template = textContextTemplate(request, languageOf(), (action) => finish(action));
if (!template.length) return "";
const menu = Menu.buildFromTemplate(template);
let finish;
return new Promise((resolve, reject) => {
let settled = false;
const cancel = () => {
finish("");
if (!window.isDestroyed() && !contents.isDestroyed?.()) menu.closePopup(window);
};
const navigation = (details) => {
if (details.isMainFrame) cancel();
};
finish = (result, error) => {
if (settled) return;
settled = true;
contents.removeListener("did-start-navigation", navigation);
contents.removeListener("destroyed", cancel);
window.removeListener("closed", cancel);
pending.delete(window);
if (error) reject(error);
else resolve(result);
};
pending.set(window, cancel);
contents.on("did-start-navigation", navigation);
contents.once("destroyed", cancel);
window.once("closed", cancel);
const zoom = contents.getZoomFactor();
try {
menu.popup({ window, x: Math.round(request.x * zoom), y: Math.round(request.y * zoom), callback: () => finish("") });
} catch (error) {
finish("", error);
}
});
};
}
module.exports = { textContextTemplate, textContextMenuHandler };
+3 -8
View File
@@ -112,13 +112,8 @@ async function main() {
const js = (src) => win.webContents.executeJavaScript(src);
const reserved = () => js(`(() => { const e = document.querySelector('.bview'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
// The browser tab beside the conversation is a switch for that view itself, so
// the page is widened with the browser's own wide control, which is how the
// person makes it the whole pane.
await until("the wide control on the browser panel", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
// A page that is put away is as wide as an agent's viewport, so wide means drawn
// and wider than it was beside the conversation.
await until("the full-view control on the browser panel", () => js(`!!document.querySelector('[data-action="browser.full"]')`));
await js(`document.querySelector('[data-action="browser.full"]').click()`);
await until("the page drawn full width", async () => (!beneath() && width() > aside.width ? guest.getBounds() : null));
// The panel widens over a moment; the rectangle is read once it has stopped.
const same = still;
@@ -149,7 +144,7 @@ async function main() {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
await js(`document.querySelector('[data-action="browser.full"]').click()`);
const back = await until("the page beside the conversation again", async () => (!beneath() && width() < placed.width ? guest.getBounds() : null));
// The column settles a pixel or two after the first measurement, so beside means
// the same place and size to within a few pixels.
+319
View File
@@ -0,0 +1,319 @@
"use strict";
// Drives the element picker in the real shell: a person's own mouse and keys
// over a page of our own, in a user-data directory of our own. Run with
// `pnpm pick-live`; it needs no kernel and no network.
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
// Nothing this test writes may land in the Studio's own profile: a run that
// picked a password field must not leave that page cached in anyone's session.
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-pick-live-")));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const fails = [];
const check = (name, ok, got) => {
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
if (!ok) fails.push(name);
};
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Pick lab</title>
<style>
body { margin: 0; font: 16px sans-serif; }
#hero { position: absolute; left: 40px; top: 60px; width: 300px; height: 120px; background: #c0392b; color: #fff; }
#logo { width: 40px; height: 20px; margin: 10px; }
#go { position: absolute; left: 40px; top: 220px; }
#pw { position: absolute; left: 40px; top: 270px; }
</style></head><body>
<div id="hero">Hero <img id="logo" alt="logo" src="${GIF}"></div>
<button id="go" class="btn__a1b2c3" data-testid="go-button" aria-label="投放" onclick="window.__clicked = true">Go</button>
<label>Password <input id="pw" type="password" value="secret-value"></label>
<script>window.__enter = false; addEventListener("keydown", (event) => { if (event.key === "Enter") window.__enter = true; });</script>
<h1 id="head" style="position:absolute;left:40px;top:340px">A heading long enough to carry its own words and ask for no page context around it</h1>
<span id="dot" style="position:absolute;left:40px;top:420px;width:7px;height:7px;background:#2ecc71"></span>
<div id="badge" class="badge__x9y8z7" aria-label="状态徽标" style="position:absolute;left:40px;top:470px">ok</div>
<em class="em__a1b2c3" style="position:absolute;left:40px;top:520px">hash</em>
<iframe id="frame" srcdoc="&lt;p&gt;inside&lt;/p&gt;" style="position:absolute;left:40px;top:570px;width:120px;height:60px"></iframe>
<div id="shadow" style="position:absolute;left:200px;top:570px;width:80px;height:40px"></div>
<script>document.getElementById("shadow").attachShadow({ mode: "open" }).innerHTML = "<span>inside shadow</span>";</script>
</body></html>`;
app.whenReady().then(async () => {
const server = http.createServer((_q, s) => {
s.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
s.end(PAGE);
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const origin = `http://127.0.0.1:${server.address().port}/`;
const win = new BrowserWindow({ width: 1400, height: 1000, show: true, webPreferences: { sandbox: true } });
await win.loadURL("data:text/html,<body style='margin:0'><p>studio page</p></body>");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
let loaded;
const ready = new Promise((r) => (loaded = r));
const page = views.create({
partition: "pick-live",
url: origin,
onEvent: (method) => method === "Page.loadEventFired" && loaded(),
onClosed() {},
onPopup() {},
onDownload() {},
});
await page.send("Page.enable");
await Promise.race([ready, wait(4000)]);
const view = views.entries.get(page.targetId).view;
view.webContents.on("console-message", (_event, _level, message) => console.log("page console:", message));
views.show(page.targetId, { x: 0, y: 0, width: 1280, height: 900 });
await wait(600);
// Input only reaches a window the platform considers live; a background
// window drops the pressed half of a click while still delivering moves.
win.show();
win.focus();
await wait(300);
view.webContents.focus();
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
const at = (id) =>
evalIn(`(() => { const b = document.getElementById(${JSON.stringify(id)}).getBoundingClientRect();
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
const atSel = (selector) =>
evalIn(`(() => { const b = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
const move = (point) => page.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, button: "none", clickCount: 0 });
const click = async (point) => {
await move(point);
// Let the hover settle before the press: a new pick arms a frame later.
await wait(80);
// A native input event rather than a CDP press: the page half is what this
// guards, and the window's own input path is what a person's click takes.
const x = Math.round(point.x);
const y = Math.round(point.y);
view.webContents.sendInputEvent({ type: "mouseDown", x, y, button: "left", clickCount: 1 });
view.webContents.sendInputEvent({ type: "mouseUp", x, y, button: "left", clickCount: 1 });
};
const overlays = () =>
evalIn(`[...document.querySelectorAll('[data-reasonix-pick="box"], [data-reasonix-pick="card"]')].map((el) => ({
kind: el.dataset.reasonixPick, text: el.innerText, shown: getComputedStyle(el).display !== "none" }))`);
await evalIn("window.__clicked = false");
// 1. A person points at the image: the highlight and the card are drawn in
// the page, and what comes back is the element and where it sits.
const picking = views.pick(page.targetId);
await wait(200);
const logo = await at("logo");
await move(logo);
await wait(150);
const drawn = await overlays();
check("hovering draws the highlight and the card inside the page", drawn.length === 2 && drawn.every((one) => one.shown), drawn);
check(
"the card names the colour and the font, and adds no background for a transparent one",
/Color/.test(drawn[1].text) && /Font/.test(drawn[1].text) && !/Background/.test(drawn[1].text),
drawn[1].text,
);
await click(logo);
const picked = await picking;
check("a click answers with the element under the pointer", picked.cancelled === false && picked.element.tagName === "img", picked);
check("the element comes with a picture of itself", /^data:image\/png/.test(picked.shot || ""), (picked.shot || "").slice(0, 24));
check("the selector names it again", picked.element.selector === "#logo", picked.element.selector);
check(
"the box is the one the page laid out",
Math.abs(picked.element.rect.width - logo.width) <= 1 && Math.abs(picked.element.rect.height - logo.height) <= 1,
{ rect: picked.element.rect, logo },
);
check("it carries the page it came from", picked.element.pageUrl === origin, picked.element.pageUrl);
check("an element with little text of its own gets its container's", picked.element.nearbyText === "Hero", picked.element.nearbyText);
check("the card is put away once the pick settles", (await overlays()).length === 0, await overlays());
// 2. A pick is not a click for the page: the button's own handler never runs.
const buttonPick = views.pick(page.targetId);
await wait(200);
await click(await at("go"));
const button = await buttonPick;
check("the page's own click handler does not run", (await evalIn("window.__clicked")) === false, await evalIn("window.__clicked"));
check(
"a button says what it is and what it is called",
button.element.tagName === "button" && button.element.role === "button" && button.element.accessibleName === "投放",
button.element,
);
check("a button with its own word asks for no context", button.element.nearbyText === undefined, button.element.nearbyText);
// 3. What a person typed into a password field never leaves the page.
const passwordPick = views.pick(page.targetId);
await wait(200);
await click(await at("pw"));
const password = await passwordPick;
check("a password field answers with a placeholder, not its value", password.element.text === "[masked password input]", password.element.text);
check("neither the markup nor the attributes carry what was typed", !JSON.stringify(password.element).includes("secret-value"), password.element.htmlExcerpt);
// 4. A heading says what it is already: the nearest article around it is the
// whole page, and that must not be handed over as context.
const headingPick = views.pick(page.targetId);
await wait(200);
await click(await at("head"));
const heading = await headingPick;
check("a heading carries no page-wide context", heading.element.nearbyText === undefined, heading.element.nearbyText);
// 5. A decorative mark has no words and no role: nothing around it makes it
// meaningful, so no context comes along either, and the fields that have no
// answer are left out instead of sent empty.
const dotPick = views.pick(page.targetId);
await wait(200);
await click(await at("dot"));
const dot = await dotPick;
check(
"a decorative mark carries no context and no empty fields",
dot.element.nearbyText === undefined && dot.element.role === undefined && dot.element.text === undefined,
dot.element,
);
// 6. The page's own identity wins over its styling: a hash class is gone
// after the next deploy, a data-testid is not.
const identity = views.pick(page.targetId);
await wait(200);
await click(await at("go"));
const byTestId = await identity;
check(
"a data-testid is the selector when the page has one",
byTestId.element.selector === '[data-testid="go-button"]' && byTestId.element.selectorBasis === "testid",
byTestId.element,
);
check("the hash class behind it is not used", !byTestId.element.selector.includes("btn__a1b2c3"), byTestId.element.selector);
const labelled = views.pick(page.targetId);
await wait(200);
await click(await at("badge"));
const byLabel = await labelled;
check(
"an aria-label is the next choice",
byLabel.element.selector === 'div[aria-label="状态徽标"]' && byLabel.element.selectorBasis === "aria-label",
byLabel.element,
);
const styled = views.pick(page.targetId);
await wait(200);
await click(await atSel(".em__a1b2c3"));
const byClass = await styled;
check(
"with neither, the styling class is used and labelled as such",
byClass.element.selectorBasis === "class" && byClass.element.selector.includes("em__a1b2c3"),
byClass.element,
);
check(
"and it still finds the element",
(await evalIn(`document.querySelector(${JSON.stringify(byClass.element.selector)})?.tagName`)) === "EM",
byClass.element.selector,
);
// 7. Stepping up with the arrow key: what the card shows and what comes back
// are the same level.
const climbing = views.pick(page.targetId);
await wait(200);
const logoPoint = await at("logo");
await move(logoPoint);
await wait(100);
const boxWidth = () => evalIn(`parseFloat(document.querySelector('[data-reasonix-pick="box"]').style.width)`);
check("the pointer starts on the image", (await boxWidth()) === 40, await boxWidth());
view.webContents.sendInputEvent({ type: "keyDown", keyCode: "Up" });
await wait(120);
view.webContents.sendInputEvent({ type: "keyUp", keyCode: "Up" });
check("one step up is the hero", (await boxWidth()) === 300, await boxWidth());
const chainText = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
check("the card shows the chain it climbed", /div#hero/.test(chainText) && !/img/.test(chainText), chainText);
// Pressed without moving: a mousemove would put the pointer back on the image.
const climbX = Math.round(logoPoint.x);
const climbY = Math.round(logoPoint.y);
view.webContents.sendInputEvent({ type: "mouseDown", x: climbX, y: climbY, button: "left", clickCount: 1 });
view.webContents.sendInputEvent({ type: "mouseUp", x: climbX, y: climbY, button: "left", clickCount: 1 });
const climbed = await climbing;
check("the answer is the level stepped up to", climbed.element.tagName === "div" && climbed.element.selector === "#hero", climbed.element);
// 9. A frame or a shadow host is named, not guessed at.
const framePick = views.pick(page.targetId);
await wait(200);
await move(await at("frame"));
await wait(100);
const frameNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
check("an embedded frame says why it cannot be picked", /embedded frame/.test(frameNote), frameNote);
await click(await at("frame"));
await wait(300);
const stillArmed = await evalIn(`!!document.querySelector('[data-reasonix-pick="box"]')`);
check("clicking the frame leaves the pick armed", stillArmed === true, stillArmed);
void views.cancelPick(page.targetId);
const frameAnswer = await framePick;
check("and the frame is not reported as an element", frameAnswer.cancelled === true, frameAnswer);
const shadowPick = views.pick(page.targetId);
await wait(200);
await move(await at("shadow"));
await wait(100);
const shadowNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
check("a shadow host says why it cannot be picked", /shadow root/.test(shadowNote), shadowNote);
void views.cancelPick(page.targetId);
await shadowPick;
// 10. A second pick on the same page replaces the first instead of stacking.
const firstPick = views.pick(page.targetId);
await wait(200);
const secondPick = views.pick(page.targetId);
await wait(300);
const draws = await evalIn(
`document.querySelectorAll('[data-reasonix-pick="box"]').length + "/" + document.querySelectorAll('[data-reasonix-pick="card"]').length`,
);
check("a second pick leaves one picker on the page", draws === "1/1", draws);
const firstAnswer = await firstPick;
check("the first pick answers as cancelled", firstAnswer.cancelled === true, firstAnswer);
void views.cancelPick(page.targetId);
await secondPick;
// 11. Shift-click keeps a set: the click that ends it carries them all.
const multi = views.pick(page.targetId);
await wait(200);
const goAt = await at("go");
await move(goAt);
await wait(100);
view.webContents.sendInputEvent({ type: "mouseDown", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
view.webContents.sendInputEvent({ type: "mouseUp", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
await wait(150);
const marked = await evalIn(`document.querySelectorAll("[data-reasonix-picked]").length`);
check("shift-click keeps the element marked", marked === 1, marked);
await click(await at("badge"));
const many = await multi;
check("the set comes back as several elements", many.elements?.length === 2, many.elements?.map((e) => e.tagName));
// 12. Verifying the selector after the page changed.
const still = await views.verify(page.targetId, "#logo");
check("a selector that still exists comes back with a fresh picture", still.found === true && /^data:image\/png/.test(still.shot || ""), still);
const gone = await views.verify(page.targetId, "#nope");
check("a selector that is gone says so", gone.found === false, gone);
// 8. The keyboard is the picker's too while it is armed: Enter must not press
// a focused button, and Escape is the one key that ends the pick — saying so.
const escaping = views.pick(page.targetId);
await wait(200);
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
await page.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
check("Enter does not reach the page", (await evalIn("window.__enter")) === false, await evalIn("window.__enter"));
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 });
const escaped = await escaping;
check("Escape cancels the pick, and says why", escaped.cancelled === true && escaped.reason === "escape", escaped);
check("and leaves nothing drawn", (await overlays()).length === 0);
// 7. A page put away under the picker ends the pick instead of swallowing
// the person's clicks on a picture they cannot reach.
const hiding = views.pick(page.targetId);
await wait(200);
views.hide();
const hidden = await hiding;
check("a page put away cancels the pick, and says why", hidden.cancelled === true && hidden.reason === "hidden", hidden);
views.hide();
page.close();
server.close();
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
app.exit(fails.length ? 1 : 0);
});
@@ -0,0 +1,150 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { createGlobalShortcut, registerGlobalShortcutIPC, PREF_KEY } = require("../src/globalshortcut");
const { savePrefs, loadPrefs, registerPrefs } = require("../src/prefs");
function fixture(binding = "") {
const registered = new Map();
const saved = [];
const win = { visible: true, focused: true, minimized: false, destroyed: false,
isVisible() { return this.visible; }, isFocused() { return this.focused; },
isMinimized() { return this.minimized; }, isDestroyed() { return this.destroyed; },
hide() { this.visible = false; this.focused = false; },
};
let shown = 0;
let failSave = false;
const api = {
register(key, callback) {
if (key === "Control+invalid") throw new TypeError("invalid accelerator");
if (key === "Control+occupied") return false;
registered.set(key, callback); return true;
},
unregister(key) { registered.delete(key); },
};
const shortcut = createGlobalShortcut({ globalShortcut: api, binding,
save(value) { if (failSave) throw new Error("disk full"); saved.push(value); },
windowOf: () => win, showWindow() { shown++; win.visible = true; win.focused = true; win.minimized = false; },
});
return { shortcut, registered, saved, win, get shown() { return shown; }, failSave() { failSave = true; } };
}
test("starts disabled without claiming an OS binding and restores a saved binding before any renderer", () => {
const off = fixture();
assert.deepEqual(off.shortcut.start(), { binding: "", status: "disabled" });
assert.equal(off.registered.size, 0);
const on = fixture("Control+Space");
assert.deepEqual(on.shortcut.start(), { binding: "Control+Space", status: "registered" });
assert.equal(on.registered.size, 1);
assert.deepEqual(on.saved, []);
});
test("bare keys and Shift-only chords cannot claim machine-wide input through apply or restored preferences", () => {
for (const binding of ["C", "Space", "F9", "Plus", " Shift ", "Shift+A", "Shift+Space"]) {
const restored = fixture(binding);
assert.deepEqual(restored.shortcut.start(), { binding, status: "invalid" });
assert.equal(restored.registered.size, 0);
const f = fixture("Control+Space"); f.shortcut.start();
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error: "invalid" });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
assert.deepEqual(f.saved, []);
}
for (const modifier of ["CommandOrControl", "CmdOrCtrl", "Command", "Cmd", "Control", "Ctrl", "Alt", "Option", "AltGr", "Super", "Meta"]) {
const f = fixture();
assert.equal(f.shortcut.apply(`${modifier}+Space`).status, "registered");
assert.equal(f.shortcut.apply(`${modifier}+Shift+Space`).status, "registered");
}
});
test("focused hides; unfocused, hidden and minimized show through the existing path", () => {
for (const changed of [{}, { focused: false }, { visible: false, focused: false }, { minimized: true }]) {
const f = fixture("Control+Space"); f.shortcut.start(); Object.assign(f.win, changed);
f.registered.get("Control+Space")();
assert.equal(f.shown, Object.keys(changed).length ? 1 : 0);
assert.equal(f.win.visible, Object.keys(changed).length > 0);
}
});
test("failed rebind preserves both registration and saved choice, successful replacement removes only the old binding", () => {
const f = fixture("Control+Space"); f.shortcut.start();
for (const [binding, error] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"], [42, "invalid"]]) {
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
assert.deepEqual(f.saved, []);
}
assert.deepEqual(f.shortcut.apply(" Control+Shift+Space "), { binding: "Control+Shift+Space", status: "registered" });
assert.deepEqual([...f.registered.keys()], ["Control+Shift+Space"]);
assert.deepEqual(f.saved, ["Control+Shift+Space"]);
f.shortcut.apply("");
assert.equal(f.registered.size, 0);
assert.deepEqual(f.saved, ["Control+Shift+Space", ""]);
});
test("disk failure rolls back the prospective registration, including disabling", () => {
const f = fixture("Control+Space"); f.shortcut.start(); f.failSave();
for (const value of ["Control+Shift+Space", ""]) {
assert.deepEqual(f.shortcut.apply(value), { binding: "Control+Space", status: "registered", error: "save" });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
}
});
test("startup failure is honest and may be replaced or disabled", () => {
for (const [binding, status] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"]]) {
const f = fixture(binding);
assert.deepEqual(f.shortcut.start(), { binding, status });
assert.equal(f.registered.size, 0);
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
}
});
test("quit unregisters its binding, suppresses queued presses and refuses new registration", () => {
const f = fixture("Control+Space"); f.shortcut.start();
const queued = f.registered.get("Control+Space");
f.shortcut.close(); f.shortcut.close(); queued();
assert.equal(f.registered.size, 0); assert.equal(f.shown, 0); assert.equal(f.win.visible, true);
assert.equal(f.shortcut.apply("Control+Shift+Space").error, "quitting");
});
test("only the authorized Studio main frame can read or immediately apply a shortcut", () => {
const f = fixture(); f.shortcut.start();
const handlers = new Map(); const sender = { mainFrame: {} };
registerGlobalShortcutIPC({ handle: (key, fn) => handlers.set(key, fn) }, event => event.sender === sender, () => f.shortcut);
for (const event of [{ sender: { mainFrame: {} }, senderFrame: {} }, { sender, senderFrame: {} }]) {
assert.equal(handlers.get("shell:global-shortcut")(event), null);
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space"), null);
}
assert.equal(f.registered.size, 0);
const event = { sender, senderFrame: sender.mainFrame };
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space").status, "registered");
assert.equal(handlers.get("shell:global-shortcut")(event).binding, "Control+Space");
assert.deepEqual(handlers.get("shell:set-global-shortcut")(event, " ".repeat(128) + "Control+Space"), { binding: "Control+Space", status: "registered", error: "invalid" });
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
assert.deepEqual(f.saved, ["Control+Space"]);
});
test("page preference flushing cannot overwrite the shell-owned saved binding", t => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-shortcut-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const file = path.join(dir, "window-prefs.json");
savePrefs(file, { [PREF_KEY]: "Control+Space", "rx-lang": "en" });
const handlers = {};
registerPrefs({ on: (key, fn) => handlers[key] = fn }, () => file, () => true);
const event = {};
handlers["prefs:load"](event);
assert.deepEqual(event.returnValue, { "rx-lang": "en" });
handlers["prefs:save"](event, { "rx-lang": "zh", [PREF_KEY]: "occupied" });
assert.deepEqual(loadPrefs(file), { "rx-lang": "zh", [PREF_KEY]: "Control+Space" });
});
test("oversized restored preferences never reach native registration", () => {
const binding = "Control+" + "x".repeat(128);
const f = fixture(binding);
assert.deepEqual(f.shortcut.start(), { binding, status: "invalid" });
assert.equal(f.registered.size, 0);
assert.deepEqual(f.saved, []);
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
});
@@ -0,0 +1,115 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync, spawn } = require("node:child_process");
const { app, globalShortcut } = require("electron");
const { wait } = require("./livekit");
const home = process.env.REASONIX_HOME;
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native shortcut smoke");
fs.mkdirSync(home, { recursive: true });
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
const { current } = require("../src/main");
const input = process.env.REASONIX_XDOTOOL || "xdotool";
const binding = "Control+Shift+F9";
let neighbour;
async function until(read, description) {
const end = Date.now() + 40000;
while (Date.now() < end) {
if (await read()) return;
await wait(100);
}
throw new Error(`Timed out: ${description}`);
}
async function run() {
await until(() => current().win && !current().win.webContents.isLoading(), "Studio window");
const win = current().win;
const js = source => win.webContents.executeJavaScript(source);
await until(() => js('!!document.querySelector(".chrome")'), "real Studio workbench");
await until(() => js('!document.getElementById("boot")'), "startup overlay finishes");
assert.equal(await js('window.reasonixHost.globalShortcut().then(value => value.status)'), "disabled");
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
await until(() => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'), "settings navigation");
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
await until(() => js('!!document.querySelector("#set-global-shortcut input:not(:disabled)")'), "shortcut settings");
const out = process.env.REASONIX_SCREENSHOTS;
async function screenshots(stage) {
if (!out) return;
fs.mkdirSync(out, { recursive: true });
for (const width of [1280, 390]) {
win.setSize(1280, 1000);
win.webContents.enableDeviceEmulation({ screenPosition: "desktop", screenSize: { width, height: 1000 }, viewSize: { width, height: 1000 }, deviceScaleFactor: 1, scale: 1 });
for (const mode of ["light", "dark"]) {
await js(`document.querySelector('[data-action="appearance.scheme"][data-value="${mode}"]').click()`);
await js('document.querySelector("#set-global-shortcut").scrollIntoView({block:"center"})');
await wait(350);
const png = await win.webContents.capturePage({ x: 0, y: 0, width, height: 1000 });
fs.writeFileSync(path.join(out, `${stage}-${mode}-${width}.png`), png.toPNG());
}
}
win.webContents.disableDeviceEmulation();
}
await screenshots("before-disabled");
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="record"]\').click()');
win.webContents.sendInputEvent({ type: "keyDown", keyCode: "F9", modifiers: ["control", "shift"] });
win.webContents.sendInputEvent({ type: "keyUp", keyCode: "F9", modifiers: ["control", "shift"] });
await until(() => js(`document.querySelector("#set-global-shortcut input").value === "${binding}"`), "captured modifier chord");
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="apply"]\').click()');
await until(() => js('window.reasonixHost.globalShortcut().then(value => value.status === "registered")'), "real OS registration");
assert.equal(globalShortcut.isRegistered(binding), true);
console.log(`ok: real Studio settings capture/apply registered the native ${process.platform} binding`);
await screenshots("after-registered");
if (process.platform === "darwin") {
execFileSync("osascript", ["-e", `tell application "System Events" to set frontmost of first application process whose unix id is ${process.pid} to true`], { timeout: 10000 });
}
win.show(); win.focus();
await until(() => win.isFocused(), "focused Studio window");
const press = () => process.platform === "darwin"
? execFileSync("osascript", ["-e", 'tell application "System Events" to key code 101 using {control down, shift down}'], { timeout: 10000 })
: execFileSync(input, ["key", "--clearmodifiers", "ctrl+shift+F9"]);
press();
await until(() => !win.isVisible(), "OS key hides focused Studio");
press();
await until(() => win.isVisible() && win.isFocused(), "OS key restores hidden Studio");
console.log("ok: real OS key hides the focused window and restores it from hidden");
win.minimize();
await until(() => win.isMinimized(), "minimized Studio");
press();
await until(() => !win.isMinimized() && win.isVisible() && win.isFocused(), "OS key restores minimized Studio");
console.log("ok: real OS key restores and focuses the minimized window");
if (process.env.REASONIX_XMESSAGE) {
neighbour = spawn(process.env.REASONIX_XMESSAGE, ["-title", "Reasonix shortcut smoke neighbour", "-buttons", "OK", "Separate native application"], { stdio: "ignore" });
await until(() => {
try { return execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim(); } catch { return false; }
}, "external application window");
const id = execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim().split("\n")[0];
execFileSync(input, ["windowfocus", "--sync", id]);
await until(() => !win.isFocused() && win.isVisible(), "external application takes focus");
press();
await until(() => win.isFocused() && win.isVisible(), "OS key raises Studio from another app");
console.log("ok: real OS key raises visible, unfocused Studio while a separate native application has focus");
neighbour.kill();
}
assert.equal(globalShortcut.register("Control+Shift+F10", () => {}), true);
const failed = await js('window.reasonixHost.setGlobalShortcut("Control+Shift+F10")');
assert.deepEqual(failed, { binding, status: "registered", error: "unavailable" });
globalShortcut.unregister("Control+Shift+F10");
const saved = JSON.parse(fs.readFileSync(path.join(app.getPath("userData"), "window-prefs.json"), "utf8"));
assert.equal(saved["shell-global-shortcut"], binding);
assert.equal((await js('window.reasonixHost.setGlobalShortcut("C")')).error, "invalid");
assert.equal(globalShortcut.isRegistered("C"), false);
assert.equal((await js('window.reasonixHost.setGlobalShortcut("invalid shortcut value")')).error, "invalid");
assert.equal((await js('window.reasonixHost.setGlobalShortcut("")')).status, "disabled");
assert.equal(globalShortcut.isRegistered(binding), false);
console.log("ok: native conflict/invalid entries preserve prior binding and disabling unregisters it");
}
run().then(() => { app.quit(); }).catch(error => {
neighbour?.kill();
console.error(error.stack || error);
app.exit(1);
});
+11 -12
View File
@@ -36,33 +36,32 @@ async function main() {
await client.request("POST", `${base}/submit`, { input: "open the page" });
const guest = await until("a page view", async () => guests()[0], 60000);
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null), 30000);
await until("the wide control", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
await until("the full-view control", () => js(`!!document.querySelector('[data-action="browser.full"]')`));
const panelRect = () => js(`(() => { const e = document.querySelector('.sidepanel'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
const covers = (view, rect) => view.x < rect.x + rect.width && view.x + view.width > rect.x && view.y < rect.y + rect.height && view.y + view.height > rect.y;
for (const [width, height, wide] of [[1440, 900, false], [1440, 900, true], [1100, 760, false], [1100, 760, true], [900, 700, true], [700, 700, true], [1440, 900, "panel-first"], [1100, 760, "panel-first"]]) {
for (const [width, height, full] of [[1440, 900, false], [1440, 900, true], [1100, 760, false], [1100, 760, true], [900, 700, true], [700, 700, true], [1440, 900, "panel-first"], [1100, 760, "panel-first"]]) {
win.setContentSize(width, height);
await wait(900);
const label = `${width}px ${wide === "panel-first" ? "side question open, then the browser widened" : wide ? "widened" : "beside the conversation"}`;
if (wide === true && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) !== "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
const label = `${width}px ${full === "panel-first" ? "side question open, then full view" : full ? "full view" : "beside the conversation"}`;
if (full === true && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) !== "full") {
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
await wait(1200);
}
if (wide === false && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
if (full === false && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "full") {
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
await wait(1200);
}
if (wide === "panel-first") {
// Back beside the conversation first, then the question, then the widening.
if ((await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
if (full === "panel-first") {
if ((await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "full") {
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
await wait(1000);
}
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
await until("the side panel", async () => (await panelRect()) || null, 10000);
await wait(800);
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
await wait(1500);
} else {
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
+143
View File
@@ -0,0 +1,143 @@
"use strict";
// Drives page sleep in the real shell with no kernel: real Chromium renderers
// through BrowserViews, a tiny sleep threshold, and the shell's own process
// memory read around the sleep. A wake must answer a command against the page
// as it loaded, not as it was. Run with `pnpm sleep-live`; it needs no network.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews.js");
const PAGE = `<!doctype html><meta charset="utf-8"><title>heavy</title>
<style>td{padding:4px 24px}</style>
<table>${("<tr>" + "<td>payload</td>".repeat(40) + "</tr>").repeat(600)}</table>
<script>let n=0; setInterval(() => { document.title = "tick " + (++n); }, 500);</script>`;
const PAGE2 = `<!doctype html><meta charset="utf-8"><title>second</title><p>the second page</p>`;
const mb = (k) => Math.round(k / 1024);
const metrics = () =>
app
.getAppMetrics()
.filter((m) => m.type === "Tab" || m.type === "Renderer")
.reduce((acc, m) => ({ count: acc.count + 1, ws: acc.ws + m.memory.workingSetSize }), { count: 0, ws: 0 });
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(what, fn, ms = 30000) {
const end = Date.now() + ms;
for (;;) {
const got = await fn();
if (got) return got;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await wait(250);
}
}
async function main() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-sleep-probe-"));
const file = path.join(dir, "page.html");
fs.writeFileSync(file, PAGE);
const file2 = path.join(dir, "page2.html");
fs.writeFileSync(file2, PAGE2);
const win = new BrowserWindow({ show: false, width: 1280, height: 900 });
await win.loadURL("about:blank");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1", sleepAfterMs: 8000, sweepMs: 2000, maxAwake: 6 });
// Every domain event each page's debugger session produces is kept, so the
// probe can assert later that the events a wake's rebuilt session must
// carry really flowed.
const handles = [];
const eventFrames = [];
for (let i = 0; i < 5; i++) {
const events = [];
eventFrames.push(events);
handles.push(
views.create({
partition: `probe-${i}`,
url: "file://" + file.replace(/\\/g, "/"),
onEvent: (method, params) => events.push({ method, params }),
onClosed: () => {},
onPopup: () => {},
onDownload: () => {},
}),
);
}
await until("pages to load and their renderers to settle", async () => {
await wait(1000);
return metrics().count >= 5;
});
await wait(1500);
// What the kernel says once at attach, said to page 3 before it dozes: the
// wake must say them again to the session it builds, or no domain event
// reaches the page after.
for (const [method, params] of [
["Page.enable", {}],
["Runtime.enable", {}],
["Network.enable", {}],
["Page.setLifecycleEventsEnabled", { enabled: true }],
]) await handles[2].send(method, params);
const before = metrics();
console.log(`[probe] 5 pages live: ${before.count} renderer processes, ${mb(before.ws)} MB working set`);
await until("the sweep to sleep every page", async () => {
await wait(1000);
return handles.every((h) => views.asleep(h.targetId));
});
await wait(2500); // let the OS reap the dead processes
const after = metrics();
console.log(`[probe] after sleep: ${after.count} renderer processes, ${mb(after.ws)} MB working set`);
console.log(`[probe] reclaimed: ${before.count - after.count} processes, ${mb(before.ws - after.ws)} MB`);
// A wake must answer a real command on a real page.
const answer = await handles[2].send("Runtime.evaluate", { expression: "document.querySelectorAll('td').length", returnByValue: true });
const cells = answer && answer.result && answer.result.value;
const woken = metrics();
console.log(`[probe] wake #3: Runtime.evaluate answered cells=${cells}; renderers now ${woken.count}, ${mb(woken.ws)} MB`);
if (after.count >= before.count) throw new Error("sleep reclaimed no renderer processes");
if (cells !== 40 * 600) throw new Error(`wake answered wrong value: ${cells}`);
if (woken.count !== after.count + 1) throw new Error(`wake rebuilt ${woken.count - after.count} renderers, wanted 1`);
// Domain events must flow after a wake on a real rebuilt session: the
// kernel's waitLoaded depends exactly on Page.frameNavigated arriving. The
// enables said before the sleep were replayed to the session the wake built.
await handles[2].send("Page.navigate", { url: "file://" + file2.replace(/\\/g, "/") });
await until(
"post-wake Page.frameNavigated for page2",
() =>
eventFrames[2].some(
(f) => f.method === "Page.frameNavigated" && f.params && f.params.frame && typeof f.params.frame.url === "string" && f.params.frame.url.includes("page2"),
),
15000,
);
const titled = await until("page2's title to read back", async () => {
const said = await handles[2].send("Runtime.evaluate", { expression: "document.title", returnByValue: true });
const value = said && said.result && said.result.value;
return value === "second" ? value : null;
}, 15000);
console.log(`[probe] post-wake: navigate answered frameNavigated for page2, title="${titled}"`);
// A command still in flight when the sleep lands must fail fast, not hang:
// the kernel reads an error it can retry, never a timeout it cannot tell
// from a dead page. The hanging ask wakes page 5, which then idles out and
// sleeps again underneath it — the exact window being pinned.
const pending = handles[4].send("Runtime.evaluate", { expression: "new Promise(() => {})", awaitPromise: true, returnByValue: true });
const held = pending.then(() => "resolved", (err) => `rejected: ${err.message}`);
await until("the sleeping page's in-flight command to settle", async () => {
await wait(500);
return !!(await Promise.race([held, Promise.resolve(false)]));
}, 25000);
const outcome = await held;
if (!outcome.startsWith("rejected")) throw new Error(`a command on a slept page ${outcome} instead of failing`);
console.log("[probe] in-flight: " + outcome);
console.log("[probe] PASS");
views.closeAll();
win.destroy();
app.exit(0);
}
app.whenReady().then(main).catch((err) => {
console.error("[probe] FAIL:", err.message);
app.exit(1);
});
+1 -1
View File
@@ -84,7 +84,7 @@ async function run() {
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
}))()`);
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openWorkspace", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "syncTheme", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
+169
View File
@@ -0,0 +1,169 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow, nativeTheme } = require("electron");
const { GROUND } = require("../src/ground");
const { loadPrefs, prefsFile } = require("../src/prefs");
const { until, wait } = require("./livekit");
const home = process.env.REASONIX_HOME;
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native theme smoke");
fs.mkdirSync(home, { recursive: true });
if (!fs.existsSync(path.join(home, "config.toml"))) {
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
}
process.chdir(home);
fs.mkdirSync(path.join(home, "electron-profile"), { recursive: true });
app.setPath("userData", path.join(home, "electron-profile"));
const startups = new Map();
app.on("browser-window-created", (_event, win) => {
// Native options settle after creation; load-start still precedes page paint.
win.webContents.once("did-start-loading", () => {
startups.set(win, { source: nativeTheme.themeSource, background: win.getBackgroundColor(), saved: loadPrefs(prefsFile(app.getPath("userData"))) });
});
});
const { current } = require("../src/main");
let untrusted;
async function run() {
const win = await until("Studio window", () => current().win && !current().win.webContents.isLoading() && current().win);
const js = source => win.webContents.executeJavaScript(source);
await until("real Studio workbench", () => js('!!document.querySelector(".chrome") && !document.getElementById("boot")'));
const saved = () => loadPrefs(prefsFile(app.getPath("userData")));
async function matches(scheme, source = scheme) {
await until(`native ${scheme}/${source} repaint`, async () => nativeTheme.themeSource === source &&
win.getBackgroundColor().toLowerCase().endsWith(GROUND[scheme].slice(1)) &&
await js(`document.documentElement.dataset.theme === ${JSON.stringify(scheme)}`));
console.log(JSON.stringify({ page: scheme, source: nativeTheme.themeSource, background: win.getBackgroundColor() }));
}
if (process.env.REASONIX_THEME_RESTART === "1") {
const startup = startups.get(win);
assert.equal(startup.saved["rx-theme"], "scheduled");
assert.equal(startup.source, startup.saved["rx-theme-resolved"]);
assert.ok(startup.background.toLowerCase().endsWith(GROUND[startup.source].slice(1)), JSON.stringify(startup));
const clock = new Date();
const actual = clock.getHours() >= 20 || clock.getHours() < 7 ? "dark" : "light";
assert.notEqual(actual, startup.source);
await matches(actual);
assert.equal(await js('localStorage.getItem("rx-theme")'), "scheduled");
console.log("ok restart: cached native startup tint, then current local-clock page/native repaint on a new kernel origin");
return;
}
nativeTheme.themeSource = "system";
const systemDark = nativeTheme.shouldUseDarkColors;
console.log(`host OS dark=${systemDark}; platform=${process.platform}`);
await js(`(() => {
const RealDate = Date;
window.__themeClock = { at: RealDate.now(), began: performance.now() };
window.Date = class extends RealDate {
constructor(...args) { super(...(args.length ? args : [window.__themeClock.at + performance.now() - window.__themeClock.began])); }
static now() { return window.__themeClock.at + performance.now() - window.__themeClock.began; }
};
})()`);
async function clock(hour, minute = 0, second = 0) {
const at = new Date(2026, 9, 29, hour, minute, second).getTime();
await js(`window.__themeClock = { at: ${at}, began: performance.now() }; window.dispatchEvent(new Event("focus"))`);
}
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
await until("settings navigation", () => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'));
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
await until("appearance modes", () => js('!!document.querySelector(\'[data-action="appearance.scheme"][data-value="scheduled"]\')'));
const mode = value => js(`document.querySelector('[data-action="appearance.scheme"][data-value="${value}"]').click()`);
async function change(field, value) {
await js(`(() => {
const input = document.querySelector('[data-action="appearance.schedule"][data-target="${field}"]');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(value)});
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
})()`);
}
await clock(19, 59, 57);
await mode("scheduled");
await matches("light");
await matches("dark");
console.log("ok real boundary timeout changes both nativeTheme.themeSource and BrowserWindow background");
await clock(6, 59, 58);
await matches("dark");
await matches("light");
await clock(21);
await matches("dark");
await change("start", "22:00");
await matches("light");
await change("start", "20:00");
await matches("dark");
await change("end", "21:00");
await matches("light");
await change("end", "07:00");
await matches("dark");
await js('document.querySelector(\'[data-action="theme.activate"][data-value="official-clear-blue"]\').click()');
await until("active theme pack", () => js('document.documentElement.dataset.pack === "official-clear-blue"'));
await matches("dark");
await js('document.querySelector(".appearance-advanced").open = true; document.querySelector(\'[data-action="appearance.contrast"][data-value="strong"]\').click()');
await matches("dark");
await clock(8);
await matches("light");
assert.equal(await js('document.documentElement.dataset.pack'), "official-clear-blue");
assert.equal(await js('document.documentElement.dataset.contrast'), "strong");
console.log("ok start/end edits, focus resume, active pack and contrast keep the native scheme aligned");
await clock(systemDark ? 8 : 21);
await matches(systemDark ? "light" : "dark");
await mode("auto");
await until("Auto native system following", () => nativeTheme.themeSource === "system");
const systemScheme = systemDark ? "dark" : "light";
await matches(systemScheme, "system");
await js('localStorage.setItem("rx-theme", "scheduled"); localStorage.setItem("rx-weight", "heavy"); window.dispatchEvent(new Event("pagehide"))');
assert.equal(saved()["rx-theme"], "scheduled");
await matches(systemScheme, "system");
await js('localStorage.setItem("rx-theme", "auto")');
console.log("ok simulated stale prefs:save cannot override the latest Auto paint or pin system following");
await clock(8);
await mode("scheduled");
await matches("light");
await mode("dark");
await matches("dark");
await js('window.reasonixHost.syncTheme("scheduled", "auto"); window.reasonixHost.syncTheme("light", "dark"); window.reasonixHost.syncTheme("invalid", "light")');
await wait(200);
await matches("dark");
untrusted = new BrowserWindow({ show: false, webPreferences: { preload: path.join(__dirname, "../src/preload.js"), sandbox: true, contextIsolation: true, nodeIntegration: false, partition: "theme-smoke-untrusted" } });
await untrusted.loadURL("data:text/html,<title>Untrusted theme reporter</title>");
await untrusted.webContents.executeJavaScript('window.reasonixHost.syncTheme("scheduled", "light")');
await wait(200);
await matches("dark");
untrusted.destroy(); untrusted = null;
await js('const iframe = document.createElement("iframe"); iframe.id = "theme-subframe"; iframe.srcdoc = "<p>Untrusted subframe</p>"; document.body.append(iframe)');
await until("isolated subframe", () => js('!!document.getElementById("theme-subframe").contentDocument?.querySelector("p")'));
assert.equal(await js('typeof document.getElementById("theme-subframe").contentWindow.reasonixHost'), "undefined");
await js('document.getElementById("theme-subframe").remove()');
console.log("ok real untrusted renderer and invalid payloads cannot repaint native chrome; subframes have no bridge");
await mode("scheduled");
const real = new Date();
const restartScheme = real.getHours() >= 20 || real.getHours() < 7 ? "light" : "dark";
await clock(restartScheme === "dark" ? 21 : 8);
await matches(restartScheme);
await js('window.dispatchEvent(new Event("pagehide"))');
assert.equal(saved()["rx-theme"], "scheduled");
assert.equal(saved()["rx-theme-resolved"], restartScheme);
const out = process.env.REASONIX_SCREENSHOTS;
if (out) {
fs.mkdirSync(out, { recursive: true });
await js('document.getElementById("set-mode").scrollIntoView({block:"start"})');
await wait(350);
fs.writeFileSync(path.join(out, "native-scheduled.png"), (await win.webContents.capturePage()).toPNG());
}
console.log("ok saved opposite-clock native tint; run again with REASONIX_THEME_RESTART=1 to check a complete restart");
console.log("note Windows border rendering and changing the host OS preference are not exercised on this host");
}
let exitCode = 0;
app.once("will-quit", () => app.exit(exitCode));
run().then(() => app.quit()).catch(error => {
untrusted?.destroy();
console.error(error.stack || error);
exitCode = 1;
app.quit();
});
+748 -2
View File
@@ -4,18 +4,20 @@ import { createRequire } from "node:module";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { EventEmitter } from "node:events";
const require = createRequire(import.meta.url);
const cuaContract = require("../../../internal/platform/computer/cua-driver.lock.json");
const { parse, readActs, start } = require("../src/host.js");
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
const { textContextTemplate, textContextMenuHandler } = require("../src/textcontextmenu.js");
const { uiLanguage } = require("../src/uilang.js");
const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
const { groundFor, themeSourceFor, GROUND } = require("../src/ground.js");
const { folderAnswer } = require("../src/folderpanel.js");
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
const { pick, loadPrefs, savePrefs, registerPrefs, PAGE_SLEEP_MS, pageSleepAfterMs } = require("../src/prefs.js");
const TOKEN = "a".repeat(64);
const line = (over) => JSON.stringify({ version: 1, origin: "http://127.0.0.1:8080", token: TOKEN, ...over });
@@ -84,6 +86,136 @@ test("the context menu mirrors what the page says is possible", () => {
});
});
test("a reader's generic text menu never offers mutation roles", () => {
const items = contextTemplate({ isEditable: false, selectionText: "picked",
editFlags: { canCopy: true, canSelectAll: true, canCut: true, canPaste: true, canUndo: true } }, "en");
assert.deepEqual(items.map((item) => item.role), ["copy", "selectAll"]);
assert.ok(items.every((item) => item.enabled));
});
const textMenuRequest = (over = {}) => ({
editable: true, selected: true, canUndo: true, canRedo: false, quote: true, x: 12.5, y: 24, ...over,
});
const menuRoles = (items) => Object.fromEntries(items.filter((item) => item.role || item.id)
.map((item) => [item.role ?? item.id, item.enabled]));
test("workbench native menus distinguish editable and reader surfaces and translate quoting", () => {
const editable = textContextTemplate(textMenuRequest(), "en", () => {});
assert.deepEqual(menuRoles(editable), { undo: true, redo: false, cut: true, copy: true, paste: true, selectAll: true });
assert.equal(editable.at(-1).label, "Add to conversation");
assert.ok(editable.filter((item) => item.role).every((item) => !item.click));
const empty = textContextTemplate(textMenuRequest({ selected: false }), "en", () => {});
assert.deepEqual(menuRoles(empty), { undo: true, redo: false, cut: false, copy: false, paste: true, selectAll: true });
assert.equal(empty.filter((item) => item.click).length, 3);
const reader = textContextTemplate(textMenuRequest({ editable: false }), "zh", () => {});
assert.deepEqual(menuRoles(reader), { copy: true, selectAll: true });
assert.equal(reader.at(-1).label, "加入对话");
assert.deepEqual(textContextTemplate(textMenuRequest({ editable: false, selected: false }), "en", () => {}), []);
assert.equal(textContextTemplate(textMenuRequest({ quote: false }), "en", () => {}).filter((item) => item.click).length, 3);
});
function textMenuHarness() {
const contents = Object.assign(new EventEmitter(), {
mainFrame: { url: "http://127.0.0.1:8080/" }, getZoomFactor: () => 1.5,
});
const window = Object.assign(new EventEmitter(), { webContents: contents, isDestroyed: () => false });
const menus = [];
const Menu = { buildFromTemplate: (template) => {
const menu = { template, popup: (options) => { menu.options = options; },
closePopup: (target) => { assert.equal(target, window); menu.closed = true; } };
menus.push(menu);
return menu;
} };
const event = { sender: contents, senderFrame: contents.mainFrame };
const handle = textContextMenuHandler(Menu, (e) => e.sender === contents ? window : null,
() => "http://127.0.0.1:8080", () => "en");
return { contents, window, menus, event, handle };
}
test("native workbench popup returns quote or dismissal and uses zoomed client coordinates", async () => {
const h = textMenuHarness();
const picked = h.handle(h.event, textMenuRequest());
const menu = h.menus[0];
assert.equal(menu.options.window, h.window);
assert.equal(menu.options.x, 19);
assert.equal(menu.options.y, 36);
menu.template.at(-1).click();
menu.options.callback();
assert.equal(await picked, "quote");
const dismissed = h.handle(h.event, textMenuRequest());
h.menus[1].options.callback();
assert.equal(await dismissed, "");
assert.equal(h.contents.listenerCount("destroyed"), 0);
assert.equal(h.window.listenerCount("closed"), 0);
});
test("workbench history and select-all commands return editor actions, not native roles", async () => {
const h = textMenuHarness();
for (const [action, accelerator] of [
["undo", "CommandOrControl+Z"],
["redo", process.platform === "darwin" ? "CommandOrControl+Shift+Z" : "CommandOrControl+Y"],
["selectAll", "CommandOrControl+A"],
]) {
const result = h.handle(h.event, textMenuRequest({ canRedo: true }));
const menu = h.menus.at(-1);
const item = menu.template.find((entry) => entry.id === action);
assert.equal(item.role, undefined);
assert.equal(item.accelerator, accelerator);
assert.equal(item.enabled, true);
item.click();
menu.options.callback();
assert.equal(await result, action);
}
const disabled = textContextTemplate(textMenuRequest({ canUndo: false }), "en", () => {});
assert.equal(disabled.find((item) => item.id === "undo").enabled, false);
assert.equal(disabled.find((item) => item.id === "redo").enabled, false);
});
test("native workbench popup rejects foreign senders, subframes, origins and malformed capabilities", async () => {
const h = textMenuHarness();
for (const event of [
{ ...h.event, sender: {} },
{ ...h.event, senderFrame: { url: h.contents.mainFrame.url } },
{ ...h.event, senderFrame: null },
]) assert.equal(await h.handle(event, textMenuRequest()), "");
for (const url of ["http://127.0.0.1:8081/", "http://127.0.0.1:8080.evil/", "about:blank"]) {
h.contents.mainFrame.url = url;
assert.equal(await h.handle(h.event, textMenuRequest()), "");
}
h.contents.mainFrame.url = "http://127.0.0.1:8080/";
for (const request of [null, [], {}, textMenuRequest({ selected: "yes" }),
textMenuRequest({ canUndo: 1 }), textMenuRequest({ quote: undefined }),
textMenuRequest({ x: NaN }), textMenuRequest({ y: Infinity }), textMenuRequest({ x: "1" }),
textMenuRequest({ items: [{ role: "quit" }] })]) {
assert.equal(await h.handle(h.event, request), "");
}
assert.equal(await h.handle(h.event, textMenuRequest({ editable: false, selected: false })), "");
assert.equal(h.menus.length, 0);
});
test("native workbench popups settle and clean up on navigation, destruction and replacement", async () => {
for (const lifecycle of ["navigation", "contents", "window", "replacement"]) {
const h = textMenuHarness();
const pending = h.handle(h.event, textMenuRequest());
if (lifecycle === "navigation") {
h.contents.emit("did-start-navigation", { isMainFrame: false });
assert.equal(h.menus[0].closed, undefined);
h.contents.emit("did-start-navigation", { isMainFrame: true });
} else if (lifecycle === "contents") h.contents.emit("destroyed");
else if (lifecycle === "window") h.window.emit("closed");
else {
const next = h.handle(h.event, textMenuRequest());
h.menus[1].options.callback();
assert.equal(await next, "");
}
assert.equal(await pending, "");
assert.equal(h.menus[0].closed, true);
assert.equal(h.contents.listenerCount("did-start-navigation"), 0);
assert.equal(h.contents.listenerCount("destroyed"), 0);
assert.equal(h.window.listenerCount("closed"), 0);
}
});
test("the edit menu's labels follow the interface language, not the system's", () => {
const params = { isEditable: true, selectionText: "", editFlags: {} };
const labels = (lang) => contextTemplate(params, lang).filter((i) => i.role).map((i) => i.label);
@@ -1020,6 +1152,30 @@ test("only the Studio window reads or writes the preferences", () => {
fs.rmSync(dir, { recursive: true, force: true });
});
test("only the Studio main frame reports a validated resolved theme", () => {
const handlers = {};
const studio = { mainFrame: {} };
const browser = { mainFrame: {} };
const paints = [];
registerPrefs({ on: (name, fn) => (handlers[name] = fn) }, () => "", event => event.sender === studio,
() => {}, paint => paints.push(paint));
const send = (sender, senderFrame, paint) => handlers["prefs:theme"]({ sender, senderFrame }, paint);
send(browser, browser.mainFrame, { mode: "scheduled", scheme: "dark" });
send(studio, {}, { mode: "scheduled", scheme: "dark" });
send(studio, null, { mode: "scheduled", scheme: "dark" });
for (const paint of [null, [], "dark", { mode: "scheduled" }, { mode: "scheduled", scheme: "auto" },
{ mode: "other", scheme: "dark" }, { mode: "light", scheme: "dark" }, { mode: "dark", scheme: "light" }]) {
send(studio, studio.mainFrame, paint);
}
assert.deepEqual(paints, []);
for (const mode of ["scheduled", "auto", "dark"]) send(studio, studio.mainFrame, { mode, scheme: "dark", extra: "discarded" });
send(studio, studio.mainFrame, { mode: "light", scheme: "light" });
assert.deepEqual(paints, [
{ mode: "scheduled", scheme: "dark" }, { mode: "auto", scheme: "dark" },
{ mode: "dark", scheme: "dark" }, { mode: "light", scheme: "light" },
]);
});
function elf64(interpreter, machine = 62) {
const phoff = 64, phentsize = 56, phnum = interpreter ? 2 : 1;
const dataOffset = phoff + phnum * phentsize;
@@ -1722,6 +1878,25 @@ test("the launch credential cookie is put back when something removes it, and on
assert.match(lines[0], /evicted/);
});
test("a refused request makes the shell check the credential cookie, and put it back only when it is missing or differs", async () => {
const { restoreIfStale } = require("../src/credential.js");
const cookie = { url: "http://127.0.0.1:5000/", name: "reasonix_token", value: "t" };
const run = async (held) => {
const sets = [];
const lines = [];
const ses = { cookies: { get: async () => held, set: async (c) => void sets.push(c) } };
await restoreIfStale(ses, cookie, (l) => lines.push(l));
return { sets, lines };
};
assert.deepEqual((await run([{ name: "reasonix_token", value: "t" }])).sets, [], "a matching cookie is left alone");
const missing = await run([]);
assert.deepEqual(missing.sets, [cookie]);
assert.match(missing.lines[0], /missing/);
const stale = await run([{ name: "reasonix_token", value: "old" }]);
assert.deepEqual(stale.sets, [cookie]);
assert.match(stale.lines[0], /differs/);
});
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
const { trayAsset, SCALES } = require("../src/trayimage.js");
const dim = (f) => {
@@ -2032,6 +2207,31 @@ test("the window frame takes the stored theme, and auto follows the system", ()
assert.equal(themeSourceFor(undefined), "system");
});
test("scheduled grounds and frames follow the painted scheme rather than the opposite OS", () => {
const prefs = { "rx-theme": "scheduled" };
for (const [scheme, systemDark] of [["dark", false], ["light", true]]) {
const painted = { mode: "scheduled", scheme };
assert.equal(themeSourceFor(prefs, painted), scheme);
assert.equal(groundFor(prefs, systemDark, painted), GROUND[scheme]);
const saved = { ...prefs, "rx-theme-resolved": scheme };
assert.equal(themeSourceFor(saved), scheme);
assert.equal(groundFor(saved, systemDark), GROUND[scheme]);
}
assert.equal(themeSourceFor({ ...prefs, "rx-theme-resolved": "invalid" }), "system");
});
test("the latest painted mode wins over delayed preference saves without pinning Auto", () => {
const stale = { "rx-theme": "scheduled", "rx-theme-resolved": "dark" };
const auto = { mode: "auto", scheme: "dark" };
assert.equal(themeSourceFor(stale, auto), "system");
assert.equal(groundFor(stale, false, auto), GROUND.light);
assert.equal(groundFor(stale, true, auto), GROUND.dark);
const light = { mode: "light", scheme: "light" };
assert.equal(themeSourceFor(stale, light), "light");
assert.equal(groundFor(stale, true, light), GROUND.light);
assert.equal(themeSourceFor({ "rx-theme": "light" }, { mode: "scheduled", scheme: "dark" }), "dark");
});
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
test("hardware acceleration is off only when the saved preference says exactly that", () => {
@@ -2178,7 +2378,6 @@ test("packaging: a Windows installer named for x64 is renamed to amd64", async (
});
const { stateFile, loadState, saveState, placement, track } = require("../src/windowstate.js");
const { EventEmitter } = require("node:events");
const AREA = { x: 0, y: 0, width: 1920, height: 1040 };
const SECOND = { x: 1920, y: 0, width: 1280, height: 1000 };
@@ -2558,6 +2757,421 @@ test("input that carries a point is scaled with the page or refused, never sent
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
});
// The pages' idle sleep. Electron is faked the way the suites above fake it,
// but richly enough that create() itself runs: a page that never went through
// create() cannot be watched going to sleep. The module is dropped from the
// require cache first, because the suites above have already loaded it against
// their own bare mock, and a cached module never loads again.
function sleepingViews({ sleepAfterMs = 5, sweepMs = 5, maxAwake = 6, capturePage, failSpawns = 0, hang = null } = {}) {
const { EventEmitter } = require("node:events");
const created = [];
const loads = [];
class FakeContents extends EventEmitter {
constructor() {
super();
this.destroyed = false;
this.url = "about:blank";
this.commands = [];
// What maySleep reads off a live page; a test flips these on the fake
// the helper's `created` array already hands back.
this.audible = false;
this.devTools = false;
const contents = this;
this.debugger = {
attach() {},
on(event, fn) {
contents.on("debugger:" + event, fn);
},
sendCommand: async (method, params) => {
contents.commands.push([method, params]);
if (hang === method) return new Promise(() => {});
return { data: "cG5n" };
},
};
}
isCurrentlyAudible() {
return this.audible;
}
isDevToolsOpened() {
return this.devTools;
}
setWindowOpenHandler(handler) {
this.openHandler = handler;
}
loadURL(url) {
this.url = url;
loads.push(url);
return Promise.resolve();
}
getURL() {
return this.url;
}
isDestroyed() {
return this.destroyed;
}
close() {
if (this.destroyed) return;
this.destroyed = true;
this.emit("destroyed");
}
capturePage() {
return capturePage ? capturePage() : Promise.resolve({ isEmpty: () => false, toJPEG: () => Buffer.from("jpeg") });
}
}
class FakeView {
constructor({ webPreferences }) {
if (failSpawns > 0) {
failSpawns--;
throw new Error("the view would not build");
}
this.webPreferences = webPreferences;
this.webContents = new FakeContents();
created.push(this);
}
setBorderRadius() {}
setBounds() {}
}
const ses = Object.assign(new EventEmitter(), {
setPermissionRequestHandler() {},
setPermissionCheckHandler() {},
webRequest: { onBeforeRequest() {} },
});
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: FakeView, session: { fromPartition: () => ses } };
return load.call(this, request, ...rest);
};
let BrowserViews;
try {
delete require.cache[require.resolve("../src/browserviews.js")];
({ BrowserViews } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView() {}, removeChildView() {} },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1", sleepAfterMs, sweepMs, maxAwake });
views.keepKeyboard = () => {};
const page = () => {
const events = { closed: 0, popups: [], downloads: [] };
const handle = views.create({
partition: "p" + created.length,
url: "https://page.test/",
onEvent: () => {},
onClosed: () => events.closed++,
onPopup: (to) => events.popups.push(to),
onDownload: (download) => events.downloads.push(download),
});
return { handle, events };
};
return { views, created, loads, page };
}
test("an idle page nobody is looking at is slept by the sweep and lives on as a tombstone", async (t) => {
const { views, created, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle, events } = page();
await later(40);
assert.deepEqual(views.asleep(handle.targetId), { url: "https://page.test/" }, "the idle page is asleep");
assert.ok(views.entries.has(handle.targetId), "the entry survives as a tombstone");
assert.equal(views.entries.get(handle.targetId).view, null);
assert.match(views.entries.get(handle.targetId).slept.picture, /^data:image\/jpeg;base64,/);
assert.equal(created.length, 1, "no view was built for a sleep");
assert.equal(events.closed, 0, "the kernel is not told of a sleep");
});
test("a send to a slept page wakes it, and the call lands on the page it comes back as", async (t) => {
const { views, created, loads, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle, events } = page();
await later(40);
assert.ok(views.asleep(handle.targetId));
const built = created.length;
await handle.send("Page.captureScreenshot", { format: "png" });
assert.equal(created.length, built + 1, "waking built the view again");
assert.equal(views.asleep(handle.targetId), null, "the page is awake");
assert.ok(views.entries.has(handle.targetId), "the targetId is the one the kernel holds");
assert.equal(loads.length, 2, "the load was kicked off again");
assert.equal(events.closed, 0, "a wake is not a death");
const contents = views.entries.get(handle.targetId).view.webContents;
assert.deepEqual(contents.commands.at(-1), ["Page.captureScreenshot", { format: "png" }]);
});
test("closing a slept page buries it, and the kernel hears of it exactly once", async (t) => {
const { views, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle, events } = page();
await later(40);
assert.ok(views.asleep(handle.targetId));
handle.close();
assert.equal(events.closed, 1, "the kernel is told once");
handle.close();
assert.equal(events.closed, 1, "and only once");
assert.equal(views.entries.has(handle.targetId), false);
assert.equal(views.asleep(handle.targetId), null);
});
test("a third page costs the least recently touched one its renderer, never the one on screen", async (t) => {
const { views, page } = sleepingViews({ sleepAfterMs: 60_000, sweepMs: 60_000, maxAwake: 2 });
t.after(() => views.closeAll());
const first = page();
const second = page();
views.show(first.handle.targetId, { x: 0, y: 0, width: 600, height: 400 });
// The page on screen is made the oldest on purpose: the cap must go past it.
views.entries.get(first.handle.targetId).lastTouch = 1;
const third = page();
await later(20);
assert.ok(views.asleep(second.handle.targetId), "the second page was slept to make room");
assert.equal(views.asleep(first.handle.targetId), null, "the page on screen keeps its renderer");
assert.equal(views.asleep(third.handle.targetId), null, "the page just created is awake");
});
test("with the setting off, the cap sleeps no page either", async (t) => {
const off = sleepingViews({ sleepAfterMs: Infinity, sweepMs: 5, maxAwake: 2 });
t.after(() => off.views.closeAll());
const one = off.page();
const two = off.page();
const three = off.page();
await later(20);
assert.equal(off.views.asleep(one.handle.targetId), null, "off means nothing sleeps: page one");
assert.equal(off.views.asleep(two.handle.targetId), null, "page two");
assert.equal(off.views.asleep(three.handle.targetId), null, "page three, though the cap is passed");
// The same cap with the setting on still sleeps the least recently touched.
const on = sleepingViews({ sleepAfterMs: 60_000, sweepMs: 60_000, maxAwake: 2 });
t.after(() => on.views.closeAll());
const oldest = on.page();
on.page();
on.page();
await later(20);
assert.ok(on.views.asleep(oldest.handle.targetId), "on, the least recently touched page sleeps");
});
test("the sweep never puts the page the person is looking at to sleep", async (t) => {
const { views, page } = sleepingViews();
t.after(() => views.closeAll());
const shown = page();
views.show(shown.handle.targetId, { x: 0, y: 0, width: 600, height: 400 });
views.entries.get(shown.handle.targetId).lastTouch = 1;
await later(40);
assert.equal(views.asleep(shown.handle.targetId), null, "the shown page is still awake");
assert.ok(views.entries.get(shown.handle.targetId).view, "and its view is still there");
});
test("closeAll takes the sweep's timer with it, and a suite full of these does not hang", async () => {
mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
try {
const { views, page } = sleepingViews({ sleepAfterMs: 10, sweepMs: 10 });
page();
views.closeAll();
assert.equal(views.sweeper, null, "the timer is gone");
views.closeAll();
assert.equal(views.sweeper, null, "and a second closeAll does not mind");
// A page made after the closeAll has nothing left to sleep it: had the
// timer survived, this tick would put it to sleep.
const { handle } = page();
mock.timers.tick(60_000);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(views.asleep(handle.targetId), null, "no sweep fired after closeAll");
} finally {
mock.timers.reset();
}
});
test("a wake replays the page's domain enables before the next command lands", async (t) => {
const { views, created, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle } = page();
// What the kernel says once at attach: the domains the page's events come from.
await handle.send("Page.enable", {});
await handle.send("Page.setLifecycleEventsEnabled", { enabled: true });
// A domain switched on mid-session joins the set; one switched off leaves it.
await handle.send("Network.enable", {});
await handle.send("Network.disable", {});
await later(40);
assert.ok(views.asleep(handle.targetId));
await handle.send("Runtime.evaluate", { expression: "1" });
assert.equal(created.length, 2, "the wake built a new view");
const said = created[1].webContents.commands.map(([method]) => method);
assert.deepEqual(said, ["Page.enable", "Page.setLifecycleEventsEnabled", "Runtime.evaluate"], "the enables are said again, before the next command — and a domain switched off is not switched back on");
assert.deepEqual(created[1].webContents.commands[1][1], { enabled: true }, "the parameters the kernel chose are the ones replayed");
assert.equal(created[0].webContents.commands.length, 4, "nothing was replayed on the session that died");
assert.equal(views.asleep(handle.targetId), null);
});
test("a wake yields no room: the page asked for outranks the pages left alone", async (t) => {
const { views, page } = sleepingViews({ maxAwake: 2, sleepAfterMs: 60, sweepMs: 5 });
t.after(() => views.closeAll());
const first = page();
const second = page();
await later(90);
assert.ok(views.asleep(first.handle.targetId), "the pages slept once they idled");
// The kernel is using the second page when it asks for the first: the cap is
// a brake on creation, not a quota held against a page the kernel wants.
await second.handle.send("Runtime.evaluate", { expression: "1" });
await first.handle.send("Runtime.evaluate", { expression: "1" });
assert.equal(views.asleep(first.handle.targetId), null, "the asked-for page woke");
assert.equal(views.asleep(second.handle.targetId), null, "the page in use was not slept to make room");
assert.ok(views.entries.get(second.handle.targetId).view, "the page in use kept its renderer");
});
test("a command the sleep outlives is failed, not left hanging", async (t) => {
const { views, page } = sleepingViews({ hang: "Runtime.evaluate" });
t.after(() => views.closeAll());
const { handle } = page();
// A session that dies never settles its open calls on its own; the kernel
// hears nothing of a sleep, so the sleep itself must answer them. The
// failure is held from the moment it is asked, so an early rejection is
// never an unhandled one.
const asked = handle.send("Runtime.evaluate", { expression: "1" });
const held = asked.then(
() => "resolved",
(err) => err,
);
await later(40);
assert.ok(views.asleep(handle.targetId));
const outcome = await held;
assert.match(String(outcome && outcome.message), /went to sleep/);
});
test("a navigation that begins while the picture is taken keeps the page awake", async (t) => {
let release;
const held = new Promise((resolve) => { release = resolve; });
let taken = 0;
const { views, page } = sleepingViews({ capturePage: () => { taken++; return held; } });
t.after(() => views.closeAll());
const { handle } = page();
const entry = views.entries.get(handle.targetId);
for (let waited = 0; waited < 40 && taken === 0; waited++) await later(5);
assert.ok(taken > 0, "the sweep is mid-capture, the picture never coming");
// A navigation starts the only way leave() is reached for one: the page is
// attached and the app page holds the keyboard, so it is held out of the
// window with a settle pending.
views.keyboard.owner = "app";
entry.attached = true;
views.leave(entry);
assert.ok(entry.returning, "the settle is pending");
release({ isEmpty: () => false, toJPEG: () => Buffer.from("jpeg") });
await later(20);
assert.equal(views.asleep(handle.targetId), null, "the page was not slept mid-flight");
assert.ok(views.entries.get(handle.targetId).view, "and its view is still there");
});
test("a spawn that fails leaves no entry behind", (t) => {
const { views } = sleepingViews({ failSpawns: 1 });
t.after(() => views.closeAll());
let closed = 0;
assert.throws(
() => views.create({ partition: "p0", url: "https://page.test/", onEvent: () => {}, onClosed: () => closed++, onPopup: () => {}, onDownload: () => {} }),
/would not build/,
);
assert.equal(closed, 0, "the kernel is told nothing of a target it never had");
assert.equal(views.entries.size, 0, "and no entry is left behind");
});
test("the nineteenth tombstone costs the oldest its place, and the kernel hears it", async (t) => {
const { views, page } = sleepingViews({ maxAwake: 32 });
t.after(() => views.closeAll());
const pages = [];
for (let i = 0; i < 19; i++) pages.push(page());
await later(60);
assert.ok(views.asleep(pages[18].handle.targetId), "the pages slept");
assert.ok(views.asleep(pages[1].handle.targetId), "the younger tombstones keep their place");
assert.equal(pages[0].events.closed, 1, "the oldest tombstone was buried and the kernel told");
assert.equal(views.asleep(pages[0].handle.targetId), null);
assert.equal(views.entries.size, 18, "eighteen tombstones remain");
});
test("a woken page comes back at the viewport the agent set", async (t) => {
const { views, created, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle } = page();
const viewport = { width: 390, height: 844, deviceScaleFactor: 0, mobile: false };
await handle.send("Emulation.setDeviceMetricsOverride", viewport);
await later(40);
assert.ok(views.asleep(handle.targetId));
await handle.send("Runtime.evaluate", { expression: "1" });
assert.equal(created.length, 2, "the wake built a new view");
const said = created[1].webContents.commands.map(([method]) => method);
assert.deepEqual(said, ["Emulation.setDeviceMetricsOverride", "Runtime.evaluate"], "the viewport is said again to the new session, before the command that woke the page");
assert.deepEqual(created[1].webContents.commands[0][1], viewport, "the parameters the agent chose are the ones replayed");
});
test("a woken page's viewport is fitted into its panel again, at the scale it had before the sleep", async (t) => {
const { views, created, page } = sleepingViews({ sleepAfterMs: 60_000, sweepMs: 60_000 });
t.after(() => views.closeAll());
const { handle } = page();
await handle.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, mobile: true });
const rect = { x: 0, y: 0, width: 195, height: 422 };
views.show(handle.targetId, rect);
const overrides = (which) => created[which].webContents.commands.filter(([m]) => m === "Emulation.setDeviceMetricsOverride");
const fitted = overrides(0).at(-1);
assert.equal(fitted[1].scale, 0.5, "the show fitted the phone viewport into the panel with a scale");
views.hide();
await views.sleep(views.entries.get(handle.targetId));
assert.ok(views.asleep(handle.targetId), "the page slept");
await handle.send("Runtime.evaluate", { expression: "1" });
assert.equal(created.length, 2, "the wake built a new view");
views.show(handle.targetId, rect);
assert.deepEqual(overrides(1).at(-1), fitted, "the new session gets the same viewport and scale the show sent before the sleep");
});
test("a page with a dialog open does not sleep", async (t) => {
const { views, created, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle } = page();
const contents = created[0].webContents;
// The kernel enables Page at attach, so the dialog events reach the shell
// through the debugger's message stream.
contents.emit("debugger:message", {}, "Page.javascriptDialogOpening", { message: "really?" }, undefined);
await later(40);
assert.equal(views.asleep(handle.targetId), null, "a page waiting on its answer is not slept under the question");
contents.emit("debugger:message", {}, "Page.javascriptDialogClosed", { result: false }, undefined);
await later(40);
assert.ok(views.asleep(handle.targetId), "the answered dialog sleeps again");
});
test("a page awaiting a certificate decision does not sleep", async (t) => {
const { views, created, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle } = page();
const contents = created[0].webContents;
contents.emit(
"certificate-error",
{ preventDefault() {} },
"https://page.test/",
"ERR_CERT_AUTHORITY_INVALID",
{ fingerprint: "ff:ee", subjectName: "page.test", issuerName: "nobody", validExpiry: 0 },
() => {},
true,
);
await later(40);
assert.ok(views.entries.get(handle.targetId).certificate, "the certificate waits on the person");
assert.equal(views.asleep(handle.targetId), null, "the undecided certificate holds the page awake");
views.entries.get(handle.targetId).certificate = null;
await later(40);
assert.ok(views.asleep(handle.targetId), "once decided, the page sleeps again");
});
test("a page playing audio does not sleep", async (t) => {
const { views, created, page } = sleepingViews();
t.after(() => views.closeAll());
const { handle } = page();
const contents = created[0].webContents;
contents.audible = true;
await later(40);
assert.equal(views.asleep(handle.targetId), null, "the sounding page keeps its renderer");
contents.audible = false;
await later(40);
assert.ok(views.asleep(handle.targetId), "the quieted page sleeps again");
});
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
const file = path.join(dir, "prefs.json");
@@ -2567,6 +3181,22 @@ test("rewind notice dismissal survives the desktop preference disk round trip",
assert.deepEqual(loadPrefs(file), record);
});
test("the page-sleep preference round-trips through the desktop preference file", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "page-sleep-prefs-"));
const file = path.join(dir, "prefs.json");
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
savePrefs(file, { "rx-page-sleep": "on" });
assert.deepEqual(loadPrefs(file), { "rx-page-sleep": "on" });
savePrefs(file, { "rx-lang": "en" });
assert.deepEqual(loadPrefs(file), { "rx-lang": "en" }, "a key the page no longer holds is dropped");
});
test("the shell reads the sleep threshold from the preference", () => {
assert.equal(pageSleepAfterMs({}), Infinity, "off until the person turns it on");
assert.equal(pageSleepAfterMs({ "rx-page-sleep": "on" }), PAGE_SLEEP_MS);
assert.equal(pageSleepAfterMs({ "rx-page-sleep": "garbage" }), Infinity);
});
const { setUnreadBadge } = require("../src/badge.js");
test("macOS marks and clears the application Dock badge", () => {
@@ -3175,3 +3805,119 @@ test("only the app's own window is answered, and nothing is read from the asker"
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
});
test("a pick arms the page, answers what it reports, and a page that cannot answer is cancelled", async () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
try {
({ BrowserViews } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView() {}, removeChildView() {} },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const element = { tagName: "img", selector: "main > img", rect: { x: 1, y: 2, width: 3, height: 4 } };
const armed = [];
const recheck = [];
const contents = {
isDestroyed: () => false,
executeJavaScript: async (code) => {
armed.push(code);
return { cancelled: false, element };
},
executeJavaScriptInIsolatedWorld: async (_worldId, scripts) => {
recheck.push(scripts[0].code);
return "img";
},
};
const shots = [];
contents.capturePage = async (at) => {
shots.push(at);
return { isEmpty: () => true, toDataURL: () => "data:image/png;base64,x" };
};
views.entries.set("t1", { targetId: "t1", view: { webContents: contents, getBounds: () => ({ width: 200, height: 200 }) } });
// Picking only reaches the page that is on screen.
views.shown = "t1";
assert.deepEqual(await views.pick("t1"), { cancelled: false, element });
assert.match(armed[0], /__reasonixElementPicker/, "the page is armed with the picker's own script");
assert.match(recheck[0], /main > img/, "the shell re-finds the selector in its own world");
assert.equal(await views.cancelPick("t1"), true);
assert.match(armed[1], /picker\.stop\(\)/, "cancelling disarms the page");
assert.deepEqual(await views.pick("gone"), { cancelled: true, reason: "gone" }, "a page that is not there picks nothing");
assert.equal(await views.cancelPick("gone"), false);
// A page that navigated out from under the pick, and one that answered
// something that is not an element, both say why rather than "nothing".
contents.executeJavaScript = async () => {
throw new Error("the page went away");
};
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "navigation" });
contents.executeJavaScript = async () => "not an answer";
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" });
// The page wrote the answer: the shell keeps known fields, clips them, and
// treats a shape it cannot read as gone.
contents.executeJavaScript = async () => ({
cancelled: false,
element: {
tagName: "img",
selector: "main > img",
text: "x".repeat(10_000),
extra: "drop me",
rect: { x: "nope", y: 2, width: 3, height: 4 },
attributes: { id: "a", bad: 5 },
},
});
contents.executeJavaScriptInIsolatedWorld = async () => "img";
const bounded = await views.pick("t1");
assert.equal(bounded.element.text.length, 4000, "an oversized string is clipped");
assert.equal("extra" in bounded.element, false, "an unknown field is dropped");
assert.equal("rect" in bounded.element, false, "a rect that is not numbers is dropped");
assert.deepEqual(bounded.element.attributes, { id: "a" }, "a non-string attribute is dropped");
contents.executeJavaScript = async () => ({
cancelled: false,
element: { tagName: "img", selector: "main > img", attributes: { ["k".repeat(600)]: "v" } },
});
const longKey = await views.pick("t1");
assert.equal(Object.keys(longKey.element.attributes)[0].length, 500, "an oversized attribute key is clipped like its value");
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img" } });
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" }, "an element without a selector is gone");
// A label with a quote and a newline still produces a script that parses.
contents.executeJavaScript = async (code) => {
armed.push(code);
return { cancelled: true, reason: "escape" };
};
await views.pick("t1", { color: 'a"b\nc' });
assert.doesNotThrow(() => new Function(armed[armed.length - 1]), "the label is embedded as JSON");
// A rect the page wrote is clamped to the view or dropped entirely.
for (const [bad, why] of [
[{ x: -1, y: 0, width: 10, height: 10 }, "a negative origin is kept and clamped"],
[{ x: NaN, y: 0, width: 10, height: 10 }, "a NaN rect is dropped"],
[{ x: Infinity, y: 0, width: 10, height: 10 }, "an infinite rect is dropped"],
]) {
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img", selector: "main > img", rect: bad } });
const picked = await views.pick("t1");
if (Number.isFinite(bad.x) && bad.x < 0) {
assert.equal(picked.element.rect.x, -1, why);
assert.deepEqual(shots[shots.length - 1], { x: 0, y: 0, width: 10, height: 10 }, "the origin is clamped to the view");
} else {
assert.equal("rect" in picked.element, false, why);
assert.equal("shot" in picked, false, "no picture is taken for a rect that is not numbers");
}
}
});
+20
View File
@@ -7,6 +7,26 @@ reviewed: 2026-09-22
# Reasonix Studio frontend
## Feedback contact
- Studio prefills new reports with the contact value from the last successful
submission, stored in this device's local browser storage.
- The field stays editable; submitting it empty clears the remembered value.
Failed submissions do not replace it.
- An unsent draft takes precedence while the page is open.
- No feedback body or screenshots are persisted by this preference.
## Pane workspace metadata
- Each pane owns one workspace-metadata read after its initial status settles.
Chrome and the composer's workspace chip consume that same result.
- A failed initial status still allows the read using the runtime's known root;
history loads independently of both status and workspace metadata.
- Changing the pane's port or directory reads again and discards superseded
responses. Focusing an already-open pane reuses its own result.
- Opening the workspace chooser still refreshes its branches and worktree
sources; it does not issue another workspace-metadata read.
## Tests
`pnpm test` runs one worker per test file on the threads pool. Threads rather
+16 -12
View File
@@ -12,8 +12,8 @@
"census:verify": "node tools/ui-census/verify.mjs"
},
"dependencies": {
"@a2ui/react": "0.13.0",
"@a2ui/web_core": "0.13.0",
"@a2ui/react": "0.14.0",
"@a2ui/web_core": "0.14.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
@@ -30,17 +30,19 @@
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.14",
"@codemirror/view": "^6.43.15",
"@lezer/highlight": "^1.2.5",
"@mantine/charts": "9.7.2",
"@mantine/core": "9.7.2",
"@mantine/hooks": "9.7.2",
"@mantine/charts": "9.7.4",
"@mantine/core": "9.7.4",
"@mantine/hooks": "9.7.4",
"@noble/curves": "2.4.0",
"codemirror": "^6.0.2",
"docx-preview": "0.4.1",
"dompurify": "^3.4.17",
"katex": "^0.19.0",
"lucide-react": "^1.60.0",
"maplibre-gl": "6.14.0",
"exceljs": "4.4.0",
"katex": "^0.19.1",
"lucide-react": "^1.64.0",
"maplibre-gl": "6.16.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"papaparse": "^5.7.0",
@@ -55,10 +57,11 @@
"remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"ssf": "0.11.2",
"zod": "3.25.76"
},
"devDependencies": {
"@babel/parser": "^8.0.7",
"@babel/parser": "^8.0.8",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/papaparse": "^5.5.2",
@@ -66,9 +69,10 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.3",
"jszip": "3.10.2",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.4",
"vitest": "^5.0.4"
"vite": "^8.3.5",
"vitest": "^5.0.5"
}
}
+29
View File
@@ -39,14 +39,20 @@ node perf/rail.mjs # 左栏会话树:2×8 / 10×50 / 20×200 / 40×500
node perf/profile.mjs # CPU 采样,按自耗时列热点(流式那一段)
node perf/switchprof.mjs # CPU 采样,量点开一份长会话的那一下
node perf/panes.mjs # 并行窗格的代价:两个会话同时流式
node perf/rail-toggle-panes.mjs # Rail-fold frame cost with 1/3/6 panes; existing-pane scroll retention and background-output follow
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
node perf/measure.mjs # Custom conversation percentage, preset retention, narrow composer controls and populated phone layout
node perf/theme-schedule.mjs # Scheduled appearance: daily boundaries, midnight, reload/resume, modes, active packs and DST; THEME_SCHEDULE_SHOTS writes wide/phone light/dark screenshots
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
node perf/reask.mjs # 改写框:没有换行的长段落,框要长到让人读得到全文,宽屏和手机宽度各一遍
node perf/composer-reflow.mjs # Draft and skill-chip wrapping during column transitions, interface zoom and native scrollbar gutters
node perf/composer-toolbar-row.mjs # The buttons under the composer stay on one row from 900 to 400 px of conversation width, with the widest realistic labels
node perf/workbench-save.mjs # File toolbar sizing, Save contrast and edit/save/read states: light/dark × Chinese/English × desktop/browser × wide/phone
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
@@ -80,9 +86,18 @@ node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/recent-sessions.mjs # Recent: global history paging, finished-session discovery and project location (light/dark × wide/390px)
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
`theme-schedule.mjs` is a manual smoke, not a default guard. For real native
effects, build the normal Studio page and host, then run `pnpm theme-live` from
`desktop/electron` with an isolated `REASONIX_HOME`.
Run it again with the same home and `REASONIX_THEME_RESTART=1` to check restart
tint and current-clock reconciliation. It reads actual `nativeTheme.themeSource`
and `BrowserWindow` background effects, including rejected IPC reporters.
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
哪些中文不必翻)。`PERF_URL` 可以指向别的地址;`bench.mjs` 和 `scale.mjs` 另收 `PERF_SCALES`、
`PERF_DELTAS`,`rail.mjs` 收 `PERF_TREE`(`区数x每区会话数`,逗号分隔),
@@ -90,6 +105,20 @@ node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出
往返摆进来,用来分清「等的是这份记录」还是「等的是一次状态读」。
性能台自己也读 `?ws=&sess=` 来决定左栏那棵树有多大。
- `composer-reflow.mjs` arms frame sampling before each viewport change;
sampling only after `setViewportSize` returns can miss the stale-mirror frame.
It selects `/init`, types the draft, then visits
1440 → 960 → 640 → 420 → 1440px at each zoom.
- The native-scrollbar case requires a space-consuming gutter, not an exact
pixel width; overlay-scrollbar browsers explicitly skip that case.
- `?panes=N` seeds the mock hub's shared runtime/session book. Tree rows and open
panes must agree on both transcript path and runtime ID.
- `rail-toggle-panes.mjs` selects two existing sessions by path, verifies that the
original transcript DOM node is hidden and restored, and checks scroll retention
while new output arrives in the background.
- The same guard checks that a neighbouring pane following the latest output
continues to do so. `RAIL_TOGGLE_SHOTS` saves its before/hidden/returned views.
## 判据
这几条是这套 UI 的性能契约,回归时先看它们:
@@ -0,0 +1,86 @@
import { chromium } from "playwright";
const browser = await chromium.launch();
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const failures = [];
const check = (name, ok, detail) => {
console.log(`${ok ? "ok" : "FAIL"} ${name}: ${detail}`);
if (!ok) failures.push(name);
};
async function inkBounds(page, png, color) {
return page.evaluate(async ({ image, color }) => {
const img = new Image();
img.src = image;
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext("2d");
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const foreground = context.getImageData(0, 0, 1, 1).data;
context.drawImage(img, 0, 0);
const pixels = context.getImageData(0, 0, img.width, img.height).data;
let top = img.height, bottom = -1;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const i = (y * img.width + x) * 4;
if ([0, 1, 2].every((c) => Math.abs(pixels[i + c] - foreground[c]) < 45)) {
top = Math.min(top, y);
bottom = Math.max(bottom, y);
}
}
}
return { found: bottom >= top, top, bottom, height: img.height };
}, { image: `data:image/png;base64,${png.toString("base64")}`, color });
}
try {
for (const scheme of ["light", "dark"]) {
for (const label of ["Demo", "季谢尔"]) for (const scale of [1, 1.25]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, colorScheme: scheme, deviceScaleFactor: scale });
await page.addInitScript(() => {
localStorage.setItem("rx-nav-rail", "off");
localStorage.setItem("reasonix.mock.account", "1");
});
await page.goto(`${base}?pref=zh&turns=3&accountLabel=${encodeURIComponent(label)}`, { waitUntil: "networkidle" });
await page.waitForSelector(".studio-user-foot .ini");
await page.evaluate(() => document.fonts.ready);
const layout = await page.evaluate(() => {
const avatar = document.querySelector(".studio-user-foot .ini");
const box = avatar.getBoundingClientRect();
const button = avatar.closest("button").getBoundingClientRect();
return { initial: avatar.textContent, color: getComputedStyle(avatar).color,
contained: box.top >= button.top && box.bottom <= button.bottom && box.left >= button.left && box.right <= button.right };
});
const tag = `${scheme}/${label}/${scale * 100}%`;
check(`${tag} avatar inside click target`, layout.contained, JSON.stringify(layout));
const png = await page.locator(".studio-user-foot .ini").screenshot();
const ink = await inkBounds(page, png, layout.color);
check(`${tag} visible glyph is not clipped`, ink.found && ink.top > 0 && ink.bottom < ink.height - 1, JSON.stringify(ink));
const centers = [];
for (const selector of [".studio-user-foot .ini > span", ".studio-workspace-kind"]) {
const bounds = await page.locator(selector).evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
const text = range.getBoundingClientRect();
const row = el.closest(".studio-user-foot").getBoundingClientRect();
return { x: text.x, y: row.y + 4, width: text.width, height: 30, color: getComputedStyle(el).color };
});
const { color, ...clip } = bounds;
const pixels = await inkBounds(page, await page.screenshot({ clip }), color);
centers.push(pixels.found ? (pixels.top + pixels.bottom) / (2 * scale) : NaN);
}
check(`${tag} avatar/workspace ink alignment`,
centers.every(Number.isFinite) && Math.abs(centers[0] - centers[1]) <= 1, centers.join(" / "));
await page.locator('[data-action="chrome.account"]').click();
await page.waitForSelector(".acct");
check(`${tag} account identity`, (await page.locator(".acct-who .nm").textContent()) === label, label);
await page.close();
}
}
} finally {
await browser.close();
}
if (failures.length) process.exitCode = 1;
@@ -0,0 +1,127 @@
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail) => {
console.log(`${ok ? " ok" : "FAIL"} ${name}: ${JSON.stringify(detail)}`);
if (!ok) fails.push(name);
};
const textKeys = ["title", "count", "name", "arg", "normalization", "originalInput"];
const weightKeys = textKeys.filter(key => key !== "title" && key !== "count");
const browser = await chromium.launch();
for (const lang of ["zh", "en"]) {
for (const fold of [false, true]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push(e.message));
await page.goto(`${BASE}?pref=${lang}${fold ? "&fold" : ""}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(async () => {
const frame = () => new Promise((r) => requestAnimationFrame(r));
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Inspect the file" });
window.__feed({ kind: "text", text: "I will inspect it first." });
window.__feed({ kind: "message", text: "I will inspect it first." });
const tool = {
id: "typography-read", name: "read_file", args: '{"path":"src/main.go"}',
argumentNormalization: { reason: "json_string_unwrapped", original: '"{\\"path\\":\\"src/main.go\\"}"', effective: '{"path":"src/main.go"}' },
};
window.__feed({ kind: "tool_dispatch", tool });
window.__feed({ kind: "tool_result", tool: { ...tool, output: "package main", durationMs: 120 } });
await frame();
window.__feed({ kind: "text", text: "The file declares package main." });
window.__feed({ kind: "message", text: "The file declares package main." });
window.__feed({ kind: "turn_done" });
});
await page.waitForSelector(".activity-title");
while (await page.locator(".activity-group:not([open]) > summary").count()) {
await page.locator(".activity-group:not([open]) > summary").first().click();
}
await page.waitForSelector(".activity-body .hl .nm", { state: "visible" });
await page.locator(".activity-body .tool-disclosure > summary .nm").first().click();
await page.waitForSelector(".activity-call-input > details > summary", { state: "visible" });
check(`${lang}/${fold}: original input starts folded`, await page.locator(".activity-call-input > details").evaluate((el) => !el.open));
await page.locator(".activity-call-input > details > summary").click();
check(`${lang}/${fold}: original input opens with its source`, await page.locator(".activity-call-input > details pre").textContent() === '"{\\"path\\":\\"src/main.go\\"}"');
const measure = async () => {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
return page.evaluate(() => {
const style = (selector) => {
const s = getComputedStyle(document.querySelector(selector));
return { size: parseFloat(s.fontSize), weight: Number(s.fontWeight), family: s.fontFamily };
};
return {
title: style(".activity-title"), count: style(".activity-count"),
name: style(".activity-body .hl .nm"), arg: style(".activity-body .hl .arg"),
normalization: style(".activity-call-input > p"), originalInput: style(".activity-call-input > details > summary"),
sidebar: style(".sesstitle"), answer: style('.call[data-k="say"] .out .txt'),
};
});
};
const base = await measure();
check(`${lang}/${fold}: activity labels preserve the 11px/500 default`, base.title.size === 11 && base.count.size === 11 && base.title.weight === 500 && base.count.weight === 500, { title: base.title, count: base.count });
const pick = async (action, index) => {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]').click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => document.getElementById("prefs-appearance").click());
const variable = action === "appearance.reading-size" ? "--read" : "--fs-scale";
const expected = action === "appearance.reading-size"
? `${(lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[index]}px` : "1.3";
await page.locator(`[data-action="${action}"]`).nth(index).click();
await page.waitForFunction(([name, value]) => document.documentElement.style.getPropertyValue(name) === value, [variable, expected]);
await page.keyboard.press("Escape");
};
await pick("appearance.reading-size", 3);
const large = await measure();
const ratio = large.answer.size / base.answer.size;
const tag = `${lang}/${fold ? "folded turn" : "activity"}`;
for (const key of textKeys) {
check(`${tag} ${key} follows body size`, Math.abs(large[key].size / base[key].size - ratio) < .02, { before: base[key].size, after: large[key].size, ratio });
}
check(`${tag} body size leaves sidebar alone`, large.sidebar.size === base.sidebar.size, large.sidebar);
await pick("appearance.text-size", 3);
const uiLarge = await measure();
for (const key of textKeys) {
const expectedRatio = key === "title" || key === "count" ? 1.3 : 1;
check(`${tag} ${key} ${expectedRatio === 1 ? "independent of" : "scales with"} interface size`,
Math.abs(uiLarge[key].size / large[key].size - expectedRatio) < .02, uiLarge[key]);
}
check(`${tag} interface size still enlarges sidebar`, uiLarge.sidebar.size > large.sidebar.size, uiLarge.sidebar);
await page.evaluate(() => { document.documentElement.dataset.weight = "light"; });
const light = await measure();
await page.evaluate(() => { document.documentElement.dataset.weight = "heavy"; });
const heavy = await measure();
for (const key of weightKeys) {
check(`${tag} ${key} follows weight preference`, heavy[key].weight > light[key].weight, { light: light[key].weight, heavy: heavy[key].weight });
}
check(`${tag} activity labels retain one 500 weight`, heavy.title.weight === 500 && heavy.count.weight === 500, { title: heavy.title, count: heavy.count });
await page.evaluate(() => {
document.documentElement.style.setProperty("--ui", "Georgia, serif");
document.documentElement.style.setProperty("--mono", "Menlo, monospace");
});
const fonts = await measure();
check(`${tag} labels use prose family; commands use mono`, fonts.title.family.startsWith("Georgia") && fonts.name.family.startsWith("Menlo") && fonts.arg.family.startsWith("Menlo"), fonts);
await page.evaluate(() => {
window.__feed({ kind: "turn_started", authoredTurn: 2, msgIndex: 1, text: "Inspect failure and running labels" });
const failed = { id: "scale-failure", name: "bash", args: '{"command":"false"}' };
window.__feed({ kind: "tool_dispatch", tool: failed });
window.__feed({ kind: "tool_result", tool: { ...failed, err: "exit status 1", output: "failed", durationMs: 120 } });
window.__feed({ kind: "tool_dispatch", tool: { id: "scale-running", name: "bash", args: '{"command":"sleep 10"}' } });
});
await page.waitForSelector(".activity-running");
for (const step of [0, 1, 2, 3]) {
await pick("appearance.reading-size", step);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const labels = await page.locator(".activity-group[data-running] > summary").evaluate(el =>
[...el.querySelectorAll(".activity-title, .activity-running, .activity-errors, .activity-count")].map(span => {
const css = getComputedStyle(span);
return { label: span.className, size: parseFloat(css.fontSize), weight: Number(css.fontWeight), lineHeight: css.lineHeight };
}));
const read = (lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[step];
const expected = 11 * 1.3 * read / (lang === "zh" ? 13 : 12.5);
check(`${tag} body step ${step}: title/running/failed/count compose text and reading scales at weight 500`,
labels.length === 4 && labels.every(label => Math.abs(label.size - expected) < .01 && label.weight === 500 && label.lineHeight === labels[0].lineHeight), labels);
}
await page.close();
}
}
await browser.close();
if (fails.length) throw new Error(`${fails.length} checks failed: ${fails.join(", ")}`);
@@ -0,0 +1,81 @@
// A sub-agent opened from the deck lands beside the conversation as a workbench tab that reads the kernel's record;
// a call with no run keeps the full-record dialog.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const calls = [
{ id: "c1", tool: "list_dir", args: '{"path":"internal/auth"}', out: "session.go\nstore.go\ntoken.go" },
{ id: "c2", tool: "read_file", args: '{"path":"internal/auth/session.go"}', out: "package auth\n\nfunc New() *Session { return &Session{} }" },
];
const messages = [
{ role: "user", content: "Audit the session code for token reuse.", msgIndex: 1 },
{ role: "assistant", content: "I will list the package, then read the session file.", msgIndex: 2, toolCalls: calls.map((c) => ({ id: c.id, name: c.tool, arguments: c.args })) },
...calls.map((c, i) => ({ role: "tool", content: c.out, toolCallId: c.id, toolName: c.tool, msgIndex: 3 + i })),
{ role: "assistant", content: "## Finding\n\nTokens are rotated on every refresh; I found no reuse path.", msgIndex: 5 },
];
const runs = [{ run: { ref: "sa_1", name: "security-review", kind: "task", model: "deepseek/deepseek-v4-pro", state: "completed", parentCall: "a1", startedAt: 1, updatedAt: 2 }, messages }];
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate((runs) => {
window.__subagents(runs);
const args = '{"description":"review","subagent_type":"security-review"}';
for (const [id, name] of [["a1", "security-review"], ["a2", "other"]]) {
window.__feed({ kind: "tool_dispatch", tool: { id, name: "task", profile: { name }, args } });
window.__feed({ kind: "tool_result", tool: { id, name: "task", profile: { name }, args, output: "done" } });
}
}, runs);
await page.evaluate(() => {
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
window.__feed({ kind: "text", text: "Both reviews are done." });
});
await page.waitForSelector(".studio-deckchip");
await page.locator('[data-action="deck.agents"]').hover();
await page.locator('.studio-deck-pop .ag-open[data-target]').first().waitFor();
const rows = await page.locator(".studio-deck-pop .ag").count();
check(`${scheme}: the deck lists both delegations`, rows === 2, `rows=${rows}`);
await page.locator('.studio-deck-pop .ag', { hasText: "security-review" }).locator('[data-action="agent.open"]').click();
await page.locator('.workbench-tab-pick', { hasText: "security-review" }).waitFor();
await page.locator(".agentrun .txt", { hasText: "rotated on every refresh" }).waitFor();
await page.waitForFunction(() => !document.querySelector(".agentrun")?.textContent.includes("## Finding"));
const text = await page.locator(".agentrun").evaluate((el) => el.textContent);
check(`${scheme}: the run opens as a workbench tab, not a dialog`, (await page.locator(".agent-tx").count()) === 0);
check(`${scheme}: it shows the model, the state and the final answer`, /deepseek-v4-pro/.test(text) && /Completed|Done/.test(text) && /rotated on every refresh/.test(text), text.slice(0, 160));
check(`${scheme}: the user's task is in it and the tool steps are folded under one line`, /Audit the session code/.test(text) && (await page.locator(".agentrun .activity-group").count()) === 1 && (await page.locator(".agentrun .activity-group[open]").count()) === 0);
await page.locator(".agentrun .activity-group > summary").click();
await page.locator(".agentrun .activity-body").waitFor();
check(`${scheme}: opening the fold shows the steps`, /list_dir|List|session\.go/.test(await page.locator(".agentrun .activity-body").evaluate((el) => el.textContent)));
const box = await page.evaluate(() => {
const hd = document.querySelector(".agentrun-hd").getBoundingClientRect();
const wb = document.querySelector(".workbench").getBoundingClientRect();
const flow = document.querySelector(".agentrun .flow").getBoundingClientRect();
return { hdRight: hd.right, wbRight: wb.right, flowRight: flow.right, flowLeft: flow.left, wbLeft: wb.left };
});
if (SHOTS) await page.screenshot({ path: `${SHOTS}-tab-${scheme}.png` });
check(`${scheme}: the record stays inside the workbench column`, box.flowRight <= box.wbRight + 1 && box.flowLeft >= box.wbLeft - 1, JSON.stringify(box));
await page.locator('[data-action="deck.agents"]').hover();
await page.locator('.studio-deck-pop .ag', { hasText: "other" }).locator('[data-action="agent.open"]').click();
await page.locator(".agent-tx").waitFor();
check(`${scheme}: a call with no run keeps the full-record dialog`, true);
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,99 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shots = process.env.SHOTS ?? "/tmp/reasonix-14759-visibility";
mkdirSync(shots, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ locale: "zh-CN" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const box = page.locator(".compose textarea");
function check(ok, message) {
if (!ok) throw new Error(message);
}
async function stable() {
await box.waitFor();
await page.addStyleTag({ content: "*, *::before, *::after { transition: none !important; animation: none !important; }" });
await page.evaluate(() => document.fonts.ready);
}
async function hint(on, shot) {
const settings = page.locator('[data-action="chrome.settings"]');
const menu = page.locator("button.studio-menu");
if (await menu.isVisible() && await menu.getAttribute("aria-pressed") !== "true") await menu.click();
await settings.click();
await page.locator('.prefs-sheet input[data-action-keydown="settings.search"]').fill("placeholder");
await page.locator('[role="option"][data-target="composer-hint"]').click();
const section = page.locator('[data-setting="composer-hint"]');
await section.locator(`[data-value="${on ? "on" : "off"}"]`).click();
check(await section.locator(`[data-value="${on ? "on" : "off"}"]`).getAttribute("aria-pressed") === "true", "Setting must reflect the selected visibility");
check(await section.locator('[data-apply="immediate"]').count() === 1, "Visibility must be immediate");
if (shot) {
await section.evaluate((el) => el.scrollIntoView({ block: "center" }));
await page.screenshot({ path: `${shots}/${shot}.png` });
}
await page.locator('button[data-action="settings.close"]').click();
}
async function visible(on) {
await page.waitForFunction((on) => Boolean(document.querySelector(".compose textarea")?.placeholder) === on, on);
check(Boolean(await box.getAttribute("aria-label")), "Hidden hint must retain the accessible input label");
check(Boolean(await box.getAttribute("aria-describedby")), "Input instructions must remain associated");
}
try {
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ["light", "dark"]) {
await page.goto(url, { waitUntil: "networkidle" });
await stable();
if (await page.locator("html").getAttribute("data-theme") !== theme) await page.locator('[data-action="appearance.theme"]').click();
await visible(true);
check(await box.evaluate((el) => getComputedStyle(el, "::placeholder").opacity) === "1", "Visible guidance must retain full theme opacity");
await page.screenshot({ path: `${shots}/${theme}-${width}-on.png` });
await hint(false, `${theme}-${width}-settings-off`);
await visible(false);
await page.screenshot({ path: `${shots}/${theme}-${width}-off.png` });
await page.reload({ waitUntil: "networkidle" });
await stable();
await visible(false);
await page.screenshot({ path: `${shots}/${theme}-${width}-off-reloaded.png` });
await box.fill("preserved draft");
const ink = await box.evaluate((el) => ({ color: getComputedStyle(el).color, opacity: getComputedStyle(el).opacity }));
check(ink.opacity === "1", "Entered text must retain full opacity");
await hint(true);
await visible(true);
check(await box.inputValue() === "preserved draft", "Changing hint must not alter the draft");
check(await box.evaluate((el) => getComputedStyle(el).color) === ink.color, "Changing hint must not alter entered ink");
await box.fill("");
await page.reload({ waitUntil: "networkidle" });
await stable();
await visible(true);
console.log(`PASS: ${theme}/${width} default on, Appearance off/on, draft and reload persistence`);
}
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.reload({ waitUntil: "networkidle" });
await stable();
await hint(false);
await box.fill("visibility smoke input");
check(await box.evaluate((el) => document.activeElement === el), "Typing must focus the composer");
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));
await box.press("Enter");
check(await page.evaluate(() => !window.__sent?.length), "IME Enter must not submit");
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })));
await page.waitForTimeout(120);
await box.press("Enter");
await page.waitForFunction(() => window.__sent?.includes("visibility smoke input"));
await page.goto(`${url}?queue=3&running=1`, { waitUntil: "networkidle" });
await stable();
await page.locator(".queue .qi").first().waitFor();
await visible(false);
check(await page.locator(".queue .qi").count() === 3, "Hiding hint must retain queued content");
await hint(true);
await visible(true);
check(await page.locator(".queue .qi").count() === 3, "Showing hint must retain queued content");
await page.screenshot({ path: `${shots}/queue-hint-on.png` });
check(errors.length === 0, errors.join("\n"));
console.log(`PASS: accessible label, entered ink, focus, IME, submit and queue; screenshots: ${shots}`);
} finally {
await browser.close();
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=4";
const browser = await chromium.launch({ ignoreDefaultArgs: ["--hide-scrollbars"] });
const box = 'textarea[role="combobox"]';
const text = "planning这个skill刚刚更新完,并且跑了一次情报规划任务,现在需要对collection技能做同步优化。";
try {
for (const [zoom, scrollbar] of [[1, false], [0.85, false], [1.5, false], [1, true]]) {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scrollbar ? "light" : "dark" });
page.setDefaultTimeout(8000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const url = new URL(base);
url.searchParams.set("zoom", String(zoom));
await page.goto(url.href, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
if (scrollbar) {
await page.addStyleTag({ content: ".compose textarea { overflow-y: scroll; scrollbar-gutter: stable; scrollbar-width: auto; } .compose textarea::-webkit-scrollbar { width: 14px; }" });
if (await page.locator(box).evaluate((el) => el.offsetWidth === el.clientWidth)) {
console.log("SKIP native scrollbar gutter: this browser uses overlay scrollbars");
await page.close();
continue;
}
}
await page.locator(box).fill("/init");
await page.locator(".slashmenu").waitFor();
await page.locator(box).press("Enter");
await page.locator(".skillchip").waitFor();
await page.locator(box).pressSequentially(text.repeat(4));
for (const width of [1440, 960, 640, 420, 1440]) {
await page.evaluate(() => {
window.__reflowFrames = [];
const sample = () => {
if (!window.__reflowFrames) return;
const input = document.querySelector('textarea[role="combobox"]');
const mirror = document.querySelector(".chipmirror");
const compose = document.querySelector(".compose").getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(mirror);
window.__reflowFrames.push({
text: input.value,
caret: input.selectionStart,
scrollbar: input.offsetWidth - input.clientWidth,
inputWidth: input.getBoundingClientRect().width,
mirrorWidth: mirror.getBoundingClientRect().width,
inputScroll: input.scrollHeight,
mirrorScroll: mirror.scrollHeight,
outside: [...range.getClientRects()].filter((r) => r.right > compose.right + 1 || r.left < compose.left - 1).length,
});
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
await page.setViewportSize({ width, height: 900 });
const states = await page.evaluate(async () => {
for (let frame = 0; frame < 15; frame++) await new Promise(requestAnimationFrame);
const states = window.__reflowFrames;
window.__reflowFrames = null;
return states;
});
for (const state of states) {
assert.equal(state.text, "/init " + text.repeat(4));
assert.equal(state.caret, state.text.length);
assert.ok(Math.abs(state.inputWidth - state.mirrorWidth) <= 1, `mirror follows the resized input: ${JSON.stringify(state)}`);
assert.ok(Math.abs(state.inputScroll - state.mirrorScroll) <= 1, `mirror and caret wrap on the same lines: ${JSON.stringify(state)}`);
assert.equal(state.outside, 0, "draft glyphs stay inside the composer");
if (scrollbar) assert.ok(state.scrollbar >= 14, "exercise a space-consuming native scrollbar");
}
console.log(`PASS zoom=${zoom} width=${width} scrollbar=${scrollbar}: draft, caret, wrapping and containment through the transition`);
}
assert.deepEqual(errors, []);
await page.close();
}
} finally {
await browser.close();
}
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
const failures = [];
try {
for (const scheme of ["light", "dark"]) {
for (const [pref, zoom] of [["zh", 1], ["en", 1], ["zh", 1.25], ["en", 1.25]]) {
const page = await browser.newPage({ viewport: { width: 1500, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
await page.goto(`${base}?pref=${pref}&ws=1&sess=1&turns=1&fullaccess&effort=max&modelref=workbuddy/deepseek-v4-pro-long-name&zoom=${zoom}`, { waitUntil: "networkidle" });
await page.waitForSelector(".turntools");
await page.evaluate(() => document.fonts.ready);
const wrapped = [];
for (let width = 900; width >= 400; width -= 10) {
const rows = await page.evaluate((w) => {
document.querySelector(".pane").style.width = `${w}px`;
const tools = document.querySelector(".turntools");
const centers = [...tools.children]
.map((el) => el.getBoundingClientRect())
.filter((box) => box.width > 0 && box.height > 0)
.map((box) => box.top + box.height / 2)
.sort((a, b) => a - b);
return centers.reduce((rows, y, i) => (i && y - centers[i - 1] > 8 ? rows + 1 : rows), 1);
}, width);
if (rows > 1) wrapped.push(width);
}
console.log(`${wrapped.length === 0 ? "PASS" : "FAIL"} ${scheme} ${pref} zoom ${zoom}: toolbar stays on one row from 900 to 400 px${wrapped.length ? `; wraps at ${wrapped.join(", ")}` : ""}`);
if (wrapped.length) failures.push(`${scheme}/${pref}/${zoom}`);
await page.close();
}
}
} finally {
await browser.close();
}
assert.deepEqual(failures, [], "the composer toolbar wrapped to a second row");
+8 -3
View File
@@ -164,7 +164,7 @@ const settled = async (page) => {
};
const found = new Map();
for (const scheme of ["light", "dark"]) {
for (const scheme of ["light", "dark"]) for (const tier of ["", "soft"]) {
const page = await browser.newPage({ viewport: { width: 1512, height: 950 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
// The receipt card is off unless a machine asked for it, and an unrendered
@@ -177,6 +177,11 @@ for (const scheme of ["light", "dark"]) {
});
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate(({ scheme, tier }) => {
document.documentElement.dataset.theme = scheme;
if (tier) document.documentElement.dataset.contrast = tier;
else delete document.documentElement.dataset.contrast;
}, { scheme, tier });
await page.waitForTimeout(400);
// 一屏里画不出所有状态:审批、提问、失败的调用、设置页各自带着自己的配色。
@@ -223,11 +228,11 @@ for (const scheme of ["light", "dark"]) {
f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: ["只在 darwin 验过"], unverified: ["Windows"], saysSomething: true } });
});
await settled(page);
for (const row of await sweep(page, scheme, "工作台")) found.set(row.key, row);
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "工作台")) found.set(row.key, row);
await page.locator('[data-action="chrome.settings"]').first().click();
await settled(page);
for (const row of await sweep(page, scheme, "设置")) if (!found.has(row.key)) found.set(row.key, row);
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "设置")) if (!found.has(row.key)) found.set(row.key, row);
await page.close();
}
@@ -41,7 +41,6 @@ try {
['.chrome [data-action="settings.section"][data-value="tools"]', "沙盒与运行边界"],
['[data-action="pane.view"][data-value="analysis"]', "打开运行分析"],
['[data-action="pane.view"][data-value="browser"]', "关闭工作台"],
['[data-action="browser.wide"]', "加宽工作台"],
['[data-action="browser.full"]', "全屏工作台"],
['[data-action="workbench.files"]', "展开文件树"],
['[data-action="workbench.new"]', "新建标签"],
@@ -56,13 +55,13 @@ try {
assert.equal(await button.getAttribute("title"), null);
const box = await tip.boundingBox();
assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1440 && box.y + box.height <= 960);
if (selector.includes("browser.wide")) await page.screenshot({ path: "/tmp/reasonix-tooltip-workbench.png" });
if (selector.includes("browser.full")) await page.screenshot({ path: "/tmp/reasonix-tooltip-workbench.png" });
if (selector.includes("reply.quote")) await page.screenshot({ path: "/tmp/reasonix-tooltip-reply.png" });
await page.mouse.move(1, 1);
await tip.waitFor({ state: "detached" });
}
await page.locator('[data-action="browser.wide"]').focus();
await page.getByRole("tooltip").filter({ hasText: "加宽工作台" }).waitFor();
await page.locator('[data-action="browser.full"]').focus();
await page.getByRole("tooltip").filter({ hasText: "全屏工作台" }).waitFor();
await page.keyboard.press("Escape");
assert.equal(await page.locator(".icon-tooltip").count(), 0);
await page.getByRole("button", { name: "下一页", exact: true }).click();
+89 -30
View File
@@ -2,6 +2,8 @@ import { createRoot } from "react-dom/client";
import { boot as bootLang } from "../src/i18n";
import { track as trackWidth } from "../src/ui/viewport";
import { pushToast } from "../src/state/toasts";
import { apply as applyPack } from "../src/ui/theme";
import { mount as mountEditor } from "./syntax-mount";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/dock.css";
@@ -26,6 +28,7 @@ import "../src/styles/stophint.css";
import "../src/styles/dialogcenter.css";
import "../src/styles/treerows.css";
import "../src/styles/workbench-changes.css";
import "../src/styles/agentrun.css";
import "../src/styles/gitgraph.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
@@ -52,11 +55,13 @@ import { MockPort } from "../src/port/mock";
import { fromHistory } from "../src/state/session";
import { HttpError } from "../src/port/port";
import type { AgentPort, Appearance, ChipCall, HistoryMessage, McpEntry } from "../src/port/port";
import type { HistoryPage } from "../src/port/session";
import type { UpdateProgress } from "../src/port/version";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub";
import type { WireEvent } from "../src/port/wire";
import type { PreviewInfo } from "../src/port/workspace";
import type { SubagentRun } from "../src/port/subagent";
import { deliveryContents, deliveryHistory } from "./delivery-data";
// Saves are coalesced (src/ui/trailing.ts), and a fixture answers too fast for
@@ -98,8 +103,14 @@ function benchPack(): ThemePack | null {
}
class BenchPort extends MockPort {
private driverRunning: boolean | undefined;
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
async account() {
const state = await super.account();
const label = query.get("accountLabel");
return label && state.signedIn && state.user ? { ...state, user: { ...state.user, label } } : state;
}
// ?mcpactions=1 adds a server whose tools are withheld and one that is followed.
constructor() {
super();
@@ -296,9 +307,35 @@ class BenchPort extends MockPort {
}
private settingCardSent = false;
private receiptSent = false;
async receiptSettings() {
const read = await super.receiptSettings();
return query.has("receipt") ? { ...read, show: true } : read;
}
private meterSent = false;
subscribe(on: (e: WireEvent) => void) {
this.subs.add(on);
if (query.has("meter") && !this.meterSent) {
this.meterSent = true;
const usage = (amount: number) => ({ cacheHitTokens: 90, cacheMissTokens: 10, completionTokens: 40, totalTokens: 140, cost: amount, currency: "CNY", currencyCode: "CNY", source: "executor" });
setTimeout(() => {
for (const [msgIndex, amount] of [[1, 1.2], [2, 0.35]] as const) {
this.feed({ kind: "turn_started", msgIndex } as unknown as WireEvent);
this.feed({ kind: "usage", usage: usage(amount) } as unknown as WireEvent);
this.feed({ kind: "turn_done" } as unknown as WireEvent);
}
}, 500);
}
if (query.has("receipt") && !this.receiptSent) {
this.receiptSent = true;
const script = "cd \"C:/Users/me/work\" && cat > \"$TMPDIR/check/verify_006.py\" <<'PYEOF'\n" + "import os, re, sys, unittest\nclass T(unittest.TestCase):\n def test_a(self):\n self.assertEqual(1, 1)\n".repeat(18) + "PYEOF\npython -m unittest verify_006 2>&1 | tail -12";
setTimeout(() => {
this.feed({ kind: "turn_started", msgIndex: 9 } as unknown as WireEvent);
this.feed({ kind: "turn_done", receipt: { saysSomething: true, severity: "warn", gaps: [{ kind: "failed_verification", detail: script }, { kind: "inconclusive_verification", detail: "go test ./internal/... 2>&1 | tail -5" }] } } as unknown as WireEvent);
}, 500);
}
const card = query.get("settingcard");
if (card && !this.settingCardSent) {
this.settingCardSent = true;
@@ -308,6 +345,8 @@ class BenchPort extends MockPort {
}
feed(ev: WireEvent) {
if (ev.kind === "turn_started") this.driverRunning = true;
if (ev.kind === "turn_done") this.driverRunning = false;
// The driver's frames are the fixture's own frames: a fed approval has to
// move /status the way a scripted one does, or a guard reads a card sitting
// over a status that still says the turn is running.
@@ -322,6 +361,20 @@ class BenchPort extends MockPort {
return query.has("deliveries") ? deliveryHistory() : storedHistory();
}
// ?paged=N serves that transcript the way a paging kernel does: N messages a
// page, counted back from the newest. Without the parameter the answer is 404
// and the read falls back to full history, so scripts written before paging
// keep measuring what they measured.
async historyPage(cursor?: string): Promise<HistoryPage> {
const size = Number(query.get("paged") ?? 0);
if (!(size > 0)) throw new HttpError(404, "paged history is off");
const all = query.has("deliveries") ? deliveryHistory() : storedHistory();
const end = cursor ? Number(cursor) : all.length;
const start = Math.max(0, end - size);
return { messages: all.slice(start, end), toolContext: [], snapshot: "harness", start, end, total: all.length,
hasMoreBefore: start > 0, nextCursor: start > 0 ? String(start) : undefined };
}
// ?queue= fills the pending queue to the count under measurement. Seeded
// here rather than by calling steer sixty-four times: what the guard asks is
// what a full queue does to the layout, not how it got full.
@@ -333,7 +386,8 @@ class BenchPort extends MockPort {
// Lines the person queued: the panel leaves out the host's own queued
// continuations, so a seed of those would draw nothing.
intent: (QUEUE_IMAGES && i === 0 ? "steer" : "followup") as "steer" | "followup",
state: "queued" as const,
state: QUEUE_STATES && i < 2 ? (i === 0 ? "blocked" as const : "uncertain" as const) : "queued" as const,
...(QUEUE_STATES && i < 2 ? { blockReason: "This queued instruction needs review before delivery. ".repeat(5) } : {}),
...(QUEUE_IMAGES && i === 0 ? { images: true } : {}),
// 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板
// (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes,
@@ -369,7 +423,8 @@ class BenchPort extends MockPort {
const goalRuntime = usage
? { turnsUsed: 4, tokensUsed: 128_400, requestsUsed: 9, workDurationMs: 12 * 60_000, noProgressTurns: usage === "blocked" ? 2 : 0, ...(usage === "blocked" ? { stopCause: "goal_stuck", lastReason: "测试一直没有通过,改动没有让失败的数量减少" } : {}) }
: undefined;
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("fullaccess") ? { toolApprovalMode: "yolo" as const } : {}), ...(query.has("effort") ? { effort: query.get("effort") ?? "" } : {}), ...(query.has("modelref") ? { modelRef: query.get("modelref") ?? "" } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
st.running = this.driverRunning ?? st.running;
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
if (!JOBS) return st;
const jobs = Array.from({ length: JOBS }, (_, i) => ({
@@ -448,6 +503,7 @@ const SWITCH_MS = Number(query.get("switchms") ?? 0);
// ?queue= is how many lines are already waiting when the window opens.
const QUEUE = Number(query.get("queue") ?? 0);
const QUEUE_BODY = query.has("queuebody");
const QUEUE_STATES = query.has("queuestates");
// ?queueimages makes the first seeded line a steer that carries a picture.
const QUEUE_IMAGES = query.has("queueimages");
const JOBS = Number(query.get("jobs") ?? 0);
@@ -455,12 +511,16 @@ const JOBS = Number(query.get("jobs") ?? 0);
// window can be seen before the kernel has named a single folder.
const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
// ?tree=none answers every tree read with no folders.
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
constructor() {
super(PANES);
}
// ?search=1 answers every query with two conversations that say it.
async searchHistory(q: string) {
if (!query.has("search")) return super.searchHistory(q);
@@ -482,20 +542,6 @@ class BenchHub extends MockHub {
return port;
}
runtimes(): Promise<RuntimeView[]> {
if (NO_PANES) return Promise.resolve([]);
if (PANES <= 1) return super.runtimes();
return Promise.resolve(
Array.from({ length: PANES }, (_, i) => ({
id: `r${i + 1}`,
base: i === 0 ? "" : `/rt/r${i + 1}`,
root: "~/projects/DeepSeek-Reasonix",
name: "DeepSeek-Reasonix",
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
})),
);
}
async moveSessionTo(path: string, target: string) {
(window.__moves ??= []).push({ path, target });
return super.moveSessionTo(path, target);
@@ -515,15 +561,11 @@ class BenchHub extends MockHub {
return new Promise((resolve) => setTimeout(() => resolve(answer), archived ? slow : 0));
}
const wait = TREE_AT - performance.now();
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
if (!WORKSPACES && query.has("trustpending") && PANES > 1) {
return super.tree().then((all) => all.map((ws, i) => i > 0 ? ws : { ...ws, sessions: Array.from({ length: PANES }, (_, k) => ({
path: `/sessions/pane-${k + 1}.jsonl`, name: `pane-${k + 1}`, title: `并行会话 ${k + 1}`, turns: 3, runtimeId: `r${k + 1}`,
})) }));
}
if (!WORKSPACES) return super.tree();
return Promise.resolve(
Array.from({ length: WORKSPACES }, (_, w) => ({
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree(window)), wait));
if (query.get("tree") === "none") return Promise.resolve([]);
if (!WORKSPACES) return super.tree(window);
return super.runtimes().then((runtimes) =>
this.recentTree(Array.from({ length: WORKSPACES }, (_, w) => ({
root: `~/projects/workspace-${w}`,
name: `workspace-${w}`,
remembered: true,
@@ -533,12 +575,13 @@ class BenchHub extends MockHub {
name: `w${w}-s${s}`,
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
turns: (s % 40) + 1,
runtimeId: runtimes.find((runtime) => runtime.sessionPath === `/sessions/w${w}-s${s}.jsonl`)?.id,
...(query.has("rows") ? {
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
} : {}),
}))),
})),
})), window),
);
}
}
@@ -562,7 +605,11 @@ declare global {
// conversations really cost.
__feed: (ev: WireEvent) => void;
__panes: () => number;
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text: string }>) => void;
// Answer every completion request with this one, as the kernel would for a command typed in full.
__complete: (answer: import("../src/port/port").Completion) => void;
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text?: string; base64?: string }>) => void;
// Sub-agent runs the kernel would list, each with the conversation it would return.
__subagents: (runs: { run: SubagentRun; messages: HistoryMessage[] }[]) => void;
// What folding a stored transcript into cards costs on its own, with no
// frame in it: the step between the read answering and the first paint.
__foldCost: () => number;
@@ -574,20 +621,32 @@ declare global {
__refuse: (method: string, message: string) => void;
// Put a notice in the window's corner the way a failed setting does.
__toast: (text: string) => void;
__syntax: { apply: typeof applyPack; mount: typeof mountEditor };
}
}
window.__toast = pushToast;
window.__syntax = { apply: applyPack, mount: mountEditor };
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
window.__panes = () => hub.feeds.size;
const fixtureBytes = (f: { text?: string; base64?: string }) =>
f.base64 !== undefined ? Uint8Array.from(atob(f.base64), (c) => c.charCodeAt(0)) : new TextEncoder().encode(f.text ?? "");
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
port.workspacePreviewInfo = async (ref) => ({
ref, kind: files[ref.path].kind, size: new TextEncoder().encode(files[ref.path].text).length,
ref, kind: files[ref.path].kind, size: fixtureBytes(files[ref.path]).length,
version: "fixture", limit: 32 << 20, readOnly: false,
});
port.workspacePreviewContent = async (ref) => ({
data: new TextEncoder().encode(files[ref.path].text).buffer, version: "fixture",
data: fixtureBytes(files[ref.path]).buffer, version: "fixture",
});
});
window.__subagents = (runs) => hub.feeds.forEach((port) => {
port.subagents = async () => runs.map((r) => r.run);
port.subagentTranscript = async (ref) => {
const found = runs.find((r) => r.run.ref === ref)!;
return { run: found.run, messages: found.messages, after: 0, next: found.messages.length, total: found.messages.length, live: false };
};
});
window.__complete = (answer) => hub.feeds.forEach((port) => { port.complete = async () => answer; });
window.__refuse = (method, message) => {
(hub as unknown as Record<string, unknown>)[method] = () => Promise.reject(new Error(message));
};
+1 -1
View File
@@ -61,7 +61,7 @@ try {
assert.equal(await page.locator(".user-image button").evaluate(el => el === document.activeElement), true);
await page.locator(".activity-group > summary").click();
const tool = page.locator('[data-call="image-shot"] .tool-disclosure');
await tool.locator("summary").click();
await tool.locator("summary .nm").click();
await page.getByRole("button", { name: "查看图片 工具截图 1", exact: true }).click();
assert.equal(await page.getByRole("dialog").locator("img").getAttribute("src"), data);
await page.getByRole("button", { name: "关闭图片预览" }).click();
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 2400, height: 1200 }, locale: "en-US" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const url = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
url.searchParams.set("pref", "en");
await page.goto(url.href, { waitUntil: "networkidle" });
await page.locator(".compose textarea").waitFor();
await page.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "text", text: "A populated conversation must remain readable when a custom width meets a narrow window." });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
});
await page.locator(".flow .out .txt").waitFor();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#prefs-appearance").click();
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
const slider = page.getByRole("slider", { name: "Conversation width", exact: true });
await slider.fill("67");
await page.getByRole("button", { name: "Full width", exact: true }).click();
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
assert.equal(await slider.inputValue(), "67", "preset switching retains the custom percentage");
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
await page.waitForFunction(() => {
const pane = document.querySelector(".pane").clientWidth;
return Math.abs(document.querySelector(".compose").getBoundingClientRect().width / pane - 0.67) < 0.01;
});
for (const selector of [".flow", ".compose"]) {
const ratio = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width / document.querySelector(".pane").clientWidth);
assert.ok(Math.abs(ratio - 0.67) < 0.01, `${selector} uses 67% of the conversation pane: ${ratio}`);
}
for (const [name, wideFlow, wideComposer] of [
["Full width", true, false], ["Full width, composer too", true, true], ["Reading", false, false],
]) {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#prefs-appearance").click();
await page.getByRole("button", { name, exact: true }).click();
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
await page.waitForFunction(([flow, composer]) => {
const fits = (selector, wide) => {
const width = document.querySelector(selector).getBoundingClientRect().width;
return wide ? width > 1800 : Math.abs(width - 760) < 1;
};
return fits(".flow", flow) && fits(".compose", composer);
}, [wideFlow, wideComposer]);
}
console.log("PASS existing presets: reading, wide transcript and wide composer keep their geometry");
console.log("PASS 67%: transcript and composer resize together; presets retain the value");
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#prefs-appearance").click();
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
await slider.fill("30");
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
await page.setViewportSize({ width: 1366, height: 900 });
await page.waitForFunction(() => document.querySelector(".compose").getBoundingClientRect().width < 350);
const outside = await page.locator(".compose").evaluate((el) => {
const box = el.getBoundingClientRect();
return [...el.querySelectorAll("button")].filter((button) => {
const rect = button.getBoundingClientRect();
return button.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
&& rect.width > 0 && (rect.right > box.right + 1 || rect.left < box.left - 1);
}).map((el) => el.textContent);
});
assert.deepEqual(outside, [], "toolbar actions stay within a 30% composer on a laptop");
const workspace = page.locator(".compose .worktree-trigger").first();
await workspace.click();
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible in a 30% composer");
await page.keyboard.press("Escape");
console.log("PASS 30%: composer actions remain contained on a laptop");
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForFunction(() => document.querySelector(".flow").getBoundingClientRect().width > 300);
for (const selector of [".flow", ".compose"]) {
const width = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width);
assert.ok(width > 300 && width <= 390, `${selector} uses the phone layout rather than 30%: ${width}`);
}
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "phone has no horizontal page overflow");
await workspace.click();
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible on a phone");
await page.keyboard.press("Escape");
assert.deepEqual(errors, []);
console.log("PASS phone: populated conversation and composer use the available width");
} finally {
await browser.close();
}
+1 -1
View File
@@ -65,7 +65,7 @@ const probe = (page) =>
};
});
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".view-action"];
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".view-action"];
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
async function reachable(page) {
@@ -0,0 +1,53 @@
// A label's ink must centre on its icon's: the line box centres on the row, but a CJK label's ink sits above the
// line box's middle, so a row can look top-heavy while every box measurement says it is level.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: "reduce" });
await ctx.addInitScript(() => localStorage.setItem("rx-nav-rail", "off"));
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".studio-quicknav button");
await page.evaluate(() => document.fonts.ready);
const rows = await page.evaluate(() => {
return [...document.querySelectorAll(".studio-new-task, .studio-search, .studio-quicknav button")].map((row) => {
const box = row.getBoundingClientRect();
const icon = row.querySelector("span").getBoundingClientRect();
return { name: row.textContent.trim(), x: box.x, y: box.y, w: box.width, h: box.height, iconRight: icon.right - box.x };
});
});
for (const r of rows) {
const shot = await page.screenshot({ clip: { x: r.x, y: r.y, width: r.w, height: r.h } });
const mids = await page.evaluate(async ({ data, split }) => {
const img = await createImageBitmap(await (await fetch("data:image/png;base64," + data)).blob());
const c = new OffscreenCanvas(img.width, img.height);
const g = c.getContext("2d");
g.drawImage(img, 0, 0);
const px = g.getImageData(0, 0, img.width, img.height).data;
const mid = (x0, x1) => {
let top = Infinity, bot = -1;
for (let y = 0; y < img.height; y++) for (let x = x0; x < Math.min(x1, img.width); x++) {
const o = (y * img.width + x) * 4;
if ((px[o] + px[o + 1] + px[o + 2]) / 3 < 170) { top = Math.min(top, y); bot = Math.max(bot, y); }
}
return (top + bot) / 2;
};
return { icon: mid(0, split), text: mid(split, img.width) };
}, { data: shot.toString("base64"), split: Math.round(r.iconRight + 4) });
const off = Math.abs(mids.text - mids.icon);
check(`${r.name}: label and icon centre within 1px`, off <= 1, `icon ${mids.icon} text ${mids.text}`);
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright";
// OFFICE_SAMPLES names a folder holding report.docx and sales.xlsx; OUT where the shots go.
const dir = process.env.OFFICE_SAMPLES;
const out = process.env.OUT ?? "/tmp";
assert.ok(dir, "set OFFICE_SAMPLES");
const files = {
"report.docx": { kind: "office", base64: readFileSync(join(dir, "report.docx")).toString("base64") },
"sales.xlsx": { kind: "office", base64: readFileSync(join(dir, "sales.xlsx")).toString("base64") },
};
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
try {
for (const [scheme, width] of [["light", 1440], ["dark", 1440], ["light", 390]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
const errors = [];
const requests = [];
page.on("pageerror", (err) => errors.push(err.message));
page.on("request", (req) => requests.push(req.url()));
await page.goto(process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html?pref=zh", { waitUntil: "networkidle" });
await page.waitForFunction(() => window.__panes?.() > 0);
await page.evaluate((files) => {
window.__previewFiles(files);
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "整理季度报告" });
window.__feed({ kind: "tool_dispatch", tool: { id: "present", name: "present_files", readOnly: true } });
window.__feed({ kind: "tool_result", tool: { id: "present", name: "present_files", readOnly: true,
output: "Presented files", deliveries: Object.keys(files).map((path) => ({ path, description: "季度材料" })) } });
window.__feed({ kind: "text", text: "报告和表格已经生成。" });
window.__feed({ kind: "turn_done" });
}, files);
for (const name of width < 600 ? ["sales.xlsx"] : ["report.docx", "sales.xlsx"]) {
await page.locator(".delivery-open").filter({ hasText: name }).click();
const frame = page.frameLocator('iframe[title="Office 文档预览"]');
await frame.locator(name.endsWith("docx") ? "section.docx" : "table").first().waitFor({ timeout: 15000 });
const sandbox = await page.locator('iframe[title="Office 文档预览"]').getAttribute("sandbox");
assert.equal(sandbox, "allow-same-origin");
await page.waitForTimeout(300);
await page.screenshot({ path: join(out, `office-${name.split(".")[1]}-${scheme}-${width}.png`) });
if (name === "report.docx") {
await page.frameLocator('iframe[title="Office 文档预览"]').getByText("续约率", { exact: false }).click({ clickCount: 3 });
await page.locator('[data-action="document.quote"]').filter({ hasText: "正文第" }).waitFor();
await page.screenshot({ path: join(out, `office-word-select-${scheme}-${width}.png`) });
}
if (name === "sales.xlsx" && width >= 600) {
const sheet = page.frameLocator('iframe[title="Office 文档预览"]');
const centre = async (cell) => {
const box = await sheet.locator(`[data-cell="${cell}"]`).boundingBox();
return [box.x + box.width / 2, box.y + box.height / 2];
};
const [bx, by] = await centre("B2");
await page.mouse.move(bx, by);
await page.mouse.down();
const [dx, dy] = await centre("D4");
await page.mouse.move(dx, dy, { steps: 6 });
await page.mouse.up();
const quote = page.locator('[data-action="document.quote"]');
await quote.filter({ hasText: "B2:D4" }).waitFor();
await page.screenshot({ path: join(out, `office-select-${scheme}-${width}.png`) });
await quote.click();
await page.locator(".compose").getByText("sales.xlsx", { exact: false }).first().waitFor();
await page.waitForTimeout(800);
await page.screenshot({ path: join(out, `office-quoted-${scheme}-${width}.png`) });
}
}
assert.deepEqual(errors, []);
assert.deepEqual(requests.filter((u) => !u.startsWith("http://127.0.0.1") && !u.startsWith("http://localhost") && !u.startsWith("data:") && !u.startsWith("blob:")), []);
await page.close();
}
} finally {
await browser.close();
}
@@ -0,0 +1,31 @@
// An enlarged settings window centres its content column between the nav and
// the window edge: the room left over is shared, not piled up on the right.
import assert from "node:assert/strict";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const browser = await chromium.launch();
try {
for (const width of [1920, 2560]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.getByRole("button", { name: "放大设置窗口" }).click();
await page.waitForTimeout(400);
const room = await page.evaluate(() => {
const main = document.querySelector(".prefs-main").getBoundingClientRect();
const col = document.querySelector(".prefs-main .prefs-col").getBoundingClientRect();
return { left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width) };
});
assert.ok(room.width <= 1200, `the column keeps its cap (${room.width})`);
assert.ok(Math.abs(room.left - room.right) <= 2, `${width}px: the room is shared evenly (left ${room.left}, right ${room.right})`);
assert.deepEqual(errors, []);
console.log(`PASS ${width}px: column ${room.width}px with ${room.left}px each side`);
await page.close();
}
} finally {
await browser.close();
}
+68 -9
View File
@@ -15,7 +15,8 @@ try {
page.setDefaultTimeout(8000);
page.on("pageerror", (error) => failures.push(error.message));
await page.goto(`${BASE}?pref=zh&turns=3`, { waitUntil: "networkidle" });
const add = page.locator(".projects-heading .projects-add");
const add = page.locator('.projects-heading [data-action="workspace.add"]');
const recent = page.locator('.projects-heading [data-action="session.recent"]');
const more = page.locator(".projects-heading .wsmore");
await add.waitFor();
await page.addStyleTag({ content: "*{transition:none!important;animation:none!important}" });
@@ -29,11 +30,14 @@ try {
const addHover = await paint(add);
await more.hover();
const moreHover = await paint(more);
check(`${scheme}: both header actions visibly highlight on hover`,
addHover.fill !== rest.fill && moreHover.fill !== rest.fill);
await recent.hover();
const recentHover = await paint(recent);
check(`${scheme}: all header actions visibly highlight on hover`,
addHover.fill !== rest.fill && moreHover.fill !== rest.fill && recentHover.fill !== rest.fill);
check(`${scheme}: project header actions have the same hover fill and ink`,
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink,
JSON.stringify({ add: addHover, more: moreHover }));
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink
&& recentHover.fill === addHover.fill && recentHover.ink === addHover.ink,
JSON.stringify({ add: addHover, more: moreHover, recent: recentHover }));
await more.click();
const menu = page.getByRole("menu", { name: "项目列表的更多操作", exact: true });
await menu.waitFor();
@@ -50,15 +54,22 @@ try {
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
await page.keyboard.press("Shift+Tab");
const recentFocus = await recent.evaluate((button) => ({
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
const moreFocus = await more.evaluate((button) => ({
focused: button === document.activeElement,
outline: getComputedStyle(button).outline,
}));
check(`${scheme}: both header actions retain visible keyboard focus`,
addFocus.focused && moreFocus.focused
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none"),
JSON.stringify({ add: addFocus, more: moreFocus }));
check(`${scheme}: all header actions retain visible keyboard focus`,
addFocus.focused && moreFocus.focused && recentFocus.focused
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none")
&& !recentFocus.outline.includes("none"),
JSON.stringify({ add: addFocus, more: moreFocus, recent: recentFocus }));
await more.click();
await menu.waitFor();
await page.locator(".projects-heading-title").click();
@@ -73,6 +84,54 @@ try {
await page.mouse.move(900, 500);
const after = await paint(more);
check(`${scheme}: dismissing the menu restores the resting background`, after.fill === rest.fill);
await recent.click();
const recentDialog = page.getByRole("dialog", { name: "最近的会话", exact: true });
await recentDialog.locator(".recent-session").first().waitFor();
check(`${scheme}: Recent opens saved session discovery`, await recentDialog.isVisible());
await page.keyboard.press("Escape");
await recentDialog.waitFor({ state: "hidden" });
check(`${scheme}: closing Recent returns focus to its own header entry`,
await recent.evaluate((button) => button === document.activeElement));
await page.goto(`${BASE}?pref=zh&ws=20&sess=8&turns=3`, { waitUntil: "networkidle" });
await add.waitFor();
const headerTop = await add.evaluate((button) => button.getBoundingClientRect().top);
const tree = page.getByRole("tree", { name: "项目与会话", exact: true });
const rows = tree.getByRole("treeitem");
await rows.first().focus();
await page.keyboard.press("End");
const last = rows.last();
check(`${scheme}: End reaches the final local or remote row`,
await last.evaluate((row) => row === document.activeElement));
const bottom = await last.evaluate((row) => {
const scroller = row.closest(".scroll");
const r = row.getBoundingClientRect(), s = scroller.getBoundingClientRect();
return { top: scroller.scrollTop, height: scroller.clientHeight, visible: r.top >= s.top && r.bottom <= s.bottom };
});
check(`${scheme}: a long project list scrolls to its final rows`, bottom.top > bottom.height && bottom.visible);
const addAtEnd = await add.evaluate((button) => {
const r = button.getBoundingClientRect();
return { top: r.top, reached: button.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)) };
});
check(`${scheme}: project controls stay fixed and clickable while the tree scrolls`,
addAtEnd.top === headerTop && addAtEnd.reached, JSON.stringify(addAtEnd));
await page.keyboard.press("Home");
check(`${scheme}: Home returns to the first project`,
await rows.first().evaluate((row) => row === document.activeElement));
await page.keyboard.press("Shift+F10");
await page.getByRole("menu", { name: "项目操作", exact: true }).waitFor();
await page.keyboard.press("Escape");
await more.click();
await page.getByRole("menuitem", { name: "选择本机会话", exact: true }).click();
await page.getByRole("button", { name: "完成选择", exact: true }).click();
check(`${scheme}: leaving batch selection returns focus to the fixed header`,
await more.evaluate((button) => button === document.activeElement));
await add.click();
await dialog.waitFor();
check(`${scheme}: the fixed add action retains local and remote project locations`,
await dialog.getByRole("button", { name: "选择本机文件夹", exact: true }).isEnabled()
&& await dialog.getByRole("button", { name: "gpu-box · SSH", exact: true }).isEnabled());
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "hidden" });
await page.close();
}
} finally {
@@ -0,0 +1,61 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const url = process.env.PERF_URL ?? "http://127.0.0.1:4407/perf.html";
const shots = process.env.QUEUE_SHOTS ?? "/tmp/queue-14497-shots";
const baseline = process.env.QUEUE_BASELINE === "1";
mkdirSync(shots, { recursive: true });
const browser = await chromium.launch();
const failures = [];
for (const theme of ["light", "dark"]) {
for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: "zh-CN" });
const page = await context.newPage();
page.on("pageerror", (e) => failures.push(e.message));
await page.goto(`${url}?queue=12&queuebody&queuestates&turns=8`, { waitUntil: "networkidle" });
await page.locator(".qi").first().waitFor();
await page.evaluate((theme) => { document.documentElement.dataset.theme = theme; }, theme);
await page.waitForTimeout(500);
const geometry = await page.evaluate(() => {
const list = document.querySelector(".qitems");
const row = document.querySelector(".qi");
const style = getComputedStyle(row);
return { border: style.borderTopWidth, height: row.getBoundingClientRect().height,
scrolls: list.scrollHeight > list.clientHeight, visibleHeight: list.clientHeight, width: document.documentElement.scrollWidth,
background: style.backgroundColor, gap: getComputedStyle(list).gap };
});
console.log(`${baseline ? "before" : "after"}/${theme}/${width}`, geometry);
await page.screenshot({ path: `${shots}/${baseline ? "before" : "after"}-${theme}-${width}.png` });
if (baseline) {
assert.notEqual(geometry.border, "0px", "baseline must expose the standalone row border");
} else {
assert.equal(geometry.border, "0px");
assert.ok(geometry.height >= 36 && geometry.height <= 40, JSON.stringify(geometry));
assert.equal(geometry.gap, "2px");
assert.ok(geometry.scrolls);
assert.ok(geometry.width <= width);
assert.equal(await page.getByRole("combobox", { name: "任务输入" }).getAttribute("placeholder"), "继续输入以排队后续修改");
const row = page.locator(".qi").first();
assert.equal(await row.locator(".qwhy").getAttribute("title"), "This queued instruction needs review before delivery. ".repeat(5));
assert.ok(geometry.visibleHeight >= 180 && geometry.visibleHeight <= 208, JSON.stringify(geometry));
await row.getByRole("button", { name: "改", exact: true }).focus();
assert.equal(await row.getByRole("button", { name: "下移", exact: true }).evaluate((el) => getComputedStyle(el).opacity), "1");
await page.screenshot({ path: `${shots}/focus-${theme}-${width}.png` });
await row.getByRole("button", { name: "改", exact: true }).click();
await page.locator(".qedit").waitFor();
assert.ok(await page.locator(".qedit").inputValue());
await page.screenshot({ path: `${shots}/edit-${theme}-${width}.png` });
await page.locator(".qedit").press("Escape");
assert.equal(await page.locator(".qedit").count(), 0);
assert.equal(await row.getByRole("button", { name: "取回", exact: true }).getAttribute("title"), "取回");
await page.locator(".qitems").evaluate((el) => { el.scrollTop = el.scrollHeight; });
await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).focus();
assert.ok(await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).isVisible());
}
await context.close();
}
}
await browser.close();
assert.deepEqual(failures, []);
console.log("Queue layout browser matrix passed");
+2 -2
View File
@@ -38,7 +38,7 @@ check("播出来的行在", (await rows.count()) === 3, `${await rows.count()}
const first = rows.nth(0);
const wasFirst = await first.locator(".pv").innerText();
await first.locator('button:has-text("改")').click();
await first.getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(500);
const box = await page.locator(".queue .qedit").count();
@@ -50,7 +50,7 @@ check("这一行说了为什么", await why.isVisible().catch(() => false), (awa
// 说明是这一行的,不是面板的:换一行再点,上一行的说明得跟着走。一条粘住不动的
// 提示会让下一次失败看起来像上一次的余音。
const second = rows.nth(1);
await second.locator('button:has-text("改")').click();
await second.getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(500);
check("说明跟着被点的那一行走", (await first.locator(".qwhy[data-err]").count()) === 0);
check("新的那一行才带着说明", await second.locator(".qwhy[data-err]").isVisible().catch(() => false));
@@ -0,0 +1,185 @@
// With the icon rail hidden, the sidebar's rows line up and read alike: every row's icon sits on one vertical line and every
// label starts on another, from the new-session button down to the wallet and feedback rows at the foot.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=1&turns=1";
const ROWS = [
["新建会话", ".studio-new-task"],
["搜索与快捷操作", ".studio-search"],
["文件", ".studio-quicknav > button:nth-child(1)"],
["工具与权限", ".studio-quicknav > button:nth-child(2)"],
["扩展", ".studio-quicknav > button:nth-child(3)"],
["记忆", ".studio-quicknav > button:nth-child(4)"],
["远程", ".studio-quicknav > button:nth-child(5)"],
["钱包与用量", ".studio-wallet:not(.studio-feedback)"],
["发送反馈", ".studio-feedback"],
];
const TOLERANCE = 1;
const spread0 = (rows, at) => rows.reduce((worst, r) => (Math.abs(r.icon - at) > Math.abs(worst) ? r.icon - at : worst), 0);
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1280, height: 900 }, deviceScaleFactor: 4, reducedMotion: "reduce" });
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const seen = [];
for (const [name, sel] of ROWS) {
const at = await page.evaluate((s) => {
const row = document.querySelector(s);
if (!row) return null;
const svg = row.querySelector("svg").getBoundingClientRect();
const walk = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
let text = null;
for (let n = walk.nextNode(); n && !text; n = walk.nextNode()) {
if (!n.textContent.trim()) continue;
const range = document.createRange();
range.selectNodeContents(n);
text = range.getBoundingClientRect().x;
}
const label = [...row.querySelectorAll("*")].find((e) => e.children.length === 0 && e.textContent.trim() && !e.closest("kbd,small,.fbk-badge")) ?? row;
const cs = getComputedStyle(label);
return { icon: svg.x + svg.width / 2, text, look: `${cs.fontSize}/${cs.fontWeight}/${cs.color}`, fill: getComputedStyle(row).backgroundColor };
}, sel);
if (at) seen.push({ name, ...at });
}
const spread = (key) => Math.max(...seen.map((r) => r[key])) - Math.min(...seen.map((r) => r[key]));
const show = (key) => seen.map((r) => `${r.name} ${r[key].toFixed(1)}`).join("; ");
check(`${scheme}: all ${ROWS.length} rows are drawn`, seen.length === ROWS.length, `${seen.length}`);
check(`${scheme}: the icons share one vertical line`, spread("icon") <= TOLERANCE, `spread ${spread("icon").toFixed(1)}px — ${show("icon")}`);
check(`${scheme}: the labels start on one vertical line`, spread("text") <= TOLERANCE, `spread ${spread("text").toFixed(1)}px — ${show("text")}`);
const folder = await page.evaluate(() => {
const r = document.querySelector(".rail .wsfolder")?.getBoundingClientRect();
return r ? r.x + r.width / 2 : null;
});
check(`${scheme}: the icons sit on the project folders' line`, folder !== null && Math.abs(spread0(seen, folder)) <= TOLERANCE, `folders ${folder}; rows ${show("icon")}`);
const lockup = await page.evaluate(() => {
const logo = document.querySelector(".studio-brand-logo").getBoundingClientRect();
const first = document.querySelector(".studio-new-task").getBoundingClientRect();
return { above: logo.top, below: first.top - logo.bottom };
});
check(`${scheme}: the logo has the same space above and below it`, Math.abs(lockup.above - lockup.below) <= TOLERANCE, `above ${lockup.above}, below ${lockup.below}`);
const logo = await page.evaluate(() => {
const r = document.querySelector(".studio-brand-logo").getBoundingClientRect();
return { x: r.x, y: r.y, h: r.height };
});
const shot = await page.screenshot({ clip: { x: 0, y: logo.y, width: 120, height: logo.h } });
const inkLeft = await page.evaluate(async (b64) => {
const img = new Image();
img.src = `data:image/png;base64,${b64}`;
await img.decode();
const c = document.createElement("canvas");
c.width = img.width; c.height = img.height;
const g = c.getContext("2d");
g.drawImage(img, 0, 0);
const d = g.getImageData(0, 0, c.width, c.height).data;
const bg = [d[0], d[1], d[2]];
for (let x = 0; x < c.width; x++) for (let y = 0; y < c.height; y++) {
const i = (y * c.width + x) * 4;
if (Math.abs(d[i] - bg[0]) + Math.abs(d[i + 1] - bg[1]) + Math.abs(d[i + 2] - bg[2]) > 60) return x / (c.width / 120);
}
return null;
}, shot.toString("base64"));
const labelLeft = seen[0].text;
check(`${scheme}: the logo's first letter starts where the row labels start`, inkLeft !== null && Math.abs(inkLeft - labelLeft) <= TOLERANCE, `logo ink ${inkLeft}, labels ${labelLeft}`);
const avatar = await page.evaluate(() => {
const ini = document.querySelector(".studio-user-foot .ini");
const row = ini?.closest(".acctrow");
if (!ini || !row) return null;
const a = ini.getBoundingClientRect();
const r = row.getBoundingClientRect();
return { same: Math.abs(a.width - r.width) <= 1 && Math.abs(a.height - r.height) <= 1, radius: getComputedStyle(ini).borderRadius, rowRadius: getComputedStyle(row).borderRadius };
});
check(`${scheme}: the account avatar is one rounded square, not a circle inside another shape`, avatar !== null && avatar.same && avatar.radius === avatar.rowRadius, JSON.stringify(avatar));
const tabGapsAt = async () => {
const boxes = await page.evaluate(() => [...document.querySelectorAll(".studio-session-segments button")].map((e) => {
const r = e.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}));
const gaps = [];
for (const box of boxes) {
const shot = await page.screenshot({ clip: box });
gaps.push(await page.evaluate(async (b64) => {
const img = new Image();
img.src = `data:image/png;base64,${b64}`;
await img.decode();
const c = document.createElement("canvas");
c.width = img.width; c.height = img.height;
const g = c.getContext("2d");
g.drawImage(img, 0, 0);
const d = g.getImageData(0, 0, c.width, c.height).data;
const mid = (c.width >> 1) * 4;
const bg = [d[mid], d[mid + 1], d[mid + 2]];
let top = -1, bottom = -1;
for (let y = 0; y < c.height; y++) {
for (let x = Math.floor(c.width * 0.2); x < Math.floor(c.width * 0.8); x++) {
const i = (y * c.width + x) * 4;
if (Math.abs(d[i] - bg[0]) + Math.abs(d[i + 1] - bg[1]) + Math.abs(d[i + 2] - bg[2]) > 90) { if (top < 0) top = y; bottom = y; break; }
}
}
return { above: top / 4, below: (c.height - 1 - bottom) / 4 };
}, shot.toString("base64")));
}
return gaps.reduce((w, g) => (Math.abs(g.above - g.below) > Math.abs(w.above - w.below) ? g : w), gaps[0]);
};
const inkExtent = async () => {
const box = await page.evaluate(() => {
const r = document.querySelector(".studio-session-segments button").getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
});
const shot = await page.screenshot({ clip: box });
return page.evaluate(async (b64) => {
const img = new Image();
img.src = `data:image/png;base64,${b64}`;
await img.decode();
const c = document.createElement("canvas");
c.width = img.width; c.height = img.height;
const g = c.getContext("2d");
g.drawImage(img, 0, 0);
const d = g.getImageData(0, 0, c.width, c.height).data;
const mid = (c.width >> 1) * 4;
const bg = [d[mid], d[mid + 1], d[mid + 2]];
let top = -1, bottom = -1;
for (let y = 0; y < c.height; y++) {
for (let x = Math.floor(c.width * 0.2); x < Math.floor(c.width * 0.8); x++) {
const i = (y * c.width + x) * 4;
if (Math.abs(d[i] - bg[0]) + Math.abs(d[i + 1] - bg[1]) + Math.abs(d[i + 2] - bg[2]) > 90) { if (top < 0) top = y; bottom = y; break; }
}
}
return { top: top / 4, bottom: bottom / 4 };
}, shot.toString("base64"));
};
const clipped = await inkExtent();
await page.addStyleTag({ content: ".studio-session-segments button > span { overflow: visible !important; }" });
const open = await inkExtent();
check(`${scheme}: the session tabs' glyphs are not cut by the label's clip box`, Math.abs(clipped.top - open.top) <= 0.25 && Math.abs(clipped.bottom - open.bottom) <= 0.25, `clipped ${JSON.stringify(clipped)} open ${JSON.stringify(open)}`);
await page.evaluate(() => { document.head.lastElementChild.remove(); });
for (const scale of [1]) {
await page.evaluate((f) => document.documentElement.style.setProperty("--fs-scale", String(f)), scale);
await page.waitForTimeout(120);
const worst = await tabGapsAt();
check(`${scheme}: at text size ${scale} each session tab's text has equal space above and below`, Math.abs(worst.above - worst.below) <= 1.5, JSON.stringify(worst));
}
await page.evaluate(() => document.documentElement.style.removeProperty("--fs-scale"));
const pillY = await page.evaluate(() => document.querySelector(".studio-session-segments").getBoundingClientRect().y);
check(`${scheme}: the session pill sits on a whole pixel, so its fill has equal edges above and below`, Number.isInteger(pillY), `y ${pillY}`);
const looks = new Set(seen.map((r) => r.look));
check(`${scheme}: every row's label has one size, weight and colour`, looks.size === 1, seen.map((r) => `${r.name} ${r.look}`).join("; "));
const fill = seen.find((r) => r.name === "新建会话")?.fill ?? "";
check(`${scheme}: New session has no fill at rest`, /rgba\(0, 0, 0, 0\)|transparent/.test(fill), fill);
await ctx.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
@@ -0,0 +1,42 @@
// With the icon rail hidden, the sidebar's own shortcut rows are one family: the same size, weight and colour,
// so none of them reads as bigger or bolder than its neighbours. The new-session button and the feedback row are
// emphasis by design and are not part of the family.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=3&sess=3&turns=2";
const FAMILY = ["搜索与快捷操作", "文件", "工具与权限", "扩展", "记忆", "远程", "钱包与用量"];
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1280, height: 900 }, reducedMotion: "reduce" });
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const looks = {};
for (const name of FAMILY) {
const el = page.getByText(name, { exact: true }).first();
await el.waitFor();
looks[name] = await el.evaluate((e) => { const c = getComputedStyle(e); return `${c.fontSize} / ${c.fontWeight} / ${c.color}`; });
}
const distinct = new Set(Object.values(looks));
check(`${scheme}: the sidebar's shortcut rows share one size, weight and colour`, distinct.size === 1,
distinct.size === 1 ? [...distinct][0] : Object.entries(looks).map(([k, v]) => `${k}: ${v}`).join("; "));
const [search, first] = await Promise.all([page.locator(".studio-search").first().boundingBox(), page.locator(".studio-quicknav > button").first().boundingBox()]);
const gap = first.y - (search.y + search.height);
check(`${scheme}: the shortcut rows follow the search row without a blank band`, gap <= 4, `${gap.toFixed(1)}px`);
await ctx.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
@@ -3,6 +3,8 @@
// median frame with PERF_GUARD panes stays within FRAME_RATIO_MAX of the one-pane median, a pane that
// was hidden comes back at the scroll position it left, and the transcript does not inherit the animated
// column widths: with a long transcript the fold costs style recalculation of every element under it.
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const CPU = Number(process.env.PERF_CPU ?? 4);
@@ -12,6 +14,8 @@ const COUNTS = (process.env.PERF_N ?? "1,3,6").split(",").map(Number);
const GUARD = Number(process.env.PERF_GUARD ?? 3);
const FRAME_RATIO_MAX = Number(process.env.PERF_FRAME_RATIO ?? 2.5);
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.RAIL_TOGGLE_SHOTS;
if (SHOTS) await mkdir(SHOTS, { recursive: true });
const pct = (a, p) => [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * p))];
const browser = await chromium.launch();
const rows = [];
@@ -62,13 +66,41 @@ for (const n of COUNTS) {
}
rows.push({ panes: n, frames: frames.length, p50: pct(frames, 0.5).toFixed(1), p95: pct(frames, 0.95).toFixed(1), worst: Math.max(...frames).toFixed(1), over50: frames.filter((f) => f > 50).length });
if (n === GUARD) {
await page.evaluate(() => { document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop = 400; });
const first = page.locator('.sessrow[data-target="/sessions/pane-1.jsonl"]');
const second = page.locator('.sessrow[data-target="/sessions/pane-2.jsonl"]');
assert.equal(await first.getAttribute("data-live"), "", "the first row must identify an existing pane");
assert.equal(await second.getAttribute("data-live"), "", "the second row must identify another existing pane");
assert.equal(await first.getAttribute("aria-selected"), "true", "the measured pane must be the first session");
const scroller = await page.locator('.pane:not([data-off]) .scroll[data-pane="flow"]').elementHandle();
assert.ok(scroller);
await scroller.evaluate((root) => { root.scrollTop = 400; });
await page.waitForTimeout(200);
await page.locator(".sessrow").nth(1).click();
await page.waitForTimeout(500);
await page.locator(".sessrow").nth(0).click();
await page.waitForTimeout(500);
kept = await page.evaluate(() => document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop);
assert.equal(await scroller.evaluate((root) => root.scrollTop), 400, "the initial reading position must be reachable");
if (SHOTS) await page.screenshot({ path: `${SHOTS}/before-hidden.png` });
await second.click();
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.ok(await scroller.evaluate((root) => root.closest(".pane").hasAttribute("data-off")), "the original pane must actually be hidden");
assert.equal(await page.locator(".pane").count(), n, "switching existing conversations must not open a new pane");
await page.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "text", text: "Background scroll preservation probe." });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
});
await page.waitForFunction(() => document.querySelector(".pane:not([data-off]) .flow")?.textContent.includes("Background scroll preservation probe."));
if (SHOTS) await page.screenshot({ path: `${SHOTS}/while-hidden.png` });
await first.click();
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-1.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.ok(await scroller.evaluate((root) => root === document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]')), "scroll-retention precondition: inspect the originally hidden transcript");
kept = await scroller.evaluate((root) => root.scrollTop);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-return.png` });
await second.click();
await page.waitForFunction(() => {
if (document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") !== "true") return false;
const root = document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]');
return root && root.scrollHeight - root.clientHeight - root.scrollTop <= 48;
});
console.log(`existing pane round trip: scrollTop 400 -> hidden with new output -> ${kept}; following neighbour remains at latest`);
}
await ctx.close();
}
+2 -2
View File
@@ -73,7 +73,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
await page.goto(`${PAGE}?queue=2&queuebody`, { waitUntil: "networkidle" });
await page.waitForSelector(".queue .qi", { timeout: 15000 });
await page.waitForTimeout(600);
await page.locator('.queue .qi').first().locator('button:has-text("改")').click();
await page.locator('.queue .qi').first().getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(600);
const q = await page.evaluate(() => {
const el = document.querySelector(".queue .qedit");
@@ -92,7 +92,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
await page.goto(`${PAGE}?queue=4&queuebody`, { waitUntil: "networkidle" });
await page.waitForSelector(".queue .qi", { timeout: 15000 });
await page.waitForTimeout(600);
await page.locator(".queue .qi").nth(1).locator('button:has-text("改")').click();
await page.locator(".queue .qi").nth(1).getByRole("button", { name: "改", exact: true }).click();
await page.waitForTimeout(600);
if (fill) { await page.locator(".queue .qedit").fill(fill); await page.waitForTimeout(400); }
const g = await page.evaluate(() => {
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const before = process.env.RECENT_BEFORE_URL;
const shots = process.env.RECENT_SHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
const errors = [];
async function settle(page, selector) {
await page.locator(selector).evaluateAll((elements) => Promise.all(elements.flatMap((element) => {
element.getBoundingClientRect();
return element.getAnimations().map((animation) => animation.finished.catch(() => {}));
})));
}
async function showRail(page) {
if (await page.locator(".rail[inert]").count()) {
await page.locator('[data-action="chrome.rail"]:not(.railveil)').filter({ visible: true }).first().click();
}
await page.locator(".rail:not([inert]) .projects-header").waitFor();
await settle(page, ".app, .rail");
}
try {
for (const scheme of ["light", "dark"]) {
for (const [size, width] of [["wide", 1440], ["phone", 390]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
const tag = `${scheme}-${size}`;
if (before && shots) {
await page.goto(`${before}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
await page.screenshot({ path: `${shots}/before-${tag}.png` });
}
await page.goto(`${base}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
if (shots) await page.screenshot({ path: `${shots}/after-heading-${tag}.png` });
await page.locator('.wsrow[data-target="~/projects/workspace-1"]').click();
await page.locator('.studio-session-segments [data-value="live"]').click();
const recent = page.getByRole("button", { name: "Recent sessions", exact: true });
const hit = await recent.evaluate((element) => {
const r = element.getBoundingClientRect();
return r.left >= 0 && r.right <= innerWidth && element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
});
assert.ok(hit, `${tag}: Recent is reachable in the project heading`);
await recent.click();
const dialog = page.getByRole("dialog", { name: "Recent sessions", exact: true });
await dialog.locator(".recent-session").first().waitFor();
await settle(page, ".pickveil");
assert.equal(await dialog.evaluate((element) => getComputedStyle(element).opacity), "1", "the dialog is actually painted");
assert.equal(await dialog.locator(".recent-session").count(), 50);
const bounds = await dialog.locator(".pickcard").boundingBox();
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width && bounds.y >= 0 && bounds.y + bounds.height <= 900, `${tag}: dialog stays inside the viewport`);
if (shots) await page.screenshot({ path: `${shots}/after-recent-${tag}.png` });
for (let i = 1; i <= 6; i++) {
await dialog.getByRole("button", { name: "Next page", exact: true }).click();
await dialog.getByText(`Page ${i + 1}`, { exact: true }).waitFor();
await dialog.locator('.picklist[aria-busy="false"] .recent-session').first().waitFor();
}
assert.equal(await dialog.locator(".recent-session").count(), 50, "history past 300 remains paged rather than hidden");
const selected = dialog.locator('.recent-session[data-target="/sessions/w1-s150.jsonl"]');
await selected.click();
await dialog.waitFor({ state: "detached" });
const located = page.locator('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]');
await located.waitFor();
await page.waitForFunction(() => document.querySelector('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]')?.getAttribute("aria-selected") === "true");
assert.equal(await page.locator('.studio-session-segments [data-value="all"]').getAttribute("aria-pressed"), "true");
assert.equal(await page.locator('.wsrow[data-target="~/projects/workspace-1"]').getAttribute("aria-expanded"), "true");
if (size === "wide") {
assert.ok(await located.evaluate((element) => element === document.activeElement), `${tag}: selection focuses the session in its project`);
} else {
assert.equal(await page.locator(".app").getAttribute("data-rail"), "off", "phone navigation reveals the conversation");
await showRail(page);
assert.ok(await located.isVisible(), "reopening the phone rail reveals the selected session in its expanded project");
const selectedBounds = await located.boundingBox();
assert.ok(selectedBounds && selectedBounds.x >= 0 && selectedBounds.x + selectedBounds.width <= width, "the selected phone row is on screen, not covered by a closing rail");
}
if (shots) await page.screenshot({ path: `${shots}/after-located-${tag}.png` });
console.log(`${tag}: heading hit target, 50-row global pages beyond 300, viewport containment, finished-session project location`);
await page.close();
}
}
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(`${base}?pref=en&turns=2`, { waitUntil: "networkidle" });
await showRail(page);
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
await page.locator('.studio-session-segments [data-value="live"]').click();
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
await page.evaluate(() => window.__feed({ kind: "turn_done" }));
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor({ state: "detached" });
await page.getByRole("button", { name: "Recent sessions", exact: true }).click();
await page.locator('.recent-session[data-target="/sessions/mock.jsonl"]').click();
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
console.log("running -> finished -> Recent -> existing pane: passed");
await page.close();
assert.deepEqual(errors, [], "no page errors");
} finally {
await browser.close();
}
+22 -2
View File
@@ -36,8 +36,9 @@ try {
<time class="sessmeta sessage">9 天前</time>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sesscopy">
<div class="sessrow sesscopy" tabindex="0">
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
<button class="wsdel" data-action="session.delete" aria-label="删除该会话"><svg viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" /></svg></button>
</div>
</div>
</div>`);
@@ -96,7 +97,26 @@ try {
assert.ok(contained, "quick actions fit inside the compact row");
}
}
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
// An earlier version has no row menu: its × is the only way a pointer deletes it.
for (const how of ["hover", "focus"]) {
await page.mouse.move(width + 200, 400);
if (how === "hover") await page.locator(".sesscopy").hover();
else await page.locator(".sesscopy").focus();
await page.waitForFunction(() => getComputedStyle(document.querySelector(".sesscopy .wsdel")).opacity === "1", null, { timeout: 1000 }).catch(() => {});
const del = await page.evaluate(() => {
const row = document.querySelector(".sesscopy");
const button = row.querySelector(".wsdel");
const box = button.getBoundingClientRect();
const edge = row.getBoundingClientRect();
return {
shown: box.width > 0 && box.height > 0 && getComputedStyle(button).opacity === "1",
inside: box.top >= edge.top && box.bottom <= edge.bottom && box.left >= edge.left && box.right <= edge.right,
};
});
assert.ok(del.shown, `${scheme}/${width}/${how}: an earlier version shows its delete button`);
assert.ok(del.inside, `${scheme}/${width}/${how}: the delete button sits inside its row`);
}
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable, an earlier version's × shows`);
await page.close();
}
}
@@ -0,0 +1,44 @@
// The settings menu is short enough to show whole on a 1080p window, and each of its icons is drawn centred in
// its box, so none of them sits above the label it belongs to.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=1&turns=1";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1920, height: 1032 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav");
await page.waitForTimeout(600);
const nav = await page.evaluate(() => {
const el = document.querySelector(".prefs-nav");
return { need: el.scrollHeight, have: el.clientHeight };
});
check(`${scheme}: the whole menu shows without a scrollbar`, nav.need <= nav.have, `${nav.need}px needed, ${nav.have}px shown`);
const icons = await page.evaluate(() =>
[...document.querySelectorAll('.prefs-nav button[data-action="settings.section"] svg')].map((svg) => {
const box = svg.viewBox.baseVal;
const bb = svg.getBBox();
return { name: svg.closest("button").textContent.trim().slice(0, 6), off: bb.y + bb.height / 2 - (box.y + box.height / 2) };
}),
);
const worst = icons.reduce((a, b) => (Math.abs(b.off) > Math.abs(a.off) ? b : a), { name: "", off: 0 });
check(`${scheme}: every icon is centred in its box vertically`, icons.length > 0 && Math.abs(worst.off) <= 0.8, `${icons.length} icons, worst ${worst.name} ${worst.off.toFixed(2)} units`);
await ctx.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shots = process.env.SURFACE_SCREENSHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
const errors = [];
const surfaces = ["nav", "rail", "chrome", "side", "compose"];
const opacity = {
dark: { nav: 0.45, rail: 0.46, chrome: 0.8, side: 0.81, compose: 0.88 },
light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 },
};
try {
for (const scheme of ["light", "dark"]) for (const width of [1600, 390]) {
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 1000 }, reducedMotion: "reduce" });
await context.addInitScript((scheme) => localStorage.setItem("rx-theme", scheme), scheme);
const page = await context.newPage();
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/themes/bench-photo/background", (route) => route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="1600" height="1000" fill="#638c99"/><circle cx="1100" cy="400" r="400" fill="#ddbf88"/></svg>' }));
await page.goto(`${base}?pack=photo&ws=1&sess=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({ content: "* { transition: none !important; animation: none !important; caret-color: transparent !important }" });
// Details no longer mount .side in the dock layout; phone omits .nav.
// Exercise their actual stylesheet selectors without changing visible UI.
await page.evaluate((surfaces) => {
for (const name of surfaces) if (!document.querySelector(`.${name}`)) {
const probe = document.createElement("aside");
probe.className = name;
probe.style.cssText = "position:fixed;left:-9999px;width:10px;height:10px";
document.body.append(probe);
}
}, surfaces);
const styles = () => page.evaluate((surfaces) => Object.fromEntries(surfaces.map((name) => {
const style = getComputedStyle(document.querySelector(`.${name}`));
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d");
ctx.fillStyle = style.backgroundColor;
ctx.fillRect(0, 0, 1, 1);
return [name, { alpha: ctx.getImageData(0, 0, 1, 1).data[3] / 255, background: style.backgroundColor, blur: style.backdropFilter, shadow: style.textShadow }];
})), surfaces);
const paint = async (surfaceOpacity, backdrop = "photo") => page.evaluate(async ({ surfaceOpacity, backdrop, scheme }) => {
const { apply } = await import("/src/ui/theme.ts");
const tokens = {
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
};
apply({ id: "bench-photo", name: "Surface fixture", tokens, background: { image: backdrop === "photo", focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4, surfaceOpacity }, ...(backdrop === "sky" ? { sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } } : {}) }, scheme);
}, { surfaceOpacity, backdrop, scheme });
const baseline = await styles();
await paint({});
assert.deepEqual(await styles(), baseline, "omission keeps every computed paint exactly");
const empty = await page.screenshot({ animations: "disabled" });
await paint(undefined);
assert.deepEqual(await page.screenshot({ animations: "disabled" }), empty, "omission keeps pixels exactly");
await paint(opacity);
const custom = await styles();
for (const name of surfaces) {
assert.ok(Math.abs(custom[name].alpha - opacity[scheme][name]) <= 1 / 255, `${scheme} ${name}: ${custom[name].background}`);
assert.equal(custom[name].blur, baseline[name].blur);
assert.equal(custom[name].shadow, baseline[name].shadow);
}
if (shots) await page.screenshot({ path: `${shots}/${scheme}-${width}-after.png`, animations: "disabled" });
await paint(opacity, "sky");
for (const [name, value] of Object.entries(await styles())) assert.ok(Math.abs(value.alpha - opacity[scheme][name]) <= 1 / 255, `sky ${name}`);
await paint(opacity, "none");
const withoutBackdrop = await styles();
await paint(undefined, "none");
assert.deepEqual(await styles(), withoutBackdrop, "data-bg off ignores surface map");
await paint({ [scheme]: { nav: 0, compose: 1 } });
const boundaries = await styles();
assert.equal(boundaries.nav.alpha, 0);
assert.equal(boundaries.compose.alpha, 1);
for (const name of ["rail", "chrome", "side"]) assert.equal(boundaries[name].background, baseline[name].background, `omitted ${name}`);
await paint(undefined);
assert.deepEqual(await styles(), baseline, "switching to an old pack clears custom paint");
await page.evaluate(async () => (await import("/src/ui/theme.ts")).apply(null, "light"));
assert.equal(await page.locator("#pack-theme").count(), 0);
assert.equal(await page.evaluate(() => document.documentElement.dataset.bg), undefined);
console.log(`ok ${scheme} ${width}: five computed alphas; exact omitted pixels; sky, backdrop removal, boundaries and pack clearing`);
await context.close();
}
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
@@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import CodeEditor from "../src/ui/CodeEditor";
// A real editor on a given text, mounted into a given element, for the guard
// that reads what a pack's syntax colours do to it.
export function mount(host: HTMLElement, path: string, value: string, dark: boolean) {
createRoot(host).render(<CodeEditor path={path} value={value} dark={dark} onChange={() => {}} onSave={() => {}} readOnly />);
}
@@ -0,0 +1,102 @@
// A pack's nine syntax colours must reach both places code is drawn — the file
// editor and a rendered code block — and a pack that names none must leave both
// exactly as they are. Only the browser resolves the variables, so only the
// browser can say so.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const NAMES = ["keyword", "string", "number", "function", "comment", "type", "operator", "punctuation", "variable"];
const TOKEN = { keyword: "synKeyword", string: "synString", number: "synNumber", function: "synFunction", comment: "synComment", type: "synType", operator: "synOperator", punctuation: "synPunctuation", variable: "synVariable" };
const HEX = ["#C01010", "#10A010", "#1010C0", "#C0A010", "#10A0A0", "#A010A0", "#808010", "#108060", "#6040C0"];
const SAMPLE = 'class Box {}\n// note\nfunction add(a) { return a + 1 }\nconst s = "x"; run(3);\n';
const HLJS = {
keyword: "hljs-keyword", string: "hljs-string", number: "hljs-number", function: "hljs-title", comment: "hljs-comment",
type: "hljs-type", operator: "hljs-operator", punctuation: "hljs-punctuation", variable: "hljs-variable",
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, colorScheme: scheme });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const out = await page.evaluate(async ({ scheme, sample, hljs, names, token, hex }) => {
const theme = window.__syntax;
const { mount } = theme;
const host = document.createElement("div");
host.style.cssText = "position:fixed;left:0;top:0;width:700px;height:260px;z-index:2147483647";
document.body.appendChild(host);
mount(host, "probe.js", sample, scheme === "dark");
const block = document.createElement("div");
block.className = "md";
block.style.cssText = "position:fixed;left:0;top:300px;width:700px;z-index:2147483647";
block.innerHTML = `<pre class="term"><code>${names.map((n) => `<span class="${hljs[n]}" data-k="${n}">${n}</span>`).join(" ")}</code></pre>`;
document.body.appendChild(block);
for (let i = 0; i < 100 && !host.querySelector(".cm-line span"); i++) await new Promise((r) => setTimeout(r, 100));
const rgb = (el) => getComputedStyle(el).color;
const spanOf = (text, nth = 0) => [...host.querySelectorAll(".cm-line span")].filter((s) => s.textContent === text)[nth];
const probe = {
keyword: spanOf("class"), string: spanOf('"x"'), number: spanOf("3"), function: spanOf("run"), comment: spanOf("// note"),
type: spanOf("Box"), operator: spanOf("+"), punctuation: spanOf("{"), variable: spanOf("a", 1) ?? spanOf("a"),
};
const read = () => ({
editor: Object.fromEntries(names.map((n) => [n, probe[n] ? rgb(probe[n]) : null])),
block: Object.fromEntries(names.map((n) => [n, rgb(block.querySelector(`[data-k="${n}"]`))])),
inherited: rgb(block.querySelector("code")),
});
const tokens = (all) => Object.fromEntries(names.filter((n) => all || ["keyword", "string", "number", "function"].includes(n)).map((n) => [token[n], hex[names.indexOf(n)]]));
const base = { bg: "#FFFFFF", bgSoft: "#F4F4F4", fg: "#1A1A1A", fgDim: "#5A5A5A", fgFaint: "#767676", accent: "#0066CC" };
const pack = (all) => ({ id: "syn", name: "Syn", tokens: { light: { ...base, ...tokens(all) }, dark: { ...base, ...tokens(all) } } });
const root = getComputedStyle(document.documentElement);
const answer = {};
theme.apply(null, scheme);
answer.plain = read();
answer.defaults = { faint: "", text: "", muted: "", fn: "" };
const swatch = document.createElement("i");
document.body.appendChild(swatch);
for (const [k, v] of [["faint", "--faint"], ["text", "--text"], ["muted", "--muted"], ["fn", "--syn-fn"]]) { swatch.style.color = `var(${v})`; answer.defaults[k] = getComputedStyle(swatch).color; }
theme.apply(pack(false), scheme);
answer.four = read();
answer.fourDefaults = {};
swatch.style.color = "var(--syn-fn)"; answer.fourDefaults.fn = getComputedStyle(swatch).color;
swatch.style.color = "var(--text)"; answer.fourDefaults.text = getComputedStyle(swatch).color;
swatch.style.color = "var(--muted)"; answer.fourDefaults.muted = getComputedStyle(swatch).color;
swatch.style.color = "var(--faint)"; answer.fourDefaults.faint = getComputedStyle(swatch).color;
theme.apply(pack(true), scheme);
answer.nine = read();
theme.apply(null, scheme);
void root;
return answer;
}, { scheme, sample: SAMPLE, hljs: HLJS, names: NAMES, token: TOKEN, hex: HEX });
const asRgb = (h) => { const n = parseInt(h.slice(1), 16); return `rgb(${n >> 16}, ${(n >> 8) & 255}, ${n & 255})`; };
for (const surface of ["editor", "block"]) {
const seen = NAMES.map((n) => out.nine[surface][n]);
check(`${scheme} ${surface}: nine syntax tokens give nine distinct colours`, new Set(seen).size === 9, seen.join(" "));
for (const n of NAMES) check(`${scheme} ${surface}: ${n} takes the pack's colour`, out.nine[surface][n] === asRgb(HEX[NAMES.indexOf(n)]), `${out.nine[surface][n]} wanted ${asRgb(HEX[NAMES.indexOf(n)])}`);
}
// A pack that names only the four old tokens leaves each new one on what that surface drew before.
const d = out.fourDefaults;
check(`${scheme} editor: comment keeps the faint ink`, out.four.editor.comment === d.faint);
check(`${scheme} editor: type keeps the function colour`, out.four.editor.type === d.fn);
for (const n of ["operator", "punctuation", "variable"]) check(`${scheme} editor: ${n} keeps the text ink`, out.four.editor[n] === d.text, `${out.four.editor[n]} vs ${d.text}`);
check(`${scheme} block: comment keeps the faint ink`, out.four.block.comment === d.faint);
check(`${scheme} block: type keeps the function colour`, out.four.block.type === d.fn);
for (const n of ["operator", "punctuation"]) check(`${scheme} block: ${n} keeps the muted ink`, out.four.block[n] === d.muted, `${out.four.block[n]} vs ${d.muted}`);
check(`${scheme} block: a variable keeps the ink it inherits`, out.four.block.variable === out.four.inherited, `${out.four.block.variable} vs ${out.four.inherited}`);
await page.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,59 @@
// A pane tab's close button has to stay inside its own tab whatever the tab
// carries: past the tab's edge it sits on the next tab, and a press closes
// nothing. The folder and host labels share the row with the title, so a long
// one is what pushes it out. Only the browser lays this out.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const LONG = "一个很长很长很长很长很长很长很长很长很长的会话标题";
const cases = [
["a long title", { title: LONG }],
["a long title in a long folder", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace" }],
["a long folder and a long host", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace", host: "build-runner-eu-west-long-hostname" }],
];
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1200, height: 760 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
for (const [label, tab] of cases) {
const got = await page.evaluate(({ title, ws, host }) => {
document.querySelector(".probe-tabs")?.remove();
const bar = document.createElement("div");
bar.className = "panetabs probe-tabs";
bar.style.cssText = "position:fixed;left:0;top:0;right:0;z-index:2147483647";
const one = (t) => `<div class="ptab" aria-selected="${t === title}"><i class="pip"></i><span class="ptab-nm">${t}</span>`
+ (ws ? `<span class="ptab-ws">${ws}</span>` : "") + (host ? `<span class="ptab-host">${host}</span>` : "")
+ `<button class="ptab-x">×</button></div>`;
bar.innerHTML = one(title) + one("hi");
document.body.appendChild(bar);
const first = bar.querySelector(".ptab");
const box = first.getBoundingClientRect();
const x = first.querySelector(".ptab-x").getBoundingClientRect();
const hit = document.elementFromPoint(x.left + x.width / 2, x.top + x.height / 2);
return {
inside: x.left >= box.left - 0.5 && x.right <= box.right + 0.5,
reached: hit === first.querySelector(".ptab-x"),
title: Math.round(first.querySelector(".ptab-nm").getBoundingClientRect().width),
detail: `tab ${Math.round(box.left)}–${Math.round(box.right)}, close ${Math.round(x.left)}–${Math.round(x.right)}`,
};
}, tab);
check(`${label}: the close button stays inside its tab`, got.inside, got.detail);
check(`${label}: a press on the close button reaches it`, got.reached);
check(`${label}: the title keeps room to be read`, got.title >= 48, `${got.title}px`);
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.THEME_SCHEDULE_SHOTS;
if (SHOTS) await mkdir(SHOTS, { recursive: true });
const browser = await chromium.launch();
const errors = [];
try {
for (const width of [1440, 390]) {
const page = await browser.newPage({ viewport: { width, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
await page.clock.install({ time: new Date("2026-10-29T19:58:00-04:00") });
await page.goto(`${BASE}?pref=en&turns=0`);
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /Appearance/ }).click();
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
const start = page.getByLabel("Dark mode starts", { exact: true });
const end = page.getByLabel("Light mode resumes", { exact: true });
assert.equal(await start.inputValue(), "20:00");
assert.equal(await end.inputValue(), "07:00");
const boxes = await page.locator('#set-mode input[type="time"]').evaluateAll((inputs) => inputs.map((input) => {
const box = input.getBoundingClientRect();
return { left: box.left, right: box.right, width: box.width };
}));
assert.ok(boxes.every((box) => box.width > 0 && box.left >= 0 && box.right <= width));
await page.clock.pauseAt(new Date("2026-10-29T19:59:59-04:00"));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "light");
if (SHOTS) {
await page.locator("#set-mode").scrollIntoViewIfNeeded();
await page.screenshot({ path: `${SHOTS}/after-light-${width}.png`, animations: "disabled" });
}
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "dark");
await page.clock.runFor(1000);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-dark-${width}.png`, animations: "disabled" });
await page.clock.fastForward("03:59:58");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("07:00:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await start.fill("21:30");
await end.fill("06:15");
await page.clock.resume();
await page.reload();
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /Appearance/ }).click();
assert.equal(await start.inputValue(), "21:30");
assert.equal(await end.inputValue(), "06:15");
assert.equal(await page.locator('[data-value="scheduled"]').getAttribute("aria-pressed"), "true");
await page.clock.setSystemTime(new Date("2026-10-30T23:00:00-04:00"));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.setSystemTime(new Date("2026-10-31T09:00:00-04:00"));
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.locator('[data-action="appearance.scheme"][data-value="dark"]').click();
assert.equal(await start.count(), 0);
await page.emulateMedia({ colorScheme: "light" });
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.locator('[data-action="appearance.scheme"][data-value="auto"]').click();
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.emulateMedia({ colorScheme: "dark" });
await page.waitForFunction(() => document.documentElement.dataset.theme === "dark");
console.log(`ok ${width}px: exact boundaries, midnight, saved controls/reload, focus/visibility resume, explicit/system modes`);
await page.close();
}
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
page.on("pageerror", (error) => errors.push(error.message));
await page.clock.install({ time: new Date("2026-11-01T00:59:00-04:00") });
await page.goto(`${BASE}?pref=zh&turns=0&pack=sky`);
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.getByRole("tab", { name: /外观/ }).click();
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
await page.getByLabel("深色开始时间", { exact: true }).fill("01:30");
await page.getByLabel("恢复浅色时间", { exact: true }).fill("01:45");
await page.clock.pauseAt(new Date("2026-11-01T01:29:59-04:00"));
await page.clock.runFor(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
assert.equal(await page.locator("html").getAttribute("data-pack"), "bench-sky");
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#080D10");
await page.clock.fastForward("15:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.clock.fastForward("45:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("15:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#F2F6F8");
await page.getByLabel("深色开始时间", { exact: true }).fill("02:30");
await page.getByLabel("恢复浅色时间", { exact: true }).fill("04:00");
await page.clock.setSystemTime(new Date("2026-03-08T01:59:59-05:00"));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
await page.clock.fastForward(1000);
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.clock.fastForward("01:00:00");
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
console.log("ok Chinese controls, active pack repaint, skipped and repeated DST hour transitions");
await page.close();
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
+84 -12
View File
@@ -1,21 +1,31 @@
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
// as the default palette stepped once, and as the recorded floors below (ratios
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
// Built-in soft deliberately lowers contrast without crossing AA, including
// small ghost text. Unset and the higher tiers retain their recorded palettes.
// Custom packs keep authored inks for soft; see TOKENS.md for that boundary.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
const INKS = Object.keys(STEPS);
const GROUNDS = ["--page", "--surface"];
const GROUNDS = ["--page", "--surface", "--raised", "--overlay", "--float", "--float-hi"];
const TIERS = ["soft", "normal", "strong"];
const FLOOR = {
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
};
const SNAPSHOT = {
light: {
"": ["#292b29", "#5e625c", "#666a63"],
normal: ["oklch(.245 .0218 85)", "oklch(.34 .0196 85)", "oklch(.405 .0196 85)"],
strong: ["oklch(.103 .0196 85)", "oklch(.26 .0184 85)", "oklch(.335 .0184 85)"],
},
dark: {
"": ["#e7e8e3", "#b4b7b0", "#8e9289"],
normal: ["oklch(.9588 .006756 115.3)", "oklch(.81 .01031 125.5)", "oklch(.728 .013 255)"],
strong: ["oklch(1 .006756 115.3)", "oklch(.893 .011 255)", "oklch(.806 .012 255)"],
},
};
const fails = [];
const check = (name, ok, detail = "") => {
@@ -29,7 +39,7 @@ page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS, SNAPSHOT }) => {
const root = document.documentElement;
const probe = document.createElement("div");
document.body.append(probe);
@@ -61,6 +71,15 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
root.dataset.theme = scheme;
delete root.dataset.contrast;
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
for (const [tier, expected] of Object.entries(SNAPSHOT[scheme])) {
if (tier) root.dataset.contrast = tier;
else delete root.dataset.contrast;
for (const [i, ink] of INKS.entries()) {
probe.style.color = expected[Math.min(i, 2)];
const ref = rgb(getComputedStyle(probe).color);
out[`${scheme}/${tier || "unset"}/${ink}/snapshot`] = { got: rgb(read(ink)).join(","), ref: ref.join(",") };
}
}
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
for (const tier of TIERS) {
const k = TIERS.indexOf(tier);
@@ -70,21 +89,74 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
const ref = rgb(getComputedStyle(probe).color);
const got = rgb(read(ink));
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), base: ratio(rgb(base[ink]), grounds[g]), got: ratio(got, grounds[g]) };
}
}
}
probe.remove();
return out;
}, { STEPS, INKS, GROUNDS, TIERS });
}, { STEPS, INKS, GROUNDS, TIERS, SNAPSHOT });
for (const [key, { ref, got }] of Object.entries(measured)) {
for (const [key, { ref, base, got }] of Object.entries(measured)) {
const [scheme, tier, ink, ground] = key.split("/");
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
if (ground === "snapshot") {
check(`${key} stays unchanged`, got === ref, `${got} vs ${ref}`);
continue;
}
if (tier === "soft") {
const lowers = ink === "--text" || ink === "--muted";
check(`${key} ${lowers ? "is genuinely softer than" : "retains the AA floor of"} unset`, lowers ? got < base - 0.1 : Math.abs(got - base) < 0.005, `${got.toFixed(2)} vs ${base.toFixed(2)}`);
} else {
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
}
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
}
const explicit = await page.evaluate((INKS) => {
const root = document.documentElement;
const p = document.createElement("div");
document.body.append(p);
const read = () => INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
const out = {};
for (const scheme of ["light", "dark"]) {
root.dataset.theme = scheme;
for (const tier of ["soft", "normal", "strong"]) {
root.dataset.contrast = tier;
out[`${scheme}/${tier}`] = read();
}
}
p.remove();
return out;
}, INKS);
for (const system of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: system, contrast: "more" });
for (const [key, expected] of Object.entries(explicit)) {
const got = await page.evaluate(({ key, INKS }) => {
const [scheme, tier] = key.split("/");
document.documentElement.dataset.theme = scheme;
document.documentElement.dataset.contrast = tier;
const p = document.createElement("div");
document.body.append(p);
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
p.remove();
return out;
}, { key, INKS });
check(`${key} outranks system ${system} and prefers-contrast: more`, JSON.stringify(got) === JSON.stringify(expected));
}
}
await page.emulateMedia({ colorScheme: "dark", contrast: "no-preference" });
const automaticDark = await page.evaluate((INKS) => {
delete document.documentElement.dataset.theme;
document.documentElement.dataset.contrast = "soft";
const p = document.createElement("div");
document.body.append(p);
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
p.remove();
return out;
}, INKS);
check("system dark soft matches explicit dark soft", JSON.stringify(automaticDark) === JSON.stringify(explicit["dark/soft"]));
for (const scheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
const same = await page.evaluate((scheme) => {
@@ -23,6 +23,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
await page.evaluate(() => document.getElementById("prefs-tools-web")?.click());
await page.waitForSelector("#set-web-access");
const r = await page.evaluate(() => {
const g = document.querySelector("#set-web-access");
@@ -0,0 +1,119 @@
import { mkdirSync } from "node:fs";
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.SHOTS_DIR;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
if (!ok) fails.push(name);
};
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
const measure = (page) => page.locator(".workbench-save").evaluate((button) => {
const style = getComputedStyle(button);
const bar = button.parentElement;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const rgba = (color) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
return [...ctx.getImageData(0, 0, 1, 1).data].map((v, i) => i === 3 ? v / 255 : v);
};
const over = (front, back) => front.slice(0, 3).map((v, i) => v * front[3] + back[i] * (1 - front[3]));
const ancestors = [];
for (let node = button.parentElement; node; node = node.parentElement) ancestors.unshift(node);
let ground = [255, 255, 255];
for (const node of ancestors) ground = over(rgba(getComputedStyle(node).backgroundColor), ground);
const opacity = Number(style.opacity);
const fill = over(rgba(style.backgroundColor), ground);
const ink = over(rgba(style.color), fill);
const lum = (rgb) => rgb.map((v) => {
v /= 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
const [hi, lo] = [lum(over([...ink, opacity], ground)), lum(over([...fill, opacity], ground))].sort((a, b) => b - a);
const text = [...button.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
const range = document.createRange();
range.selectNodeContents(text);
const width = button.getBoundingClientRect().width;
const natural = range.getBoundingClientRect().width + button.querySelector("svg").getBoundingClientRect().width
+ parseFloat(style.columnGap) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
+ parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
const rect = bar.getBoundingClientRect();
const children = [...bar.children].map((node) => node.getBoundingClientRect());
return {
width, natural, contrast: (hi + 0.05) / (lo + 0.05), disabled: button.disabled,
singleRow: Math.max(...children.map((r) => r.top)) < Math.min(...children.map((r) => r.bottom)),
contained: children.every((r) => r.left >= rect.left - 1 && r.right <= rect.right + 1),
};
});
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
for (const locale of ["zh", "en"]) {
for (const width of [1440, 390]) {
for (const desktop of [true, false]) {
const page = await browser.newPage({ viewport: { width, height: 1000 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(15000);
page.on("pageerror", (error) => fails.push(error.message));
// Native capabilities render both optional toolbar controls; no native action is invoked.
if (desktop) await page.addInitScript(() => {
window.reasonixHost = {
shell: "electron", platform: "win32", titleBar: false,
isWindowMaximised: async () => false, setUnreadBadge: async () => {},
revealPath: async () => null, revealWorkspace: async () => null,
};
});
const url = new URL(BASE);
url.searchParams.set("pref", locale);
url.searchParams.set("ws", "2");
url.searchParams.set("sess", "6");
url.searchParams.set("turns", "2");
await page.goto(url.href, { waitUntil: "networkidle" });
const workbench = page.locator('[data-action="pane.view"][data-value="browser"]');
if (!(await workbench.isVisible())) await page.locator('[data-action="chrome.more"]').click();
await workbench.click();
await page.locator('[data-action="workbench.launch-files"]').click();
await page.locator(".workbench-filelist button").filter({ hasText: "README.md" }).click();
await page.locator(".workbench-save").waitFor();
await page.evaluate(() => document.fonts.ready);
const tag = `${scheme}/${locale}/${width}/${desktop ? "desktop" : "browser"}`;
const verify = async (state) => {
await page.locator(".workbench-save").evaluate(async (button) => {
await Promise.all(button.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
const result = await measure(page);
check(`${tag}/${state}: content-sized Save`, result.width <= result.natural + 1, `${result.width.toFixed(1)}px vs ${result.natural.toFixed(1)}px content`);
check(`${tag}/${state}: controls share one row`, result.singleRow);
check(`${tag}/${state}: controls stay inside toolbar`, result.contained);
check(`${tag}/${state}: readable label`, result.contrast >= 4.5, `${result.contrast.toFixed(2)}:1`);
check(`${tag}/${state}: native disabled state`, result.disabled === (state !== "modified"));
if (SHOTS && desktop && locale === "zh") await page.screenshot({ path: `${SHOTS}/${scheme}-${width}-${state}.png` });
};
await verify("unchanged");
await page.locator('[data-action="workbench.mode"][data-value="file"]').click();
const editor = page.locator(".cm-content");
await editor.fill("# Save button regression\n\nChanged in the editor.");
await page.locator(".workbench-save:not(:disabled)").waitFor();
await verify("modified");
await page.locator(".workbench-save").click();
await page.locator(".workbench-save:disabled").waitFor();
await verify("saved");
await page.locator('[data-action="workbench.mode"][data-value="read"]').click();
check(`${tag}: saved content remains readable`, await page.locator(".workbench-read h1").textContent() === "Save button regression");
await page.close();
}
}
}
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`${fails.length} failures: ${fails.join("; ")}`);
process.exitCode = 1;
}
+1784 -196
View File
File diff suppressed because it is too large. Load diff
+40 -48
View File
@@ -1,6 +1,7 @@
import { MARKET_ACTIONS } from "./actions_market";
import { SCHEDULE_ACTIONS } from "./actions_schedule";
import { DOCUMENT_ACTIONS } from "./actions_document";
import { FEEDBACK_ACTIONS } from "./actions_feedback";
// The census oracle is independent of production literals so disagreements fail.
// An id names the intent; data-target and data-value name its entity and choice.
@@ -98,15 +99,23 @@ export const ACTIONS: UIAction[] = [
{ id: "chrome.open-project", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-entry-visible", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-entry-move", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-entries-reset", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.live-grace", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.legacy-sessions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.open-session-limit", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.live-stream-budget", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.office-preview", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.page-sleep", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.configure", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.prepare-cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.prepare-details", kind: "view", target: "none", proof: "interaction" },
@@ -118,13 +127,16 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-environment", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-page", kind: "view", target: "none", proof: "interaction" },
{ id: "session.recent-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.batch-delete", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.wide", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.full", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
@@ -143,9 +155,11 @@ export const ACTIONS: UIAction[] = [
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
{ id: "composer.running-message-intent", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.thinking-preview", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.account", kind: "navigation", target: "none", proof: "interaction" },
{ id: "metrics.details", kind: "view", target: "optional", proof: "interaction" },
{ id: "metrics.hide-amounts", kind: "view", target: "none", proof: "interaction" },
@@ -280,14 +294,7 @@ export const ACTIONS: UIAction[] = [
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
{ id: "sponsor.paypal", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "sponsor.qr", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
...FEEDBACK_ACTIONS,
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
@@ -298,43 +305,10 @@ export const ACTIONS: UIAction[] = [
{ id: "toast.report", kind: "view", target: "none", proof: "interaction" },
{ id: "errbar.report", kind: "view", target: "none", proof: "interaction" },
{ id: "toast.dismiss", kind: "view", target: "entity", proof: "interaction" },
{ id: "toast.act", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "model.group", kind: "view", target: "entity", proof: "interaction" },
{ id: "model.group-clear", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
@@ -397,6 +371,7 @@ export const ACTIONS: UIAction[] = [
// the row's own action, so this one never writes anything itself.
{ id: "tree.navigate", kind: "view", target: "none", proof: "interaction" },
{ id: "settings.close", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "pane.activate", kind: "navigation", target: "none", proof: "interaction" },
// How this session is being read. One id across a bar button and a menu row,
// because reaching a view from the bar and from the detail menu is one intent
@@ -486,6 +461,7 @@ export const ACTIONS: UIAction[] = [
{ id: "provider.preset", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.preset-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.preset-toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.preset-vendor", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.preset-back", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.preset-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
@@ -535,12 +511,14 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.pin", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.more", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.full-installer", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -570,12 +548,17 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.scene", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.volume", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.parallax", kind: "view", target: "none", proof: "interaction" },
{ id: "theme.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "theme.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
// Usage changes what report is read, not canonical session state.
{ id: "usage.range.today", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.start", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.end", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.month", kind: "view", target: "none", proof: "interaction" },
@@ -587,11 +570,7 @@ export const ACTIONS: UIAction[] = [
// different capability from changing its opacity, so the four sliders share
// one id and say which parameter on data-value.
//
// These stay apart on purpose. theme.activate installs a palette pack;
// appearance.scheme is light, dark or follow-the-system. wallpaper.change
// and wallpaper.remove decide which image there is; appearance.background is
// how the image that is already there is drawn. And weight and contrast are
// two different things a reader wants, however alike they look in CSS.
// A pack supplies colours; the scheme decides when its light or dark set is used.
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -608,16 +587,21 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Dragging the column's edge is a view adjustment held on this machine,
// not a setting written to the kernel: it changes what is drawn, not what
// the transcript says.
{ id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
// Kept where the window keeps them: these are this browser's, and no kernel
// state moves.
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.schedule", kind: "view", target: "entity", proof: "browser" },
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.page", kind: "navigation", target: "none", proof: "interaction" },
// Finding a setting changes what is on screen and nothing else: it reaches
// no kernel state, and the jump it offers is settings.section, because
// arriving from a search is the same arrival as arriving from the nav.
@@ -648,6 +632,9 @@ export const ACTIONS: UIAction[] = [
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.quote-selection", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.text-undo", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.text-redo", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.text-cut", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.text-copy", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.text-paste", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.text-select-all", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.copy-ref", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.menu", kind: "view", target: "entity", proof: "interaction" },
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
@@ -664,6 +651,7 @@ export const ACTIONS: UIAction[] = [
{ id: "deck.agents", kind: "view", target: "none", proof: "interaction" },
{ id: "deck.jobs", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.file", kind: "view", target: "entity", proof: "interaction" },
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The folder-trust question and the no-sandbox note beside the composer.
@@ -685,10 +673,12 @@ export const ACTIONS: UIAction[] = [
{ id: "sandbox.add-write-root", kind: "kernel-mutation", target: "optional", proof: "authority-effect" },
{ id: "sandbox.remove-write-root", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "present-files.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "size.set", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "size.keep", kind: "view", target: "none", proof: "interaction" },
{ id: "size.restore", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "tree.retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "history.more", kind: "view", target: "none", proof: "interaction" },
{ id: "update.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "update.restart", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "update.later", kind: "view", target: "none", proof: "interaction" },
@@ -772,6 +762,8 @@ export const ACTIONS: UIAction[] = [
// ── The two side panels. Reached from the keyboard here, and from each
// gutter's own grip, which this pass has not annotated yet.
{ id: "rail.star", kind: "view", target: "none", proof: "interaction" },
{ id: "rail.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
...SCHEDULE_ACTIONS,
...DOCUMENT_ACTIONS,
@@ -3,6 +3,10 @@ import type { UIAction } from "./actions";
export const DOCUMENT_ACTIONS: UIAction[] = [
{ id: "document.zoom", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.page", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.sheet", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.quote", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.select-cells", kind: "view", target: "none", proof: "interaction" },
{ id: "document.select-text", kind: "view", target: "none", proof: "interaction" },
{ id: "document.scroll", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "delivery.preview", kind: "navigation", target: "entity", proof: "interaction" },
@@ -0,0 +1,48 @@
import type { UIAction } from "./actions";
export const FEEDBACK_ACTIONS: UIAction[] = [
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.preview", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.account-section", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
];
@@ -0,0 +1,29 @@
# Vendor marks
These SVG files come from the MIT-licensed lobe-icons collection (npm package `@lobehub/icons-static-svg`, version 1.101.0, https://github.com/lobehub/lobe-icons). The licence of that collection is reproduced below.
The marks themselves remain trademarks of their respective vendors. Reasonix shows a mark only to identify the vendor of a service or model, which implies no affiliation or endorsement.
---
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-0-_R_0_)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-1-_R_0_)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-0-_R_0_" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-1-_R_0_" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-2-_R_0_" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 781 B

@@ -0,0 +1 @@
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>

After

Width:  |  Height:  |  Size: 819 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Meta</title><path d="M6.897 4h-.024l-.031 2.615h.022c1.715 0 3.046 1.357 5.94 6.246l.175.297.012.02 1.62-2.438-.012-.019a48.763 48.763 0 00-1.098-1.716 28.01 28.01 0 00-1.175-1.629C10.413 4.932 8.812 4 6.896 4z" fill="url(#lobe-icons-meta-0-_R_0_)"></path><path d="M6.873 4C4.95 4.01 3.247 5.258 2.02 7.17a4.352 4.352 0 00-.01.017l2.254 1.231.011-.017c.718-1.083 1.61-1.774 2.568-1.785h.021L6.896 4h-.023z" fill="url(#lobe-icons-meta-1-_R_0_)"></path><path d="M2.019 7.17l-.011.017C1.2 8.447.598 9.995.274 11.664l-.005.022 2.534.6.004-.022c.27-1.467.786-2.828 1.456-3.845l.011-.017L2.02 7.17z" fill="url(#lobe-icons-meta-2-_R_0_)"></path><path d="M2.807 12.264l-2.533-.6-.005.022c-.177.918-.267 1.851-.269 2.786v.023l2.598.233v-.023a12.591 12.591 0 01.21-2.44z" fill="url(#lobe-icons-meta-3-_R_0_)"></path><path d="M2.677 15.537a5.462 5.462 0 01-.079-.813v-.022L0 14.468v.024a8.89 8.89 0 00.146 1.652l2.535-.585a4.106 4.106 0 01-.004-.022z" fill="url(#lobe-icons-meta-4-_R_0_)"></path><path d="M3.27 16.89c-.284-.31-.484-.756-.589-1.328l-.004-.021-2.535.585.004.021c.192 1.01.568 1.85 1.106 2.487l.014.017 2.018-1.745a2.106 2.106 0 01-.015-.016z" fill="url(#lobe-icons-meta-5-_R_0_)"></path><path d="M10.78 9.654c-1.528 2.35-2.454 3.825-2.454 3.825-2.035 3.2-2.739 3.917-3.871 3.917a1.545 1.545 0 01-1.186-.508l-2.017 1.744.014.017C2.01 19.518 3.058 20 4.356 20c1.963 0 3.374-.928 5.884-5.33l1.766-3.13a41.283 41.283 0 00-1.227-1.886z" fill="#0082FB"></path><path d="M13.502 5.946l-.016.016c-.4.43-.786.908-1.16 1.416.378.483.768 1.024 1.175 1.63.48-.743.928-1.345 1.367-1.807l.016-.016-1.382-1.24z" fill="url(#lobe-icons-meta-6-_R_0_)"></path><path d="M20.918 5.713C19.853 4.633 18.583 4 17.225 4c-1.432 0-2.637.787-3.723 1.944l-.016.016 1.382 1.24.016-.017c.715-.747 1.408-1.12 2.176-1.12.826 0 1.6.39 2.27 1.075l.015.016 1.589-1.425-.016-.016z" fill="#0082FB"></path><path d="M23.998 14.125c-.06-3.467-1.27-6.566-3.064-8.396l-.016-.016-1.588 1.424.015.016c1.35 1.392 2.277 3.98 2.361 6.971v.023h2.292v-.022z" fill="url(#lobe-icons-meta-7-_R_0_)"></path><path d="M23.998 14.15v-.023h-2.292v.022c.004.14.006.282.006.424 0 .815-.121 1.474-.368 1.95l-.011.022 1.708 1.782.013-.02c.62-.96.946-2.293.946-3.91 0-.083 0-.165-.002-.247z" fill="url(#lobe-icons-meta-8-_R_0_)"></path><path d="M21.344 16.52l-.011.02c-.214.402-.519.67-.917.787l.778 2.462a3.493 3.493 0 00.438-.182 3.558 3.558 0 001.366-1.218l.044-.065.012-.02-1.71-1.784z" fill="url(#lobe-icons-meta-9-_R_0_)"></path><path d="M19.92 17.393c-.262 0-.492-.039-.718-.14l-.798 2.522c.449.153.927.222 1.46.222.492 0 .943-.073 1.352-.215l-.78-2.462c-.167.05-.341.075-.517.073z" fill="url(#lobe-icons-meta-10-_R_0_)"></path><path d="M18.323 16.534l-.014-.017-1.836 1.914.016.017c.637.682 1.246 1.105 1.937 1.337l.797-2.52c-.291-.125-.573-.353-.9-.731z" fill="url(#lobe-icons-meta-11-_R_0_)"></path><path d="M18.309 16.515c-.55-.642-1.232-1.712-2.303-3.44l-1.396-2.336-.011-.02-1.62 2.438.012.02.989 1.668c.959 1.61 1.74 2.774 2.493 3.585l.016.016 1.834-1.914a2.353 2.353 0 01-.014-.017z" fill="url(#lobe-icons-meta-12-_R_0_)"></path><defs><linearGradient id="lobe-icons-meta-0-_R_0_" x1="75.897%" x2="26.312%" y1="89.199%" y2="12.194%"><stop offset=".06%" stop-color="#0867DF"></stop><stop offset="45.39%" stop-color="#0668E1"></stop><stop offset="85.91%" stop-color="#0064E0"></stop></linearGradient><linearGradient id="lobe-icons-meta-1-_R_0_" x1="21.67%" x2="97.068%" y1="75.874%" y2="23.985%"><stop offset="13.23%" stop-color="#0064DF"></stop><stop offset="99.88%" stop-color="#0064E0"></stop></linearGradient><linearGradient id="lobe-icons-meta-2-_R_0_" x1="38.263%" x2="60.895%" y1="89.127%" y2="16.131%"><stop offset="1.47%" stop-color="#0072EC"></stop><stop offset="68.81%" stop-color="#0064DF"></stop></linearGradient><linearGradient id="lobe-icons-meta-3-_R_0_" x1="47.032%" x2="52.15%" y1="90.19%" y2="15.745%"><stop offset="7.31%" stop-color="#007CF6"></stop><stop offset="99.43%" stop-color="#0072EC"></stop></linearGradient><linearGradient id="lobe-icons-meta-4-_R_0_" x1="52.155%" x2="47.591%" y1="58.301%" y2="37.004%"><stop offset="7.31%" stop-color="#007FF9"></stop><stop offset="100%" stop-color="#007CF6"></stop></linearGradient><linearGradient id="lobe-icons-meta-5-_R_0_" x1="37.689%" x2="61.961%" y1="12.502%" y2="63.624%"><stop offset="7.31%" stop-color="#007FF9"></stop><stop offset="100%" stop-color="#0082FB"></stop></linearGradient><linearGradient id="lobe-icons-meta-6-_R_0_" x1="34.808%" x2="62.313%" y1="68.859%" y2="23.174%"><stop offset="27.99%" stop-color="#007FF8"></stop><stop offset="91.41%" stop-color="#0082FB"></stop></linearGradient><linearGradient id="lobe-icons-meta-7-_R_0_" x1="43.762%" x2="57.602%" y1="6.235%" y2="98.514%"><stop offset="0%" stop-color="#0082FB"></stop><stop offset="99.95%" stop-color="#0081FA"></stop></linearGradient><linearGradient id="lobe-icons-meta-8-_R_Line truncated

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>

After

Width:  |  Height:  |  Size: 655 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Loaded 100 of 1607 files, more files were not shown because too many files have changed in this diff. Show more