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
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
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
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
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
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 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 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
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
imcreazy 602c9a3e1d fix(studio): closing the last page offers a new tab 2026-10-21 07:13:30 +08:00
717 changed files with 30047 additions and 1702 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"
]
}
]
+672
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
@@ -223,6 +269,32 @@ 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/...
- name: test (Windows published command job owner)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
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
@@ -294,6 +366,606 @@ jobs:
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
# leg to every tool test: two real PowerShell launches must share the
+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")
}
+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
+3 -2
View File
@@ -10,11 +10,12 @@
"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",
@@ -31,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
+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 };
+18 -3
View File
@@ -1,5 +1,5 @@
"use strict";
const { app, BrowserWindow, dialog, ipcMain, Menu, 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.
@@ -31,7 +31,8 @@ const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor, themeSourceFor } = require("./ground");
const { loadPrefs, pageSleepAfterMs, 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");
@@ -90,6 +91,7 @@ let browserRelay = null;
let reload = null;
let powerGuard = null;
let logs = null;
let windowShortcut = null;
let handshaken = false;
let starting = null;
@@ -407,6 +409,7 @@ nativeTheme.on("updated", () => {
});
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));
@@ -554,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);
@@ -601,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();
+14 -5
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
@@ -20,13 +21,12 @@ function pageSleepAfterMs(prefs) {
return prefs && prefs["rx-page-sleep"] === "on" ? PAGE_SLEEP_MS : Infinity;
}
// 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.
// 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;
}
@@ -54,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
@@ -70,4 +73,10 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
});
}
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 };
+2
View File
@@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
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)),
@@ -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);
});
+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);
+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
+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;
+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();
}
+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));
+32 -2
View File
@@ -55,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";
@@ -104,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();
@@ -352,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.
@@ -363,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,
@@ -478,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);
@@ -485,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;
@@ -501,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;
@@ -513,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) => ({
@@ -546,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}`,
+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();
}
@@ -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();
}
+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;
}
+6
View File
@@ -108,7 +108,9 @@ 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" },
@@ -296,6 +298,7 @@ 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: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
@@ -451,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" },
@@ -592,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.
@@ -641,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.
@@ -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("");
}
});
}
});
+16 -9
View File
@@ -484,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.",
@@ -544,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",
@@ -573,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}",
@@ -599,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",
@@ -751,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.",
@@ -962,7 +968,6 @@ export const EN: Record<string, string> = {
"返回正文": "Back to the conversation",
"完成": "Done",
"快捷键": "Keyboard shortcuts",
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。": "These work anywhere in the window. Where a chord shows ⌘, Windows and Linux use Ctrl.",
"在对话里查找": "Find in the conversation",
"搜索会话": "Search conversations",
"在提问之间移动": "Move between your questions",
@@ -1223,8 +1228,6 @@ export const EN: Record<string, string> = {
"选择会话文件…": "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",
@@ -1602,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",
@@ -1693,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.",
@@ -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…",
@@ -13,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.",
@@ -50,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",
@@ -472,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",
+38 -1
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",
@@ -127,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",
@@ -167,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",
@@ -756,6 +773,8 @@ export const EN_SETTINGS: Record<string, string> = {
"摘要请求用了 {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",
@@ -1003,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",
@@ -1090,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",
@@ -1121,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",
@@ -1351,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.",
@@ -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("");
});
}
});
+11
View File
@@ -65,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": "会话文件超过安全导入限制(大小或记录数)。",
@@ -118,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 这些常见封装。",
@@ -165,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": "这台服务器未开放权限编辑",
@@ -17,6 +17,8 @@ const PAYLOAD: Record<string, string | undefined> = {
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",
@@ -70,6 +72,8 @@ describe("notice wording", () => {
["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"],
@@ -7,6 +7,7 @@ import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
import { JOB_FAILED, JOB_FINISHED, JOB_STARTED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice";
import { NOTICE_TEXT } from "./notices";
import { KEPT_TURNS_FOLDED, USER_TURNS_DROPPED, turnsDroppedVars } from "./turns_dropped";
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
export type NoticeVars = Record<string, string | number>;
@@ -30,6 +31,8 @@ const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefine
[COMPACTION_TELEMETRY]: compactionNoticeVars,
[CONTEXT_COMPOSITION]: contextCompositionVars,
[SUBAGENT_WAITING]: subagentWaitingVars,
[USER_TURNS_DROPPED]: turnsDroppedVars,
[KEPT_TURNS_FOLDED]: turnsDroppedVars,
display_currency: (detail) => ({ mode: detail || "auto" }),
compact_declined: compactReason("compact_declined"),
compact_failed: compactReason("compact_failed"),
@@ -51,6 +51,8 @@ export const NOTICE_TEXT: Record<string, string> = {
compact_declined: "无需压缩:{why}",
compact_failed: "压缩失败:{why}",
context_composition: "上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}",
user_turns_dropped: "你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。",
kept_turns_folded: "你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。",
subagent_waiting: "子代理「{task}」在等 {by} 先结束:{why}",
compact_held: "自动压缩暂缓,上次尝试没有完成:{why}",
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
@@ -0,0 +1,16 @@
export const USER_TURNS_DROPPED = "user_turns_dropped";
export const KEPT_TURNS_FOLDED = "kept_turns_folded";
// The typed payload these notices carry as their detail, read as the variables
// their sentences are worded from. A detail that is not one reads as none, so the
// notice keeps the kernel's English.
export function turnsDroppedVars(detail: string | undefined): Record<string, string> | undefined {
if (!detail) return undefined;
let p: { count?: unknown };
try {
p = JSON.parse(detail) as { count?: unknown };
} catch {
return undefined;
}
return typeof p.count === "number" && p.count > 0 ? { n: String(p.count) } : undefined;
}
+1
View File
@@ -9,6 +9,7 @@ import "./styles/composition-picker.css";
import "./styles/dock.css";
import "./styles/studio.css";
import "./styles/dialogcenter.css";
import "./styles/settings-type.css";
import "./styles/stophint.css";
import "./styles/phonecomposer.css";
import "./styles/tips.css";
+24
View File
@@ -24,6 +24,14 @@ export interface GraphicsInfo {
compositing: string;
}
export interface GlobalShortcutInfo {
binding: string;
status: "disabled" | "registered" | "invalid" | "unavailable" | "unsupported";
error?: "invalid" | "unavailable" | "save" | "quitting";
}
const unsupportedShortcut: GlobalShortcutInfo = { binding: "", status: "unsupported" };
export interface TextContextMenuRequest {
editable: boolean;
selected: boolean;
@@ -52,6 +60,8 @@ export interface HostPort {
captureWindow(): Promise<Uint8Array | null>;
/** How the shell draws this launch; null where it has no say, as in a tab. */
graphics(): Promise<GraphicsInfo | null>;
globalShortcut(): Promise<GlobalShortcutInfo>;
setGlobalShortcut(binding: string): Promise<GlobalShortcutInfo>;
/** The shell's own processes as a type and two sizes each, for the memory a
* report may carry; empty where there is no shell to ask, as in a tab. */
processMemory(): Promise<FeedbackProcessMemory[]>;
@@ -181,6 +191,8 @@ interface ElectronBridge {
closeWindow(): Promise<void>;
captureWindow?(): Promise<Uint8Array | null>;
graphics?(): Promise<GraphicsInfo | null>;
globalShortcut?(): Promise<GlobalShortcutInfo | null>;
setGlobalShortcut?(binding: string): Promise<GlobalShortcutInfo | null>;
processMemory?(): Promise<unknown>;
openExternal(url: string): Promise<void>;
setUnreadBadge?(unread: boolean): Promise<void>;
@@ -250,6 +262,12 @@ class ElectronHost implements HostPort {
graphics() {
return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null);
}
globalShortcut() {
return this.api.globalShortcut?.().then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut);
}
setGlobalShortcut(binding: string) {
return this.api.setGlobalShortcut?.(binding).then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut);
}
processMemory() {
return this.api.processMemory?.().then((rows: unknown) => (Array.isArray(rows) ? (rows as FeedbackProcessMemory[]) : [])).catch(() => []) ?? Promise.resolve([]);
}
@@ -358,6 +376,12 @@ class BrowserHost implements HostPort {
graphics() {
return Promise.resolve(null);
}
globalShortcut() {
return Promise.resolve(unsupportedShortcut);
}
setGlobalShortcut() {
return Promise.resolve(unsupportedShortcut);
}
processMemory() {
return Promise.resolve([]);
}
@@ -0,0 +1,24 @@
import { afterEach, expect, it, vi } from "vitest";
// Host selection is cached at module load/use; each test needs a fresh module.
afterEach(() => vi.unstubAllGlobals());
it("does not grant browser or network viewers a host-machine shortcut", async () => {
vi.resetModules();
vi.stubGlobal("window", {});
const { host } = await import("./host");
expect(await host().globalShortcut()).toEqual({ binding: "", status: "unsupported" });
expect(await host().setGlobalShortcut("CommandOrControl+Shift+Space")).toEqual({ binding: "", status: "unsupported" });
});
it("asks the local Electron shell immediately, without browser storage", async () => {
vi.resetModules();
const state = { binding: "Control+Shift+Space", status: "registered" };
const apply = vi.fn(async () => state);
vi.stubGlobal("window", { reasonixHost: { shell: "electron", platform: "linux", globalShortcut: async () => state, setGlobalShortcut: apply } });
const { host } = await import("./host");
expect(await host().globalShortcut()).toEqual(state);
expect(await host().setGlobalShortcut(state.binding)).toEqual(state);
expect(apply).toHaveBeenCalledWith(state.binding);
});
+2
View File
@@ -101,6 +101,8 @@ export interface OpenWithTarget {
id: string;
name: string;
available: boolean;
why?: string;
within?: string;
}
export interface TreeWorkspace {
+37 -3
View File
@@ -14,6 +14,20 @@ export interface ThemeBackground {
taskOpacity: number;
overlayStrength: number;
textGlow?: "default" | "none";
surfaceOpacity?: ThemeSurfaceOpacities;
}
export interface ThemeSurfaceOpacities {
light?: ThemeSurfaceOpacity;
dark?: ThemeSurfaceOpacity;
}
export interface ThemeSurfaceOpacity {
nav?: number;
rail?: number;
chrome?: number;
side?: number;
compose?: number;
}
// A live backdrop the window draws, rather than a picture it places. Absent on
@@ -80,13 +94,29 @@ export interface Appearance {
textSize?: number;
fontUi?: string;
fontMono?: string;
// How far prose may run: "standard" holds it to a reading line length,
// "full" lets it use the window. Cards, commands and output are not
// governed by it.
// Custom width sizes the conversation and composer as a percentage of the pane.
width?: string;
widthPercent?: number;
widthRange?: ZoomRange;
wallpaper?: Wallpaper;
}
export interface WallpaperLayer {
url: string;
x: number;
y: number;
w: number;
h: number;
alpha: number;
effects?: string[];
}
export interface WallpaperAudioTrack {
url: string;
volume: number;
loop: boolean;
}
export interface Wallpaper {
// Content-addressed, so the bytes at this address never change.
url: string;
@@ -98,6 +128,10 @@ export interface Wallpaper {
title?: string;
audio?: string;
audios?: string[];
audioTracks?: WallpaperAudioTrack[];
layers?: WallpaperLayer[];
sceneWidth?: number;
sceneHeight?: number;
parallax?: number;
muted?: boolean;
volume?: number;
+9 -5
View File
@@ -3,7 +3,7 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { JobOutput, PlanAction } from "./session";
import { approvalKind } from "./mock_approval";
import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, UploadReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
@@ -11,7 +11,7 @@ import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
import { MEMORIES, mockMemoryCatalog } from "./mock_memory";
import { mockModels } from "./mock_models";
import { mockUsage } from "./mock_usage";
import { mockUploadUsage, mockUsage } from "./mock_usage";
export class MockPort extends MockWorkspace implements AgentPort {
private listeners = new Set<(ev: WireEvent) => void>();
@@ -112,9 +112,9 @@ export class MockPort extends MockWorkspace implements AgentPort {
private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m }));
async usage(query: number | UsageQuery): Promise<UsageReport> {
return mockUsage(query);
}
async usage(query: number | UsageQuery): Promise<UsageReport> { return mockUsage(query); }
uploadUsage(days: number): Promise<UploadReport> { return Promise.resolve(mockUploadUsage(days)); }
async memories(): Promise<MemoryCatalog> {
return mockMemoryCatalog(this.mem);
@@ -416,6 +416,10 @@ export class MockPort extends MockWorkspace implements AgentPort {
return `/mock/branch-${turn}.jsonl`;
}
async continueFromTail(path: string): Promise<{ path: string; dropped: number }> {
return { path: path.replace(/\.jsonl$/, "-tail.jsonl"), dropped: 12 };
}
// The second prompt onwards is scripted to have run bash, so mock mode can
// show the consent stage the real kernel demands on partial coverage.
async prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan> {
+4 -2
View File
@@ -17,15 +17,17 @@ export class MockLook {
// What the kernel announces; the zoom test holds it equal to config.ZoomMin..ZoomMax.
private static readonly ZOOM = { min: 0.8, max: 1.8, step: 0.05 };
private static readonly WIDTH = { min: 30, max: 100, step: 1 };
async appearance(): Promise<Appearance> {
return { ...this.look, zoomRange: MockLook.ZOOM };
return { ...this.look, zoomRange: MockLook.ZOOM, widthPercent: this.look.widthPercent || 80, widthRange: MockLook.WIDTH };
}
async saveAppearance(look: Appearance): Promise<Appearance> {
const { min, max } = MockLook.ZOOM;
const zoom = look.zoom ? Math.min(Math.max(look.zoom, min), max) : look.zoom;
this.look = { ...look, zoom, wallpaper: this.look.wallpaper && { ...this.look.wallpaper, ...look.wallpaper } };
const widthPercent = look.widthPercent ? Math.min(Math.max(Math.round(look.widthPercent), MockLook.WIDTH.min), MockLook.WIDTH.max) : this.look.widthPercent;
this.look = { ...look, zoom, widthPercent, wallpaper: this.look.wallpaper && { ...this.look.wallpaper, ...look.wallpaper } };
return this.appearance();
}
@@ -8,30 +8,30 @@ export function mockModels(efforts: string[]): ModelEntry[] {
return [
{
ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high", webSearch: true,
kind: "openai", vendor: "api.deepseek.com", mark: "deepseek", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high", webSearch: true,
contextWindow: 131072, price: { input: 2, output: 8, currency: "CNY" },
},
{
ref: "deepseek-anthropic/deepseek-v4-pro", provider: "deepseek-anthropic",
model: "deepseek-v4-pro", kind: "anthropic", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY",
model: "deepseek-v4-pro", kind: "anthropic", vendor: "api.deepseek.com", mark: "deepseek", keyEnv: "DEEPSEEK_API_KEY",
efforts, effort: "high", contextWindow: 131072,
},
{
ref: "deepseek/deepseek-flash", provider: "deepseek", model: "deepseek-flash",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", efforts, effort: "high",
kind: "openai", vendor: "api.deepseek.com", mark: "deepseek", keyEnv: "DEEPSEEK_API_KEY", efforts, effort: "high",
contextWindow: 131072, price: { input: 0.5, output: 2, currency: "CNY" },
},
{
ref: "kimi/kimi-k2-vision", provider: "kimi", model: "kimi-k2-vision",
kind: "openai", vendor: "api.moonshot.cn", keyEnv: "KIMI_API_KEY", vision: true, contextWindow: 262144,
kind: "openai", vendor: "api.moonshot.cn", mark: "kimi", keyEnv: "KIMI_API_KEY", vision: true, contextWindow: 262144,
},
{
ref: "myrelay/gpt-4o", provider: "myrelay", model: "gpt-4o", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", vision: true, contextWindow: 131072,
vendor: "relay.example.com", serviceMark: "openrouter", keyEnv: "MYRELAY_API_KEY", vision: true, contextWindow: 131072,
},
{
ref: "myrelay/claude-sonnet-4", provider: "myrelay", model: "claude-sonnet-4", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", contextWindow: 200000,
vendor: "relay.example.com", serviceMark: "openrouter", keyEnv: "MYRELAY_API_KEY", contextWindow: 200000,
},
{
ref: "myrelay-work/gpt-4o", provider: "myrelay-work", model: "gpt-4o", kind: "openai",
@@ -35,20 +35,20 @@ export class MockProvider extends MockBoundary {
{
name: "deepseek", kind: "openai", effortField: "reasoning_effort", baseUrl: "https://api.deepseek.com",
models: ["deepseek-v4-pro", "deepseek-flash"], default: "deepseek-v4-pro",
hasKey: true, inUse: true, preset: false, keyEnv: "DEEPSEEK_API_KEY", canSetVision: false,
hasKey: true, inUse: true, preset: false, mark: "deepseek", keyEnv: "DEEPSEEK_API_KEY", canSetVision: false,
balanceUrl: "https://api.deepseek.com/user/balance", impliedBalanceFormat: "deepseek", balanceFormats: ["deepseek", "moonshot", "openrouter"],
},
{
name: "deepseek-anthropic", kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic",
models: ["deepseek-v4-pro"], default: "deepseek-v4-pro",
hasKey: true, inUse: false, preset: false, keyEnv: "DEEPSEEK_API_KEY",
hasKey: true, inUse: false, preset: false, mark: "deepseek", keyEnv: "DEEPSEEK_API_KEY",
canSetVision: false, canWebSearch: true, webSearch: true,
canSetThinking: true, sendsThinking: true, effortField: "output_config.effort",
},
{
name: "myrelay", kind: "openai", effortField: "reasoning_effort", baseUrl: "https://relay.example.com/v1",
models: ["gpt-4o", "claude-sonnet-4"], default: "gpt-4o",
hasKey: true, inUse: false, preset: false, keyEnv: "MYRELAY_API_KEY",
hasKey: true, inUse: false, preset: false, mark: "openrouter", keyEnv: "MYRELAY_API_KEY",
balanceUrl: "https://relay.example.com/user/balance", impliedBalanceFormat: "", balanceFormats: ["deepseek", "moonshot", "openrouter"],
visionModels: ["gpt-4o"], canSetVision: true, canSetThinking: true, sendsThinking: true,
reasoningProtocol: "openai", supportedEfforts: ["low", "medium", "high"],
@@ -67,7 +67,7 @@ export class MockProvider extends MockBoundary {
{
name: "myrelay-responses", kind: "responses", baseUrl: "https://relay.example.com/v1",
models: ["gpt-4o"], default: "gpt-4o",
hasKey: true, inUse: false, preset: false, keyEnv: "MYRELAY_API_KEY",
hasKey: true, inUse: false, preset: false, mark: "openrouter", keyEnv: "MYRELAY_API_KEY",
canSetContinuation: true, continuation: "",
canSetThinking: true, sendsThinking: true, effortField: "reasoning.effort",
},
@@ -96,11 +96,22 @@ export class MockProvider extends MockBoundary {
}
async providerPresets(): Promise<ProviderPreset[]> {
const p = (id: string, label: string, vendor: string, kind = "openai", baseUrl = `https://api.${vendor}.example/v1`): ProviderPreset => ({ id, label, kind, baseUrl, vendor });
return [
{ id: "kimi-cn", label: "Kimi CN API", kind: "openai", baseUrl: "https://api.moonshot.cn/v1" },
{ id: "kimi-global", label: "Kimi Global API", kind: "openai", baseUrl: "https://api.moonshot.ai/v1" },
{ id: "stepfun", label: "StepFun", kind: "openai", baseUrl: "https://api.stepfun.com/v1" },
{ id: "stepfun-anthropic", label: "StepFun Anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/anthropic" },
p("deepseek-responses", "DeepSeek Official Responses API", "deepseek", "responses", "https://api.deepseek.com"),
p("glm-cn", "GLM CN API", "zhipu", "openai", "https://open.bigmodel.cn/api/paas/v4"),
p("zai-global", "Z.AI Global API", "zhipu", "openai", "https://api.z.ai/api/paas/v4"),
p("kimi-cn", "Kimi CN API", "kimi", "openai", "https://api.moonshot.cn/v1"),
p("kimi-global", "Kimi Global API", "kimi", "openai", "https://api.moonshot.ai/v1"),
p("minimax-cn-api", "MiniMax CN API", "minimax", "openai", "https://api.minimaxi.com/v1"),
p("qwen-cn", "Qwen CN API", "qwen", "openai", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
p("mimo-api", "MiMo API", "mimo", "openai", "https://api.xiaomimimo.com/v1"),
p("longcat-openai", "LongCat OpenAI", "longcat", "openai", "https://api.longcat.chat/openai/v1"),
p("stepfun", "StepFun", "stepfun", "openai", "https://api.stepfun.com/v1"),
p("openrouter", "OpenRouter", "openrouter", "openai", "https://openrouter.ai/api/v1"),
p("openai", "OpenAI", "openai", "openai", "https://api.openai.com/v1"),
p("gemini", "Google Gemini", "google", "openai", "https://generativelanguage.googleapis.com/v1beta/openai"),
p("token-rhythm", "Token Rhythm", "token-rhythm", "openai", "https://tokenrhythm.studio/v1"),
];
}
+18 -1
View File
@@ -1,4 +1,4 @@
import type { UsageHour, UsageQuery, UsageReport } from "./port";
import type { UploadReport, UsageHour, UsageQuery, UsageReport } from "./port";
import { USAGE_PRICED, USAGE_TOKENS } from "./mock_memory";
import { DEFAULT_USAGE_DAYS } from "./usage";
@@ -108,3 +108,20 @@ export function mockUsage(query: number | UsageQuery): UsageReport {
],
};
}
// A fortnight of uploads with quiet days, two providers and one heavy day.
export function mockUploadUsage(days: number): UploadReport {
const shown = Math.min(days, 14);
const daily = Array.from({ length: shown }, (_, i) => ({
day: new Date(Date.now() - (shown - 1 - i) * DAY_MS).toISOString().slice(0, 10),
bytes: [0, 18e6, 240e6, 410e6, 95e6, 0, 0, 120e6, 760e6, 330e6, 40e6, 0, 210e6, 150e6][i] ?? 0,
}));
const total = daily.reduce((n, d) => n + d.bytes, 0);
return {
from: daily[0]?.day ?? "", to: daily[daily.length - 1]?.day ?? "", bytes: total, requests: 1280, daily,
providers: [
{ provider: "deepseek", bytes: Math.round(total * 0.86), requests: 1100, percent: 86 },
{ provider: "relay-work", bytes: Math.round(total * 0.14), requests: 180, percent: 14 },
],
};
}
+6
View File
@@ -54,6 +54,12 @@ export interface ModelEntry {
displayName?: string;
model: string;
modelAlias?: string;
// The vendor slug of the preset this provider came from; absent when none declared it.
mark?: string;
// The model as it reads with its entry's shared prefix left out; absent draws it as it is.
modelShort?: string;
// The service's own vendor slug; differs from mark on a relay, whose models are others'.
serviceMark?: string;
kind?: string;
active?: boolean;
// Configured but outside the access list the person added, so a role cannot pick it.
@@ -7,7 +7,7 @@ import type { WireEvent } from "./wire";
interface Source { url: string; closed: boolean; open: (conn: string) => void; say: (pane: string, frame: object) => void }
function stubHub(answer: (method: string, url: string, body: string) => { status: number; code?: string } = () => ({ status: 204 })) {
function stubHub(answer: (method: string, url: string, body: string) => { status: number; code?: string; json?: object } = () => ({ status: 204 })) {
const sources: Source[] = [];
const calls: { method: string; url: string; body: string }[] = [];
vi.stubGlobal(
@@ -35,7 +35,7 @@ function stubHub(answer: (method: string, url: string, body: string) => { status
const call = { method: init.method ?? "GET", url, body: String(init.body ?? "") };
calls.push(call);
const a = answer(call.method, url, call.body);
return { ok: a.status < 300, status: a.status, json: async () => ({ code: a.code }) } as Response;
return { ok: a.status < 300, status: a.status, json: async () => a.json ?? { code: a.code } } as Response;
});
return { sources, calls };
}
@@ -238,6 +238,36 @@ describe("a pane's subscription over the shared stream", () => {
sub();
});
// The kernel stands at 7 when the pane subscribes; the shared stream opens later.
const attachAt = async (watermark: number) => {
const hub = stubHub((method, url) => method === "GET" && url === "/rt/r1/events/replay"
? { status: 200, json: { frames: [], complete: true, watermark: 7 } }
: { status: 204 });
const got: number[] = [];
const gap = vi.fn();
const sub = subscribeStream("/rt/r1", (ev: WireEvent) => got.push(ev.seq ?? 0), gap, undefined, "r1");
await flush();
const s = open(hub.sources)[0];
s.open("c1");
await flush();
s.say("r1", { kind: "stream_watermark", seq: watermark });
s.say("r1", { kind: "tick", seq: watermark + 1 });
sub();
return { got, gap };
};
it("says what went by between subscribing and the shared stream attaching the pane is a hole", async () => {
const { got, gap } = await attachAt(9);
expect(gap).toHaveBeenCalledOnce();
expect(got).toEqual([10]);
});
it("attaches without a hole when the stream did not move in between", async () => {
const { got, gap } = await attachAt(7);
expect(gap).not.toHaveBeenCalled();
expect(got).toEqual([8]);
});
it("keeps the budget out of panes that cost no connection", async () => {
stubHub();
const sub = subscribeStream("/rt/r1", () => {}, undefined, undefined, "r1");
+5 -5
View File
@@ -1,8 +1,7 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
export type * from "./composition";
import { DeliveryError, HttpError } from "./http_error";
import type { DeliveryFault } from "./http_error";
import { DeliveryError, HttpError, type DeliveryFault } from "./http_error";
import type { SideProgress } from "./side";
/** What `subscribe` hands back: the disposer, and where the transport has one, a
@@ -20,10 +19,10 @@ export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adju
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
import type { MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory";
import type { UsageQuery, UsageReport } from "./usage";
import type { UploadUsagePort, UsageQuery, UsageReport } from "./usage";
export { DEFAULT_USAGE_DAYS } from "./usage";
export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
export type { Money, UploadDay, UploadProvider, UploadReport, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
@@ -32,6 +31,7 @@ import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } fro
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
import type { SessionTailPort } from "./session_tail";
// The port is one contract; its subjects each keep their own file, the way the
// wire and the layers below already do. This is where a reader still finds
@@ -183,7 +183,7 @@ export interface TrayPrefs {
closeToTray: boolean;
}
export interface AgentPort extends WorkspacePreviewPort {
export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, UploadUsagePort {
providerSetup(): Promise<ProviderSetup | null>;
saveProviderKey(apiKey: string): Promise<void>;
// The models that answer the job asked about; "chat" when none is named.
@@ -6,10 +6,16 @@ export interface ProviderEntry {
// The label a person gave this source. Only ever shown: every ref, the
// default model and saved sessions keep pointing at name.
displayName?: string;
// The vendor slug the source's preset declares; absent for a hand-written source.
mark?: string;
kind: string;
baseUrl: string;
models: string[];
modelAliases?: Record<string, string>;
// Whether the prefix every model shares is left out of how they are drawn, and each
// model as it then reads; a model drawn as it is has no entry.
hideModelPrefix?: boolean;
modelShorts?: Record<string, string>;
default: string;
hasKey: boolean;
keyEnv?: string;
@@ -126,6 +132,9 @@ export interface ProviderPreset {
label: string;
kind: string;
baseUrl: string;
// Who makes or runs the service, declared by the kernel's catalogue. The mark
// drawn for it is keyed on this, never on the label.
vendor: string;
}
export interface ProviderPresetInstall {
@@ -244,6 +253,8 @@ export interface ProviderEdit {
name: string;
// Omitted preserves aliases; blank or missing values reset listed models.
modelAliases?: Record<string, string>;
// Omitted leaves the choice alone.
hideModelPrefix?: boolean;
// Omitted leaves the wire format alone.
kind?: string;
baseUrl?: string;
@@ -0,0 +1,5 @@
export interface SessionTailPort {
// Copies the newest part of a session too large to open into a new session;
// dropped is how many earlier messages were left out. The source is untouched.
continueFromTail(path: string): Promise<{ path: string; dropped: number }>;
}
+7 -1
View File
@@ -1,6 +1,6 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, UploadReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -83,6 +83,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
}
return this.get<UsageReport>("/usage?" + q);
}
uploadUsage(days: number) {
return this.get<UploadReport>(`/usage/upload?days=${days}`);
}
memories() {
return this.get<MemoryCatalog>("/memory");
}
@@ -476,6 +479,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
async branchAt(turn: number, name: string) {
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
}
continueFromTail(path: string) {
return this.post0<{ path: string; dropped: number }>("/session/tail", { path });
}
availableUndo() { return this.get<RewindUndo | null>("/rewind/undo"); }
undoRewind(transactionId: string) {
@@ -0,0 +1,20 @@
import { afterEach, expect, it, vi } from "vitest";
import { SsePort } from "./sse";
afterEach(() => vi.unstubAllGlobals());
// The kernel answers a default-preset change with 204 and no body, the way a
// status-only POST does. Reading that as JSON is what made a change that landed
// report "Unexpected end of JSON input".
it("treats the empty 204 answer to a default-preset change as success", async () => {
const calls: { url: string; body: unknown }[] = [];
vi.stubGlobal("fetch", vi.fn(async (url: string, init?: { body?: string }) => {
calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
return { ok: true, status: 204, text: async () => "", json: async () => { throw new SyntaxError("Unexpected end of JSON input"); } };
}));
const port = new SsePort("", "r1");
await expect(port.setDefaultAgentPreset("user:reviewer", false)).resolves.toBeUndefined();
await expect(port.setDefaultAgentPreset("user:reviewer", true)).resolves.toBeUndefined();
expect(calls.map((c) => c.body)).toEqual([{ id: "user:reviewer", workspace: false }, { id: "user:reviewer", workspace: true }]);
expect(calls.every((c) => c.url.endsWith("/agent-presets/default"))).toBe(true);
});
@@ -12,7 +12,7 @@ export class SseBoundary extends SseShell {
agentPresets() { return this.get<AgentPresetCatalog>("/agent-presets"); }
saveAgentPreset(definition: AgentComposition, expectedRevision: string) { return this.post0<AgentPresetVersion>("/agent-presets", { definition, expectedRevision }); }
deleteAgentPreset(id: string, revision: string) { return this.del(`/agent-presets/${encodeURIComponent(id)}?revision=${encodeURIComponent(revision)}`); }
setDefaultAgentPreset(id: string, workspace: boolean) { return this.post0<void>("/agent-presets/default", { id, workspace }); }
setDefaultAgentPreset(id: string, workspace: boolean) { return this.post("/agent-presets/default", { id, workspace }); }
exportAgentPreset(id: string) { return this.get<AgentComposition>(`/agent-presets/${encodeURIComponent(id)}/export`); }
importAgentPreset(content: AgentComposition) { return this.post0<{definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}>("/agent-presets/import", { content }); }
agentComposition() { return this.get<CompositionSummary | null>("/agent-composition"); }
@@ -18,6 +18,7 @@ export class SseLook extends SseHttp {
fontUi: look.fontUi ?? "",
fontMono: look.fontMono ?? "",
width: look.width ?? "",
widthPercent: look.widthPercent ?? 0,
opacity: look.wallpaper?.opacity ?? 0,
dim: look.wallpaper?.dim ?? 0,
focusX: look.wallpaper?.focusX ?? 0.5,
@@ -35,6 +35,14 @@ export function subscribeStream(base: string, onEvent: (ev: WireEvent) => void,
let recovering = booting;
let held: WireEvent[] = [];
let live = true;
// The shared stream attaches a pane only once it is open, which can be long
// after this call. Where the stream stood at subscribing is read at once, and
// an attach that finds it has moved on since reports what went by as a hole.
let subscribedAt: number | null = null;
let attachedAt: number | null = null;
const blindAttach = () => {
if (subscribedAt !== null && attachedAt !== null && attachedAt > subscribedAt) onGap?.();
};
const deliver = (ev: WireEvent) => {
// The cursor is the record of what has been folded, and the replay and
@@ -103,7 +111,11 @@ export function subscribeStream(base: string, onEvent: (ev: WireEvent) => void,
// A bootstrap states that position from its snapshot instead, and taking
// it from here would baseline the cursor over frames already held.
if (seen === null) {
if (!booting) seen = ev.seq ?? 0;
if (!booting) {
seen = ev.seq ?? 0;
attachedAt = seen;
blindAttach();
}
return;
}
if (!recovering && ev.seq && ev.seq > seen) void recover(seen);
@@ -157,6 +169,13 @@ export function subscribeStream(base: string, onEvent: (ev: WireEvent) => void,
// stream instead of costing the window a connection each.
const detach = lane ? mux.attach(lane, { cursor: () => seen, onData: feed, own }) : own();
if (bootstrap) void bootstrapFrom(bootstrap);
else if (lane) {
// A position that cannot be read cannot vouch for the attach either.
void positionNow(base).catch(() => -1).then((at) => {
subscribedAt = at;
if (live) blindAttach();
});
}
// A subscription that gave its connection up before the stream ever stated a
// position has nothing to resume from, so what went by is a hole it cannot
// name: it says so, and the caller re-reads.
@@ -182,3 +201,13 @@ export function subscribeStream(base: string, onEvent: (ev: WireEvent) => void,
},
);
}
// Where the stream stands now, as the attach read of a client with no stream
// open answers it.
async function positionNow(base: string): Promise<number> {
const res = await fetch(`${base}/events/replay`, { credentials: "same-origin" });
if (!res.ok) throw new Error(String(res.status));
const { watermark } = (await res.json()) as { watermark?: unknown };
if (typeof watermark !== "number") throw new Error("the attach read carried no watermark");
return watermark;
}
+31
View File
@@ -78,6 +78,10 @@ export interface UsageReport {
// Set when a counted call's money is unknown (no rate card, or a quote that
// could not price its usage). cost is then a floor, not the bill.
costIncomplete?: boolean;
// How many of those counted calls are under that floor, and the distinct
// reasons the rows recorded (the kernel's incomplete-reason vocabulary).
unpricedCalls?: number;
incompleteReasons?: string[];
active_days: number;
top_model: string;
top_provider: string;
@@ -88,3 +92,30 @@ export interface UsageReport {
models: UsageModel[];
providers: UsageProvider[];
}
export interface UploadDay {
day: string;
bytes: number;
}
export interface UploadProvider {
provider: string;
bytes: number;
requests: number;
percent: number;
}
// What model providers were sent: request bodies as they went out, before the
// connection adds its own overhead. Downloads are not counted.
export interface UploadReport {
from: string;
to: string;
bytes: number;
requests: number;
daily: UploadDay[];
providers: UploadProvider[];
}
export interface UploadUsagePort {
uploadUsage(days: number): Promise<UploadReport>;
}
@@ -7,13 +7,21 @@ afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.removeItem(NEW_T
it("leaves old installations unselected and ignores unsupported stored identities", () => {
expect(newTaskComposition()).toBeUndefined();
for (const raw of ["", "default", "user:review", "project:review", "builtin:retired", "{bad"]) {
for (const raw of ["", "default", "user:", "user:has space", "project:review", "builtin:retired", "{bad"]) {
localStorage.setItem(NEW_TASK_PRESET_KEY, raw);
expect(newTaskPreset()).toBeUndefined();
}
expect(setNewTaskPreset("project:review")).toBe(false);
});
it("accepts a personal preset as the default, which a project's shared preset cannot be", () => {
expect(setNewTaskPreset("user:review-pro")).toBe(true);
expect(newTaskPreset()).toBe("user:review-pro");
expect(newTaskComposition()).toBe("user:review-pro");
expect(setNewTaskPreset("project:review")).toBe(false);
expect(newTaskPreset()).toBe("user:review-pro");
});
it("round-trips explicit built-in choices while preserving request and history choices", () => {
for (const id of ["builtin:general", "builtin:minimal"]) {
expect(setNewTaskPreset(id)).toBe(true);
@@ -2,11 +2,13 @@ import { useSyncExternalStore } from "react";
export const NEW_TASK_PRESET_KEY = "rx-new-task-agent-preset";
const listeners = new Set<() => void>();
type BuiltinPreset = "builtin:general" | "builtin:minimal";
const valid = (value: unknown): value is BuiltinPreset =>
value === "builtin:general" || value === "builtin:minimal";
// A built-in preset, or one of the person's own: a project's shared preset is
// not offered, because it exists only where that project is open.
type NewTaskPresetId = "builtin:general" | "builtin:minimal" | `user:${string}`;
const valid = (value: unknown): value is NewTaskPresetId =>
value === "builtin:general" || value === "builtin:minimal" || (typeof value === "string" && /^user:[\w.-]+$/.test(value));
export function newTaskPreset(): BuiltinPreset | undefined {
export function newTaskPreset(): NewTaskPresetId | undefined {
try {
const value = localStorage.getItem(NEW_TASK_PRESET_KEY);
return valid(value) ? value : undefined;
@@ -21,6 +23,11 @@ export function setNewTaskPreset(id: string): boolean {
return true;
}
export function forgetNewTaskPreset() {
try { localStorage.removeItem(NEW_TASK_PRESET_KEY); } catch { return; }
listeners.forEach(fn => fn());
}
function subscribe(fn: () => void) {
listeners.add(fn);
const refresh = (event: StorageEvent) => {
+39 -2
View File
@@ -1,9 +1,46 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from "vitest";
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, setLiveGraceMinutes, setOpenSessionLimit } from "./prefs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, setLiveGraceMinutes, setOpenSessionLimit, onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "./prefs";
beforeEach(() => localStorage.clear());
describe("collapsed plan progress on this machine", () => {
it("is off by default and only accepts an explicit on preference", () => {
expect(showsPlanProgress()).toBe(false);
for (const raw of ["", "true", "off", "garbage"]) {
localStorage.setItem("rx-plan-progress", raw);
expect(showsPlanProgress()).toBe(false);
}
setShowsPlanProgress(true);
expect(showsPlanProgress()).toBe(true);
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
setShowsPlanProgress(false);
expect(showsPlanProgress()).toBe(false);
});
it("notifies mounted subscribers and removes them on unsubscribe", () => {
const heard = vi.fn();
const stop = onPlanProgressChange(heard);
setShowsPlanProgress(true);
stop();
setShowsPlanProgress(false);
expect(heard).toHaveBeenCalledTimes(1);
});
it("stays usable with unavailable storage", () => {
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("unavailable"); });
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("unavailable"); });
try {
expect(showsPlanProgress()).toBe(false);
expect(() => setShowsPlanProgress(true)).not.toThrow();
expect(showsPlanProgress()).toBe(false);
} finally {
get.mockRestore();
set.mockRestore();
}
});
});
describe("how long a finished session stays in the running list", () => {
it("is off until this machine chooses otherwise", () => {
expect(liveGraceMinutes()).toBe(0);
Loaded 100 of 717 files, more files were not shown because too many files have changed in this diff. Show more