Compare commits

..
Author SHA1 Message Date
esengine 4effa7a785 feat(studio): files in the changes list get the file tree's right-click menu 2026-10-28 23:21:22 -07: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
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
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
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 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
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
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
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
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
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
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
imcreazy 602c9a3e1d fix(studio): closing the last page offers a new tab 2026-10-21 07:13:30 +08:00
1099 changed files with 58988 additions and 3884 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"
]
}
]
+744 -6
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,702 @@ 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"
- 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
@@ -315,7 +1053,7 @@ 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'
+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'
+3
View File
@@ -159,7 +159,10 @@ jobs:
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
+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
+5 -5
View File
@@ -288,11 +288,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.
+5 -5
View File
@@ -269,11 +269,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和开场视频。
+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)
}
}
+47
View File
@@ -72,6 +72,38 @@ go run ./cmd/reasonix serve # from the repository root
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
```
## 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 +111,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
+4 -2
View File
@@ -10,14 +10,16 @@
"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",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_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 +32,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
+373 -79
View File
@@ -11,24 +11,28 @@ 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;
// 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 +43,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 +87,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 +97,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 +193,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 +204,207 @@ 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,
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.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 +412,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 +438,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 +480,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 +508,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 +517,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 +530,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 +538,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 +566,9 @@ 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);
}
}
putAway(view, entry) {
@@ -310,8 +582,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":
@@ -336,6 +615,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 +719,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 +733,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 +784,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",
+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 };
+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 };
+25 -4
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");
@@ -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;
@@ -231,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),
@@ -397,12 +402,16 @@ registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, ()
followTheme();
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
void powerGuard?.refresh();
browserViews?.setSleepAfterMs(pageSleepAfterMs(storedPrefs()));
});
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) => {
@@ -548,7 +557,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);
@@ -595,6 +615,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();
+23 -6
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;
}
@@ -46,12 +54,15 @@ function savePrefs(file, prefs) {
// after these handlers exist.
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
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
@@ -62,4 +73,10 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
});
}
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 };
+3
View File
@@ -28,8 +28,11 @@ contextBridge.exposeInMainWorld("reasonixHost", {
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)),
+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.
@@ -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", "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);
+564 -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);
@@ -2197,7 +2329,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 };
@@ -2577,6 +2708,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");
@@ -2586,6 +3132,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", () => {
+9
View File
@@ -7,6 +7,15 @@ 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.
## Tests
`pnpm test` runs one worker per test file on the threads pool. Threads rather
+7 -7
View File
@@ -32,15 +32,15 @@
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.14",
"@lezer/highlight": "^1.2.5",
"@mantine/charts": "9.7.2",
"@mantine/core": "9.7.2",
"@mantine/hooks": "9.7.2",
"@mantine/charts": "9.7.3",
"@mantine/core": "9.7.3",
"@mantine/hooks": "9.7.3",
"@noble/curves": "2.4.0",
"codemirror": "^6.0.2",
"dompurify": "^3.4.17",
"katex": "^0.19.0",
"lucide-react": "^1.60.0",
"maplibre-gl": "6.14.0",
"lucide-react": "^1.61.0",
"maplibre-gl": "6.15.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"papaparse": "^5.7.0",
@@ -68,7 +68,7 @@
"jsdom": "^30.1.3",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.4",
"vitest": "^5.0.4"
"vite": "^8.3.5",
"vitest": "^5.0.5"
}
}
+2
View File
@@ -42,6 +42,7 @@ node perf/panes.mjs # 并行窗格的代价:两个会话同时流式
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/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
@@ -49,6 +50,7 @@ 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 # 双语验证:英文启动、界面译文到位、切回中文
@@ -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(", ")}`);
+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();
+1 -1
View File
@@ -37,7 +37,7 @@ try {
const one = await open("");
const tree = await one.evaluate(() => ({
folders: [...document.querySelectorAll(".workbench-change-folder")].length,
rows: [...document.querySelectorAll('.workbench-change-row[data-action="workbench.diff"]')].length,
rows: [...document.querySelectorAll('.workbench-change-row[data-action-click="workbench.diff"]')].length,
overflow: document.querySelector(".workbench-explorer").scrollWidth > document.querySelector(".workbench-explorer").clientWidth + 1,
}));
check(`${tag}: the changes are a folder tree`, tree.folders >= 2 && tree.rows === 4, JSON.stringify(tree));
+36 -3
View File
@@ -2,11 +2,12 @@ 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";
import "../src/styles/studio.css";
import "../src/styles/flowgrip.css";
import "../src/styles/changedpill.css";
import "../src/styles/askskip.css";
import "../src/styles/pastekeep.css";
@@ -54,6 +55,7 @@ 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";
@@ -103,6 +105,11 @@ function benchPack(): ThemePack | null {
class BenchPort extends MockPort {
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();
@@ -351,6 +358,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.
@@ -362,7 +383,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,
@@ -477,6 +499,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);
@@ -484,6 +507,7 @@ 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;
@@ -500,6 +524,12 @@ class BenchHub extends MockHub {
}
private readonly settingsPort = new BenchPort();
private opened = false;
async open(req: Parameters<MockHub["open"]>[0]) {
this.opened = true;
return super.open(req);
}
machinePort(): AgentPort {
return this.settingsPort;
@@ -512,7 +542,7 @@ class BenchHub extends MockHub {
}
runtimes(): Promise<RuntimeView[]> {
if (NO_PANES) return Promise.resolve([]);
if (NO_PANES && !this.opened) return Promise.resolve([]);
if (PANES <= 1) return super.runtimes();
return Promise.resolve(
Array.from({ length: PANES }, (_, i) => ({
@@ -545,6 +575,7 @@ class BenchHub extends MockHub {
}
const wait = TREE_AT - performance.now();
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
if (query.get("tree") === "none") return Promise.resolve([]);
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}`,
@@ -607,9 +638,11 @@ 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;
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
+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,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();
}
@@ -73,6 +73,46 @@ 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 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));
+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,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");
+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;
}
+142 -142
View File
@@ -69,14 +69,14 @@ importers:
specifier: ^1.2.5
version: 1.2.5
'@mantine/charts':
specifier: 9.7.2
version: 9.7.2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
specifier: 9.7.3
version: 9.7.3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
'@mantine/core':
specifier: 9.7.2
version: 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
specifier: 9.7.3
version: 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@mantine/hooks':
specifier: 9.7.2
version: 9.7.2([email protected])
specifier: 9.7.3
version: 9.7.3([email protected])
'@noble/curves':
specifier: 2.4.0
version: 2.4.0
@@ -90,11 +90,11 @@ importers:
specifier: ^0.19.0
version: 0.19.0
lucide-react:
specifier: ^1.60.0
version: 1.60.0(@types/[email protected])([email protected])
specifier: ^1.61.0
version: 1.61.0(@types/[email protected])([email protected])
maplibre-gl:
specifier: 6.14.0
version: 6.14.0
specifier: 6.15.0
version: 6.15.0
papaparse:
specifier: ^5.7.0
version: 5.7.0
@@ -109,7 +109,7 @@ importers:
version: 19.3.1([email protected])
react-map-gl:
specifier: 8.1.3
version: 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
version: 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/[email protected])([email protected])
@@ -161,7 +161,7 @@ importers:
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.2
version: 6.1.2([email protected].4(@types/[email protected])([email protected]))
version: 6.1.2([email protected].5(@types/[email protected])([email protected]))
jsdom:
specifier: ^30.1.3
version: 30.1.3(@noble/[email protected])
@@ -172,11 +172,11 @@ importers:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: ^8.3.4
version: 8.3.4(@types/[email protected])([email protected])
specifier: ^8.3.5
version: 8.3.5(@types/[email protected])([email protected])
vitest:
specifier: ^5.0.4
version: 5.0.4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected]))
specifier: ^5.0.5
version: 5.0.5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected]))
packages:
@@ -580,24 +580,24 @@ packages:
'@lit/[email protected]':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@mantine/[email protected].2':
resolution: {integrity: sha512-biwAZ4RDSmhy2dqeyqBK1XlFg3+eZGYyt3bEDuTBu/at/fbAguhIsmFTbqYjqMcTpWNyX9BNxJ+mhMFslee1eQ==}
'@mantine/[email protected].3':
resolution: {integrity: sha512-p8SMOVEmQATcHIszSYdKdU6MBq8Ozdm+QJeFz+Zrtpquv0+ZVHIb262emZV544a7UmwOGdMN5DxmhQ8lewrbvg==}
peerDependencies:
'@mantine/core': 9.7.2
'@mantine/hooks': 9.7.2
'@mantine/core': 9.7.3
'@mantine/hooks': 9.7.3
react: ^19.2.0
react-dom: ^19.2.0
recharts: '>=3.2.1'
'@mantine/[email protected].2':
resolution: {integrity: sha512-+cggJDD0PO057SwZU/dKlWPjp0LaAn1nEM9W1m/Ia4Cjb/Hy9DjZ4I7R7xirp/eZ6xnV7OqeGmPFSJ8zeGa5kQ==}
'@mantine/[email protected].3':
resolution: {integrity: sha512-wYJ9Bd82a7HqwJ+hubFqUIocBGMuw6txzdPnnleHvv1h0NlnkESevK/WHTRh4FtOw33B5N2ADT34s3x1/8d8jg==}
peerDependencies:
'@mantine/hooks': 9.7.2
'@mantine/hooks': 9.7.3
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/[email protected].2':
resolution: {integrity: sha512-11emsRp/Mg+vfaKVyoMwiMdCHmO/15XOL6JG9fACkclVZnLPBVvRIaDdRBudsR3ALGZ5iTzrArPJDjjoeO6X4w==}
'@mantine/[email protected].3':
resolution: {integrity: sha512-GMHoMS1qul1dBLIHd/N9ef2Pr8UKYYHfuM+g2ej3gYqU0kH4sTiR9FNHksoTQi0PzATRoaNlUYjjMe9rbDRw5A==}
peerDependencies:
react: ^19.2.0
@@ -620,8 +620,8 @@ packages:
'@mapbox/[email protected]':
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
'@maplibre/geojson-vt@6.1.2':
resolution: {integrity: sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==}
'@maplibre/geojson-vt@6.3.0':
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
'@maplibre/[email protected]':
resolution: {integrity: sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==}
@@ -723,8 +723,8 @@ packages:
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@oxc-project/[email protected]2.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
'@oxc-project/[email protected]5.0':
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
'@preact/[email protected]':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
@@ -740,98 +740,98 @@ packages:
react-redux:
optional: true
'@rolldown/[email protected]2':
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/[email protected]2':
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
'@rolldown/[email protected]5':
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1115,8 +1115,8 @@ packages:
oxc-transform-react:
optional: true
'@vitest/[email protected].4':
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
'@vitest/[email protected].5':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
peerDependencies:
msw: ^2.4.9 || ^3.0.0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -1126,8 +1126,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].4':
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
'@vitest/[email protected].5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -1613,8 +1613,8 @@ packages:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
[email protected]0.0:
resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==}
[email protected]1.0:
resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -1629,8 +1629,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
[email protected]4.0:
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
[email protected]5.0:
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
[email protected]:
@@ -2008,8 +2008,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
[email protected]2:
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
[email protected]5:
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -2139,8 +2139,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
[email protected].1:
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
[email protected].3:
resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==}
engines: {node: '>=22.19.0'}
[email protected]:
@@ -2211,13 +2211,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
[email protected].4:
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
[email protected].5:
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.7.1
'@vitejs/devtools': ^0.7.1 || ^0.8.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
@@ -2254,20 +2254,20 @@ packages:
yaml:
optional: true
[email protected].4:
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
[email protected].5:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.4
'@vitest/browser-preview': 5.0.4
'@vitest/browser-playwright': 5.0.5
'@vitest/browser-preview': 5.0.5
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.4
'@vitest/coverage-v8': 5.0.4
'@vitest/ui': 5.0.4
'@vitest/coverage-istanbul': 5.0.5
'@vitest/coverage-v8': 5.0.5
'@vitest/ui': 5.0.5
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -2783,18 +2783,18 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@mantine/[email protected].2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
'@mantine/[email protected].3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
dependencies:
'@mantine/core': 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@mantine/hooks': 9.7.2([email protected])
'@mantine/core': 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@mantine/hooks': 9.7.3([email protected])
react: 19.3.1
react-dom: 19.3.1([email protected])
recharts: 3.10.1(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
'@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
'@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@floating-ui/react': 0.27.20([email protected]([email protected]))([email protected])
'@mantine/hooks': 9.7.2([email protected])
'@mantine/hooks': 9.7.3([email protected])
clsx: 2.1.1
react: 19.3.1
react-dom: 19.3.1([email protected])
@@ -2804,7 +2804,7 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@mantine/[email protected].2([email protected])':
'@mantine/[email protected].3([email protected])':
dependencies:
react: 19.3.1
@@ -2824,7 +2824,7 @@ snapshots:
'@types/geojson': 7946.0.16
pbf: 5.1.2
'@maplibre/geojson-vt@6.1.2':
'@maplibre/geojson-vt@6.3.0':
dependencies:
kdbush: 4.1.0
@@ -2912,7 +2912,7 @@ snapshots:
'@noble/[email protected]': {}
'@oxc-project/[email protected]2.0': {}
'@oxc-project/[email protected]5.0': {}
'@preact/[email protected]': {}
@@ -2928,49 +2928,49 @@ snapshots:
react: 19.3.1
react-redux: 9.3.0(@types/[email protected])([email protected])([email protected])
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]2':
'@rolldown/[email protected]5':
optional: true
'@rolldown/[email protected]': {}
@@ -3152,29 +3152,29 @@ snapshots:
react: 19.3.1
react-dom: 19.3.1([email protected])
'@vis.gl/[email protected]([email protected]4.0)([email protected]([email protected]))([email protected])':
'@vis.gl/[email protected]([email protected]5.0)([email protected]([email protected]))([email protected])':
dependencies:
'@maplibre/maplibre-gl-style-spec': 19.3.3
react: 19.3.1
react-dom: 19.3.1([email protected])
optionalDependencies:
maplibre-gl: 6.14.0
maplibre-gl: 6.15.0
'@vitejs/[email protected]([email protected].4(@types/[email protected])([email protected]))':
'@vitejs/[email protected]([email protected].5(@types/[email protected])([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.4(@types/[email protected])([email protected])
vite: 8.3.5(@types/[email protected])([email protected])
'@vitest/[email protected].4([email protected].4(@types/[email protected])([email protected]))':
'@vitest/[email protected].5([email protected].5(@types/[email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.4
'@vitest/spy': 5.0.5
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
vite: 8.3.4(@types/[email protected])([email protected])
vite: 8.3.5(@types/[email protected])([email protected])
'@vitest/[email protected].4': {}
'@vitest/[email protected].5': {}
[email protected]: {}
@@ -3578,7 +3578,7 @@ snapshots:
parse5: 8.0.1
saxes: 6.0.0
tough-cookie: 6.0.2
undici: 8.12.1
undici: 8.12.3
w3c-xmlserializer: 6.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -3682,7 +3682,7 @@ snapshots:
[email protected]: {}
[email protected]0.0(@types/[email protected])([email protected]):
[email protected]1.0(@types/[email protected])([email protected]):
dependencies:
react: 19.3.1
optionalDependencies:
@@ -3694,13 +3694,13 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
[email protected]4.0:
[email protected]5.0:
dependencies:
'@mapbox/point-geometry': 1.1.0
'@mapbox/tiny-sdf': 2.3.0
'@mapbox/unitbezier': 1.0.0
'@mapbox/vector-tile': 3.0.0
'@maplibre/geojson-vt': 6.1.2
'@maplibre/geojson-vt': 6.3.0
'@maplibre/maplibre-gl-style-spec': 26.4.4
'@maplibre/mlt': 1.3.0
'@maplibre/vt-pbf': 4.3.2
@@ -4175,14 +4175,14 @@ snapshots:
[email protected]: {}
[email protected]([email protected]4.0)([email protected]([email protected]))([email protected]):
[email protected]([email protected]5.0)([email protected]([email protected]))([email protected]):
dependencies:
'@vis.gl/react-mapbox': 8.1.3([email protected]([email protected]))([email protected])
'@vis.gl/react-maplibre': 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
'@vis.gl/react-maplibre': 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
react: 19.3.1
react-dom: 19.3.1([email protected])
optionalDependencies:
maplibre-gl: 6.14.0
maplibre-gl: 6.15.0
[email protected](@types/[email protected])([email protected]):
dependencies:
@@ -4365,26 +4365,26 @@ snapshots:
dependencies:
protocol-buffers-schema: 3.6.1
[email protected]2:
[email protected]5:
dependencies:
'@oxc-project/types': 0.152.0
'@oxc-project/types': 0.155.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.12
'@rolldown/binding-android-arm64': 1.2.12
'@rolldown/binding-darwin-arm64': 1.2.12
'@rolldown/binding-darwin-x64': 1.2.12
'@rolldown/binding-freebsd-x64': 1.2.12
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
'@rolldown/binding-linux-arm64-gnu': 1.2.12
'@rolldown/binding-linux-arm64-musl': 1.2.12
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
'@rolldown/binding-linux-s390x-gnu': 1.2.12
'@rolldown/binding-linux-x64-gnu': 1.2.12
'@rolldown/binding-linux-x64-musl': 1.2.12
'@rolldown/binding-openharmony-arm64': 1.2.12
'@rolldown/binding-win32-arm64-msvc': 1.2.12
'@rolldown/binding-win32-x64-msvc': 1.2.12
'@rolldown/binding-android-arm-eabi': 1.2.15
'@rolldown/binding-android-arm64': 1.2.15
'@rolldown/binding-darwin-arm64': 1.2.15
'@rolldown/binding-darwin-x64': 1.2.15
'@rolldown/binding-freebsd-x64': 1.2.15
'@rolldown/binding-linux-arm-gnueabihf': 1.2.15
'@rolldown/binding-linux-arm64-gnu': 1.2.15
'@rolldown/binding-linux-arm64-musl': 1.2.15
'@rolldown/binding-linux-ppc64-gnu': 1.2.15
'@rolldown/binding-linux-s390x-gnu': 1.2.15
'@rolldown/binding-linux-x64-gnu': 1.2.15
'@rolldown/binding-linux-x64-musl': 1.2.15
'@rolldown/binding-openharmony-arm64': 1.2.15
'@rolldown/binding-win32-arm64-msvc': 1.2.15
'@rolldown/binding-win32-x64-msvc': 1.2.15
[email protected]: {}
@@ -4515,7 +4515,7 @@ snapshots:
[email protected]: {}
[email protected].1: {}
[email protected].3: {}
[email protected]:
dependencies:
@@ -4620,22 +4620,22 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
[email protected].4(@types/[email protected])([email protected]):
[email protected].5(@types/[email protected])([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.30
rolldown: 1.2.12
rolldown: 1.2.15
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.5
esbuild: 0.28.2
fsevents: 2.3.3
[email protected].4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected])):
[email protected].5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.4([email protected].4(@types/[email protected])([email protected]))
'@vitest/mocker': 5.0.5([email protected].5(@types/[email protected])([email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -4646,7 +4646,7 @@ snapshots:
tinybench: 6.2.2
tinyexec: 1.3.1
tinyglobby: 0.2.17
vite: 8.3.4(@types/[email protected])([email protected])
vite: 8.3.5(@types/[email protected])([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.5
+21 -46
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.
@@ -107,9 +108,12 @@ export const ACTIONS: UIAction[] = [
{ 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.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" },
@@ -127,7 +131,6 @@ export const ACTIONS: UIAction[] = [
{ 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" },
@@ -149,6 +152,7 @@ export const ACTIONS: UIAction[] = [
{ 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" },
@@ -283,15 +287,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.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" },
...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" },
@@ -302,44 +298,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.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: "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" },
@@ -492,6 +454,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" },
@@ -541,6 +504,7 @@ 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" },
@@ -576,6 +540,10 @@ 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" },
@@ -618,7 +586,6 @@ export const ACTIONS: UIAction[] = [
// 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.reading-width", kind: "view", target: "none", proof: "interaction" },
{ 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
@@ -629,6 +596,7 @@ export const ACTIONS: UIAction[] = [
{ 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.
@@ -659,6 +627,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" },
@@ -675,6 +646,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.
@@ -701,6 +673,7 @@ export const ACTIONS: UIAction[] = [
{ 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" },
@@ -784,6 +757,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,
@@ -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

@@ -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>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -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>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>

After

Width:  |  Height:  |  Size: 235 B

@@ -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>OpenRouter</title><path d="M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z"></path></svg>

After

Width:  |  Height:  |  Size: 382 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>Perplexity</title><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z" fill="#22B8CD" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 603 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>Qwen</title><path d="M19.713 2.825h-5.907c0-.251-.066-.503-.196-.728L12.819.728A1.46 1.46 0 0 0 11.559 0h-1.58c-.52 0-1 .275-1.26.728l-2.42 4.19a1.45 1.45 0 0 0 0 1.453l.791 1.37c.26.448.74.727 1.26.727h11.36c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-.79-1.37a1.46 1.46 0 0 0-1.26-.728zM.195 9.905l2.951 5.115a1.46 1.46 0 0 0-.531.531l-.791 1.37a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h4.84c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451L5.086 7.083a1.46 1.46 0 0 0-1.26-.728h-1.58c-.52 0-1 .276-1.26.728l-.79 1.37a1.45 1.45 0 0 0 0 1.452m15.888 13.367 2.951-5.115c.217.125.469.196.728.196h1.582c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-2.42-4.191a1.46 1.46 0 0 0-1.259-.728h-1.582c-.52 0-1 .276-1.259.728l-5.678 9.838a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h1.582c.52 0 1-.275 1.26-.728z" fill="#082dff"></path></svg>

After

Width:  |  Height:  |  Size: 1016 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>Stepfun</title><path d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z" fill="url(#lobe-icons-stepfun-_R_0_)" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-stepfun-_R_0_" x1="1.646" x2="18.342" y1="1.916" y2="22.091"><stop stop-color="#01A9FF"></stop><stop offset="1" stop-color="#0160FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 676 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>Volcengine</title><path d="M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z" fill="#00E5E5"></path><path d="M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z" fill="#006EFF"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z" fill="#006EFF"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z" fill="#00E5E5"></path></svg>

After

Width:  |  Height:  |  Size: 858 B

@@ -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>XiaomiMiMo</title><path d="M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z"></path></svg>

After

Width:  |  Height:  |  Size: 2.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>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { boot, STORAGE } from "./index";
import { say } from "./kernel";
import { toolErrorDetail, toolRefusalReason } from "../ui/cards/outcome";
import type { Tool } from "../port/wire";
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
// The calls the host stops before they run. The model keeps the English text;
// the person reading the card gets the reason in their own language.
const BLOCKED = [
"shell.mixed_mutation_verification",
"shell.inline_interpreter_not_last",
"batch.skipped_after_failed_change",
"batch.check_after_stopped_call",
];
describe("calls the host stops before they run", () => {
for (const language of ["zh", "en"]) {
it(`are worded in ${language}, and the card shows that instead of the English line`, () => {
localStorage.setItem(STORAGE, language);
boot();
for (const code of BLOCKED) {
const tool = { name: "bash", args: {}, err: "blocked: raw English wording", refusalCode: code } as unknown as Tool;
expect(say({ code }, ""), code).not.toBe("");
expect(toolRefusalReason(tool), code).not.toBe("");
expect(toolErrorDetail(tool), `${code}: the English line is not drawn when the reason is`).toBe("");
}
});
}
});
@@ -0,0 +1,40 @@
import { tokens } from "./format";
import { t } from "./index";
export const COMPACTION_TELEMETRY = "compaction_telemetry";
const HOW: Record<string, string> = {
summarized: "摘要压缩",
degraded: "降级压缩,摘要不可用",
native: "服务商原生压缩",
snip: "裁剪旧工具输出",
};
interface Fold {
mode: string;
source_tokens: number;
projection_tokens: number;
input_tokens: number;
output_tokens: number;
cache_hit_tokens: number;
request_count: number;
}
// The typed payload a finished-fold notice carries as its detail, read as the
// variables its sentence is worded from. A detail that is not one reads as none,
// so the notice keeps the kernel's English.
export function compactionNoticeVars(detail: string | undefined): Record<string, string> | undefined {
if (!detail) return undefined;
let p: Partial<Fold>;
try {
p = JSON.parse(detail) as Partial<Fold>;
} catch {
return undefined;
}
if (typeof p.mode !== "string" || p.mode === "" || typeof p.source_tokens !== "number" || typeof p.projection_tokens !== "number") return undefined;
const asked = typeof p.request_count === "number" && p.request_count > 0 && typeof p.input_tokens === "number" && typeof p.output_tokens === "number";
const usage = asked
? t("摘要请求用了 {in} 输入、{out} 输出 token,缓存命中 {hit}。", { in: tokens(p.input_tokens!), out: tokens(p.output_tokens!), hit: tokens(p.cache_hit_tokens ?? 0) })
: "";
return { how: t(HOW[p.mode] ?? p.mode), src: tokens(p.source_tokens), proj: tokens(p.projection_tokens), usage };
}
+66 -15
View File
@@ -17,6 +17,7 @@ import { EN_SETTINGS } from "./en_settings";
import { EN_TOOL_BLURBS } from "./en_tool_blurbs";
import { EN_PERSONA } from "./en_persona";
import { EN_METRICS } from "./en_metrics";
import { EN_FIND } from "./en_find";
import { EN_STORAGE } from "./en_storage";
import { EN_USAGE } from "./en_usage";
import { EN_COMPOSER } from "./en_composer";
@@ -35,6 +36,12 @@ export const EN: Record<string, string> = {
"显示更多": "Show more",
...EN_COMPOSITION,
...EN_DOCUMENT,
"正在读取历史消息…": "Reading history messages…",
"历史加载失败 · 重试": "History could not be loaded · Retry",
"加载更早的消息": "Load earlier messages",
"更早还有 {n} 条消息,点击加载": "{n} more messages before these — click to load",
"正在查找较早消息…": "Searching earlier messages…",
"部分历史尚未读取": "Some history has not been read",
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
...EN_SURFACES,
"重命名聊天": "Rename chat",
@@ -57,6 +64,7 @@ export const EN: Record<string, string> = {
...EN_COMMUNITY,
...EN_SPONSOR,
...EN_METRICS,
...EN_FIND,
...EN_STORAGE,
...EN_USAGE,
...EN_COMPOSER,
@@ -71,6 +79,10 @@ export const EN: Record<string, string> = {
"选择本地文件夹": "Choose local folder",
"选择模型": "Choose model",
"定位到你说的:{text}": "Jump to what you said: {text}",
"定位到第 {a} 至 {b} 条": "Jump to questions {a} to {b}",
"第 {a} 至 {b} 条": "Questions {a} to {b}",
"右键给这处加星标": "Right-click to star this question",
"右键取消这处星标": "Right-click to remove the star",
"你的第 {n} 条消息": "Your message {n}",
// ── 转录:卡片与流 ───────────────────────────────────────────────
"本次会话另外允许了 {n} 项": "{n} more allowed for this session",
@@ -120,6 +132,12 @@ export const EN: Record<string, string> = {
"下载为文件": "Download as file",
"展开全部 {n} 行": "Show all {n} lines",
"复制": "Copy",
"撤销编辑": "Undo",
"重做": "Redo",
"剪切": "Cut",
"粘贴": "Paste",
"全选": "Select all",
"此浏览器不允许读取剪贴板,请使用键盘粘贴": "This browser cannot read the clipboard. Use the keyboard to paste.",
"已复制": "Copied",
"复制不了": "Copy blocked",
"复制失败,请重试": "Copy failed. Try again.",
@@ -131,7 +149,7 @@ export const EN: Record<string, string> = {
"用默认浏览器打开此页": "Open this page in the default browser",
"用系统默认程序打开这个文件": "Open this file with the system default program",
"用系统默认程序打开这个文件,网页里的脚本会照常运行": "Opens this file with the system default program; scripts in the page run as usual",
"选中的内容": "Selected text",
"文本操作": "Text actions",
"引用 {name}": "Quote {name}",
"此名称不能表示为 @ 引用": "This name cannot be represented as an @ reference",
"此名称可能被识别为 MCP 资源": "This name could resolve to an MCP resource",
@@ -266,7 +284,6 @@ export const EN: Record<string, string> = {
"收起度量栏": "Collapse the metrics rail",
"展开度量栏": "Show the metrics rail",
"调整工作区栏宽度": "Resize the workspace rail",
"调整正文字宽": "Resize the reading column",
"调整度量栏宽度": "Resize the metrics rail",
"从列表移除": "Remove from the list",
"改为输入路径": "Type a path instead",
@@ -467,6 +484,7 @@ export const EN: Record<string, string> = {
"批准后还会发生": "Approving also",
"并行启动 {n} 个候选": "starts {n} attempts in parallel",
"并行启动多个候选": "starts several attempts in parallel",
"把下面 {n} 个文件夹加入你的项目列表并标记为可信;加入后,会话才能把任务派发到这些项目。没有出现在这里的文件夹不会被加入。": "Adds these {n} folders to your project list and marks them trusted, so sessions can then dispatch tasks to them. A folder that is not listed here is not added.",
",各自在独立的 git 工作副本里无人值守地运行:它们的工具调用按「自动审核」处理,不再逐个询问(拒绝规则仍然生效;会话为「不询问」时候选也不询问)。最后由评审选出一个写回工作区。": ", each running unattended in its own git worktree: their tool calls are handled as Auto review, without asking one by one (deny rules still apply; if this session never asks, neither do they). A judge then writes one of them back to the workspace.",
"候选要做的任务": "The attempts' task",
"这次批准的效力超出这一次调用本身。": "This approval covers more than this one call.",
@@ -527,6 +545,9 @@ export const EN: Record<string, string> = {
"标题": "Titling",
"本会话": "This session",
"计划": "Plan",
"收起计划": "Collapse the plan",
"{n} 进行中": "{n} in progress",
"{n} 待处理": "{n} pending",
"执行计划中": "Executing plan",
"正在执行已批准的计划": "Executing an approved plan",
"尚未制定": "None yet",
@@ -556,6 +577,9 @@ export const EN: Record<string, string> = {
// ── 会话树 ───────────────────────────────────────────────────────
"新会话": "New session",
"这个会话的历史超过了安全读取上限,没有打开(文件完好,未改动)。可以只取最近的内容,另存为新会话继续。": "This session's history is over the safe read limit, so it was not opened (the file is intact and unchanged). You can carry only its most recent part over into a new session.",
"从最近的内容继续": "Continue from the most recent part",
"已从最近的内容另存为新会话,较早的 {n} 条没有带入;原会话文件保持不变。": "Saved the most recent part as a new session; {n} earlier messages were not carried over. The original session file is unchanged.",
"标签数已到上限 {n},已把「{title}」从标签栏收起,对话仍在列表里。": "The open-session limit ({n}) was reached, so \"{title}\" was folded out of the tab bar. The conversation is still in the list.",
"打开项目文件夹": "Open project folder",
"新会话 {n}": "New session {n}",
@@ -582,8 +606,10 @@ export const EN: Record<string, string> = {
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
"使用当前位置": "Stay where I am",
"隔离": "Isolated",
"原目录": "Local checkout",
"隔离目录": "Worktree",
"独立工作树": "Isolated worktree",
"直接在已打开的项目文件夹中工作,与使用该目录的其他会话共享文件。": "Work directly in the opened project folder, sharing files with other sessions using it.",
"在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。": "Edit code in a separate Git working directory; ports, databases, and other runtime resources may still be shared.",
"发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。": "Create an isolated worktree when you send the message, using the selected branch's committed version without uncommitted changes; ports, databases, and other runtime resources may still be shared.",
"移除": "Remove",
"删除": "Delete",
"删除该会话": "Delete this session",
@@ -685,18 +711,25 @@ export const EN: Record<string, string> = {
"Enter a typeface name or choose one installed on this device. The text below previews your selection; unavailable typefaces fall back to the default.",
"字体预览 · 中文 Aa Bb 0123": "Typeface preview · Aa Bb 0123",
"壁纸": "Wallpaper",
"选择图片…": "Choose an image…",
"选择图片或视频…": "Choose an image or video…",
"音量": "Volume",
"导入 Wallpaper Engine…": "Import Wallpaper Engine…",
"文件夹里需要 project.json 和 scene.pkg": "The folder needs project.json and scene.pkg",
"Wallpaper Engine 场景": "Wallpaper Engine scene",
"未命名场景": "Untitled scene",
"打开声音": "Unmute sound",
"关闭声音": "Mute sound",
"场景以主图和声音播放;粒子和着色器效果暂未再现。": "The scene plays its backdrop and sound; particles and shader effects are not reproduced yet.",
"换一张…": "Choose another…",
"浓度": "Strength",
"压暗": "Dim",
"横向焦点": "Horizontal focus",
"纵向焦点": "Vertical focus",
"当前窗口尺寸下,此图片只有左右会被裁剪,上下正好填满,因此纵向焦点无法调整。": "At the current window size this image is cropped only left and right and fills the height exactly, so the vertical focus cannot be moved.",
"当前窗口尺寸下,此图片只有上下会被裁剪,左右正好填满,因此横向焦点无法调整。": "At the current window size this image is cropped only top and bottom and fills the width exactly, so the horizontal focus cannot be moved.",
"此图片与窗口宽高比一致,四边均未裁剪;改变窗口尺寸后才可调整焦点。": "This image matches the window's aspect ratio and is not cropped on any side; the focus becomes adjustable once the window changes shape.",
"当前窗口尺寸下,此壁纸只有左右会被裁剪,上下正好填满,因此纵向焦点无法调整。": "At the current window size this wallpaper is cropped only left and right and fills the height exactly, so the vertical focus cannot be moved.",
"当前窗口尺寸下,此壁纸只有上下会被裁剪,左右正好填满,因此横向焦点无法调整。": "At the current window size this wallpaper is cropped only top and bottom and fills the width exactly, so the horizontal focus cannot be moved.",
"此壁纸与窗口宽高比一致,四边均未裁剪;改变窗口尺寸后才可调整焦点。": "This wallpaper matches the window's aspect ratio and is not cropped on any side; the focus becomes adjustable once the window changes shape.",
"壁纸只显示在窗口的空白区域,卡片和输入框保持不透明以确保文字清晰。任务运行时壁纸会自动降低透明度。":
"The image fills only the empty areas of the window; cards and the composer stay opaque so text remains legible. While a task runs, the wallpaper fades further.",
"图片太大了,先压到 {n} MB 以内": "That image is too large — bring it under {n} MB first",
"语言": "Language",
"跟随系统": "Follow the system",
"中文": "中文",
@@ -727,9 +760,6 @@ export const EN: Record<string, string> = {
"代码块、命令、差异与工具输出共用这一等宽字体,不跟随界面主题改变。":
"Code blocks, commands, diffs and tool output share this monospace font and do not change with the interface theme.",
"等宽字体": "Monospace font",
"更多个性化": "More personalization",
"已设置自定义壁纸": "Custom wallpaper set",
"壁纸与背景位置": "Wallpaper and background position",
"文字粗细": "Text weight",
"视觉效果": "Visual effects",
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.",
@@ -935,7 +965,19 @@ export const EN: Record<string, string> = {
"所有项目均可使用": "Available in every project",
"写入仓库,clone 仓库的人也会获得": "Written into the repository, so whoever clones it gets it too",
"返回": "Back",
"返回正文": "Back to the conversation",
"完成": "Done",
"快捷键": "Keyboard shortcuts",
"在对话里查找": "Find in the conversation",
"搜索会话": "Search conversations",
"在提问之间移动": "Move between your questions",
"工作台全屏": "Workbench full screen",
"缩小 / 放大 / 复原界面": "Make the interface smaller / larger / its default size",
"发送(发送键设为修饰键 + Enter 时)": "Send when the send key is set to the modifier plus Enter",
"发送,或用 Shift 换行": "Send, or a new line with Shift",
"关闭当前弹层": "Close the open panel",
"聚焦分隔条后调宽或用 Home 复位": "Widen a divider once focused, or reset it with Home",
"窗口高亮时逐条跳到你问过的问题": "Step through your own questions while the rail has focus",
"仓库地址,或将文件夹拖入此处": "A repository address, or drop a folder in",
"选文件夹": "Choose a folder",
"将添加": "Will be added",
@@ -969,6 +1011,10 @@ export const EN: Record<string, string> = {
"这些地址直连": "Reach these addresses directly",
"测试目标": "Test target",
"无法读取网络配置。": "Could not read the network config.",
"正在读取网络配置…": "Loading network settings…",
"测试已保存配置": "Test saved settings",
"有未保存的改动;连接测试使用已保存的配置。": "There are unsaved changes. Connection tests use the saved settings.",
"已保存配置的测试结果": "Test results for saved settings",
// ── 命令解释器 ───────────────────────────────────────────────────
"当前生效": "In effect",
@@ -1178,10 +1224,10 @@ export const EN: Record<string, string> = {
"Palettes install under themes/ in the memory directory, one theme.json per folder. A palette sets surfaces, accent, corner radii and type; the status colours (success, warning, failure) are unaffected.",
"导入主题…": "Import theme…",
"正在导入…": "Importing…",
"从会话文件导入": "Import from a session file",
"选择会话文件…": "Choose a session file…",
"打开主题目录": "Open themes folder",
"阅读": "Read",
"设置需要一个打开的会话。请先在左栏添加一个文件夹。": "Settings need an open session. Add a folder in the sidebar first.",
"反馈需要一个打开的会话。请先在左栏添加一个文件夹。": "Feedback needs an open session. Add a folder in the sidebar first.",
"{host} 需要登录": "{host} asks you to sign in",
"代理 {host} 需要登录": "The proxy {host} asks you to sign in",
"用户名": "Username",
@@ -1559,7 +1605,7 @@ export const EN: Record<string, string> = {
"在文件管理器中显示": "Show in file manager",
"文件夹已不在磁盘上": "Folder is gone",
"删除会话:{title}": "Delete session: {title}",
"会话在文件夹里打开,先添加一个": "A session opens in a folder; add one first",
"没有文件夹也能开始:会话会在应用自己的目录里打开": "You can start without a folder: the session opens in the app's own directory",
"添加文件夹": "Add folder",
"其中 {live} 个对话正在运行,停止后才能移除": "{live} of them are running; stop them before removing",
"正在运行,停止后才能删除": "Running; stop it before deleting",
@@ -1650,6 +1696,10 @@ export const EN: Record<string, string> = {
"正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首": "Prose and the composer both follow the window; a very wide composer makes a line harder to find the start of",
"每行长度控制在一眼能回到行首的范围": "Keeps a line short enough that your eye finds the next one",
"正文跟随窗口宽度,宽屏上不留两侧空白": "Prose follows the window, with nothing left empty at the sides",
"自定义比例": "Custom percentage",
"按比例调整对话和输入框占用的区域": "Set the conversation and composer width as a percentage",
"对话区域宽度": "Conversation width",
"自定义比例同时调整对话和输入框;窄窗口会优先保证可用空间。": "Custom percentage resizes both the conversation and composer; narrow windows prioritize usable space.",
"行宽只管正文:代码、命令与工具输出始终占满可用宽度。": "Line width applies to prose only — code, commands and tool output always use the width available.",
"决策模型": "Decision models",
"为 system_one 工具配置辅助决策后端,不会替换当前对话的主模型。": "Configure auxiliary decision backends for the system_one tool without replacing the main chat model.",
@@ -1827,6 +1877,7 @@ export const EN: Record<string, string> = {
"同一段内容被逐字重复": "The same block is being repeated verbatim",
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
"等待你的输入": "Waiting for you",
"后台任务已开始:{name}": "Background job started: {name}",
"后台任务已结束:{name}": "Background job finished: {name}",
"后台任务已终止:{name}": "Background job killed: {name}",
"后台任务 {name} 失败,需要处理": "Background job {name} failed: needs attention",
@@ -59,13 +59,10 @@ export const EN_COMPOSER: Record<string, string> = {
"创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.",
"创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.",
"配置工作目录": "Configure workspace",
"独立 worktree · {branch}": "Isolated worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}",
"新建 worktree": "New worktree",
"独立 worktree": "Isolated worktree",
"独立工作树 · {branch}": "Isolated worktree · {branch}",
"当前目录": "Current folder",
"此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.",
"如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.",
"使用当前目录": "Use current folder",
"关闭工作目录配置": "Close workspace configuration",
"发送消息时开始创建。": " Creation starts when you send the message.",
"选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.",
@@ -78,7 +75,6 @@ export const EN_COMPOSER: Record<string, string> = {
"选择起始分支": "Choose source branch",
"搜索分支": "Search branches",
"搜索分支…": "Search branches…",
"当前目录": "Current folder",
"查看完整路径": "Show full path",
"复制完整路径": "Copy full path",
"复制操作编号": "Copy operation ID",
@@ -112,6 +108,8 @@ export const EN_COMPOSER: Record<string, string> = {
"正在发送…": "Sending…",
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
"描述任务…": "Describe a task…",
"继续输入以排队后续修改": "Keep typing to queue further changes",
"继续输入…": "Keep typing…",
"任务输入": "Task input",
"命令与技能": "Commands and skills",
"正在加载命令与技能…": "Loading commands and skills…",
@@ -8,8 +8,6 @@ export const EN_DOCUMENT: Record<string, string> = {
"已请求系统应用打开": "Requested opening in the system application",
"已请求显示文件位置": "Requested showing the file location",
"已复制路径": "Path copied",
"加宽工作台": "Widen workbench",
"恢复分栏": "Restore split view",
"全屏工作台": "Expand workbench",
"退出工作台全屏": "Exit expanded workbench",
"展开文件树": "Show file tree",
@@ -0,0 +1,7 @@
/** The other way of reading the query, beside the box it belongs to.
*
* `区分大小写` already lives in the settings catalogue; keeping a second copy
* here would put one key in two catalogue files. */
export const EN_FIND: Record<string, string> = {
"把查询当作正则表达式": "Read the query as a regular expression",
};
+23 -4
View File
@@ -3,6 +3,8 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"历史内容已改变,正在重新读取最近的消息": "History changed. Reloading the latest messages.",
"当前内核不支持历史分页": "This kernel does not support history paging.",
"此会话的组合已绑定原项目,请在目标项目新建会话": "This conversation's composition is bound to its project. Start a new conversation in the target project",
"起始分支已不存在,请重新选择分支": "The source branch no longer exists. Choose another branch",
"会话或工作目录已改变,请新建会话后重新选择 worktree": "The session or workspace changed. Start a new session to select a worktree",
@@ -11,6 +13,10 @@ export const EN_KERNEL: Record<string, string> = {
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
"无法读取目标文件:它在你配置的禁读列表里。": "Cannot read the target file: it is on the forbidden-read list you configured.",
"这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。": "This command puts a step the host cannot confirm leaves the workspace alone together with a verification check, so it was blocked and did not run. Split it into two calls: change first, then verify on its own.",
"这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。": "The inline interpreter in this command is not its last segment, so later steps decide the exit status and a failure inside the interpreter would read as success. It was blocked and did not run. Put it last, or run it on its own.",
"同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。": "An earlier change in the same batch of calls failed or was blocked, so this step did not run. Deal with that change first, then retry.",
"同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。": "An earlier call in the same batch was stopped before it ran, so this check would not measure the state you asked for and did not run. Re-run the stopped call first, or run this check on its own.",
"无法读取目标文件:它不在这个会话允许读取的文件夹里。": "Cannot read the target file: it is outside the folders this session may read.",
"读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。": "Reading a video needs ffmpeg (with ffprobe) installed and on the system path. Install it and try again.",
"不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。": "This video format is not recognised. MP4/MOV, MKV/WebM, AVI, FLV, ASF, MPEG and Ogg containers are supported.",
@@ -48,6 +54,11 @@ export const EN_KERNEL: Record<string, string> = {
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
"找不到该隔离结果,它可能已被应用或丢弃。未执行任何变更": "The isolated result no longer exists; it may have been applied or discarded. Nothing was changed",
"本会话未启用工作树隔离([agent] worktree_isolation),本次任务未开始": "Worktree isolation is not enabled in this session ([agent] worktree_isolation), so the task did not start",
"工作树隔离需要一个已有提交的 Git 工作区,本次任务未开始": "Worktree isolation needs a Git workspace with a commit, so the task did not start",
"工作区在任务开始后改动了相同文件,未应用任何内容。请协调后重新应用或丢弃该结果": "The workspace changed the same files after the task began, so nothing was applied. Reconcile, then apply again or discard the result",
"此次隔离任务包含不支持的参数,未开始执行": "This isolated task combines arguments that are not supported, so it did not start",
"搜索模型「{model}」的格式应为 服务/模型": "Search model “{model}” must be written as service/model",
"搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它": "Search model “{model}”'s connection has not been added yet: add that connection first, then pick it",
"搜索模型「{model}」已不在配置里": "Search model “{model}” is no longer configured",
@@ -265,9 +276,14 @@ export const EN_KERNEL: Record<string, string> = {
"本机没有操作系统沙箱,「关进沙箱」一档无法保存": "This machine has no OS sandbox, so the jailed mode cannot be saved",
"该文件夹仍有 {n} 个打开的面板,请先关闭再移除": "This folder still has {n} open panes — close them before removing it",
"不支持该图片格式,请改用 PNG、JPEG、WebP、AVIF 或 GIF":
"That image format will not work — use PNG, JPEG, WebP, AVIF or GIF",
"不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)":
"That format is not supported; use an image (PNG, JPEG, WebP, AVIF, GIF) or a video (MP4, WebM)",
"图片内容为空": "The image is empty",
"场景包过大,请压缩至 {limit} MB 以内": "That scene package is too large — bring it under {limit} MB",
"无法读取这个 Wallpaper Engine 场景": "This Wallpaper Engine scene could not be read",
"只支持 Scene 类型的 Wallpaper Engine 壁纸": "Only Scene wallpapers from Wallpaper Engine are supported",
"这个场景里没有可用的主图": "This scene has no backdrop to show",
"不支持该音频格式": "That audio format is not supported",
"压缩阈值未能保存:{detail}": "The compaction threshold was not saved: {detail}",
"本次请求未包含阈值,未做任何修改": "The request carried no threshold, so nothing changed",
"{host} 上的内核没有应答,连接可能断了": "The kernel on {host} is not answering — the connection may be down",
@@ -465,6 +481,8 @@ export const EN_KERNEL: Record<string, string> = {
"打不开这个会话": "Could not open that conversation",
"该路径位于会话目录之外": "That path is outside the conversations directory",
"没有这个会话": "No such conversation",
"这个会话的历史超过了安全读取上限,没有打开;文件完好未改动": "This conversation's history is over the safe read limit, so it was not opened; the file is intact and unchanged",
"没能取出这个会话最近的内容": "Could not take the most recent part of this conversation",
"该地址不接受此种请求方式": "That address does not accept this request method",
"请求体必须是 application/json": "The request body must be application/json",
"这台服务器未开放权限编辑": "This server does not open permission editing",
@@ -475,7 +493,6 @@ export const EN_KERNEL: Record<string, string> = {
"已有一个迁移正在进行,请等待其完成": "A move is already running — wait for it to finish",
"这不是有效的插件名称": "That is not a plugin name",
"该插件未安装": "That plugin is not installed",
"图片数据不是 base64 编码": "The image data is not base64",
"HTTP 上不提供 shell 命令": "Shell commands are not served over HTTP",
"这个会话里没有这条调用的输出": "This session has no output for that call",
"要取的输出范围不对,请重新展开这条输出": "The part of the output asked for is not valid; open the output again",
@@ -544,7 +561,6 @@ export const EN_KERNEL: Record<string, string> = {
"余额地址必须是 http 或 https 开头的网址": "The balance address must be an http or https URL",
"这个余额格式不在可选范围内": "That balance format is not one of the available ones",
"额外设置中的「{path}」不能为空值(null)": "“{path}” in the extra settings cannot be null",
"图片过大,请压缩至 {limit} MB 以内": "That image is too large — bring it under {limit} MB",
"{path} 不在当前工作树中,无法查看其改动": "{path} is not inside this working tree, so its changes cannot be shown",
"无法读取该文件的改动 —— git 未返回结果": "Could not read this file's changes — git returned nothing usable",
"无法读取本次运行的轨迹记录,因此无法确定其覆盖范围":
@@ -583,6 +599,7 @@ export const EN_KERNEL: Record<string, string> = {
"本次版本切换未能启动:{detail}": "This version change did not start: {detail}",
"有 {n} 项任务正在运行,重启会中断它们": "{n} tasks are running, and a restart would stop them",
"没有已下载好的版本可供重启安装,请重新点击安装": "No downloaded version is waiting for a restart; choose Install again",
"没有正在下载的版本可供取消,可能已经完成或已停止": "No download is running for that version; it may have finished or stopped",
"本次启动未能记录为健康状态:{detail}": "This launch could not be recorded as healthy: {detail}",
"该请求未发往 Studio 监听的地址,已被拒绝": "That request was not addressed to the address Studio listens on — refused",
"该页面不属于 Studio,无法对其操作": "That page is not Studio itself, so it may not drive it",
@@ -648,6 +665,8 @@ export const EN_KERNEL: Record<string, string> = {
"A fetch backend did not answer; the next one in the chain was tried. See the notice for details",
"配置的网页抓取后端无法使用,已跳过;检查用户配置里的 web_fetch_backends":
"A configured fetch backend cannot be used and was skipped; check web_fetch_backends in your user config",
"这个会话创建之后,有内置工具发生了变化;它按当时记录的工具继续,模型服务商的缓存需要重新建立,第一次回复可能稍慢":
"A built-in tool changed after this conversation started. It continues with the tools it recorded; the model provider's cache has to be rebuilt, so the first reply may be slower.",
"搜索后端没有返回结果,已换用链里的下一个后端;详情见通知":
"A search backend did not answer; the next one in the chain was tried. See the notice for details",
"配置的搜索后端无法使用,已跳过;检查用户配置里的 web_search_backends 和 searxng_url":
+53 -2
View File
@@ -2,6 +2,23 @@
// alone — one catalogue is still the model, and this file is read as its
// continuation: same keying by Chinese source text, same grouping by screen.
export const EN_SETTINGS: Record<string, string> = {
"全局显示 / 隐藏快捷键": "Global show / hide shortcut",
"默认关闭。仅在 Studio 运行时生效;窗口聚焦时隐藏,否则显示并聚焦。不会启动已退出的应用,也不更改窗口内快捷键。": "Off by default. While Studio is running, hide its focused window or show and focus it otherwise. Does not launch a stopped app or change in-app shortcuts.",
"快捷键组合": "Shortcut binding",
"点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。": "Click Record and press a modifier and key combination, or edit the shortcut directly. Esc cancels recording; Clear immediately disables the shortcut. Wayland or OS-reserved bindings may not be supported.",
"请按修饰键与按键组合…": "Press a modifier and key combination…",
"录制快捷键": "Record shortcut",
"应用快捷键": "Apply shortcut",
"清除快捷键": "Clear shortcut",
"全局快捷键已关闭": "Global shortcut disabled",
"已注册:{value}": "Registered: {value}",
"快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。": "Invalid accelerator. Use a modifier and key combination, e.g. CommandOrControl+Shift+Space.",
"系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。": "The OS could not register this shortcut: it may be occupied, denied permission, or unsupported by this desktop.",
"当前环境不支持全局快捷键。": "Global shortcuts are not supported in this environment.",
"快捷键未能保存;原设置保持不变。": "Could not save the shortcut; the previous setting is unchanged.",
"Studio 正在退出,未更改快捷键。": "Studio is quitting; the shortcut was not changed.",
"无法读取或更改 shell 快捷键状态。请重新打开设置后再试。": "Could not read or change shell shortcut status. Reopen settings to try again.",
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。": "These keys work inside the window; ⌘ means Ctrl on Windows and Linux. Configure global show / hide separately above, in the desktop shell only.",
"加载中…": "Loading…",
"AI 文件交付卡片": "AI file delivery cards",
"启用 AI 文件交付卡片": "Enable AI file delivery cards",
@@ -80,6 +97,10 @@ export const EN_SETTINGS: Record<string, string> = {
"按两次 Esc 停止": "Press Esc twice to stop",
"按一次 Esc 立即停止": "Press Esc once to stop at once",
"再按一次 Esc 停止当前任务": "Press Esc again to stop the current task",
"空闲网页休眠": "Idle page sleep",
"一段时间没有驱动的网页会被收起以省内存,需要时自动恢复。默认关闭。": "A page nothing has driven for a while is folded away to save memory, and comes back on its own when needed. Off by default.",
"开启(15 分钟后)": "On (after 15 minutes)",
"保持打开": "Keep open",
"发送快捷键": "Send shortcut",
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
"Enter 发送": "Enter to send",
@@ -123,6 +144,7 @@ export const EN_SETTINGS: Record<string, string> = {
"项目配置覆盖了这一项;此处保存的是全局设置。": "The project config overrides this; what you set here is saved globally.",
"尚无可用搜索模型": "No search models available",
"{model}(不可用)": "{model} (unavailable)",
"{model}(未声明读图)": "{model} (not declared to read images)",
"模型用途": "Model uses",
"用途": "Use",
"使用模型": "Model",
@@ -163,7 +185,6 @@ export const EN_SETTINGS: Record<string, string> = {
"搜索设置…": "Search settings…",
"搜索设置": "Search settings",
"没有匹配的设置": "No matching setting",
"设置项:{n}": "Settings: {n}",
"立即生效": "In effect now",
"当前会话": "This session",
"当前模型": "This model",
@@ -744,8 +765,16 @@ export const EN_SETTINGS: Record<string, string> = {
"没有可折叠的内容": "No foldable region remains",
"没有值得折叠的内容": "Nothing left worth folding",
"已压缩": "Compacted",
"上下文已压缩({how}):约 {src} → {proj}。{usage}": "Context compacted ({how}): about {src} → {proj}. {usage}",
"摘要压缩": "summarized",
"降级压缩,摘要不可用": "degraded, no summary available",
"服务商原生压缩": "compacted by the provider",
"裁剪旧工具输出": "old tool output trimmed",
"摘要请求用了 {in} 输入、{out} 输出 token,缓存命中 {hit}。": "The summary request used {in} input and {out} output tokens, {hit} from cache.",
"无需压缩:{why}": "Nothing to compact: {why}",
"压缩失败:{why}": "Compaction failed: {why}",
"你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。": "{n} earlier message(s) of yours were too large to keep whole and now survive only through the summary. Prefix a message with [[keep]] to hold it verbatim.",
"你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。": "{n} message(s) you marked [[keep]] went past the room [[keep]] has, so the oldest now survive only through the summary.",
"子代理「{task}」在等 {by} 先结束:{why}": "Sub-agent \"{task}\" is waiting for {by} to finish: {why}",
"另一个子代理": "another sub-agent",
"两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace",
@@ -853,6 +882,8 @@ export const EN_SETTINGS: Record<string, string> = {
"下载完整安装包": "Download the full installer",
"重启以完成安装": "Restart to finish",
"已下载,待重启": "Downloaded, waiting for a restart",
"取消下载": "Cancel download",
"已取消下载": "Download cancelled",
"正在安装…": "Installing…",
"正在重启到新版本…": "Restarting into the new version…",
"安装失败": "Install failed",
@@ -991,6 +1022,16 @@ export const EN_SETTINGS: Record<string, string> = {
"关闭窗口后在托盘中继续运行": "Keep running in the tray when the window is closed",
"关闭窗口不会中断会话和后台任务;从托盘菜单退出才会完全关闭程序": "Closing the window does not stop sessions or background tasks; use Quit in the tray menu to exit completely",
"本轮执行": "This turn",
"批准与规则": "Approval and rules",
"沙箱与执行": "Sandbox and execution",
"联网与浏览器": "Web and browser",
"子代理与协作": "Sub-agents and collaboration",
"外观与主题": "Appearance and theme",
"会话显示": "Conversation display",
"导航与侧栏": "Navigation and sidebar",
"窗口与性能": "Window and performance",
"位于项目文件夹内": "Inside a project folder",
"位于项目文件夹 {path} 内,出于安全不从这里启动": "Inside the project folder {path}, so it is not started from here for safety",
"运行环境": "Where it runs",
"记录与用量": "What it remembers",
"这台机器": "This machine",
@@ -1078,6 +1119,7 @@ export const EN_SETTINGS: Record<string, string> = {
"单轮生成上限;留空使用内核的模型默认值。": "The per-turn generation limit; leave blank to use the runtime's model default.",
"高级连接选项": "Advanced connection options",
"从常用服务开始": "Start from a common service",
"按厂商筛选": "Filter by vendor",
"留空就用预设自带的名称;重名时内核会自动编号。": "Leave empty to use the preset's own name; the kernel numbers a duplicate.",
"模型目录、上下文窗口和思考设置由内核按这个服务的预设一并创建。": "The kernel creates the model catalogue, context window and thinking settings for this service from its preset.",
"没有匹配的服务": "No matching service",
@@ -1109,6 +1151,9 @@ export const EN_SETTINGS: Record<string, string> = {
"思考自动": "Thinking automatic",
"会话折叠": "Transcript folding",
"会话中各部分默认展开还是收起。失败的步骤与正在运行的操作可以单独处理;已手动展开或收起的块保持原样。": "Whether each part of a conversation starts open or folded. Failed steps and work still running can be handled on their own; a block you have already opened or closed stays as it is.",
"收起时显示计划进度": "Show collapsed plan progress",
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
"计划进度": "Plan progress",
"折叠预设": "Folding preset",
"精简": "Compact",
"详尽": "Detailed",
@@ -1121,7 +1166,9 @@ export const EN_SETTINGS: Record<string, string> = {
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
"压缩简报": "Compaction brief",
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
"收起并预览首句": "Folded, with first sentence",
"收起时预览思考开头": "Preview the beginning when collapsed",
"在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。": "Show one line of opening text below the thinking header, including while thinking is in progress. Hidden when expanded.",
"先检查配置,再决定改哪里。": "Check the configuration first, then decide what to change.",
"进行中展开": "Open while running",
"失败时展开": "Open on failure",
"改动时展开": "Open on file change",
@@ -1337,6 +1384,10 @@ export const EN_SETTINGS: Record<string, string> = {
"图标栏": "Icon rail",
"导航入口": "Navigation entries",
"放大设置窗口": "Enlarge the settings window",
"隐藏共同前缀": "Hide the shared prefix",
"{name} 的模型名前缀": "Model name prefix of {name}",
"路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。": "The same prefix a routing service puts on every model (such as group/) is left out of how they are drawn. Display only: model ids do not change, an alias you typed wins, and it takes effect on save.",
"缩小设置窗口": "Shrink the settings window",
"图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。": "The shortcuts to pages in the icon rail and the sidebar: hide any of them or change their order. Sessions, feedback, account and settings are always kept.",
"还原排列": "Reset arrangement",
"上移:{name}": "Move up: {name}",
@@ -7,6 +7,7 @@ export const EN_TOOL_BLURBS: Record<string, string> = {
"用起止文本锚点删除文件里的一整段内容,适合大块删除。": "Delete a contiguous range of a file between two text anchors, for large removals.",
"按名字从 Go 源文件里删除一个函数、方法或类型等符号。": "Delete a named function, method, type or other symbol from a Go source file.",
"把任务交给你另一个项目里的会话:指定项目并写明要做什么;对方没有等待中的会话时会在那里新开一个。": "Hand a task to a session in another of your projects: name the project and say what to do; a session is opened there if none is waiting.",
"请你把一批文件夹加入项目列表:卡片逐条列出路径,你批准后才会加入并标记为可信,之后才能向它们派发任务。": "Ask you to add a batch of folders to your project list: the card lists every path, and they are added and marked trusted only once you approve, so tasks can then be dispatched to them.",
"搜索并阅读内置在本版本里的官方文档。": "Search and read the official documentation built into this version.",
"在隔离的子代理里做只读的代码库调查,只返回提炼后的结论。": "Investigate the codebase read-only in an isolated sub-agent and return only a distilled answer.",
"把 2–64 个子代理任务按依赖关系一起派出,返回摘要和取回完整结果的引用。": "Dispatch 2–64 sub-agent tasks as a dependency graph and return previews plus references for the full results.",
@@ -46,4 +47,9 @@ export const EN_TOOL_BLURBS: Record<string, string> = {
"派出一个子代理处理一项专项子任务,只返回它的最终答案。": "Start a sub-agent for a focused sub-task and return only its final answer.",
"获取一个网址并返回文本内容;HTML 会被精简为可读文本。": "Fetch a URL and return its text; HTML is reduced to readable text.",
"搜索网页获取最新信息,返回摘要和来源。": "Search the web for current information and return a summary with sources.",
"向更强的模型求助:它读完到目前为止的对话后,用方案、诊断或审查回答你的问题;它自己不执行任何东西。": "Ask a stronger model for advice: it reads the conversation so far and answers with a plan, a diagnosis or a review; it runs nothing itself.",
"把一个明确的编码任务并行做 2–4 遍,每遍在独立的 git 工作树里;再由评审比较,只把最好的一份写回工作区。": "Run one well-specified coding task 2–4 times in parallel, each in its own git worktree, then have a judge compare them and write only the best back into the workspace.",
"把已有的最终文件以可点击的卡片展示给你,包括命令行生成的文档;路径须在工作区或已授权的文件夹内,打开的是当前文件而不是快照,也不保证内容正确。": "Present existing final files to you as clickable cards, including shell-created documents; paths must be in the workspace or authorized folders, it opens the current files rather than snapshots, and it does not certify correctness.",
"读取已连接的 MCP 服务器自己列出的一份资源(文档、记录、文件);服务器没列出的地址会被拒绝,返回的是服务器的数据,不是你的指令。": "Read one resource (a document, a record, a file) that a connected MCP server lists itself; an address the server does not list is refused, and what comes back is the server's data, not your instructions.",
"运行一小段 Starlark(类 Python)脚本来调用多个工具,几步相互依赖的操作只需一次往返,只返回脚本打印的内容;每次调用都和模型直接调用一样受检查和审批。": "Run a short Starlark (Python-like) script that calls several tools, so dependent steps take one round trip and only what the script prints comes back; every call is checked and approved as if the model made it directly.",
};
@@ -31,6 +31,14 @@ export const EN_USAGE: Record<string, string> = {
"美元 USD": "US dollar (USD)",
"无法读取币种设置。": "Could not read the currency setting.",
"用量与成本": "Usage & cost",
"上传量": "Upload volume",
"发给模型服务商的请求内容大小(不含连接本身的开销,也不含下载)。每次请求都会带上完整的对话,所以它取决于上下文有多长、请求有多少次,缓存命中只省费用、不省流量。":
"The size of the request bodies sent to model providers (without the connection's own overhead, and without downloads). Every request carries the whole conversation, so it depends on how long the context is and how many requests are made; cache hits save cost, not bandwidth.",
"还没有记录:从这个版本起开始统计。": "Nothing recorded yet: counting starts with this version.",
"今天上传": "Uploaded today",
"近 {n} 天上传": "Uploaded in the last {n} days",
"{size},{n} 次请求,平均每次 {avg}": "{size} in {n} requests, {avg} each on average",
"{size}({share})": "{size} ({share})",
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
"正在读取记录…": "Reading the records…",
@@ -62,6 +70,7 @@ export const EN_USAGE: Record<string, string> = {
"花费": "Spend",
"含估算": "includes estimates",
"未定价调用未计入": "unpriced calls not included",
"其中 {n} 笔未定价": "{n} calls unpriced",
"{day} 起 · {n} 天有记录": "from {day} · {n} days recorded",
"这段时间 {tok} tokens 里有 {rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "{rate}% of these {tok} tokens hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
"命中 {a} · 未命中 {b}": "{a} hit · {b} missed",
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { boot, STORAGE } from "./index";
import { say } from "./kernel";
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
// The refusals internal/runtime/isolation and the isolated task tool emit; a
// tool card renders say(code), so an unworded one shows no reason at all.
const ISOLATION_REFUSALS = ["isolation.unknown_id", "isolation.unavailable", "isolation.not_git", "isolation.apply_conflict", "isolation.unsupported_arg"];
describe("isolation refusals", () => {
for (const language of ["zh", "en"]) {
it(`are worded in ${language}`, () => {
localStorage.setItem(STORAGE, language);
boot();
for (const code of ISOLATION_REFUSALS) expect(say({ code }, ""), code).not.toBe("");
});
}
});
@@ -1,6 +1,7 @@
import { t } from ".";
import { elapsed } from "./format";
export const JOB_STARTED = "job_started";
export const JOB_FINISHED = "job_finished";
export const JOB_KILLED = "job_killed";
export const JOB_FAILED = "job_failed";
+20 -3
View File
@@ -30,6 +30,8 @@ const SAID: Record<string, string> = {
"present_files.editing_disabled": "这台服务器未开放文件交付设置",
"present_files.save_failed": "文件交付设置未能保存:{detail}",
"present_files.no_enabled": "本次请求未说明开关状态,未做任何修改",
"history.snapshot_changed": "历史内容已改变,正在重新读取最近的消息",
"history.pagination_unsupported": "当前内核不支持历史分页",
"agent_composition.invalid": "预设内容无效,请检查标识、模块和参数",
"agent_composition.unavailable": "组合所需模块 {module} 当前不可用:{detail}",
"agent_composition.mcp_unavailable": "MCP 服务 {module} 不可用:{detail}。请在 MCP 设置中检查连接或认证,或在预设中取消选择该服务后重试。",
@@ -63,6 +65,11 @@ const SAID: Record<string, string> = {
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
"isolation.unknown_id": "找不到该隔离结果,它可能已被应用或丢弃。未执行任何变更",
"isolation.unavailable": "本会话未启用工作树隔离([agent] worktree_isolation),本次任务未开始",
"isolation.not_git": "工作树隔离需要一个已有提交的 Git 工作区,本次任务未开始",
"isolation.apply_conflict": "工作区在任务开始后改动了相同文件,未应用任何内容。请协调后重新应用或丢弃该结果",
"isolation.unsupported_arg": "此次隔离任务包含不支持的参数,未开始执行",
"session.archive_invalid": "会话文件无效,请选择完整的原生会话导出文件。",
"session.archive_unsupported": "不支持此会话文件版本,请使用兼容版本导出。",
"session.archive_too_large": "会话文件超过安全导入限制(大小或记录数)。",
@@ -116,6 +123,10 @@ const SAID: Record<string, string> = {
"workspace.root_not_granted": "这个文件夹没有授权给当前工作区",
"workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。",
"workspace.read_forbidden": "无法读取目标文件:它在你配置的禁读列表里。",
"shell.mixed_mutation_verification": "这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。",
"shell.inline_interpreter_not_last": "这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。",
"batch.skipped_after_failed_change": "同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。",
"batch.check_after_stopped_call": "同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。",
"workspace.read_outside_scope": "无法读取目标文件:它不在这个会话允许读取的文件夹里。",
"video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。",
"video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。",
@@ -163,6 +174,8 @@ const SAID: Record<string, string> = {
"session.open_failed": "打不开这个会话",
"session.outside_dir": "该路径位于会话目录之外",
"session.unknown": "没有这个会话",
"session.replay_limit": "这个会话的历史超过了安全读取上限,没有打开;文件完好未改动",
"session.tail_failed": "没能取出这个会话最近的内容",
"request.method_not_allowed": "该地址不接受此种请求方式",
"request.bad_content_type": "请求体必须是 application/json",
"permissions.editing_disabled": "这台服务器未开放权限编辑",
@@ -177,7 +190,6 @@ const SAID: Record<string, string> = {
"storage.move_running": "已有一个迁移正在进行,请等待其完成",
"plugin.bad_name": "这不是有效的插件名称",
"plugin.not_installed": "该插件未安装",
"wallpaper.not_base64": "图片数据不是 base64 编码",
"shell.unavailable_over_http": "HTTP 上不提供 shell 命令",
"roles.unknown": "不存在「{role}」这个角色",
"roles.model_unknown": "没有已配置的模型匹配「{model}」",
@@ -445,9 +457,13 @@ const SAID: Record<string, string> = {
"remote.auth_failed": "{host} 不接受该凭据。请更换密钥,或在设置中填写正确的环境变量名。",
// ── 壁纸 ─────────────────────────────────────────────────────────
"wallpaper.unsupported_type": "不支持该图片格式,请改用 PNG、JPEG、WebP、AVIF 或 GIF",
"wallpaper.unsupported_type": "不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)",
"wallpaper.empty": "图片内容为空",
"wallpaper.too_large": "图片过大,请压缩至 {limit} MB 以内",
"wallpaper.scene_too_large": "场景包过大,请压缩至 {limit} MB 以内",
"wallpaper.scene_invalid": "无法读取这个 Wallpaper Engine 场景",
"wallpaper.scene_unsupported": "只支持 Scene 类型的 Wallpaper Engine 壁纸",
"wallpaper.scene_no_image": "这个场景里没有可用的主图",
"wallpaper.audio_unsupported": "不支持该音频格式",
// ── 来不及了 ──────────────────────────────────────
"steer.already_applied": "该条已发送给模型,无法撤回",
@@ -795,6 +811,7 @@ const SAID: Record<string, string> = {
"update.install_rejected": "本次版本切换未能启动:{detail}",
"update.restart_busy": "有 {n} 项任务正在运行,重启会中断它们",
"update.nothing_ready": "没有已下载好的版本可供重启安装,请重新点击安装",
"update.nothing_to_cancel": "没有正在下载的版本可供取消,可能已经完成或已停止",
"loopback.host_rejected": "该请求未发往 Studio 监听的地址,已被拒绝",
"loopback.origin_rejected": "该页面不属于 Studio,无法对其操作",
@@ -9,12 +9,16 @@ const PAYLOAD: Record<string, string | undefined> = {
extension_skipped: JSON.stringify({ extension: "e", point: "tool.before" }),
inbox_recovered: JSON.stringify({ count: 2 }),
permission_rules_dormant: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
job_started: JSON.stringify({ id: "j", label: "build" }),
job_finished: JSON.stringify({ id: "j", label: "build" }),
job_killed: JSON.stringify({ id: "j", label: "build" }),
job_failed: JSON.stringify({ id: "j", label: "build" }),
job_stalled: JSON.stringify({ id: "j", label: "build", quietSeconds: 900 }),
compaction_telemetry: JSON.stringify({ mode: "summarized", source_tokens: 122525, projection_tokens: 61192, input_tokens: 40710, output_tokens: 8186, cache_hit_tokens: 256, request_count: 1 }),
context_composition: JSON.stringify({ total: 561061, at: 419430, active: 400000, parts: [{ name: "tool_output", tokens: 500000 }] }),
subagent_waiting: JSON.stringify({ task: "fix the typo", by: "rewrite the docs", whole: true }),
user_turns_dropped: JSON.stringify({ count: 5, tokens: 1578, budget: 8000 }),
kept_turns_folded: JSON.stringify({ count: 2, tokens: 900, budget: 4000 }),
display_currency: "USD",
compact_declined: undefined,
compact_failed: "summary_failed",
@@ -67,6 +71,11 @@ describe("notice wording", () => {
["job_stalled", '{"id":"j"}'],
["job_stalled", '{"id":"j","quietSeconds":"900"}'],
["job_stalled", '{"id":"j","quietSeconds":0}'],
["compaction_telemetry", '{"mode":"summarized"}'],
["user_turns_dropped", '{"count":0}'],
["kept_turns_folded", "not json"],
["compaction_telemetry", '{"mode":"","source_tokens":1,"projection_tokens":1}'],
["compaction_telemetry", '{"mode":"summarized","source_tokens":"122525","projection_tokens":1}'],
["compact_failed", "no_such_reason"],
])("%s reads valid JSON of the wrong shape (%s) as no payload", (code, detail) => {
expect(noticeWording(code, detail)).toBeUndefined();
Loaded 100 of 1099 files, more files were not shown because too many files have changed in this diff. Show more