Compare commits

...
638 Commits
Author SHA1 Message Date
esengine 6f4bef828d test(model): skip the swap-after-open tests on Windows, which cannot replace an open file 2026-10-17 09:52:42 -07:00
esengine 94f3dc758d test(model): a swap over an open file is refused on Windows, not a failure 2026-10-17 09:49:37 -07:00
esengine 443d9afb6d docs(model): give the unavailable-video code its own line 2026-10-17 09:08:42 -07:00
esengine 30b2cf89de fix(model): open only the videos a tool in this process handed out
A stored reference carried a path the adapter opened as written, so a session
file that was edited, restored or imported could name a file no tool had
checked. read_video now records the path it verified under the file's hash in a
process-local registry; the adapter resolves through it and reports an
ungranted reference as video.file_unavailable.
2026-10-17 09:08:20 -07:00
esengine addbb5bf05 fix(model): hash the opened video before sending a byte of it 2026-10-17 05:48:17 -07:00
esengine dedc3e5af3 fix(model): open a video file once and send exactly what was checked 2026-10-17 05:45:21 -07:00
esengine 6c53e4d6a4 refactor: walk the messages backward with slices.Backward 2026-10-17 04:35:17 -07:00
esengine 506ef7e5de style: align the native video line with the card body 2026-10-17 04:35:17 -07:00
esengine d45454a300 feat: show a natively sent video on the card and in the terminal; document it 2026-10-17 04:35:17 -07:00
esengine d0f220096b refactor: keep the video change inside the file, function and comment budgets 2026-10-17 04:35:17 -07:00
esengine d1488d5d0c feat(boot): wire native video through the real build and prove it at the vendor request boundary 2026-10-17 04:35:17 -07:00
esengine a91aaa6c4d feat(config): a user-level ceiling below the documented native video limit 2026-10-17 04:35:17 -07:00
esengine 1502533094 feat(tools): read_video hands an MP4 to a model that watches video natively 2026-10-17 04:35:17 -07:00
esengine e47de51cab feat(model): send a referenced video to endpoints that declare a protocol, and say so when one cannot 2026-10-17 04:35:17 -07:00
esengine 4b297506db feat(provider): a message can reference a video file; it rides only the request before the model answers 2026-10-17 04:35:17 -07:00
esengine 2a51c686bc feat(config): declare which models read video natively and how a file reaches them 2026-10-17 04:35:16 -07:00
YHH 189f7672c7 Merge pull request #13614 from xxoingr/fix/control-pdf-cancel-bounded-taskkill
fix(control): cancel a PDF extraction through proc's bounded tree kill
2026-10-17 04:32:12 -07:00
YHH ffe2885867 Merge pull request #13446 from chuiwenwei/fix/e2ebench-unmeasured-run-not-accounted
fix(e2ebench): account a sidecar-recovered run as a lower bound
2026-10-17 04:27:53 -07:00
xxoingr 2234b0f8a0 fix(control): cancel a PDF extraction through proc's bounded tree kill
setShellKillTree on Windows ran its own taskkill /F /T inside
exec.Cmd.Cancel with no time limit. #13323 bounded the taskkill in
proc.KillTree because Wait does not return until the cancel hook has,
but this hook never went through KillTree, so a PDF text extraction past
its 8 s deadline could still hang on a taskkill that never answers.

Delegate to proc.SetCancelKillsTree, the helper proc.RunCommand already
uses, so the only taskkill left is the bounded one in proc.
2026-10-17 19:12:32 +08:00
Will 27c92d2dfb fix(e2ebench): a lower-bound run stays partial across legs and out of the meter ratio
Review follow-up: Partial now accumulates across legs (a killed first leg plus a
complete second leg is still a lower bound), and the meter comparison skips a
Partial run the same way it skips an unaccounted one, since its missing tail is
not a divergence. Rebased onto current studio; the stub tests use requireShellStub
so test (windows-latest) can run.
2026-10-17 19:06:09 +08:00
Will d74e5ad218 fix(e2ebench): account a sidecar-recovered run as a lower bound
A leg whose final record was missing but whose <metrics>.partial snapshot was
read was marked Unaccounted, which gatherSuiteStats skips outright. That
contradicted Partial's meaning — real numbers that stop at the last snapshot
and belong in the totals — so the recovered tokens, cost and lower-bound hint
were all unreachable. Only a leg with neither record is now Unaccounted; a
recovered snapshot stays accounted and sets Partial, matching the semantics
swebench mode already uses.

renderMeterAccounting also treated PromptTokens+CompletionTokens == 0 as an
absent harness measurement, inferring state from a value. A persisted record
saying zero is a valid zero, so the guard is now the explicit Unaccounted flag
alone.

Tests: the copied audit case that asserted unaccounted=1 for a sidecar-recovered
run encoded the wrong semantics and now asserts Partial=true, Unaccounted=false
and that 1200/80 and the cost reach the aggregates; the divergence fixture that
relied on zero tokens sets Unaccounted explicitly, and a new case pins that a
persisted zero is priced on both sides. The record-less case and the healthy-run
control are unchanged in intent.
2026-10-17 19:05:20 +08:00
YHH 6d74605fd0 Merge pull request #13609 from esengine/fix/13605-browser-steals-focus
fix(desktop): the agent's built-in browser no longer takes the keyboard from the page you are typing in
2026-10-17 04:03:12 -07:00
esengine b8fa5ed0f6 docs(release): note for the agent browser keyboard fix 2026-10-17 03:45:51 -07:00
esengine 3f6e122e30 test(desktop): stage the person's click the way the platform delivers it 2026-10-17 03:45:51 -07:00
esengine 1ce9cb84c5 test(desktop): the person's own click into the agent's page keeps the keyboard there 2026-10-17 03:45:51 -07:00
esengine 03c71ce0ee fix(desktop): hand the keyboard back a turn after the platform gives it to the agent's page 2026-10-17 03:45:51 -07:00
esengine 02bdb8b8bc wip debug 2026-10-17 03:45:50 -07:00
esengine 41318d5da9 fix(desktop): the agent's browser no longer takes the keyboard from the app page 2026-10-17 03:45:50 -07:00
esengine 5bea200f3a wip probe 2026-10-17 03:45:50 -07:00
esengine 639a53278c test(desktop): a live check of who owns the keyboard while the agent drives its browser 2026-10-17 03:45:50 -07:00
YHH d53132fdf6 Merge pull request #13603 from esengine/a5/mux-attach-baseline
fix: keep the open pane across a failed pane-list read and resume a first mux attach from where it attached
2026-10-17 03:42:47 -07:00
YHH e5d01a32ce Merge pull request #13561 from par73e/fix/11860-compaction-cache-prefix
fix(compaction): reuse eligible sent request prefixes safely
2026-10-17 03:41:27 -07:00
YHH 8e4fe65a87 Merge pull request #13580 from esengine/feat/11577-text-size
feat(desktop): an interface text size, separate from the interface zoom
2026-10-17 03:27:56 -07:00
YHH a4bc92bcc9 Merge pull request #13549 from esengine/feat/0b-peer-threads
feat(peers): where each hand-off stands: waiting, working, replied or declined
2026-10-17 03:24:16 -07:00
par73e b739448a83 fix(compaction): reuse eligible sent request prefixes safely 2026-10-17 18:22:08 +08:00
esengine d586ffdb67 fix: keep the open pane across a failed pane-list read and resume a first mux attach from where it attached 2026-10-17 03:15:39 -07:00
YHH 42a50deb7e Merge branch 'studio' into feat/0b-peer-threads 2026-10-17 03:09:06 -07:00
esengine 907aba3e90 Merge remote-tracking branch 'origin/studio' into feat/11577-text-size 2026-10-17 02:56:59 -07:00
YHH fed3ca2b75 Merge pull request #13592 from xxoingr/test/secrets-sinks-vanished-walk
test(secrets): let the sink registry walk past an in-tree temp folder
2026-10-17 02:39:38 -07:00
YHH b952a3fbd6 Merge pull request #13575 from esengine/feat/11577-fs-scale
refactor(desktop): every interface font size rides one scale, so a text-size setting can reach it
2026-10-17 02:27:33 -07:00
YHH e35748e0ba Merge pull request #13589 from esengine/feat/granted-folder-file-open
feat(workbench): open a file in a folder the user granted, read-only
2026-10-17 02:17:58 -07:00
xxoingr d0f3e547fa test(secrets): let the sink registry walk past an in-tree temp folder
TestCredentialSinkRegistry walks internal/frontend/serve, where serve's
own tests create and remove extra-* folders through tempAside. Run in
parallel with those tests, the walk can list such a folder and fail when
it is gone a moment later, the same race that failed the storage
state-path guard. Skip a vanished entry with testenv.VanishedMidWalk.
2026-10-17 17:14:33 +08:00
esengine 842aa364ce Merge remote-tracking branch 'origin/feat/11577-fs-scale' into feat/11577-text-size 2026-10-17 02:12:53 -07:00
esengine 1eecbc113b Merge remote-tracking branch 'origin/studio' into feat/11577-fs-scale
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/sidepanel.css
2026-10-17 02:05:28 -07:00
esengine b72c6b4506 feat(workbench): open a file in a folder the user granted, read-only
GET /workspace/file takes an optional root, checked against the grant list and re-vetted on read through the decision the picture route uses. The workbench carries the folder, never saves, and says why.
2026-10-17 01:51:12 -07:00
YHH 3d983ca0f3 Merge pull request #13550 from esengine/feat/13526-concurrency
feat(delegation): sub-agent concurrency and parallel writers default to no limit; depth keeps a named stop at 8
2026-10-17 01:49:28 -07:00
YHH f2b47cdfe0 Merge pull request #13567 from esengine/fix/13253-composer-feel
feat(desktop): the composer keeps the 1.x feel — a border sweep on hover and focus, no state word inside the box
2026-10-17 01:26:54 -07:00
YHH 0e740d3299 Merge pull request #13581 from esengine/a5/mux-soak-clean
test(serve): per-pane delivery metrics on the multiplexed stream and a hundred-pane soak
2026-10-17 01:26:32 -07:00
YHH 522b9f61e4 Merge pull request #13577 from esengine/feat/13540-view-image
feat(tools): view_image shows a model the picture in a workspace file, for models that read images
2026-10-17 01:22:56 -07:00
esengine 0bd263426b test(serve): per-pane delivery metrics on the multiplexed stream and a hundred-pane soak 2026-10-17 01:08:45 -07:00
esengine 8300f72870 feat(tools): view_image shows a model the picture in a workspace file, for models that read images 2026-10-17 01:08:33 -07:00
YHH b4e9ae74bb Merge pull request #13574 from esengine/a5/relay-harness
test(relay): drive a hosted page against a real Studio through a stand-in relay in CI
2026-10-17 01:05:49 -07:00
YHH 9a67cbaa26 Merge pull request #13558 from par73e/fix/13288-side-panel-layout
fix(desktop): collapse side questions without losing their state
2026-10-17 01:03:21 -07:00
YHH 824e406f40 Merge pull request #13576 from esengine/fix/mcp-same-name-activation
fix(mcp): projects of one repository that declare a server by the same name keep their enables side by side
2026-10-17 00:55:28 -07:00
YHH 8809ed83bb Merge pull request #13548 from esengine/feat/0b-dispatch
feat(peers): a model tool that hands a task to a session in another project, default off
2026-10-17 00:54:34 -07:00
YHH ddb3d81907 Merge pull request #13556 from par73e/fix/12750-windows-remote-script
fix(remote): launch detached Windows kernels from a script file
2026-10-17 00:47:42 -07:00
YHH 154c8a8d9e Merge pull request #13551 from esengine/a5/ds-fetch
feat(fetch): DeepSeek Fetch API as a paid fallback for web_fetch, behind the billing-consent card
2026-10-17 00:41:17 -07:00
esengine d0c3dd5212 docs(release): shorten the text-size note 2026-10-17 00:40:54 -07:00
esengine bf02618c64 feat(desktop): an interface text size, separate from the interface zoom 2026-10-17 00:40:29 -07:00
YHH 5c0ea6f90a Merge branch 'studio' into fix/13288-side-panel-layout 2026-10-17 00:36:55 -07:00
YHH 295a981d83 Merge pull request #13568 from esengine/fix/job-stalled-notice-localized
fix(jobs): word the stalled background job warning in the interface language
2026-10-17 00:31:24 -07:00
par73e 121c0cf7ca fix(desktop): collapse side questions without losing their state 2026-10-17 15:31:06 +08:00
esengine f2e3ff8433 ci(relay): build the host from the repository root, where its module is 2026-10-17 00:31:05 -07:00
esengine 1a0732b97e fix(mcp): projects of one repository that declare a server by the same name keep their enables side by side 2026-10-17 00:27:54 -07:00
YHH e9388db72b Merge pull request #13547 from esengine/a5/ds-search-paid
feat(search): DeepSeek Search API as a paid fallback behind a billing-consent card
2026-10-17 00:26:06 -07:00
esengine 5d99a796ce test(desktop): the type-scale tests read sizes that ride the text-size scale 2026-10-17 00:21:51 -07:00
esengine af765d6b82 refactor(desktop): every interface font size rides one scale, so a text-size setting can reach it 2026-10-17 00:21:51 -07:00
esengine 4eef35a0b5 ci(relay): pass the base ref through the environment 2026-10-17 00:20:56 -07:00
YHH 720dc69c75 Merge pull request #13570 from esengine/fix/state-root-walk-wait
test(config): the churn in the walk test never reuses a directory name
2026-10-17 00:19:26 -07:00
esengine 81729ee851 test(relay): drive a hosted page against a real Studio through a stand-in relay in CI 2026-10-17 00:19:05 -07:00
par73e bb62bb8057 fix(remote): launch detached Windows kernels from a script file 2026-10-17 15:14:22 +08:00
esengine fdb5096f0c Merge a5/ds-search-paid into a5/ds-fetch 2026-10-17 00:11:34 -07:00
esengine e930472cf7 Merge studio into a5/ds-search-paid 2026-10-17 00:08:48 -07:00
esengine d6302bfae2 test(config): the churn in the walk test never reuses a directory name 2026-10-17 00:04:07 -07:00
YHH 8bf412accd Merge pull request #13564 from esengine/fix/mux-open-timeout
fix(studio): panes read their own event streams when the shared one never opens
2026-10-16 23:50:55 -07:00
YHH 72de55dc11 Merge pull request #13490 from xxoingr/test/storage-guard-vanished-walk
test(storage): let the state-path guard walk past an in-tree temp folder
2026-10-16 23:49:20 -07:00
esengine 0264316a83 fix(jobs): word the stalled background job warning in the interface language
recordStalled emitted a notice with no code, so the frontend showed the kernel's English sentence as is. It now carries job_stalled and a JobNotice payload with the quiet span, like the other job notices; the English text stays the fallback.
2026-10-16 23:48:02 -07:00
esengine a0efc71493 feat(desktop): the composer keeps the 1.x feel — a border sweep on hover and focus, no state word inside the box 2026-10-16 23:41:10 -07:00
YHH 507debd468 Merge branch 'studio' into feat/13526-concurrency 2026-10-16 23:32:00 -07:00
YHH 4e57ca2009 Merge branch 'studio' into feat/0b-dispatch 2026-10-16 23:31:56 -07:00
esengine 99ec610785 test(studio): every way a hub is built has to put a kernel frame into the pane 2026-10-16 23:28:26 -07:00
YHH f19d2fc208 Merge pull request #13545 from esengine/a5/ds-search
feat(search): web_search can answer from the user's own SearXNG, through an ordered backend chain
2026-10-16 23:27:12 -07:00
esengine faa5520f45 docs(release): shorten the open-wait note 2026-10-16 23:26:46 -07:00
esengine 9ab3feecde docs(release): note for the shared-stream open wait 2026-10-16 23:26:28 -07:00
esengine b6e34eb144 fix(studio): panes read their own event streams when the shared one never opens 2026-10-16 23:25:50 -07:00
YHH 801f53bf46 Merge pull request #13555 from esengine/feat/13526-workspaces
feat(desktop): the sidebar remembers as many projects as you open; desktop.max_workspaces is your own limit
2026-10-16 23:22:43 -07:00
YHH 28fc37a14a Merge pull request #13557 from esengine/fix/13539-side-panel-anchor
fix(desktop): the side-conversation panel opens above the composer, inside its pane
2026-10-16 23:22:35 -07:00
YHH a51bb50d07 Merge pull request #13559 from esengine/fix/project-mcp-tests-share-cache
test(boot): run the three tests that share the docs-helper cache entry one after another
2026-10-16 23:19:54 -07:00
YHH 91aa427a7c Merge pull request #13562 from tongsailong/fix/web-remote-mux
fix(web): preserve live remote updates with older Studio kernels
2026-10-16 23:15:16 -07:00
阿稳 b543d8629e fix(web): keep remote event streams compatible with older kernels
Hosted Web enables the multiplexed event stream for every pane, but a
Studio kernel without GET /events/mux answers it with the SPA shell. The
mux client waits for the stream's open frame to name its connection, so
no pane ever subscribed and none fell back to its own stream: the page
kept loading and stopped receiving events until a reload rebuilt it from
a snapshot.

RemoteEventSource is a polled stand-in that cannot carry that protocol,
so a hub reached over the relay now reads each pane's own /events. A
local hub keeps the shared stream.

The event contract is unchanged: attach, resume, cursor, watermark and
the pending-prompt attach all keep the code paths PR #13499 fixed.
2026-10-17 14:02:37 +08:00
esengine 7f5cdad445 test(boot): run the three tests that share the docs-helper cache entry one after another 2026-10-16 22:37:53 -07:00
YHH 2b3b7c4d0c Merge pull request #13554 from esengine/docs/studio-2.43.0-notes-2
docs(release): fold the remaining fragments into the studio 2.43.0 notes
2026-10-16 21:55:15 -07:00
YHH 2d21bbc582 Merge pull request #13553 from esengine/fix/gitcmd-expired-ctx
fix(gitcmd): a git stopped by the deadline is the context's error, not a refusal of the folder
2026-10-16 21:52:58 -07:00
esengine a49133cc46 fix(desktop): the side button draws its own panel into the composer and takes the focus back 2026-10-16 21:50:26 -07:00
esengine a481f2d0b1 fix(desktop): the side-conversation panel opens above the composer, inside its pane 2026-10-16 21:45:42 -07:00
esengine 63618012a3 feat(desktop): the sidebar remembers as many projects as you open; desktop.max_workspaces is your own limit 2026-10-16 21:39:16 -07:00
esengine 83437904bb docs(release): fold the remaining fragments into the studio 2.43.0 notes
Fold the fragments merged after the first notes commit into 2.43.0.md and remove every consumed fragment, so the release commit leaves release-notes/studio/unreleased empty.
2026-10-16 21:38:42 -07:00
esengine ebe180c771 review: refuse names that cannot be public before a URL goes to DeepSeek; say the query string is sent as written 2026-10-16 21:34:57 -07:00
esengine 9fb2bbb542 review: refuse names that cannot be public before a URL goes to DeepSeek; say the query string is sent as written 2026-10-16 21:33:48 -07:00
esengine ccb2a7ad64 fix(gitcmd): a git stopped by the deadline is the context's error, not a refusal of the folder 2026-10-16 21:32:13 -07:00
esengine db06675c87 feat(delegation): sub-agent concurrency and parallel writers default to no limit; depth keeps a named stop at 8 2026-10-16 21:21:30 -07:00
YHH 97c9c5b24e Merge pull request #13536 from esengine/fix/13467-gate-stops-readiness
fix(control): a recorded await_user ends the readiness rounds the host would start
2026-10-16 21:16:41 -07:00
YHH d74c6bec95 Merge pull request #13502 from esengine/feat/13478-await-auto
feat(control): continue a task list handed back to the user once the ask timeout passes unanswered
2026-10-16 21:14:47 -07:00
YHH 639b852ed1 Merge pull request #13537 from esengine/feat/mux-page
feat(studio): carry every local pane on the hub's one multiplexed event stream
2026-10-16 21:14:28 -07:00
YHH 55b61653ef Merge pull request #13546 from esengine/feat/card-grammar-all
feat(desktop): card grammar slices 3–5 and the one-row brand lockup
2026-10-16 21:14:27 -07:00
YHH e997e19599 Merge pull request #13535 from esengine/fix/lease-wait-flake
test(workspacelease): wait for the first wait report instead of a fixed deadline
2026-10-16 21:10:46 -07:00
esengine 427d1a24b4 feat(peers): where each hand-off stands: waiting, working, replied or declined 2026-10-16 21:06:21 -07:00
esengine 16d82778ff feat(fetch): DeepSeek Fetch API as a paid fallback for web_fetch, behind the billing-consent card 2026-10-16 21:05:11 -07:00
esengine 88260228ad Merge origin/studio into feat/mux-page
Resolved by taking the page commit as applied on top of studio, where the
stream budget it builds on already landed under a different commit.
2026-10-16 20:57:50 -07:00
YHH c13c7fde60 Merge branch 'studio' into fix/lease-wait-flake 2026-10-16 20:56:10 -07:00
YHH 436726ef56 Merge branch 'studio' into fix/13467-gate-stops-readiness 2026-10-16 20:56:07 -07:00
YHH 52a10d8793 Merge branch 'studio' into feat/13478-await-auto 2026-10-16 20:56:03 -07:00
YHH 880a9e0b94 Merge branch 'studio' into feat/card-grammar-all 2026-10-16 20:55:58 -07:00
esengine a317b0998e feat(peers): a model tool that hands a task to a session in another project, default off 2026-10-16 20:54:32 -07:00
YHH 58e94afbf3 Merge pull request #13542 from esengine/test/replay-wait-for-log
test(serve): wait for the ask to reach the log before asserting what a resume replays
2026-10-16 20:53:33 -07:00
esengine f61017127a review: snippet cut test, cap rationale, privacy note for always allow 2026-10-16 20:49:30 -07:00
esengine 49c08b17c7 feat(search): DeepSeek Search API as a paid fallback behind a billing-consent card 2026-10-16 20:43:48 -07:00
YHH dbb6ded60a Merge pull request #13483 from esengine/fix/history-search-test-race
test(history): close the shared catalog before resetting it in the session-search test
2026-10-16 20:42:07 -07:00
esengine 5304594bb0 Merge remote-tracking branch 'origin/studio' into feat/card-grammar-all 2026-10-16 20:39:11 -07:00
YHH cd81a8aea4 Merge branch 'studio' into fix/13467-gate-stops-readiness 2026-10-16 20:38:34 -07:00
YHH b6b6001e06 Merge branch 'studio' into feat/13478-await-auto 2026-10-16 20:38:29 -07:00
YHH 4e4597c630 Merge branch 'studio' into test/replay-wait-for-log 2026-10-16 20:38:25 -07:00
esengine 31370255fb test(studio): the card shell guard counts four cards now that a remembered fact is a row 2026-10-16 20:38:20 -07:00
YHH 7d870bdef3 Merge pull request #13533 from esengine/a5/busy-mux
feat(serve): carry every local pane on one multiplexed event stream
2026-10-16 20:36:39 -07:00
YHH 370b5a252c Merge pull request #13534 from esengine/fix/mcp-add-persists-schema
fix(plugin): a server connected by hand leaves its handshake in the cache
2026-10-16 20:34:00 -07:00
esengine 4c6ea3b000 docs(release): one fragment for the card grammar slices and the brand lockup 2026-10-16 20:31:08 -07:00
esengine c23b749298 Merge remote-tracking branch 'origin/feat/brand-lockup-badge' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 20:30:58 -07:00
esengine c37f2656d8 Merge remote-tracking branch 'origin/feat/card-grammar-slice5' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:30:58 -07:00
esengine 1cb9b4300c Merge remote-tracking branch 'origin/feat/card-grammar-slice4' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:30:57 -07:00
esengine 1f424fbff0 Merge remote-tracking branch 'origin/feat/card-grammar-slice3' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 20:30:57 -07:00
esengine 3ae1b7baef fix(i18n): English wording for the search backend notices 2026-10-16 20:11:48 -07:00
esengine 0d68e0a986 feat(search): web_search can answer from the user's own SearXNG, through an ordered backend chain 2026-10-16 20:11:48 -07:00
YHH 19640573dc Merge pull request #13516 from esengine/feat/13458-task-description
feat(tools): bash takes an optional description that names a background job
2026-10-16 20:09:12 -07:00
esengine 79e0e9e73d fix(plugin): a server connected by hand leaves its handshake in the cache 2026-10-16 20:08:11 -07:00
esengine ec014ebd0a Merge remote-tracking branch 'origin/studio' into feat/brand-lockup-badge
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 20:05:02 -07:00
esengine 2b103885e8 Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice5
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:04:26 -07:00
esengine b46a1e11ec Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice4
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/main.tsx
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:03:37 -07:00
esengine 3306732c50 Merge origin/studio into a5/busy-mux 2026-10-16 20:03:09 -07:00
esengine ca2da5c53a Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice3
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:02:28 -07:00
esengine b5d3c529f2 test(serve): wait for the ask to reach the log before asserting what a resume replays 2026-10-16 19:56:37 -07:00
YHH 0cac9ae4b9 Merge pull request #13518 from esengine/perf/13176-cold-list
perf(sessionstore): read the sidecars of a cold session folder on a bounded pool
2026-10-16 19:47:43 -07:00
esengine e5d3291c03 fix(jobs): bound the model-written description for display 2026-10-16 19:38:28 -07:00
YHH 03f9c22072 Merge pull request #13538 from esengine/fix/tidy-perf-guards
test(studio): the receipt card guard turns the card on, and the reask probe draft goes
2026-10-16 19:36:25 -07:00
esengine dbfaf19aae Merge remote-tracking branch 'origin/studio' into feat/13458-task-description
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 19:28:52 -07:00
esengine f375354301 chore(studio): drop the committed perf build output and ignore every dist-perf directory 2026-10-16 19:28:47 -07:00
YHH 6fbeb4ce21 Merge pull request #13499 from tongsailong/fix/v2.42.0-dev
fix(web): hand pending prompts to late clients and stop false stale reports
2026-10-16 19:22:57 -07:00
esengine dfb0ff4bc4 test(studio): the receipt card guard turns the card on, and the reask probe draft goes 2026-10-16 19:16:19 -07:00
esengine 1f5aa55677 fix(control): a recorded await_user ends the readiness rounds the host would start 2026-10-16 19:14:39 -07:00
esengine 21200e3312 test(workspacelease): wait for the first wait report instead of a fixed deadline 2026-10-16 19:07:58 -07:00
YHH 3dcc52b6d2 Merge pull request #13529 from esengine/feat/13526-side-unlimited
feat(side): no cap on open side conversations; agent.max_side_sessions is the user's own limit
2026-10-16 19:07:37 -07:00
YHH 872ed93d49 Merge branch 'studio' into perf/13176-cold-list 2026-10-16 19:07:05 -07:00
YHH 574723954c Merge branch 'studio' into fix/history-search-test-race 2026-10-16 19:07:01 -07:00
YHH f5e9b01e91 Merge pull request #13496 from esengine/feat/card-grammar-slice2
feat(desktop): draw host notices as a one-line bar, severity in the glyph ink only
2026-10-16 19:05:43 -07:00
esengine 477ce369bb feat(studio): carry every local pane on the hub's one multiplexed event stream 2026-10-16 19:05:41 -07:00
esengine f46ac4f856 feat(control): continue a task list handed back to the user once the ask timeout passes unanswered 2026-10-16 19:05:39 -07:00
esengine 0d64b3ec14 fix(desktop): only a few panes hold a live event stream, the rest resume from their cursor 2026-10-16 19:02:00 -07:00
YHH 4aae38f890 Merge pull request #13522 from esengine/fix/peers-body-read
fix(peers): reduce a hand-written message body where the spool is read
2026-10-16 18:57:36 -07:00
YHH 1778e4cc43 Merge pull request #13520 from esengine/fix/peers-read-side-names
fix(peers): reduce the names in another session's record where they are read
2026-10-16 18:54:12 -07:00
esengine 6a2e9af854 test(serve): a retired mux subscription leaves no goroutine behind 2026-10-16 18:53:17 -07:00
esengine 5e5996d059 Merge remote-tracking branch 'origin/studio' into feat/brand-lockup-badge 2026-10-16 18:49:52 -07:00
esengine f1a5b77c80 Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice5
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 18:49:20 -07:00
YHH 6397a75614 Merge pull request #13531 from esengine/fix/13491-stream-limit
fix(desktop): only a few panes hold a live event stream, the rest resume from their cursor
2026-10-16 18:49:09 -07:00
esengine 07705bb0d1 Merge studio into feat/card-grammar-slice4
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
#	desktop/frontend-next/src/ui/cards/RememberCard.tsx
2026-10-16 18:46:19 -07:00
esengine 618807518b Merge studio into feat/card-grammar-slice3
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 18:45:16 -07:00
esengine 95936b5167 feat(serve): carry every local pane on one multiplexed event stream
A window that gives each pane its own EventSource spends one HTTP/1.1 connection per pane, and a browser origin holds six, so the sixth stream starves every stop, submit and read behind it. GET /events/mux is one stream for all panes of a window; PUT/DELETE /events/mux/{conn}/panes/{id} subscribe a pane with its own cursor. /events and /events/replay are unchanged.
2026-10-16 18:44:09 -07:00
esengine 84199bf82d Merge studio into feat/card-grammar-slice2
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
#	desktop/frontend-next/src/ui/cards/NoticeCard.tsx
2026-10-16 18:42:12 -07:00
YHH d40dc23eab Merge pull request #13504 from esengine/feat/13478-egress-open
feat(sandbox): a user setting that stops the egress question for hosts outside allowed_domains
2026-10-16 18:35:20 -07:00
esengine 450243769c fix(desktop): only a few panes hold a live event stream, the rest resume from their cursor 2026-10-16 18:34:32 -07:00
esengine 63a4755b12 feat(side): no cap on open side conversations; agent.max_side_sessions is the user's own limit 2026-10-16 18:27:07 -07:00
esengine b13f478809 feat(sandbox): a user setting that stops the egress question for hosts outside allowed_domains 2026-10-16 18:25:27 -07:00
YHH bcf0f3f5cb Merge pull request #13515 from esengine/perf/13176-tree-versions
perf(serve): the sidebar tree no longer resolves or opens every session on each refresh
2026-10-16 18:24:27 -07:00
YHH 4296a8365b Merge pull request #13524 from esengine/fix/13472-chevron-risk-colour
fix(desktop): colour the permission picker's chevron and icon like its label
2026-10-16 18:11:01 -07:00
YHH f8747c5abd Merge pull request #13521 from esengine/fix/13345-storage-shared-root
fix(storage): do not credit a root with the entries a neighbour declares
2026-10-16 18:04:56 -07:00
esengine bc89385120 fix(desktop): colour the permission picker's chevron and icon like its label
In the risk state the chevron kept its grey and, in the light theme, the
warning icon used a lighter orange than the label.
2026-10-16 17:56:56 -07:00
esengine 20eb088e61 fix(peers): reduce a hand-written message body where the spool is read 2026-10-16 17:53:33 -07:00
YHH d596bf63c2 Merge pull request #13517 from esengine/fix/13513-idle-cost-placeholder
fix(desktop): show no session cost before anything has been priced
2026-10-16 17:52:36 -07:00
阿稳 6560ae0c62 fix(web): keep the tree stream's attach asking past the watermark
RemoteEventSource now leaves the attach cursor empty so the kernel hands a late client the pending prompt. The tree stream shares that class but reads the tree rather than history, so an empty attach for it re-read the tree on every poll and failed tree_events.test.ts. The attach query is now chosen once from the path: the main stream attaches with no cursor, the tree attaches past the watermark as it always did.

Verified with the whole frontend suite: typecheck, 5139 tests, census, census:verify, build, and no fixtures in the bundle.
2026-10-17 08:50:48 +08:00
esengine 42dbb39367 fix(storage): do not credit a root with the entries a neighbour declares
Home declares no entries, so the survey treated it as sole occupant of its
directory and counted the sessions and archive that state declares in the
same folder (one folder on a default Windows install).
2026-10-16 17:50:24 -07:00
esengine e122025906 feat(desktop): the sidebar brand is one row, wordmark and a bordered badge 2026-10-16 17:49:07 -07:00
esengine 052a7424b4 fix(peers): reduce the names in another session's record where they are read 2026-10-16 17:48:05 -07:00
YHH 6767b6baff Merge pull request #13514 from esengine/docs/studio-2.43.0-notes
docs(release): studio 2.43.0 notes
2026-10-16 17:43:42 -07:00
esengine bbe3163ad8 perf(sessionstore): read the sidecars of a cold session folder on a bounded pool 2026-10-16 17:43:16 -07:00
esengine 65e973a63d fix(desktop): show no session cost before anything has been priced
The cost chip drew a literal "¥" default, so a fresh session read
"本轮 ¥0.00" in a currency nothing had priced, whatever the display
currency was. Leave it out until the first priced round or restored total.
2026-10-16 17:39:34 -07:00
esengine c412db874b docs(release): studio 2.43.0 notes
Gather the 67 pending fragments into release-notes/studio/2.43.0.md,
Chinese then English, and remove them.
2026-10-16 17:34:43 -07:00
esengine 9c29eace57 perf(serve): the sidebar tree no longer resolves or opens every session on each refresh 2026-10-16 17:32:09 -07:00
esengine 042c7fbe9f feat(desktop): name a background job for a person, with its full text on hover and focus 2026-10-16 17:31:39 -07:00
esengine a95d08ddb6 test(boot): regenerate the tool schema baseline for the bash description 2026-10-16 17:31:39 -07:00
esengine b73ac5f285 feat(tools): bash takes an optional description that names a background job 2026-10-16 17:31:39 -07:00
YHH 138167ec7d Merge pull request #13509 from esengine/fix/13472-policy-chevron
fix(desktop): draw the permission picker's chevron with the shared icon
2026-10-16 17:31:19 -07:00
YHH 7b7b98faec Merge pull request #13508 from esengine/feat/13478-goal-retry
feat(goal): retry a Goal paused on its completion check, within a user-set allowance
2026-10-16 17:30:08 -07:00
YHH 9cb5fbace7 Merge pull request #13506 from esengine/feat/remember-choice
feat(desktop): one remember-choice footer for approvals, with where each choice is kept
2026-10-16 17:28:43 -07:00
YHH 87d1aa19b4 Merge pull request #13507 from esengine/feat/13478-plan-approval-switch
feat(studio): a Plan approval switch in Settings
2026-10-16 17:27:51 -07:00
YHH e958985ec1 Merge pull request #13477 from esengine/fix/13023-sidebar-dup-search
fix(desktop): drop the duplicate magnifier beside the session filters
2026-10-16 17:24:38 -07:00
阿稳 e5f440d2c3 fix(web): read the attach watermark inside the prompt-snapshot lock
The polled attach took its pending-prompt snapshot under promptEmitMu but read the broadcaster watermark after that lock was released. A prompt raised in between was numbered at or below the watermark the client then adopted as its cursor, so the next resume skipped it — the original bug in a narrow window.

The watermark is now read in the same critical section as the snapshot, and before it: a prompt raised after that point is numbered above the cursor the client adopts, so the worst case is a prompt named twice rather than one lost. writeEventsReplay reads the watermark once and passes it in, instead of the writer reading it after the lock.

The shell's no-store comment is trimmed to the comment budget so the lint gate passes.
2026-10-17 08:23:07 +08:00
esengine 4c57f2293c fix(desktop): draw the permission picker's chevron with the shared icon
The picker used a 10px text glyph while every sibling picker uses the
StudioIcon down svg, so it rendered smaller and below the centre line.
2026-10-16 17:19:39 -07:00
YHH c982081998 Merge pull request #13503 from esengine/feat/13385-resource-templates
feat(mcp): let the resource tool read addresses a server's declared templates build
2026-10-16 17:14:26 -07:00
YHH 7260e9b303 Merge pull request #13505 from esengine/fix/13493-slash-hints-locale
fix(control): localize slash command argument hints
2026-10-16 17:12:02 -07:00
esengine 22cacc9001 docs(release): shorten the note 2026-10-16 17:09:22 -07:00
YHH 97bbdea635 Merge pull request #13489 from esengine/test/syntax-wrapped-differential
test(shellsafe): differential test for read-only commands wrapped in shell syntax and quoting
2026-10-16 17:06:55 -07:00
esengine 227ca9aa00 feat(goal): retry a Goal paused on its completion check, within a user-set allowance 2026-10-16 17:03:54 -07:00
esengine bc9b0a7429 feat(studio): a Plan approval switch in Settings 2026-10-16 16:49:41 -07:00
esengine 1a1f7c7834 feat(mcp): resource templates, plus a pinned test for the server+uri subject 2026-10-16 16:48:39 -07:00
esengine 1c36b1ab7d fix(control): localize slash command argument hints
The argument completion of /goal, /memory, /mcp, /skills, /plugins, /theme,
/currency, /reasoning-language, /ask-timeout and /feedback returned English
literals; read them from the i18n catalog like /language and /effort do.
2026-10-16 16:47:02 -07:00
esengine c2b66a05fa feat(desktop): one remember-choice footer for approvals, with where each choice is kept 2026-10-16 16:44:03 -07:00
YHH 656ebacea5 Merge pull request #13501 from esengine/test/view-keys-palette-timeout
test(desktop): give the palette test the same ten seconds the other lazy-chunk tests get
2026-10-16 16:36:15 -07:00
esengine febabc561a feat(mcp): the resource tool also admits addresses a declared resource template builds 2026-10-16 16:29:11 -07:00
esengine 04b43991a6 docs(release): do not imply the name filter is always visible 2026-10-16 16:27:43 -07:00
YHH 18a6ee58db Merge pull request #13495 from esengine/feat/card-grammar-slice1
feat(desktop): draw a brought-to-the-end result as the same Line as the Execution group
2026-10-16 16:25:24 -07:00
esengine 74ceafaf16 test(desktop): give the palette test the same ten seconds the other lazy-chunk tests get 2026-10-16 16:23:26 -07:00
YHH e0cd5e5d97 Merge pull request #13457 from esengine/feat/remove-timeline-line
feat(desktop): remove the timeline rail from the conversation
2026-10-16 16:23:24 -07:00
esengine c4009946fd test(desktop): drop the guard for the magnifier that is gone 2026-10-16 16:22:26 -07:00
YHH 739d22e0fe Merge pull request #13484 from esengine/fix/network-probe-codes
fix(i18n): word the network test steps from codes so the English window stays English
2026-10-16 16:19:37 -07:00
YHH f7bc5e1e16 Merge pull request #13487 from esengine/feat/13478-egress-always
feat(sandbox): offer always-allow on the egress card, writing the host to the user's allowed_domains
2026-10-16 16:19:02 -07:00
YHH 8dd7b6dee3 Merge pull request #13485 from esengine/feat/13385-mcp-resource-tool
feat(mcp): a read_mcp_resource tool for resources a server lists, off by default and user-file only
2026-10-16 16:18:05 -07:00
YHH 0d7a0aedc7 Merge pull request #13479 from esengine/feat/13478-plan-auto-approve
feat(plan): let the user's own config start a submitted plan without the approval click
2026-10-16 16:16:39 -07:00
YHH 9350f7f125 Merge pull request #13482 from esengine/fix/differential-vanishing-file
test(shellsafe): tolerate git's background maintenance in the read-only differential snapshot
2026-10-16 16:15:34 -07:00
阿稳 24ab1a0a4f Merge remote-tracking branch 'origin/studio' into fix/v2.42.0-dev 2026-10-17 07:04:54 +08:00
esengine dd1d63b3ce feat(desktop): an opened step's detail is a bordered panel, not a left rule 2026-10-16 15:54:06 -07:00
esengine 0b1c62be41 feat(desktop): a low-risk allowed guardian review and a remembered fact are rows 2026-10-16 15:29:57 -07:00
esengine ec08aa36b1 feat(desktop): one shell for the cards the reader reads or acts on 2026-10-16 14:44:21 -07:00
esengine 10472be8d4 feat(desktop): draw host notices as a one-line bar, severity in the glyph ink only 2026-10-16 13:58:00 -07:00
esengine 9400099593 feat(desktop): draw a brought-to-the-end result as the same Line as the Execution group 2026-10-16 13:26:25 -07:00
YHH 99e0c42d37 Merge pull request #13494 from esengine/fix/a11y-focus-rings
fix(a11y): keep Tab inside modal dialogs and show focus on the session delete button
2026-10-16 12:47:51 -07:00
esengine 6924986399 fix(a11y): keep Tab inside modal dialogs and show focus on the session delete button 2026-10-16 12:20:10 -07:00
YHH 4244fead38 Merge pull request #13492 from esengine/perf/pane-rerender
perf(desktop): keep hidden panes from re-rendering on a tab switch
2026-10-16 11:53:20 -07:00
esengine 288416be16 perf(desktop): keep hidden panes from re-rendering on a tab switch 2026-10-16 11:41:32 -07:00
YHH df9ac7c6bc Merge pull request #13488 from xxoingr/test/worktree-branch-checkout-crlf
test(worktree): compare a branch checkout's content, not its line ending
2026-10-16 11:24:14 -07:00
YHH 17b3541ff1 Merge pull request #13486 from xxoingr/test/plugin-legacy-cache-audit-windows
test(plugin): deny cache-dir writes through an ACL on Windows
2026-10-16 11:23:02 -07:00
xxoingr 144d2ff802 test(storage): let the state-path guard walk past an in-tree temp folder
TestEveryTopLevelStatePathIsAnOwnedEntry walks every package under
internal/ and failed the moment WalkDir could not open an entry. The
serve, control and config tests take their extra folders from their own
package directory (tempAside), so when go test runs packages in
parallel the guard can list one of those folders and find it gone a
moment later:

  open .../internal/frontend/serve/extra-3867033970: no such file or directory

The config package's state-root walk already tolerates this through
vanishedMidWalk, including the sharing-violation and access-denied
errors Windows gives for a directory being deleted. Move that helper to
testenv as VanishedMidWalk, unchanged, and use it from both walks.
2026-10-17 02:14:32 +08:00
esengine 48f0e7c207 test(shellsafe): differential test for read-only commands wrapped in shell syntax and quoting 2026-10-16 11:12:09 -07:00
xxoingr 2fd609387e test(worktree): compare a branch checkout's content, not its line ending
TestCreateFromBranchKeepsSourceAndSeparatesTasks required the checked
out README to be exactly "base\n". Git for Windows ships core.autocrlf
true in its system config, so the worktree holds "base\r\n" and the
test failed on Windows CI. Trim it as the sibling dirty-source test in
worktree_test.go already does; the check still tells the selected
base apart from the current head and the dirty source.
2026-10-17 02:01:30 +08:00
esengine 0f5fc69554 feat(sandbox): offer always-allow on the egress card, writing the host to the user's allowed_domains 2026-10-16 11:00:29 -07:00
xxoingr b4f819872f test(plugin): deny cache-dir writes through an ACL on Windows
TestAuditLegacyCacheUpgradeReportsOnlySuccess blocks the legacy-key
upgrade write by chmod-ing the cache dir to 0555. On Windows that only
sets the read-only attribute, which does not stop new files in a
directory, so the upgrade reached disk and the test failed at its own
fixture check on every Windows CI run since it landed.

Deny write to Everyone with icacls there instead, the mechanism
internal/contract/config's makeDirReadOnly already uses.
2026-10-17 01:56:49 +08:00
YHH 01d1e7cfb1 Merge pull request #13432 from imcreazy/fix/host-tool-suspension
fix(boot): keep the host tool groups out of the schema when no helper can run them
2026-10-16 10:45:46 -07:00
esengine da480c3181 feat(mcp): a read_mcp_resource tool for resources a server lists, off by default and user-file only 2026-10-16 10:39:19 -07:00
esengine 7cf009fbf0 fix(i18n): word the network test steps from codes so the English window stays English 2026-10-16 10:28:54 -07:00
YHH 1c4e89b0aa Merge pull request #13459 from SivanCola/codex/fix-studio-reference-enter
fix(studio): confirm @ references before sending / 修复目录引用回车误发送
2026-10-16 10:21:00 -07:00
YHH 566f48044c Merge pull request #13481 from esengine/test/debt-4
test(evidence): cover child evidence merge, background leases and the unreviewed baseline
2026-10-16 10:20:31 -07:00
esengine e53d5e348f test(history): close the shared catalog before resetting it in the session-search test 2026-10-16 10:19:14 -07:00
esengine 022dd7faee test(shellsafe): tolerate git's background maintenance in the read-only differential snapshot 2026-10-16 10:15:19 -07:00
YHH ee10c06d63 Merge pull request #13480 from esengine/fix/narrow-model-list-header
fix(desktop): keep the model list heading on one line at phone widths
2026-10-16 10:13:29 -07:00
esengine cab7fb6afb test(evidence): cover child evidence merge, background leases and the unreviewed baseline 2026-10-16 10:08:06 -07:00
esengine 3ff71f0882 fix(desktop): keep the model list heading on one line at phone widths 2026-10-16 09:58:51 -07:00
SivanCola 46a139b533 fix(studio): publish completion selection with candidates
Problem: Studio CI caught ArrowDown selection returning to the default row
before Enter could confirm the selected reference.

Root cause: candidates rendered before a passive effect reset selection and
keyboard ownership. That later update could overwrite navigation after the
rows had already committed.

Fix: store the completion answer, active row, and keyboard ownership in one
state value. Publish each answer with its default selection and remove the
deferred reset. Guard navigation against an answer that has become empty.

Verification: four deterministic reference/slash publication and early
navigation regressions fail on the old hook. The PR merge snapshot with this
patch passes 616 frontend files / 5,132 tests; 92 completion/composer cases
pass with --repeats 10. Typecheck, build, census and census verification,
go vet, make lint, make check, and builtin/boot tests pass. Chromium confirms
ArrowDown plus Enter accepts the selected reference and slash command.
2026-10-17 00:56:20 +08:00
esengine f3079d585d fix(i18n): English wording for the plan-approval refusals 2026-10-16 09:48:56 -07:00
esengine 82d939bf94 feat(plan): let the user's own config start a submitted plan without the approval click 2026-10-16 09:46:29 -07:00
YHH ac59488c0c Merge pull request #13474 from esengine/test/debt-3
test(evidence): cover completion adjudication and path evidence
2026-10-16 09:33:23 -07:00
esengine 6773367d05 fix(desktop): drop the duplicate magnifier beside the session filters 2026-10-16 09:30:37 -07:00
YHH 6f5e7a1a6d Merge pull request #13473 from esengine/fix/upgrade-page-name-collisions
fix(upgrade): refuse a page whose entries would land on one another
2026-10-16 09:28:26 -07:00
YHH 3260a68023 Merge pull request #13468 from esengine/feat/11376-s4-peer-tools
feat(peers): list_peers and send_peer_message tools for the model
2026-10-16 09:27:31 -07:00
SivanCola daa4cf4044 test(serve): verify exact references through the completion route
Problem: Review requested proof that production completion removes exact
reference candidates independently of the frontend MockPort fixture.

Root cause: The frontend interaction tests and the existing control unit
test did not exercise the registered GET /complete route together with its
controller data, scoped filesystem lookup, and UTF-16 boundary.

Fix: Add seven real HTTP cases using the production handler and disposable
workspace files. Cover partial paths, exact filenames, prose and emoji
prefixes, escaped nested paths, retaining a genuine longer alternative,
and an exact final alternative. No production filtering behavior changes.

Verification: The completion HTTP tests and full serve package pass, along
with go vet, make lint, make check, and the builtin/boot pre-commit suites.
Removing only the existing control.dropTyped call via a temporary Go build
overlay makes six HTTP subtests fail on the retained identity insertion;
this checks regression sensitivity and is not a production defect claim.
2026-10-17 00:24:16 +08:00
esengine 8e6aad3ee0 test(evidence): cover completion adjudication and path evidence 2026-10-16 09:22:55 -07:00
YHH 5b93b29c97 Merge pull request #13434 from imcreazy/fix/crashreport-pid-reuse
fix(crashreport): tell a recycled pid from the reader that wrote the dump
2026-10-16 09:22:46 -07:00
esengine dfd8d1a89c fix(upgrade): refuse a page whose entries would land on one another 2026-10-16 09:18:09 -07:00
esengine 47a4aad04e docs(release-notes): note for #13457 2026-10-16 09:11:28 -07:00
esengine bdaf57e287 feat(desktop): remove the timeline rail from the conversation 2026-10-16 09:11:28 -07:00
YHH 9082368925 Merge pull request #13431 from imcreazy/fix/serve-dir-flag
fix(serve): take the project root from --dir, as other verbs do
2026-10-16 09:09:05 -07:00
YHH 2dffa4d0ed Merge pull request #13465 from esengine/chore/coverage-floors
test(shellsafe): run what the classifier calls a read; uniq treats a word after its file as an output
2026-10-16 09:07:34 -07:00
YHH b148ae453d Merge pull request #13454 from esengine/feat/read-video-frames
feat(tools): read_video samples frames from a video with ffmpeg
2026-10-16 09:05:26 -07:00
esengine 95ecd55391 test(permission): pin the generic to/text subject rule 2026-10-16 08:59:15 -07:00
esengine 440fc18a62 feat(peers): list_peers and send_peer_message tools for the model (S4a of #11376) 2026-10-16 08:56:03 -07:00
YHH b806b594d0 Merge pull request #13469 from esengine/feat/12040-provider-draft
feat(providers): fold the web search, thinking and continuation switches into the connection draft
2026-10-16 08:51:01 -07:00
YHH acc368ee2a Merge pull request #13466 from esengine/fix/peer-frame-tag-casing
fix(peers): the message frame cannot be closed or reopened by a differently cased or spaced tag
2026-10-16 08:50:09 -07:00
imcreazy 40bd3f06b8 fix(crashreport): tell a recycled pid from the reader that wrote the dump 2026-10-16 23:28:39 +08:00
imcreazy da4bb5886d fix(boot): keep the host tool groups out of the schema when no helper can run them 2026-10-16 23:28:04 +08:00
imcreazy 063ad81319 fix(serve): take the project root from --dir, as other verbs do 2026-10-16 23:27:26 +08:00
esengine f7e19e9ee5 feat(providers): fold the web search, thinking and continuation switches into the connection draft 2026-10-16 08:23:17 -07:00
esengine a0301e55fc test(shellsafe): run the git commands the classifier calls reads against a real repository 2026-10-16 08:12:26 -07:00
esengine d02933aeaf fix(peers): the message frame cannot be closed or reopened by a differently cased or spaced tag 2026-10-16 08:07:38 -07:00
esengine 5c33fb2e06 test(shellsafe): run what the classifier calls a read; uniq treats a word after its file as an output 2026-10-16 08:04:01 -07:00
阿稳 144d1817ad Merge remote-tracking branch 'origin/studio' into fix/v2.42.0-dev
Brings the three Web fixes onto the current studio line: the hosted page's own-fetch stale check, the pending approval/ask handed to an attaching polled client, and the no-store shell.

The one conflict, internal/frontend/serve/web_entry_test.go, was both sides appending a test at the same point; both are kept.
2026-10-16 23:03:16 +08:00
esengine ff224380d2 test(shellsafe): run what the classifier calls a read; uniq treats a word after its file as an output 2026-10-16 08:03:15 -07:00
YHH c3c19e0748 Merge pull request #13273 from SivanCola/codex/13264-session-worktree
feat(studio): 会话 worktree 配置与统一聊天区菜单 / session workspace setup
2026-10-16 08:00:39 -07:00
YHH 82697031ca Merge pull request #13464 from esengine/test/control-permission-rules
test: cover permission rule saving, network settings and bash path evidence
2026-10-16 07:57:55 -07:00
SivanCola c99dcf42bf test(studio): pin exact reference Enter behavior
Problem: Review requested coverage for Enter after a manually completed
reference, including the case where another candidate is still available.

Root cause: The kernel already removes an insertion equal to the typed
token, but MockPort kept that row and manufactured an extra confirmation
step in development fixtures.

Fix: Keep MockPort's candidate filtering aligned with the kernel and add
four composer regressions. A full path with no remaining candidates sends
in one Enter. An open menu containing a genuine alternative still confirms
that alternative before submission, as required by the interaction contract.

Verification: The three exact-path cases failed against the old fixture.
All 5099 frontend tests, typecheck, build, make check, go vet, and make lint
pass. The existing kernel dropTyped regression passes. Real-browser checks
confirm single-Enter submission for exact paths at wide/light and phone/dark
sizes. The builtin and boot pre-commit suites pass.
2026-10-16 22:54:44 +08:00
YHH 76b326c602 Merge pull request #13463 from esengine/test/control-mcp-install
test(control): cover MCP install guards and import
2026-10-16 07:54:26 -07:00
esengine a691b52eb9 test: cover permission rule saving, network settings and bash path evidence 2026-10-16 07:46:29 -07:00
esengine 38f8a56f1b test(control): cover MCP install guards and import 2026-10-16 07:42:26 -07:00
YHH adc41fdd97 Merge pull request #13462 from esengine/test/safety-control-debt
test: cover the inbox edit verbs, completion reports and write inspection
2026-10-16 07:39:54 -07:00
YHH 94d1976713 Merge pull request #13461 from esengine/feat/pane-fold-notice
feat(desktop): say which session a full window folded out of the tab bar
2026-10-16 07:34:19 -07:00
YHH 95d7cb2cf6 Merge pull request #13460 from esengine/feat/11376-s3-peers-ui
feat(desktop): messages between sessions in Studio (S3 of #11376)
2026-10-16 07:33:37 -07:00
esengine 92d084515c feat(tools): read_video samples frames from a video with ffmpeg
A read-only tool that turns a video into a few time-labelled pictures for a
model that reads images. The container is named from the file's own bytes and
ffmpeg is forced to it with file-only protocols; failures carry typed codes.
It is reached through use_capability and stays out of the schema prefix (#13399).
2026-10-16 07:31:37 -07:00
esengine f33caaca90 test: cover the inbox edit verbs, completion reports and write inspection 2026-10-16 07:28:32 -07:00
阿稳 8d49317e5d fix(web): hand pending prompts to late clients and stop false stale reports
The polled transport attached past the watermark and dropped what came back, so a page opened after a prompt was raised never saw the approval or ask the run was waiting on. An attach now carries the pending prompt to that client alone, and the client reads the frames it is sent.

A hosted page checked for a newer build through the global fetch, which remoteHub has already replaced with a tunnel to the kernel; it compared its own build against the kernel's and reported itself behind forever. The check now reads through the page's own fetch, bound before the swap.

The shell names which build is current and was served with no cache policy, so a reused copy left an open page behind and reloading landed on that same copy. It is now served no-store; the content-hashed bundles under /assets keep their long cache.
2026-10-16 22:22:53 +08:00
YHH 3765035e3d Merge pull request #13455 from esengine/feat/shellsafe-git-readonly
feat(shellsafe): classify read-only git listings as reads
2026-10-16 07:22:10 -07:00
esengine f82277b80d feat(desktop): say which session a full window folded out of the tab bar
With an open-session limit set, opening one more session reuses or closes an
idle pane. A calm note now names the conversation, the limit, and that it is
still in the list; a note is a toast tone with no report button (#13419).
2026-10-16 07:17:56 -07:00
esengine db902db79d refactor(desktop): session-coordination settings groups in their own component 2026-10-16 07:16:11 -07:00
SivanCola f5b06287a1 fix(studio): confirm reference completion before sending
Problem: Pressing Enter while an @ file or directory candidate was visible
could submit an unfinished prompt instead of confirming the reference.

Root cause: The menu highlighted its first item but only owned Enter after
explicit navigation. Directory acceptance also shared the descent path.

Fix: Let the visible selection own Enter and distinguish confirmation from
Tab descent. Finish accepted references with a separator, preserve existing
whitespace and trailing text, and clear the old completion before drafting
continues. Update both locale hints.

Verification: Twelve new composer regressions reproduce the old behavior
and cover files, directories, nested Tab descent, pointer acceptance,
escaped paths, send preferences, Shift keys, IME, and Escape. The full
frontend suite passes (609 files, 5095 tests), alongside typecheck, build,
UI census, make check, go vet, and make lint. Browser interaction was
verified with the local MockPort workspace.
2026-10-16 22:14:53 +08:00
esengine ae30efbfca feat(desktop): messages between sessions in Studio (S3 of #11376) 2026-10-16 07:13:42 -07:00
esengine 23eafa84f5 fix(shellsafe): git config reads one key and never a credential; remote show needs -n 2026-10-16 06:55:36 -07:00
esengine 07a5c0d8d3 feat(shellsafe): classify read-only git listings as reads 2026-10-16 06:38:49 -07:00
YHH 1fbb650324 Merge pull request #13450 from esengine/feat/shellsafe-gh-readonly
feat(shellsafe): classify read-only gh calls as reads
2026-10-16 06:20:23 -07:00
YHH e3be160d52 Merge branch 'studio' into feat/shellsafe-gh-readonly 2026-10-16 06:04:38 -07:00
YHH eaff7c8ae2 Merge pull request #13430 from esengine/feat/11376-s2-peer-send
feat(peers): send messages to another session, /peer and HTTP verbs
2026-10-16 05:59:00 -07:00
YHH 7f37c01a91 Merge pull request #13445 from esengine/feat/tool-result-to-end
feat(desktop): bring a tool call's result to the end of the transcript
2026-10-16 05:56:04 -07:00
esengine 7a41081c4f feat(desktop): bring a tool call's result to the end of the transcript
A button on a finished tool card repeats that call's own recorded card in a
section at the end of the transcript, as a view only: nothing is stored or
sent, and the original stays where it happened (#13394).
2026-10-16 05:44:38 -07:00
esengine 7dace9cfb0 Merge studio into feat/11376-s2-peer-send 2026-10-16 05:43:03 -07:00
YHH a4cf9d0591 Merge pull request #13437 from chuiwenwei/fix/sessionstore-unreadable-sidecar-not-absent
fix(sessionstore): an unreadable sidecar is not an absent one
2026-10-16 05:40:45 -07:00
YHH 4c549631c5 Merge pull request #13448 from esengine/fix/e2ebench-windows-stubs
test(e2ebench): skip the shell-stub tests on Windows
2026-10-16 05:39:31 -07:00
esengine 42a1d24662 fix(shellsafe): a gh filter that loads jq modules is not a read 2026-10-16 05:39:12 -07:00
esengine 783fef7409 fix(shellsafe): a gh filter that can reach the process environment is not a read 2026-10-16 05:36:44 -07:00
esengine f5116c49fa docs(release-notes): shorten the note for #13450 2026-10-16 05:33:11 -07:00
esengine 423c906b25 docs(release-notes): note for #13450 2026-10-16 05:32:48 -07:00
esengine 82e8118db2 feat(shellsafe): classify read-only gh calls as reads 2026-10-16 05:31:41 -07:00
esengine 0836d7a2c6 test(e2ebench): skip the shell-stub tests on Windows 2026-10-16 05:18:27 -07:00
YHH 06a4b4622a Merge pull request #13447 from esengine/fix/13441-notice-english
fix(i18n): give the fifteen untranslated notices English and guard the table
2026-10-16 05:13:39 -07:00
YHH 1a1b953233 Merge pull request #13443 from esengine/fix/provider-key-origin
fix(providers): a draft address on another origin never borrows the saved key
2026-10-16 05:01:43 -07:00
YHH 7c406be221 Merge pull request #13440 from esengine/fix/import-keeps-source-titles
fix(sessionstore): an imported 1.x conversation keeps the title 1.x showed
2026-10-16 04:59:11 -07:00
esengine b456bd34e1 fix(i18n): give the fifteen untranslated notices English and guard the table 2026-10-16 04:57:09 -07:00
YHH b7b7687e17 Merge pull request #13442 from esengine/fix/13433-host-exit-keeps-reason
fix(host): a failing exit keeps its crash file with the code and the reason
2026-10-16 04:47:16 -07:00
esengine 7eeebe89be fix(peers): a sent message carries no process writer id, which can exceed the id limit 2026-10-16 04:42:45 -07:00
YHH c929e444b9 Merge pull request #13427 from chuiwenwei/fix/plugin-cache-upgrade-not-silently-unpersisted
fix(plugin): a legacy cache upgrade is not claimed unless it reached disk
2026-10-16 04:40:09 -07:00
YHH 6b383ad920 Merge pull request #13423 from esengine/feat/cli-ships-page
feat(release): the CLI archives carry the web page
2026-10-16 04:38:19 -07:00
YHH 8cb8af0c14 Merge pull request #13428 from chuiwenwei/fix/e2ebench-unmeasured-run-not-accounted
fix(e2ebench): report a suite run whose metrics never landed as unmeasured
2026-10-16 04:38:17 -07:00
esengine 8cf0b5e94f fix(host): a failing exit keeps its crash file with the code and the reason 2026-10-16 04:36:41 -07:00
esengine c0aad054f4 fix(providers): a draft address on another origin never borrows the saved key 2026-10-16 04:33:13 -07:00
YHH d952d8c7fd Merge pull request #13426 from chuiwenwei/fix/sessionstore-unreadable-checkpoint-blocks-rewrite
fix(sessionstore): refuse a degraded rewrite when the checkpoint cannot be read
2026-10-16 04:32:46 -07:00
YHH 1e312b66d8 Merge pull request #13425 from chuiwenwei/fix/stats-flush-reports-write-failure
fix(stats): Flush reports the first append that failed
2026-10-16 04:32:12 -07:00
esengine eb384283d1 fix(sessionstore): an imported 1.x conversation keeps the title 1.x showed 2026-10-16 04:29:55 -07:00
YHH 5829f2a1c5 Merge pull request #13422 from esengine/feat/upgrade-refreshes-page
feat(cli): upgrade keeps the page beside the binary in step with the kernel
2026-10-16 04:25:52 -07:00
esengine 8370d9cf45 feat(peers): send messages to another session, /peer and HTTP verbs (S2 of #11376) 2026-10-16 04:05:54 -07:00
esengine bfba90b356 feat(cli): upgrade keeps the page beside the binary in step with the kernel
When an install already has frontend-next/dist beside the binary, upgrade
reads the page out of the verified archive, validates it in memory, swaps it
in before the binary and puts it back if the binary does not land (#13406).
2026-10-16 04:04:18 -07:00
Will 2a3756b9e5 fix(sessionstore): an unreadable sidecar is not an absent one
ListSessionOrder keeps a row whose branch-meta sidecar cannot be read, so one
broken sibling cannot hide a folder, but every column of that row is then a zero
value. recoveryArchiveLineage consumed that row as 'not a recovery copy of this
conversation' and SetSessionLineageArchived reported success while a copy of the
row stayed active and listed on its own. The lineage walk now reads each listing
row's sidecar itself and refuses the transaction, so the archive endpoint
reports the read failure instead of a partial success.

ListBranches dropped a branch whose sidecar could not be decoded while a missing
sidecar built defaults and kept it; interruptedTurnContinuedOnRecoveryBranch
read that absence as 'the turn did not continue on a recovery branch' and
stripped the interrupted turn's messages. An unreadable sidecar is now handled
exactly like a missing one.
2026-10-16 19:00:23 +08:00
YHH 95c4c6da28 Merge pull request #13419 from esengine/feat/tab-limit-setting
feat(studio): a setting for how many session tabs stay open before one is folded away
2026-10-16 03:59:44 -07:00
YHH cc66ec38d3 Merge pull request #13418 from esengine/feat/nav-to-top-bar
feat(desktop): move the pane views to the window bar and drop the view strip
2026-10-16 03:58:34 -07:00
YHH 5d4676a277 Merge pull request #13416 from esengine/fix/13254-status-running-excludes-finishing
fix(control): report a turn as running only while it executes, not while its TurnDone is delivered
2026-10-16 03:57:58 -07:00
YHH 93c13cf4e7 Merge pull request #13424 from esengine/fix/linux-folder-panel
fix(desktop): a Linux folder panel that cannot be drawn asks for the path instead of doing nothing
2026-10-16 03:51:57 -07:00
SivanCola 3687254d05 docs(studio): describe narrow composer focus behaviour
Problem: release notes did not mention the focus behaviour shared by all
narrow composer toolbars, making the compact empty state look accidental.

Root cause: the behaviour and pointer stability rationale were documented
only in the internal style contract.

Fix: add matching Chinese and English release-note entries explaining that
empty narrow toolbars remain compact on focus and expand for draft text,
attachments or an open menu, keeping pointer clicks on the same button.

Verification: 12 release-note rendering and Markdown reference tests passed;
make check and git diff --check passed. Documentation-only change.
2026-10-16 18:50:20 +08:00
YHH 7180fc96fc Merge pull request #13420 from esengine/fix/findpage-symlinked-exe
fix(serve): find the page beside the real executable of a link
2026-10-16 03:40:41 -07:00
esengine 3959932434 feat(release): the CLI archives carry the web page
The cli job builds the page once and packs it beside the executable under
frontend-next/dist, where serve.FindPage looks, through a packer and a checker
that are run by the job and by tests (#13406).
2026-10-16 03:39:02 -07:00
esengine bee7a23b66 fix(desktop): a Linux folder panel that cannot be drawn asks for the path instead of doing nothing 2026-10-16 03:38:01 -07:00
esengine cb195c983d feat(studio): a setting for how many session tabs stay open before one is folded away 2026-10-16 03:29:38 -07:00
SivanCola 1f30e8e747 fix(studio): align composer menu density with the application
Problem: the five composer menus appeared oversized beside the application's
compact toolbar and other menus.

Root cause: the shared composer menu style imposed 15px labels, 13px notes,
380px panels, 20px corners and generous spacing on every menu. Workspace
toggle and note borders also produced adjacent divider lines.

Fix: use 13px labels and 11px notes, 12px corners and 8px panel insets; choose
280-340px widths by content. Tighten rows, headings, search, chips, checks
and the workspace switch together, leaving one workspace note divider.
Keep all changes scoped to composer menus and update their style contract
and bilingual release note.

Verification: 22 related suites / 226 tests; production build; make lint,
make check and git diff --check. In-app browser checks cover all five menus
in light/dark themes, 390px and 320px viewport bounds, model/branch search,
Escape dismissal and focus restoration. Mock data only.
2026-10-16 18:29:00 +08:00
YHH a1f63d7964 Merge pull request #13417 from esengine/feat/11376-s1-peer-hold
feat(peermail): hold what other sessions send for the user, and deliver it only once approved
2026-10-16 03:28:12 -07:00
esengine d72352b23d fix(serve): find the page beside the real executable of a link
FindPage now also looks beside the file an executable link resolves to,
after the directory it was invoked from, so a package manager that links the
binary onto the PATH still finds the page next to it (#13406).
2026-10-16 03:27:24 -07:00
SivanCola 450a59d0f7 merge: sync studio and preserve inline composer workspace controls
Problem: the workspace PR conflicted with the current studio composer, and
restoring its toolbar slot exposed narrow-pane overlap and pointer failures.

Root cause: the new side-question control shares the same toolbar; a fixed
model minimum could overrun its flex group. Focus-dependent phone folding
also moved buttons between pointer press and release.

Fix: retain both worktree setup and the upstream side-question button; allow
model shrinking and internal wrapping at the narrowest widths. Keep empty
phone toolbar geometry stable on focus and extend browser guards to cover
both workspace variants, pointer opening and nested button overlap.

Verification: 5,021 frontend tests; affected suites 126 tests after CSS edits;
production and perf builds; make lint, make check and diff whitespace checks;
hoverradius, branch-menu, modelrows and extended narrow browser guards;
light/dark worktree selection, Escape/focus, menu bounds and first send.
2026-10-16 18:17:31 +08:00
esengine 38ebf5f677 feat(desktop): move the pane views to the window bar and drop the view strip
Run analysis and Workbench become icons on the window bar for the focused
pane; the Conversation / Run analysis / Workbench row, the plan peek and
their styles are removed (#13369).
2026-10-16 03:14:10 -07:00
SivanCola 340bbd24b6 fix(studio): restore inline workspace controls and shared pill styling
Problem: workspace and branch controls were moved outside the composer, and
the footer-only 8px override broke the shared toolbar radius browser guard.

Root cause: the workspace slot bypassed the existing branch toolbar layout
and overrode the common mode button geometry.

Fix: restore both controls to the original toolbar slot, reuse branch layout
and pill styling, remove the footer overrides and keep runtime statistics
below the card. Cover new and existing conversations in the browser guard,
load the production menu stylesheet in the perf fixture and align the docs.

Verification: reproduced both layout assertions and the light/dark radius
failure before the fix; 4,831 frontend tests passed; production build,
make lint and make check passed; hoverradius, branch-menu, modelrows and
narrow browser guards passed.
2026-10-16 18:12:21 +08:00
esengine 5cec67c0d4 feat(peermail): hold what other sessions send for the user, and deliver it only once approved 2026-10-16 03:10:54 -07:00
YHH 25dcf4d60f Merge pull request #13411 from esengine/feat/11376-s0-peermail-spool
feat(peermail): the spool, the live record and the forbidden-read directory for messages between sessions
2026-10-16 02:55:05 -07:00
esengine 448e7fe468 fix(control): report a turn as running only while it executes, not while its TurnDone is delivered 2026-10-16 02:46:28 -07:00
YHH c6f134a4c7 Merge pull request #13415 from esengine/feat/13384-write-lease-toggle
feat(studio): a switch for how sessions in one project wait on each other
2026-10-16 02:29:16 -07:00
esengine cb138521d5 feat(peermail): the spool, the live record and the forbidden-read directory for messages between sessions 2026-10-16 02:24:00 -07:00
YHH 1ea7d64cf3 Merge pull request #13413 from esengine/fix/import-registers-workspaces
fix(migration): list the projects a recovered session was routed back into
2026-10-16 02:17:58 -07:00
esengine c79454f8ef feat(studio): a switch for how sessions in one project wait on each other 2026-10-16 02:09:28 -07:00
esengine e0bbc694d7 fix(migration): list the projects a recovered session was routed back into 2026-10-16 02:03:14 -07:00
YHH 8c35e9c8b2 Merge pull request #13262 from xxoingr/fix/cli-stop-offering-light-preset
fix(cli): stop offering the retired light execution setting
2026-10-16 01:58:41 -07:00
YHH b60a5cfe0e Merge pull request #13408 from esengine/feat/12040-last-check-dot
feat(providers): the list remembers how each source's last test ended
2026-10-16 01:56:02 -07:00
YHH 1bccebf836 Merge pull request #13409 from esengine/fix/13406-readable-page-not-built
fix(serve): tell a browser what to do when this server has no web page
2026-10-16 01:37:03 -07:00
esengine df2a1f6769 fix(serve): tell a browser what to do when this server has no web page 2026-10-16 01:25:02 -07:00
YHH caeef8eb94 Merge pull request #13407 from esengine/fix/13402-open-rows
fix(serve): a session open in a pane keeps its titled row whatever the list leaves out
2026-10-16 01:17:46 -07:00
esengine 0b8457466e feat(providers): the list remembers how each source's last test ended 2026-10-16 01:14:36 -07:00
esengine ece69f0930 feat(providers): the list remembers how each source's last test ended 2026-10-16 01:12:47 -07:00
esengine 064f04c1e9 fix(serve): a session open in a pane keeps its titled row whatever the list leaves out 2026-10-16 01:05:51 -07:00
YHH 2332e65698 Merge pull request #13405 from esengine/fix/12040-draft-aware-check
fix(providers): test the address, key and protocol on the form, not only the saved ones
2026-10-16 00:59:20 -07:00
YHH b94404c2f2 Merge pull request #13404 from esengine/feat/feedback-mine-filter
feat(desktop): filter my feedback by progress and new replies
2026-10-16 00:59:15 -07:00
YHH c32febd274 Merge pull request #13240 from chuiwenwei/fix/memory-suppression-audit-symmetry
fix(session): audit every automatic-recall suppression identically
2026-10-16 00:51:24 -07:00
YHH 02c7d24ec4 Merge pull request #13239 from chuiwenwei/fix/checkpoint-turn-reuse-after-unreadable
fix(checkpoint): keep turn numbering above unreadable checkpoint files
2026-10-16 00:49:21 -07:00
YHH fc65474e6b Merge pull request #13261 from xxoingr/test/boot-thought-pause-after-receipt
test(boot): start the scripted thought's pause after the host takes it
2026-10-16 00:46:38 -07:00
YHH 040e48f927 Merge pull request #13260 from xxoingr/fix/filelock-free-lock-after-deadline
fix(filelock): take a free lock even after the caller's deadline has passed
2026-10-16 00:44:03 -07:00
YHH 9765c5db5e Merge pull request #13401 from esengine/feat/12040-typed-connection-check
feat(studio): ask for protocol, address and key before the source name
2026-10-16 00:40:16 -07:00
YHH 9c36cb3a3b Merge pull request #13400 from esengine/feat/side-question-composer
feat(desktop): move the side question entry into the composer toolbar
2026-10-16 00:40:07 -07:00
esengine f4010fc6b6 fix(providers): test the address, key and protocol on the form, not only the saved ones 2026-10-16 00:36:13 -07:00
esengine e778a376b5 feat(desktop): filter my feedback by progress and new replies
A sticky bar of groups (all, new replies, needs your reply, open, closed)
with counts sits at the top of My feedback; rows that do not match are
hidden rather than unmounted so open threads and reply drafts survive (#13388).
2026-10-16 00:33:51 -07:00
esengine de77acf72d feat(desktop): move the side question entry into the composer toolbar
The side question becomes an icon after the attach button, with a tooltip
and a pressed state, instead of a text button on the view strip (#13369).
2026-10-16 00:26:15 -07:00
esengine e2c861d525 feat(studio): ask for protocol, address and key before the source name 2026-10-16 00:19:17 -07:00
YHH 115de56961 Merge pull request #13389 from esengine/feat/4783-job-detail
feat(studio): a running background task can be opened to read its output
2026-10-16 00:18:46 -07:00
YHH 2350eb91b1 Merge pull request #13398 from esengine/feat/13111-origin-enum
fix(agent): label a sub-agent's report with the origin of what it fetched
2026-10-16 00:12:34 -07:00
YHH 3c3b000d81 Merge pull request #13397 from esengine/fix/13395-claim-wording
fix(delegation): say what a writer without write_paths claims under the optimistic write lease
2026-10-16 00:09:16 -07:00
YHH 67baf20bd2 Merge pull request #13396 from esengine/design/pane-nav
feat(desktop): show the goal in the composer plan fold and add view shortcuts
2026-10-16 00:06:04 -07:00
esengine f4a14f3325 feat(studio): a running background task can be opened to read its output 2026-10-16 00:05:48 -07:00
YHH c6446343b8 Merge pull request #13393 from esengine/feat/11942-cross-session-search
feat(history): search conversation text from the sidebar
2026-10-16 00:05:21 -07:00
esengine d5544f8368 fix(agent): label a sub-agent's report with the origin of what it fetched 2026-10-16 00:02:09 -07:00
esengine b634815e3a fix(delegation): say what a writer without write_paths claims under the optimistic write lease 2026-10-15 23:59:14 -07:00
esengine 7d21a467c4 feat(desktop): show the goal in the composer plan fold and add view shortcuts
The composer's plan fold now carries the /goal text when there is no plan,
and the three pane views get Ctrl/Cmd 1-3 shortcuts and palette entries.
Prerequisite for removing the top plan peek and view strip (#13369).
2026-10-15 23:49:21 -07:00
esengine 8ae2737bf8 feat(history): search conversation text from the sidebar 2026-10-15 23:44:36 -07:00
YHH 74f8239944 Merge pull request #13391 from esengine/feat/12254-cancel-one-call
feat(agent): stop one running tool call without ending the turn
2026-10-15 23:38:44 -07:00
esengine 22ba1cdf2d feat(agent): stop one running tool call without ending the turn 2026-10-15 23:06:05 -07:00
YHH 438f1e7d4a Merge pull request #11299 from Guan0923/codex/short-user-bubble
feat(studio): create conversation branches from final replies
2026-10-15 22:58:01 -07:00
YHH 82a0084226 Merge pull request #13387 from esengine/feat/11962-ask-interface-size
feat(studio): ask once whether the interface is too small on a wide display at 100% scaling
2026-10-15 22:54:09 -07:00
YHH d75669c7c6 Merge pull request #13386 from esengine/fix/13377-archived-open
fix(serve): an open archived session no longer returns as an unlisted new-session row
2026-10-15 22:27:53 -07:00
esengine f8e132334a feat(studio): ask once whether the interface is too small on a wide display at 100% scaling 2026-10-15 22:25:54 -07:00
esengine e14e031993 fix(serve): an open archived session no longer returns as an unlisted new-session row 2026-10-15 22:15:22 -07:00
Guan09 ad01fab3d6 fix(studio): preserve BranchAt checkpoints and source settings 2026-10-16 13:14:18 +08:00
Guan09 80d7b978c0 fix(studio): scope fork UI and preserve conversation checkpoints 2026-10-16 13:13:16 +08:00
Guan09 83b530f0d3 feat(studio): 调整消息操作并支持从最终答复创建对话分支 2026-10-16 13:13:16 +08:00
YHH 27f0dd00fc Merge pull request #13383 from esengine/fix/13345-tree-index
perf(sessionstore): remember the session listing so a restart does not open every sidecar
2026-10-15 22:05:03 -07:00
esengine 70a21569fd test(sessionstore): use the current idioms in the listing index tests 2026-10-15 21:53:59 -07:00
esengine d9a3e7b930 docs(sessionstore): shorten the listing comment 2026-10-15 21:51:08 -07:00
esengine a440eed706 docs: shorten the listing index note 2026-10-15 21:51:08 -07:00
esengine ec66acdf93 perf(sessionstore): remember the session listing so a restart does not open every sidecar 2026-10-15 21:51:08 -07:00
YHH d51242c956 Merge pull request #13382 from esengine/fix/13379-ask-error-style
fix(studio): give the ask card's delivery error room and colour
2026-10-15 21:43:17 -07:00
YHH e64d1e0b49 Merge pull request #13381 from esengine/fix/12883-empty-skill
fix(skill): a review skill chosen with no task reviews the pending changes by default
2026-10-15 21:36:41 -07:00
esengine 678a7f6eb5 fix(studio): give the ask card's delivery error room and colour 2026-10-15 21:34:30 -07:00
YHH 15507b8e22 Merge pull request #13380 from esengine/fix/13148-fallback-notice
fix(agent): say why a fold that stopped matching is no longer used
2026-10-15 21:33:59 -07:00
esengine d76f5e1594 test(control): drop an unused helper 2026-10-15 21:24:55 -07:00
esengine c1c5676e69 refactor(control): the typed subagent slash moves to its own method 2026-10-15 21:23:53 -07:00
esengine b1e369773c fix(skill): a review skill chosen with no task reviews the pending changes by default 2026-10-15 21:23:14 -07:00
esengine f02c20db16 fix(agent): say why a fold that stopped matching is no longer used 2026-10-15 21:22:55 -07:00
YHH f47ee0f2fb Merge pull request #13378 from esengine/fix/13345-tree-retry
fix(studio): the sidebar offers a retry when the first read is slow, and a retry does not queue
2026-10-15 21:18:24 -07:00
esengine 6c9e696290 style(studio): keep the retry rule within the stylesheet budget 2026-10-15 21:07:10 -07:00
esengine c57dc18a7a docs(studio): shorten the sidebar retry note 2026-10-15 21:05:25 -07:00
esengine 2ca0e9b771 fix(studio): the sidebar offers a retry when the first read is slow, and a retry does not queue 2026-10-15 21:05:25 -07:00
YHH 21f226e608 Merge pull request #13376 from esengine/fix/kept-failures-effect
test(boot): hold the failed-result retention bound at the provider boundary
2026-10-15 21:03:56 -07:00
YHH e9b5b44c1d Merge pull request #13374 from esengine/feat/13125-granted-root-images
feat(studio): conversation pictures load from folders the user granted the workspace
2026-10-15 20:58:38 -07:00
YHH c1150171e3 Merge pull request #13373 from esengine/fix/13368-user-images
fix(studio): a tall picture sent with a message no longer spills out of its thumbnail
2026-10-15 20:46:54 -07:00
esengine 1b55821b6f test(boot): hold the failed-result retention bound at the provider boundary
#13366 bounded the failed tool results a fold keeps, with component tests. This
adds the effect test through the real Build: 60 turns of failing commands fold
seven times, and the request right after a fold must not carry more failed
results than the retention budget plus the recent tail allow (4 with the bound,
9 without it, where the 50% ceiling is the only thing that clears them).

Refs #13148
2026-10-15 20:45:48 -07:00
esengine 5c7992e840 docs(studio): shorten the user picture note 2026-10-15 20:36:54 -07:00
esengine 8325582049 fix(studio): a tall picture sent with a message no longer spills out of its thumbnail 2026-10-15 20:36:33 -07:00
esengine 02107977f4 feat(studio): conversation pictures load from folders the user granted the workspace 2026-10-15 20:33:56 -07:00
esengine 4215adc58a feat(serve): /workspace/image can read from a folder the user granted the workspace 2026-10-15 20:33:56 -07:00
YHH d06ffdddd5 Merge pull request #13366 from esengine/fix/kept-failures-budget
fix(agent): bound the failed tool results a fold keeps verbatim
2026-10-15 20:33:30 -07:00
YHH 1a140858c5 Merge pull request #13367 from esengine/feat/update-download-mode
feat(update): a Download update mode that fetches but never installs or restarts
2026-10-15 20:26:45 -07:00
Will b96b977897 fix(e2ebench): report a suite run whose metrics never landed as unmeasured
runSegments read only the final metrics file. When a child was killed or
timed out it folded the missing record in as an accounted zero, deleted the
<metrics>.partial snapshot the sink had been writing, and left Unaccounted
unset, so every published total omitted what the run spent.

Read the sidecar when the final record is absent, keep the file it came from,
set Partial, and mark the run Unaccounted when neither exists.
renderMeterAccounting no longer prices a run whose harness reported no tokens
against the meter, which invented a -100% divergence.
2026-10-16 11:24:20 +08:00
Will e795d822f6 fix(plugin): stop reporting a legacy cache-key upgrade that did not persist
LoadCachedSchemaForSpec rewrote a legacy-keyed entry in memory, discarded the
SaveCachedSchema error, and returned the hit with the new CacheKey. With a
read-only or full cache dir the file still held the legacy key, so boot was
handed a key that a later read cannot reproduce and pinned the session's MCP
tool identity to a key with no on-disk counterpart
(internal/assembly/boot/mcp_registration.go:76).

Keep the hit but claim the upgraded key only once the rewrite reached disk; on
failure the returned object keeps the legacy key the file holds, which matches
the package's best-effort, silent-degradation policy. A successful upgrade is
unchanged, and no caller can now observe a key the file disagrees with.
2026-10-16 11:20:39 +08:00
Will 9f33c8c45e fix(sessionstore): refuse a degraded image rewrite when the checkpoint is unreadable
checkpointImages reported "no such image" for a checkpoint it could not
inspect, so refuseDegradedCheckpointRewrite read a stat failure, a read
failure, a non-regular file, or a file past the replay ceiling as "nothing to
protect" and let a transcript missing an image replace the only copy of that
image — the exact case where the guard cannot know.

Return the inspection error from checkpointImages, and have the guard fail
closed under ErrSessionImagesUnavailable for any checkpoint that exists but
cannot be read whole. A genuinely missing checkpoint still proceeds: there is
no second copy to protect. The restore path stays best-effort and now logs why
it could not inspect the checkpoint.
2026-10-16 11:20:20 +08:00
Will 6858c06de7 fix(stats): Flush reports the first append that failed
recordDispatcher.run discarded Writer.Append's error, so Flush — the only
success/failure signal the stats pipeline has — returned nil even when every
queued append failed. The dispatcher now keeps the first failure since the last
flush sentinel and hands it to the waiter through that sentinel, so a caller
that gates a deterministic read on Flush cannot mistake a failed write for a
durable one.

The failure is consumed by the flush that reports it, so a later flush over a
recovered directory returns nil, while a still-broken one keeps reporting. A
full queue may still drop a record; that stays documented and unchanged.

Tests: the audit red test plus controls for a healthy flush persisting records
and for failure-then-convergence.
2026-10-16 11:20:05 +08:00
esengine e715e2eac3 fix(agent): bound the failed tool results a fold keeps verbatim
A fold keeps failed tool results since the latest digest. The projection places
them right after that digest, so the next fold read last fold's survivors as
"since the latest digest" and kept them again: retention grew by every round's
failures and only stopped when the candidate crossed the 50% checkpoint ceiling.

Failed results now have their own retention budget (5% of the window, like kept
user turns), newest first; the rest fold into the digest, whose coverage guard
still names them. [[keep]] messages stay outside the budget.

Refs #13148
2026-10-15 20:18:53 -07:00
esengine 4342f7033f feat(studio): register the restart notice actions 2026-10-15 20:10:13 -07:00
esengine fad7beb5f8 feat(studio): the restart notice uses the calm surface; harness can stage an update 2026-10-15 20:10:13 -07:00
esengine b08c7b0ea9 docs(studio): shorten the download mode note 2026-10-15 20:10:13 -07:00
esengine 23618027e5 feat(studio): the Download update mode in settings and the restart notice 2026-10-15 20:10:13 -07:00
esengine 1ade11a727 feat(studio): the update watch downloads in download mode and reports the release waiting 2026-10-15 20:10:13 -07:00
esengine 5a2df57570 feat(config): a download update mode that fetches but never installs or restarts 2026-10-15 20:10:13 -07:00
YHH ed121465e6 Merge pull request #13364 from esengine/fix/13343-theme-contrast
test(theme): every shipped pack's grey inks read on the surfaces they are set on
2026-10-15 20:09:05 -07:00
YHH 900cf4c064 Merge pull request #13362 from esengine/fix/13354-md-local-images
fix(studio): local pictures in a reply load from the workspace channel or say why not
2026-10-15 19:59:43 -07:00
esengine ad67dd9d1b test(theme): every shipped pack's grey inks read on the surfaces they are set on 2026-10-15 19:58:50 -07:00
esengine 5f4dc69ebf fix(studio): local pictures in a reply load from the workspace channel or say why not 2026-10-15 19:50:23 -07:00
YHH 02aa56aaa8 Merge pull request #13361 from esengine/fix/13358-skill-hint
fix(studio): announce loading only while a slash word is waiting for the list
2026-10-15 19:39:28 -07:00
YHH 1a06a37546 Merge pull request #13360 from esengine/fix/12780-switching-state
fix(studio): the branch chip says which branch it is switching to
2026-10-15 19:37:44 -07:00
esengine e76f82b095 fix(studio): announce loading only while a slash word is waiting for the list 2026-10-15 19:29:19 -07:00
esengine ce3a71736d fix(studio): the branch chip says which branch it is switching to 2026-10-15 19:25:57 -07:00
YHH af7e4166e6 Merge pull request #13357 from esengine/fix/13343-running-actions
fix(studio): a reply's actions wait until the tools under it have finished
2026-10-15 19:22:36 -07:00
YHH fb2bab37ca Merge pull request #13356 from esengine/fix/13353-autoname-reason
fix(serve): say why automatic session naming failed
2026-10-15 19:21:03 -07:00
YHH 6f073db2ab Merge pull request #13355 from esengine/feat/13346-run-elapsed
feat(studio): the running chip says how long the turn has been running
2026-10-15 19:13:37 -07:00
esengine 7cd0830d47 refactor(studio): the empty-conversation hero moves out of Transcript.tsx 2026-10-15 19:11:00 -07:00
esengine 653d49bdb2 fix(studio): a reply's actions wait until the tools under it have finished 2026-10-15 19:08:15 -07:00
esengine 5add43c159 fix(serve): say why automatic session naming failed 2026-10-15 19:04:01 -07:00
esengine 343d100610 docs(studio): shorten the running chip note 2026-10-15 18:59:15 -07:00
esengine 5cd4b0347d test(studio): the run start is unknown until a turn starts in this window 2026-10-15 18:57:20 -07:00
esengine 13404bd977 feat(studio): the running chip says how long the turn has been running 2026-10-15 18:57:03 -07:00
YHH ff55e2dfa2 Merge pull request #13351 from esengine/fix/13350-feedback-shot-zoom
fix(studio): attached feedback screenshots open at full size
2026-10-15 18:54:15 -07:00
esengine ecc8138c11 fix(studio): attached feedback screenshots open at full size 2026-10-15 18:42:58 -07:00
YHH d2020b1309 Merge pull request #13348 from esengine/fix/13220-toast-names-config-file
fix(studio): a refused settings save names the config file that did not parse
2026-10-15 18:38:44 -07:00
YHH 7df661b5ef Merge pull request #13344 from esengine/fix/13293-unproven-gap
fix(agent): stop telling the model that making an unproven change again settles it
2026-10-15 18:34:37 -07:00
esengine 5d79d0f450 fix(studio): a refused settings save names the config file that did not parse 2026-10-15 18:27:08 -07:00
esengine bc2c691ba4 fix(agent): stop telling the model that making an unproven change again settles it 2026-10-15 18:23:53 -07:00
YHH fe1dceced9 Merge pull request #13340 from esengine/test/13295-system-prompt-change-effect
test(boot): a changed system prompt says the saved fold was not reused
2026-10-15 18:11:45 -07:00
esengine 81bb10fae1 test(boot): a changed system prompt says the saved fold was not reused
The covered-prefix hash includes the system message, so editing the system
prompt between two visits retires a saved compaction. #13336 added the notice
at the agent and control layers; this pins it at boot.Build, and pins that an
unchanged prompt stays quiet.

Refs #13295
2026-10-15 16:48:04 -07:00
YHH 55c3d81bbc Merge pull request #13339 from esengine/docs/release-studio-2.42.0
docs(release): Studio 2.42.0 notes
2026-10-15 16:40:16 -07:00
esengine ffd73e115e docs(release): Studio 2.42.0 notes 2026-10-15 16:28:01 -07:00
YHH 1a33ce5692 Merge pull request #13337 from esengine/fix/13335-lease-domain-notice
fix(workspacelease): say which git repository folds two folders into one write domain
2026-10-15 14:06:21 -07:00
esengine 6e2ed58a34 fix(workspacelease): say which git repository folds two folders into one write domain 2026-10-15 13:56:32 -07:00
YHH 2be1dba2da Merge pull request #13336 from esengine/fix/projection-drop-attribution
fix(control): say why a saved fold was not reused when a session is opened
2026-10-15 13:14:19 -07:00
esengine a717691db9 docs(release-notes): shorten the fold-not-reused fragment 2026-10-15 13:01:14 -07:00
esengine dfdbfbd4d8 fix(control): say why a saved fold was not reused when a session is opened 2026-10-15 12:59:32 -07:00
YHH 7b7af1a78c Merge pull request #13334 from esengine/fix/overview-cell-align
fix(studio): overview figures on one row share a baseline
2026-10-15 12:57:08 -07:00
YHH 59526e5156 Merge pull request #13333 from esengine/docs/release-note-gaps-1016
docs(release-notes): fragments for the expired-pin fix and the branch-switch latency
2026-10-15 12:54:14 -07:00
esengine 935200d9e8 fix(studio): overview figures on one row share a baseline 2026-10-15 12:46:41 -07:00
YHH 9006ff804c Merge pull request #13332 from esengine/feat/overview-context
feat(studio): the context window in the session overview
2026-10-15 12:45:42 -07:00
esengine c57cf43aeb docs(release-notes): fragments for the expired-pin fix and the branch-switch latency 2026-10-15 12:43:14 -07:00
esengine a9e2a12927 docs(release-notes): shorten the overview window fragment 2026-10-15 12:30:49 -07:00
esengine 97dee44a3d fix(studio): the overview's hand-over button has its own action value 2026-10-15 12:28:53 -07:00
esengine 2192bfb20c feat(studio): the context window in the session overview, stated as the next request's content 2026-10-15 12:28:09 -07:00
YHH 5516d5a7d8 Merge pull request #13331 from esengine/fix/context-card-guard
test(studio): scope the context-card hover guard to the context anchor
2026-10-15 12:18:39 -07:00
esengine d10871190c test(studio): scope the context-card hover guard to the context anchor 2026-10-15 12:09:08 -07:00
YHH ccdd8a721f Merge pull request #13330 from esengine/feat/session-overview
feat(studio): a session overview behind the cache meter, each figure beside what it counts
2026-10-15 12:03:58 -07:00
esengine 941b66d0fc feat(studio): a session overview behind the cache meter, each figure beside what it counts 2026-10-15 11:49:28 -07:00
YHH 008d5c5e35 Merge pull request #13329 from esengine/test/lazy-swap-poll
test(plugin): wait for the registry swap, not just the server
2026-10-15 11:41:33 -07:00
YHH 18abe2b8b0 Merge pull request #13328 from esengine/feat/session-usage-status
feat(serve): /status carries the session's usage by source, from the cost ledger
2026-10-15 11:37:57 -07:00
esengine e43caacac5 test(plugin): wait for the registry swap, not just the server 2026-10-15 11:30:17 -07:00
YHH b3056b2326 Merge pull request #13327 from esengine/fix/scroll-jump-ends
fix(studio): the jump buttons go straight to the ends; Shift+click steps through expanded blocks
2026-10-15 11:12:16 -07:00
esengine 4f2a495578 feat(serve): /status carries the session's usage by source, from the cost ledger 2026-10-15 11:10:37 -07:00
esengine d3e45aff25 fix(studio): the jump buttons go straight to the ends; Shift+click steps through expanded blocks 2026-10-15 10:59:38 -07:00
YHH 0fe09f3a1d Merge pull request #13323 from esengine/fix/bound-taskkill
fix(proc): bound the taskkill a Windows stop depends on
2026-10-15 10:56:47 -07:00
YHH 3d18fee663 Merge pull request #13325 from esengine/fix/steer-resend-dedupe
fix(inbox): the same words sent again while the first copy waits are not another item
2026-10-15 10:54:30 -07:00
esengine ee9e753baf style(tools): range over SplitSeq in the dev server test 2026-10-15 10:47:18 -07:00
esengine da3359c59b fix(inbox): the same words sent again while the first copy waits are not another item 2026-10-15 10:42:29 -07:00
YHH d7f0055d5e Merge pull request #13267 from esengine/feat/13228-browser-tab-rename
feat(studio): rename a built-in browser tab by double-clicking it
2026-10-15 10:31:29 -07:00
YHH f5a010780c Merge pull request #13324 from esengine/fix/skill-update-record-heal
fix(skills): retry a failed update record and let the next update heal it
2026-10-15 10:21:46 -07:00
esengine fbfdf8bdbe feat(studio): rename a built-in browser tab by double-clicking it 2026-10-15 10:20:57 -07:00
esengine b238636ce4 fix(skills): retry a failed update record and let the next update heal it 2026-10-15 10:10:10 -07:00
YHH a6b496de58 Merge pull request #13322 from esengine/fix/job-stop-in-progress
fix(jobs): say when a background job's stop is already under way, and refresh the list after a refused stop
2026-10-15 10:09:03 -07:00
esengine 9edb081dab test(tools): a dev server's process tree ends on Stop and on kill_shell, in both Windows shells 2026-10-15 10:05:54 -07:00
esengine 95ec1dd78b fix(proc): bound the taskkill a stop depends on 2026-10-15 10:02:55 -07:00
esengine 421dd778cd test(proc): a hung taskkill must not hold the stop 2026-10-15 10:01:04 -07:00
esengine 0c199e0c25 fix(jobs): say when a background job's stop is already under way, and refresh the list after a refused stop 2026-10-15 09:54:32 -07:00
YHH 878baaae07 Merge pull request #13321 from esengine/feat/skill-source-update
feat(skills): update an installed skill from its source folder, after previewing the change
2026-10-15 09:53:36 -07:00
esengine 373c1e773c feat(skills): update an installed skill from its source folder, after previewing the change 2026-10-15 09:38:45 -07:00
YHH 9bd7e7f104 Merge pull request #13317 from esengine/feat/skill-source-lock
feat(skills): remember where an installed skill came from, and say whether it is still in step
2026-10-15 09:33:34 -07:00
YHH 824975d344 Merge pull request #13313 from esengine/feat/update-mode
feat(update): an update mode setting, with a background notify tier
2026-10-15 09:29:06 -07:00
esengine 04c74845ea feat(skills): remember where an installed skill came from, and say whether it is still in step 2026-10-15 09:24:32 -07:00
esengine 65192c35a5 test(config): the update mode survives a desktop save 2026-10-15 09:19:20 -07:00
YHH c39c9e2155 Merge pull request #13316 from esengine/docs/release-note-gaps
docs(release-notes): fragments for four merged changes that had none
2026-10-15 09:07:08 -07:00
YHH 8a36ceab41 Merge pull request #13314 from xxoingr/docs/guide-rewind-confirm-key
docs(guide): list the rewind picker's partial-coverage confirmation
2026-10-15 09:04:16 -07:00
YHH b9b482df5e Merge pull request #13315 from esengine/fix/skill-store-grant-refusal
fix(config): the skills folder refuses a write grant with its own identity and the way forward
2026-10-15 09:03:39 -07:00
esengine d2f075edeb docs(release-notes): fragments for four merged changes that had none 2026-10-15 08:57:30 -07:00
SivanCola 1d90810a02 feat(studio): configure session worktrees and unify composer menus
Problem: concurrent tasks need separate code directories rooted at a chosen
branch, and composer configuration menus use inconsistent visual scales.

Root cause: isolated workspaces only started from HEAD and their entry was
outside the new-conversation composer. Menu surfaces had independent styles.

Fix: stage worktree and exact base-ref selection before the first submit,
carry draft attachments through confined roots, preserve session settings,
reject stale preparation and roll back unpublished worktrees on failure.
Keep existing conversations and older servers on the branch control.
Share composer menu surfaces, moderate 15px labels, 13px descriptions and
neutral selection states across mode, workspace, permissions, model and effort.
Move workspace controls below the input card beside runtime statistics, retain
branch labels in narrow panes, and wrap model/effort at the smallest widths.

Verification: 4831 frontend tests; TypeScript and production build; real Git
worktree/source/refusal and attachment confinement tests; serve, control,
builtin and boot suites; go vet, make lint and make check. Browser interaction
and light/dark responsive captures are kept outside the repository.

Fixes #13264
2026-10-15 23:56:52 +08:00
esengine 3336055f25 fix(config): the skills folder refuses a write grant with its own identity and the way forward 2026-10-15 08:53:21 -07:00
xxoingr 65d5a9b8ed docs(guide): list the rewind picker's partial-coverage confirmation
A file restore the checkpoint covers only partly stops for one more
answer, taken with Enter or y (the panel's own hint says so); the key
table in both guides listed neither the step nor y. The new test pins
that step: it does not commit before the answer, Esc steps back without
restoring, and Enter or y commits.
2026-10-15 23:50:55 +08:00
esengine 5e45f2481a feat(update): an update mode setting, with a background notify tier 2026-10-15 08:40:28 -07:00
YHH c8bf792009 Merge pull request #13310 from esengine/fix/scrolljump-quiet
fix(studio): quieter jump-to-top/bottom buttons
2026-10-15 08:29:20 -07:00
YHH 3fd20922ce Merge pull request #13309 from esengine/test/v5-import-markers
test(migration): 24-digit by-id sessions import beside empty legacy markers
2026-10-15 08:25:07 -07:00
YHH 879debe477 Merge pull request #13308 from esengine/test/cancel-hung-gateway
test(openai): cancelling ends a stream wherever the gateway goes silent
2026-10-15 08:22:35 -07:00
esengine bdf9629c4b fix(studio): quieter jump-to-top/bottom buttons 2026-10-15 08:18:28 -07:00
YHH 23fb0e3b3d Merge pull request #13307 from esengine/fix/13302-plan-chip-right
fix(studio): keep the plan chip at the right edge of the tab row
2026-10-15 08:14:42 -07:00
esengine fc0ddd5082 test(migration): 24-digit by-id sessions import beside empty legacy markers 2026-10-15 08:14:13 -07:00
YHH 8d5b0e79f5 Merge pull request #13282 from esengine/feat/13276-model-prices
feat(providers): per-model price entry in the connection editor
2026-10-15 08:13:31 -07:00
esengine 786395c23e test(openai): cancelling ends a stream wherever the gateway goes silent 2026-10-15 08:11:00 -07:00
YHH 8d83e5322f Merge pull request #13306 from esengine/fix/update-stall-fact
fix(update): say when a download stalls and keeps stalling
2026-10-15 08:09:32 -07:00
esengine b610ed563d fix(studio): keep the plan chip at the right edge of the tab row 2026-10-15 08:03:31 -07:00
YHH c671e7aee7 Merge pull request #13298 from esengine/fix/openwith-labels
fix(studio): translate the open-with names and drop the duplicate file manager entry
2026-10-15 07:59:41 -07:00
esengine cf0b55cbf6 fix(update): say when a download stalls and keeps stalling 2026-10-15 07:58:51 -07:00
YHH 3536abdb11 Merge pull request #13287 from esengine/feat/13258-mirror-reply-images
feat(feedback-sync): bring a mirrored reply comment up to date when its images change
2026-10-15 07:57:50 -07:00
esengine 8d76564db6 test(studio): tolerate the model id appearing in its price row 2026-10-15 07:54:25 -07:00
YHH 86b7f399ba Merge branch 'studio' into feat/13276-model-prices 2026-10-15 07:52:04 -07:00
esengine 28e8874cb4 fix(studio): translate the open-with names and drop the duplicate file manager entry 2026-10-15 07:49:37 -07:00
YHH 723f0fabcf Merge pull request #13277 from esengine/feat/13247-shell-args
feat(shell): launcher arguments for a pinned shell (wsl.exe -d <distro> -- /bin/bash)
2026-10-15 07:49:32 -07:00
YHH 81742a513c Merge pull request #13300 from esengine/fix/13293-unproven-ask
fix(agent): tell the turn what discharges a change the host could not read
2026-10-15 07:43:30 -07:00
esengine 369fbf83b4 feat(shell): launcher arguments for a pinned shell (wsl.exe -d <distro> -- /bin/bash) 2026-10-15 07:35:36 -07:00
YHH 465882cad6 Merge pull request #13296 from esengine/fix/legacy-import-24hex-ids
fix(sessionstore): import 1.x Desktop sessions whose ids are 24 digits
2026-10-15 07:34:22 -07:00
esengine ece5895330 fix(agent): tell the turn what discharges a change the host could not read 2026-10-15 07:32:49 -07:00
esengine 1be3ce4c17 fix(sessionstore): import 1.x Desktop sessions whose ids are 24 digits 2026-10-15 07:23:45 -07:00
YHH 6891c67dc7 Merge pull request #13241 from chuiwenwei/fix/sessionstore-torn-checkpoint-replay
fix(sessionstore): keep the turns a torn checkpoint still decodes
2026-10-15 07:22:45 -07:00
YHH 6bcb1b3f6a Merge pull request #13238 from chuiwenwei/fix/context-batch-failed-runs-not-scored
fix(context-retrieval): stop scoring runs that never produced a transcript
2026-10-15 07:19:54 -07:00
YHH cfb6e5436d Merge pull request #13289 from esengine/feat/13284-compaction-remains
feat(compaction): say what a fold still leaves behind when it is a large share of the window
2026-10-15 07:11:31 -07:00
esengine c9d5f416e6 feat(compaction): say what a fold still leaves behind when it is a large share of the window 2026-10-15 07:01:28 -07:00
YHH 41a6830338 Merge pull request #13285 from esengine/fix/feedback-capture-teardown
fix(studio): stop the window capture quietly when its dialog is already gone
2026-10-15 06:52:50 -07:00
esengine a8d4af21e8 feat(feedback-sync): bring a mirrored reply comment up to date when its images change 2026-10-15 06:50:06 -07:00
YHH 9d1e1387fa Merge pull request #13283 from esengine/fix/optimistic-test-userconfig
test(boot): write the optimistic-claims test's user config where the platform reads it
2026-10-15 06:48:24 -07:00
esengine 86acd819a4 fix(studio): stop the window capture quietly when its dialog is already gone 2026-10-15 06:43:17 -07:00
YHH 457f44d28b Merge pull request #13265 from esengine/feat/13227-open-with-header
feat(studio): open the project in other programs from the file panel
2026-10-15 06:40:48 -07:00
esengine 63599d05ec test(boot): write the optimistic-claims test's user config where the platform reads it 2026-10-15 06:37:14 -07:00
YHH 5df01bceb6 Merge pull request #13201 from chuiwenwei/fix/memory-expired-pinned-guidance
fix(memory): keep expired facts out of pinned guidance
2026-10-15 06:36:55 -07:00
esengine 20e70f1e49 feat(providers): per-model price entry in the connection editor 2026-10-15 06:31:54 -07:00
YHH 8deaa510f0 Merge pull request #13281 from esengine/feat/13274-subagent-claims-optimistic
feat(delegation): optimistic claims for sub-agents that declare no write_paths
2026-10-15 06:23:31 -07:00
YHH 9911440dba Merge pull request #13280 from esengine/feat/13212-thinking-preview
feat(studio): optional first-sentence preview beside folded thinking
2026-10-15 06:11:41 -07:00
esengine 30b102ea55 feat(delegation): optimistic claims for sub-agents that declare no write_paths 2026-10-15 06:09:33 -07:00
esengine 236520bc6d feat(studio): optional first-sentence preview beside folded thinking 2026-10-15 06:01:38 -07:00
YHH 0bbc7aa3fe Merge pull request #13279 from esengine/fix/13275-isolated-config-locks
fix(config): keep config edit locks inside an isolated Reasonix home
2026-10-15 05:49:40 -07:00
esengine 143c407f8c fix(config): keep config edit locks inside an isolated Reasonix home 2026-10-15 05:41:12 -07:00
YHH 43b70aeab4 Merge pull request #13272 from esengine/fix/13269-denied-capability-message
fix(agent): say why a write_paths sub-agent may not run a capability, and what to do
2026-10-15 05:18:09 -07:00
esengine ba5ae25730 Merge remote-tracking branch 'origin/studio' into feat/13227-open-with-header
# Conflicts:
#	desktop/frontend-next/src/i18n/en.ts
#	desktop/frontend-next/src/ui/WorkbenchFileBar.tsx
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
2026-10-15 05:12:31 -07:00
YHH 4d752dbe46 Merge pull request #13263 from esengine/feat/13255-file-reveal
feat(studio): show the open file in the file manager from its toolbar
2026-10-15 05:09:15 -07:00
esengine d546028843 fix(agent): say why a write_paths sub-agent may not run a capability, and what to do 2026-10-15 05:06:55 -07:00
YHH e89f4b86c3 Merge pull request #13271 from esengine/feat/13264-worktree-base
feat(worktree): create an isolated copy from a chosen branch
2026-10-15 05:00:31 -07:00
YHH f133f0354b Merge pull request #13270 from esengine/feat/13258-reply-images-ui
feat(feedback): replies can carry screenshots
2026-10-15 04:54:48 -07:00
esengine b6fdbb89dd feat(worktree): create an isolated copy from a chosen branch 2026-10-15 04:50:49 -07:00
esengine 856f2fd3f9 feat(feedback): replies can carry screenshots 2026-10-15 04:35:47 -07:00
YHH b1ca562c42 Merge pull request #13257 from esengine/feat/13254-tab-pip-motion
feat(studio): the working session's tab dot breathes
2026-10-15 04:29:24 -07:00
esengine 31afb4a8e6 refactor(studio): the unread mark has one form now that the tab dot carries it 2026-10-15 04:20:23 -07:00
YHH 4ad336d7e7 Merge pull request #13266 from esengine/fix/13259-composer-toolbar
fix(studio): keep the composer toolbar on one line in a narrow conversation column
2026-10-15 04:18:06 -07:00
esengine 349281ede0 fix(studio): keep the composer toolbar on one line in a narrow conversation column 2026-10-15 04:07:13 -07:00
esengine 4974aa9755 feat(studio): open the project in other programs from the file panel 2026-10-15 04:06:24 -07:00
esengine aab5763204 feat(studio): an unread finished session is shown by the tab dot itself 2026-10-15 03:32:17 -07:00
esengine 59996652db feat(studio): show the open file in the file manager from its toolbar 2026-10-15 03:27:56 -07:00
xxoingr c02541014b fix(cli): stop offering the retired light execution setting
agentpreset.Normalize maps "light" and "economy" to balanced, and All()
lists balanced and delivery; light's own policy no longer exists. The CLI
still put light forward as a choice, in seven hand-written strings: the
--preset help of run, tui and serve, the --profile deprecation warning of
each, and parseRuntimeProfile's refusal ("want light, balanced, or
delivery"). The guides went further and described a Light policy (direct-
first planning, targeted verification) that nothing enforces, so a user
who picked it got balanced without being told.

The seven strings now come from agentpreset.All() through livePresets in
preset_flags.go, and the docs (GUIDE, GUIDE.zh-CN, CLI.zh-CN, TOOL_CONTRACT)
offer balanced and delivery, drop the Light policy bullet, and say the
retired names select balanced. light and economy still parse.
2026-10-15 18:27:31 +08:00
xxoingr dcc5616f94 test(boot): start the scripted thought's pause after the host takes it
TestEffectThinkingTimeIsKeptWithTheTurn failed in the race job on studio
(run 38635511666): "thought kept 46 ms, framed 46 ms; want both at least
50". The scripted provider wrote the reasoning chunk into a buffered
channel and began its 60 ms pause at once, while the host times the
thought from when it takes that chunk; a host that reads the first chunk
late measures less than the pause. Returning the stream 30 ms late
reproduces it (29 ms measured).

The channel is now unbuffered, so the pause starts only after the host has
taken the reasoning chunk and the measured thought cannot be shorter than
it.
2026-10-15 18:18:07 +08:00
xxoingr c66e7f5e17 fix(filelock): take a free lock even after the caller's deadline has passed
acquireLocal waited on the in-process token and ctx.Done in one select.
When both were ready (a lock nobody held, and a deadline already spent)
select picked one at random, so half the time Acquire refused a free lock
with the context error. The file-lock half of acquire already tries once
before it looks at ctx; the in-process half now takes an available token
first too. A held lock still fails at once once the deadline has passed.

This is the failure behind the unsealed evidence bundles on Windows CI.
TestPlanCriterionIsVerifiedByItsStepsCommands (studio run 38664267053)
reported "bundle not sealed: failure trusted_state.unwritable, contract
failure """ after 3.09 s: sealShadowBundle's two-second sealWait covers
the contract seal and object writes as well as the lock wait, so slow
writes spent it, and the bundle's own Append then met a free stream lock
with an expired ctx. With the first write slowed by 2.2 s, 1 of 6 turns
ended unsealed with that exact signature before this change, 0 of 6 after.
2026-10-15 18:14:22 +08:00
esengine b44514d8b1 feat(studio): the working session's tab dot breathes 2026-10-15 03:02:04 -07:00
YHH d7ce590b16 Merge pull request #13218 from esengine/fix/13213-stale-page
feat(studio): a hosted page left open across a deploy says it is not the latest build
2026-10-15 03:01:53 -07:00
YHH 92f8237356 Merge branch 'studio' into fix/memory-expired-pinned-guidance 2026-10-15 02:56:05 -07:00
YHH 20c01d470a Merge pull request #13256 from esengine/fix/13013-fold-unseen-changes
fix(agent): do not refuse a fold for changes the summarizer was never shown
2026-10-15 02:39:49 -07:00
esengine ed488806dc fix(agent): do not refuse a fold for changes the summarizer was never shown 2026-10-15 02:29:47 -07:00
YHH 2ca2a9b53c Merge pull request #13216 from esengine/feat/13210-ask-countdown
feat(ask): show the countdown to an ask-timeout skip and stop it when the question is taken over
2026-10-15 01:40:34 -07:00
YHH 8372c30ed1 Merge pull request #13224 from esengine/ci/let-studio-pushes-finish
ci: let a push to studio finish instead of cancelling the run before it
2026-10-15 01:32:30 -07:00
esengine dbf09924f9 feat(ask): show the countdown to an ask-timeout skip and stop it when the question is taken over 2026-10-15 01:29:16 -07:00
YHH a563a06e24 Merge pull request #13243 from esengine/feat/feedback-account-link
feat(feedback): link an install to the signed-in account only when the person asks
2026-10-15 01:24:12 -07:00
esengine e96af1ff8d feat(feedback): link an install to the signed-in account only when the person asks 2026-10-15 01:16:25 -07:00
YHH 201424f7f7 Merge pull request #13234 from par73e/fix/12780-workspace-branch-latency
perf(studio): reduce latency after selecting a Git branch
2026-10-15 00:51:47 -07:00
YHH f8223c51b4 Merge pull request #13225 from esengine/feat/13221-delete-with-copies
feat(studio): deleting a conversation can take its recovery copies along
2026-10-15 00:50:45 -07:00
YHH 0290335f25 Merge pull request #13203 from chuiwenwei/fix/memory-recall-freshness
fix(memory): re-check a delivered fact by reading its own file, not its stat
2026-10-15 00:50:00 -07:00
Will c438c5532c fix(sessionstore): recover only a torn final checkpoint record
Keeping the decoded prefix across ANY decode error dropped every record after
a mid-file break, and that truncated prefix then competed as a checkpoint that
extends the replay. Damage before the last record now refuses the checkpoint.
2026-10-15 15:18:43 +08:00
Will 9044392d84 fix(session): audit every automatic-recall suppression identically
recallTail had three outcomes and only two reached the audit channel: a
carried memory note and a normal recall each recorded a content-free
decision, while the retrieval ablation switch returned silently and left
lastRecall pointing at an earlier turn. A trajectory could not tell
"retrieval switched off" from "recall found nothing" from "suppressed by a
carried note".

All three now record a RecallResult and emit one memory-recall audit. The
identity travels as a typed memory.SuppressionCode on its own field, from
RecallResult through event.MemoryRecallAudit into the trajectory record's
suppression_code. The prose sentence stays exactly as it was, for readers
only: consumers match the typed field and never split the code out of the
text, so wording cannot become load-bearing again. The new field is additive
and omitempty, so existing records and batches decode unchanged.

The ablated turn also records lastRecall, matching the note path: the
diagnostic surface is defined as the last real turn's decision, and that
turn did decide not to retrieve.
2026-10-15 15:13:33 +08:00
Will cd355b6e4a fix(context-retrieval): keep failed runs out of the boundary report
reportBoundaries folded every row into byKey, so a run that never produced a
transcript re-entered the cue-boundary table as a zero measurement after the
funnel, dose and reformulation exits had excluded it. Skip failed rows, count
them, and say scored=0 with the failed count when nothing is left to compare.
2026-10-15 15:12:52 +08:00
Will 0485537024 fix(context-retrieval): stop scoring runs that never produced a transcript
A run that failed before its first model turn was appended as a zero-valued
contextMetrics, so results.jsonl gained an empty row, summarize counted it in
Scored and its stage tables, and batch.json reported it as completed with an
empty task id. Stamp such a run with its task, arm and NoEvidence stage, mark it
failed, and keep it out of every statistic while counting it in the manifest as
failed_runs.
2026-10-15 15:03:53 +08:00
par73e 416dd550d2 perf(studio): reduce latency after selecting a Git branch 2026-10-15 14:56:01 +08:00
Will c8054adf8e fix(checkpoint): keep turn numbering above unreadable checkpoint files
load() dropped a turn file that existed but would not read, so a resumed
session numbered the next turn below it and Begin overwrote the only copy
of that snapshot. The store now reserves those turn numbers in memory:
NextTurn() counts them and UnreadableTurns() names them, while Bounds()
and List() still expose only readable checkpoints. No new on-disk state.
2026-10-15 14:51:40 +08:00
Will a345688087 test(checkpoint): pin turn-number reuse after an unreadable checkpoint
A persisted turn-N.json that cannot be read is dropped by load() exactly
like an absent one, so NextTurn() restarts below it and the next Begin
overwrites the only copy. This test operates on a real truncated file
written by the store's own persist path.
2026-10-15 14:51:37 +08:00
Will ac625731ba fix(sessionstore): keep the turns a torn checkpoint still decodes
checkpointExtendingReplay read the .jsonl checkpoint with the strict loader
and folded a decode error into "this checkpoint does not extend the log".
The in-place append in appendSessionDisplayReadModel is exactly the write a
crash leaves half done, so the torn checkpoint's own intact lines were
discarded with it: resolveReplayedMessages then returned the shortened
replay with err == nil, and the next save rewrote both the checkpoint and
the log from that shorter transcript. Every turn the checkpoint still held
was lost permanently.

Decode tolerantly the way transformAndCopyJsonl already does, and keep the
existing prefix checks, so the tolerant result is adopted only when it
still extends the replayed prefix. A checkpoint that cannot yield a usable
prefix (unreadable, shorter, contradicting) keeps today's "does not extend"
behaviour; there is no third state.
2026-10-15 14:47:15 +08:00
Will ea10a83fd0 fix(memory): track the remembered fact and settle a reloaded change
Two gaps in the delivered-fact re-check this branch added:

- applyWrite kept the pre-reload stamps of the facts the model already
  holds, so a change that one reload had settled was re-detected by every
  later recall: the snapshot reloaded and the whole store was re-tokenized
  on each turn until a recall returned hits. Re-stamp the kept facts
  against the snapshot just loaded, dropping what it found gone, so the
  steady state stays one read of each delivered fact's own file. The
  pre-branch code cleared the set on reload, which is why this is not a
  new promise.

- The remember tool's typed Memory was discarded at the queue: the note
  carries prose and the controller must not parse it back into memory, so
  a fact the model wrote itself was absent from the delivered set and an
  external edit before the next recall served the stale session snapshot.
  memory.Queue now carries the typed facts the tool wrote, remember passes
  result.Memory, and the controller records it as delivered. The model's
  own write still queues no turn-tail note.

Adds the delivery/change matrix (100 cells over delivery x change time x
change shape) with the reload-count companion, and the provider-boundary
regression for the remember path.
2026-10-15 14:36:54 +08:00
Will 2c5ed024f5 fix(memory): re-check a delivered fact by reading its own file, not its stat
Problem: a fact saved through Controller.SaveMemory and then edited outside the
session is still recalled from the stale snapshot on the next turn; the per-turn
re-check that catches external edits compared each recalled fact with a stat, so
a rewrite that preserved the file's byte length and modification time stayed
invisible and the model kept reading the stale body; and the identity read behind
that stat, Store.Read, resolves a name by listing the whole store directory and
parsing every fact in it.

Root cause: the re-check set held only the previous recall's hits. A save queues
a turn-tail note, and a note suppresses recall for that turn, so a just-saved
fact was never a hit and so was never re-checked. Its check also rested on size
plus modification time, which is not semantic equality -- the repository's
context-projection rule says freshness detection must not rest on mtime.

Fix: baseline the facts the previous turn handed the model -- the previous
recall's hits, plus the facts a write note carried -- with a digest of each
fact's own file, read once at the canonical path its scope and slug name. Before
a recall tail is built those files are re-read directly and their digests
compared: different bytes reload the snapshot, identical bytes never do. The
stamp carries no size and no modification time, so no stat result can decide
that a fact is unchanged; the bytes are the semantic version because they are
exactly what the renderer turns into the model-visible fact. Store.Read stays as
the fallback for a fact whose file is not where its name says.

Cost: one read of each delivered fact's own file per recall, plus one when it is
baselined and, on that baseline, one parse of the same file to confirm the stamp
describes the delivered fact; a snapshot reload only when a delivered fact's
bytes differ or its file is gone. A formatting-only external rewrite re-reads the
fact into the snapshot even though the parsed fact is equal -- the safe
direction. A fact edited into relevance that the previous turn never handed over
stays invisible until the session reloads; no full scan, which
docs/SESSION_MEMORY_RETRIEVAL.md now states, along with the no-hit retention and
the identity fallback.

This extends the merged fix in 99fe3dbbf, which covers the recall-hit path only.

Review round 1 fixed two regressions in this baseline:

- A fact edited between the snapshot load and its first recall was tracked with
  the edited file's own digest, so the stamp described bytes the turn never
  carried and SameContent() then called the stale snapshot body unchanged
  forever. stampFact now marks a fact tracked only when the one file it just
  digested re-parses to the very fact being delivered (memory.FactAt + sameFact,
  the comparison the untracked fallback already used); otherwise it records the
  fact untracked and the identity read decides. The probe stays on that one file,
  so no listing and no second scan come back.

- recordDelivered overwrote the delivered set on every recall, so a turn that hit
  nothing erased the facts the previous turn was still holding and the next
  recall had nothing left to re-check. It now keeps the previous set when this
  recall returned no hits, while still appending the facts a write note carried.

Verification: a same-size/preserved-mtime rewrite is asserted first at the
Compose boundary (red on the stat-based check) and then at the true provider
boundary, through the real Build stack with a recording provider that shows the
refreshed body in the request the executor actually sends. The tracked path's
freedom from directory listing is asserted by arming the memory package's
read-dir seam to fail while stamping and while re-checking; FactAt is pinned to
the same single file. Both new tests go red with the stat-based conclusion
restored behind the same API and green with it removed.
TestRecallReloadsAFactEditedBeforeItsFirstRecall (seed, load, rewrite, Compose
twice) and TestRecallKeepsCheckingAfterATurnWithNoHits (recall A, an unrelated
no-hit turn, edit A, ask again) are red on the pre-fix behaviour and green now.
Each review fix was reverted alone with both tests kept: the first test goes red
and the second passes, and the reverse for the other. Go tests, vet, gofmt,
golangci-lint (0 issues) and make check (repolint: clean).

Refs #12948
2026-10-15 14:28:17 +08:00
YHH 61337d9940 Merge pull request #13226 from esengine/fix/13217-retry-inconclusive-open
fix(git): give a session whose git did not answer one second attempt at its repository
2026-10-14 23:15:59 -07:00
esengine d83661f9b0 fix(git): give a session whose git did not answer one second attempt at its repository 2026-10-14 23:04:02 -07:00
YHH b7eba48e91 Merge pull request #13223 from esengine/fix/13221-delete-with-copies
fix(studio): a refused settings save says where to see and repair the broken config
2026-10-14 23:03:54 -07:00
YHH d97c576e03 Merge pull request #13222 from esengine/fix/13217-git-absence-reason
fix(git): say why a folder with a .git has no repository identity
2026-10-14 22:48:32 -07:00
esengine ddc48fd74b fix(studio): a refused settings save says where to see and repair the broken config 2026-10-14 22:47:46 -07:00
esengine 35ba5eb42d ci: let a push to studio finish instead of cancelling the run before it 2026-10-14 22:46:24 -07:00
esengine d8dbd38070 feat(studio): deleting a conversation can take its recovery copies along 2026-10-14 22:46:17 -07:00
YHH 1d1d8730ff Merge branch 'studio' into fix/memory-expired-pinned-guidance 2026-10-14 22:44:52 -07:00
esengine 38e7d7a628 fix(git): say why a folder with a .git has no repository identity 2026-10-14 22:38:53 -07:00
esengine adb00dd7fa feat(studio): a hosted page left open across a deploy says it is not the latest build 2026-10-14 22:19:52 -07:00
YHH a20d34351f Merge pull request #13211 from esengine/test/request-cost-budget
test(serve): budget the plugin package parses a hot endpoint may cost
2026-10-14 21:44:22 -07:00
esengine 2e8634d735 test(serve): budget the plugin package parses a hot endpoint may cost 2026-10-14 21:33:05 -07:00
YHH b58734ee4e Merge pull request #13209 from esengine/perf/complete-args-loads
perf(control): completing command arguments loads each config once
2026-10-14 21:31:14 -07:00
YHH 87823024e6 Merge pull request #13208 from esengine/fix/13204-close-unknown-runtime
fix(serve): closing a runtime the hub no longer holds is typed, and the port treats it as closed
2026-10-14 21:25:54 -07:00
YHH 557a387518 Merge pull request #13206 from esengine/feat/13108-task-slots
fix(mcp): promoted task jobs do not hold the inline task slots
2026-10-14 21:22:43 -07:00
YHH 0437ca8982 Merge pull request #13207 from esengine/perf/complete-abort
perf(studio): abandon a completion request a newer keystroke has made stale
2026-10-14 21:21:38 -07:00
esengine 8feb1adb9b perf(control): completing command arguments loads each config once 2026-10-14 21:19:00 -07:00
esengine 2a3d9b7d61 fix(serve): closing a runtime the hub no longer holds is typed, and the port treats it as closed 2026-10-14 21:17:23 -07:00
esengine 2b191c3dc5 perf(studio): abandon a completion request a newer keystroke has made stale 2026-10-14 21:12:15 -07:00
esengine 28ccd2db41 fix(mcp): promoted task jobs do not hold the inline task slots 2026-10-14 21:07:33 -07:00
YHH f87b34184a Merge pull request #13200 from chuiwenwei/fix/memory-cross-scope-identity
fix(memory): identify the cross-scope name refusal and hand back both ids
2026-10-14 21:05:42 -07:00
YHH e598d482e7 Merge pull request #13205 from esengine/perf/13197-complete-loads
perf(control): completing a command name reads no package, config or memory data
2026-10-14 20:59:18 -07:00
esengine 38632c72d7 perf(control): completing a command name reads no package, config or memory data 2026-10-14 20:49:53 -07:00
YHH 16936dc139 Merge pull request #13199 from esengine/feat/13195-menu-autoname
feat(studio): auto-name a session from the sidebar menu
2026-10-14 20:49:01 -07:00
YHH c2ebf7bd56 Merge pull request #13202 from esengine/feat/13108-mcp-tasks-job
feat(mcp): continue a task that outran its timeout as a background job
2026-10-14 20:48:11 -07:00
esengine efb2418dfa feat(mcp): continue a task that outran its timeout as a background job 2026-10-14 20:41:05 -07:00
esengine d0d73fcc0f feat(studio): auto-name a session from the sidebar menu 2026-10-14 20:39:27 -07:00
YHH b47f0e45d2 Merge pull request #13198 from esengine/fix/13194-flow-top-feather
fix(studio): fade the tab strip's lower edge over a wallpaper
2026-10-14 20:37:05 -07:00
esengine 1cd7705536 fix(studio): fade the tab strip's lower edge over a wallpaper 2026-10-14 20:27:51 -07:00
YHH 95888d14fd Merge pull request #13193 from esengine/feat/13108-mcp-tasks-sync
feat(mcp): follow a tool call the server answers as a task to its result
2026-10-14 20:01:58 -07:00
YHH e506bf7de2 Merge pull request #13190 from esengine/feat/13110-skill-dir-install
feat(skill): read and show the specification's license, compatibility and metadata
2026-10-14 19:55:55 -07:00
esengine 091995c737 fix(skill): bound the served license, compatibility and metadata 2026-10-14 19:40:29 -07:00
YHH 4234059fa6 Merge pull request #13192 from esengine/fix/13048-feedback-entry
fix(studio): draw the sidebar's feedback entry in the accent colour
2026-10-14 19:39:54 -07:00
Will d2464b59c4 fix(memory): keep expired facts out of pinned guidance
An explicit expiry is a hard boundary: the fact index and automatic recall drop
the fact past it, and the memory tool paths report it as expired. Pinned guidance
did not check it, so an expired body kept riding the stable prefix, an expired
project fact kept shadowing the live global guidance it used to override, and an
expired body kept consuming the pinned budget that bounds what the prefix sends.

Both places now skip facts that are past their expiry, so the prefix, the shadow
rule and the budget agree with the index and recall. This covers facts that are
already expired when the session loads them; withdrawing a pinned body that
crosses its expiry mid-session is a separate prefix question.
2026-10-15 10:37:42 +08:00
Will bbda70c670 fix(memory): identify the cross-scope name refusal and hand back both ids
An unqualified save whose name was active in two scopes was refused with a bare
sentence. A code caller could not tell that refusal from a write failure, and
the model received neither the two ids the name could have meant nor a way to
say which fact it wanted.

The refusal now wraps ErrNameActiveInTwoScopes, so errors.Is separates a
cross-scope name collision from a write failure, and its sentence renders that
code, both active ids with their scopes, and the two ways out: update the fact
you meant by its stable id, or save with a scope-qualified name. The remember
tool returns this error verbatim, and the agent renders it as the model-facing
tool result, so the identity and both ids reach the model without a second,
drifting sentence.

Tests cover the identity and both ids at the store and at the tool, the refusal
leaving both facts at their own bytes and revision, and the two unambiguous
saves (stable id, scope-qualified name) still succeeding. A neighbouring test
pins that a name active in exactly one scope is still updated, not refused.
2026-10-15 10:37:42 +08:00
Will e4f6eb117b fix(memory): refuse an unqualified save whose name is active in two scopes
An unqualified name that was active in another scope was archived after the save
was published, so a failure there left the same name active twice, and the
archiving itself removed a fact the caller never named.

The collision is now reported before anything is written, and the post-commit
archiving is gone.
2026-10-15 10:37:42 +08:00
esengine ad37cd5302 feat(mcp): follow a tool call the server answers as a task to its result 2026-10-14 19:37:41 -07:00
esengine bcddb2963b fix(studio): draw the sidebar's feedback entry in the accent colour 2026-10-14 19:30:25 -07:00
YHH 0e41cd7947 Merge pull request #13189 from esengine/fix/trace-fixture-flake
test(serve): the trace fixtures do not compare two more measured clocks
2026-10-14 19:29:05 -07:00
esengine 02cd4622d9 feat(skill): read and show the specification's license, compatibility and metadata 2026-10-14 19:20:31 -07:00
esengine 29bca265b3 test(serve): the trace fixtures do not compare two more measured clocks 2026-10-14 19:19:39 -07:00
YHH b1c2bc6756 Merge pull request #13187 from esengine/fix/13177-branch-stale-boundary
fix(control): refuse a running-turn branch whose recorded boundary names another prompt
2026-10-14 19:03:08 -07:00
YHH 361e56c298 Merge pull request #13186 from esengine/perf/13176-tree-canonical
perf(serve): do not canonicalise every listed session path in /tree
2026-10-14 18:58:21 -07:00
esengine 25c2b4ec91 fix(control): refuse a running-turn branch whose recorded boundary names another prompt 2026-10-14 18:53:18 -07:00
YHH c05f72c582 Merge pull request #13185 from esengine/feat/13177-branch-closed-turn-while-running
feat(studio): branch a conversation from its latest finished reply while it runs
2026-10-14 18:49:38 -07:00
esengine 0392ffed05 perf(serve): do not canonicalise every listed session path in /tree 2026-10-14 18:49:13 -07:00
YHH c442b018c4 Merge pull request #13183 from esengine/fix/13180-open-error-class
fix(serve): a file that cannot be opened is not reported as missing
2026-10-14 18:40:50 -07:00
esengine e9da355450 feat(studio): branch a conversation from its latest finished reply while it runs 2026-10-14 18:39:33 -07:00
YHH acee6497d3 Merge pull request #13182 from esengine/fix/13179-daily-desc
fix(studio): list the newest day first in the daily usage table
2026-10-14 18:38:56 -07:00
YHH abac8621e9 Merge pull request #13181 from esengine/fix/13178-context-card-hover
fix(studio): keep the context card open while the pointer crosses to it
2026-10-14 18:34:00 -07:00
esengine a39b4b1cac fix(serve): a file that cannot be opened is not reported as missing 2026-10-14 18:31:25 -07:00
esengine 6a3cd375cb fix(studio): list the newest day first in the daily usage table 2026-10-14 18:29:13 -07:00
esengine dffb4787e6 fix(studio): keep the context card open while the pointer crosses to it 2026-10-14 18:24:33 -07:00
YHH 9862aa5f84 Merge pull request #13175 from kevin9327/fix/gitstatus-rename-numstat
fix(gitstatus): count lines on renamed and copied paths
2026-10-14 18:11:12 -07:00
kevin9327 281caed006 fix(gitstatus): count lines on renamed and copied paths
git diff --numstat -z writes a rename or copy as the counts, a trailing
tab, then the old and new paths as their own NUL fields. Trimming that
record removed the tab, so the parser treated it as a short line and
dropped it. Status and Summary then reported no line counts for the
change. The counts stay on the new path, which is the name status lists.
2026-10-15 00:14:07 +00:00
kevin9327 1023ea7bcb test(gitstatus): cover rename and copy numstat records
git diff --numstat -z emits a rename or copy as the counts, a tab, NUL,
the old path, NUL, and the new path. Status and Summary need those counts
on the destination, beside an ordinary edit in the same diff.
2026-10-15 00:14:07 +00:00
1163 changed files with 57989 additions and 3846 deletions

No files matched your search

+2 -2
View File
@@ -15,7 +15,7 @@ permissions:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Cheap path gate for pull requests. PRs confined to docs/release-notes
@@ -88,7 +88,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/cli-archive-contents.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
+21 -12
View File
@@ -900,12 +900,31 @@ jobs:
go-version-file: go.mod
cache: true
# The page every archive carries beside the executable, built once here
# rather than once per target: serve.FindPage looks for
# frontend-next/dist next to the binary.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- name: Build the page
working-directory: desktop/frontend-next
run: pnpm install --frozen-lockfile && pnpm build
# Every target the release line claims to support, from one runner: the
# CLI is CGO-free, so none of them needs a toolchain of its own.
- name: Cross-compile and archive
env:
VERSION: ${{ needs.resolve.outputs.version }}
SHA: ${{ needs.resolve.outputs.sha }}
PAGE_DIR: desktop/frontend-next/dist
run: |
set -euo pipefail
mkdir -p dist
@@ -917,11 +936,7 @@ jobs:
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
-o "reasonix$ext" ./cmd/reasonix
if [ "$os" = windows ]; then
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
else
tar czf "dist/reasonix-$os-$arch.tar.gz" "reasonix$ext"
fi
scripts/package-cli-archive.sh "$os" "$arch" "reasonix$ext" "$PAGE_DIR" dist
rm -f "reasonix$ext"
done
# The digest list the remote verifies against. Resolved on the
@@ -933,13 +948,7 @@ jobs:
# A release that published the archives without the digest list is worse
# than one that published neither: the routes resolve SHA256SUMS first.
- name: Check the set is complete
run: |
set -euo pipefail
for want in reasonix-darwin-amd64.tar.gz reasonix-darwin-arm64.tar.gz \
reasonix-linux-amd64.tar.gz reasonix-linux-arm64.tar.gz \
reasonix-windows-amd64.zip reasonix-windows-arm64.zip SHA256SUMS; do
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
run: python3 scripts/check-cli-archives.py dist
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+86 -2
View File
@@ -16,7 +16,7 @@ permissions:
concurrency:
group: studio-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
frontend:
@@ -141,27 +141,111 @@ jobs:
node perf/stickyfold.mjs
node perf/crumb.mjs
node perf/hoverradius.mjs
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/explorersize.mjs
node perf/rowhit.mjs
node perf/tab-pip.mjs
node perf/context-card-hover.mjs
node perf/sessquick.mjs
node perf/sessionrows.mjs
node perf/wheel.mjs
node perf/branch-tooltip.mjs
node perf/branch-menu.mjs
node perf/image-preview.mjs
node perf/user-images.mjs
node perf/toast-anchor.mjs
node perf/addfolder-rest.mjs
node perf/scroll-jump.mjs
node perf/add-project-selects.mjs
node perf/setup-defer.mjs
node perf/search-toggle.mjs
node perf/narrow.mjs
node perf/settings-rows.mjs
node perf/narrow-steps.mjs
node perf/job-names.mjs
node perf/text-size.mjs
node perf/fs-scale.mjs
node perf/composer-feel.mjs
node perf/side-panel.mjs
node perf/card-shell.mjs
node perf/line-cards.mjs
node perf/detail-panel.mjs
node perf/brand-lockup.mjs
node perf/notice-line.mjs
node perf/brought-line.mjs
node perf/modal-focus.mjs
node perf/focus-ring.mjs
node perf/narrow-forms.mjs
node perf/i18n-rendered.mjs
node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip
# What a hosted page does over the relay, end to end: the production bundle,
# the real Studio host and kernel, a real Chromium; only the accounts API and
# the relay are stand-ins (tools/relayfake). Event-transport changes are
# judged here, because a unit test of one side cannot see the other.
relay-harness:
name: relay harness
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Transport changed?
id: scope
env:
BASE_REF: ${{ github.base_ref }}
run: |
paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] &&
git diff --quiet "origin/$BASE_REF...HEAD" -- $paths; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- if: steps.scope.outputs.run == 'true'
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- if: steps.scope.outputs.run == 'true'
name: Build the host, the stand-ins and the page
run: |
go build -o desktop/frontend-next/relay/bin/studio-host ./cmd/reasonix-studio-host
go build -o desktop/frontend-next/relay/bin/relayfake ./tools/relayfake
cd desktop/frontend-next
pnpm install --frozen-lockfile
pnpm exec vite build --outDir dist-relay --emptyOutDir
pnpm exec playwright install --with-deps chromium
- if: steps.scope.outputs.run == 'true'
name: Scenarios (hosted page over the relay; local page on a flapping network)
working-directory: desktop/frontend-next
env:
RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host
RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake
RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay
run: |
node relay/live.mjs
node relay/storm.mjs
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
# moves the Windows and macOS legs onto an ubuntu runner with every step
# skipped, so the check names still report without a scarce runner. A push to
+1
View File
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
# running, which on Windows is every rebuild while Studio is open.
*.exe
*.exe~
/relayfake
@@ -0,0 +1,35 @@
# Compaction prefix probe
## Method
1. Measured on 2026-10-17 against the official `https://api.deepseek.com` OpenAI adapter, using the model returned by `/models`: `deepseek-flash`. Thinking was disabled. Inputs were synthetic; no workspace history or credentials are recorded here.
2. Each of three text and three rich-history trials sent a main request to warm the prefix, then called the actual agent summary path, then an independent summary control. Rich history included a synthetic closed `read_file` call, result and reasoning. Warmup outputs were discarded, never executed.
3. A summary had a 1,024-token output cap; warmups had 64. Success means a heading, preservation of the fixture's `ParseDuration` and zero-value constraint, no emitted tool calls, and `finish_reason=stop`. This checks the fixture's contract, not arbitrary-summary factual completeness.
4. First-attempt reuse success counts successful reused summaries / six reused summaries. Fallback rate counts independent requests initiated by the reuse coordinator / six reuse trials; the six explicit independent controls are excluded. Both were 6/6 success and 0/6 fallbacks. Independent controls also succeeded 6/6.
5. The raw result file records provider-reported usage and full stream latency, measured from adapter `Stream` until channel close. Token rows below sum only the three indicated summary requests; they exclude warmup, model listing and any earlier exploratory requests. Cache residency was uncontrolled; prior probes had warmed these synthetic prefixes.
## Results
| History | Shape | N | Prompt | Hit | Miss | Output | Median ms | Estimated USD |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Text | Reused | 3 | 3,714 | 3,072 | 642 | 636 | 1,713 | 0.000487116 |
| Text | Independent | 3 | 1,218 | 768 | 450 | 644 | 1,610 | 0.000456204 |
| Rich | Reused | 3 | 3,780 | 3,072 | 708 | 742 | 1,774 | 0.000560616 |
| Rich | Independent | 3 | 1,341 | 768 | 573 | 921 | 1,815 | 0.000640854 |
1. Estimates use the [official pricing](https://api-docs.deepseek.com/quick_start/pricing/) checked on the measurement date: off-peak Flash USD per million tokens, hit 0.003, miss 0.15, output 0.6. October 17 was a Saturday. Formula: `(hit*0.003 + miss*0.15 + output*0.6)/1_000_000`. These are token estimates, not account deductions.
2. Reuse hit fractions were 82.7% (text) and 81.3% (rich), versus 63.1% and 57.3% for controls. This small fixture does **not** establish cost savings: retaining the system/catalog increased both total input and input cost. Text total cost increased 6.8%; rich total decreased 12.5% because output was shorter, not because input was cheaper.
3. The six warmups consumed 7,032 prompt and 231 output tokens, estimated USD 0.000346680. All 18 recorded generation calls together cost an estimated USD 0.002491470. No failed-digest retry occurred in these live trials; the deterministic fallback tests establish its behavior and accounting.
4. A rejected nonempty reused answer earns one independent-shape fallback. A separate truncation retry may enlarge the output cap, for at most three logical model calls total. Every actual attempt emits and records its own spend; returned usage is their sum. Empty output, stream errors and cancellation do not earn shape retries.
5. These sequential, six-pair trials establish interoperability for one model, mode and short fixture. They do not estimate production failure probability, thinking-mode behavior, other models, cold-cache cost, or large-history savings.
## Reproduction
1. Provide a test key through an existing secret environment configuration. Do not put credentials in source, command arguments or captured logs. Select a model currently returned by `/models`.
2. Run the opt-in probe; it skips without `DEEPSEEK_API_KEY`. The dedicated build tag avoids enabling unrelated live tests.
```sh
REASONIX_LIVE_MODEL=deepseek-flash go test -tags live_compaction ./internal/runtime/agent -run '^TestLiveCompactionPrefix$' -v -count=1
```
3. This probe exercises actual summary construction and the HTTP adapter. The hermetic `TestEffectCompactionSharesThePreviouslySentPrefix` separately exercises manual, automatic pressure and rich history through `boot.Build`, comparing actual JSON request-prefix and schema bytes. Its sparse-history case also proves an interleaved retained user turn survives the fold.
@@ -0,0 +1,512 @@
[
{
"model": "deepseek-flash",
"rich": false,
"trial": 1,
"results": [
{
"kind": "warm",
"milliseconds": 828,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 2006,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 234,
"TotalTokens": 1472,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1817,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 234,
"TotalTokens": 640,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": false,
"trial": 2,
"results": [
{
"kind": "warm",
"milliseconds": 820,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1633,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 187,
"TotalTokens": 1425,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1555,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 202,
"TotalTokens": 608,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": false,
"trial": 3,
"results": [
{
"kind": "warm",
"milliseconds": 905,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1713,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 215,
"TotalTokens": 1453,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1610,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 208,
"TotalTokens": 614,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 1,
"results": [
{
"kind": "warm",
"milliseconds": 561,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1774,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 256,
"TotalTokens": 1516,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 2271,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 381,
"TotalTokens": 828,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 2,
"results": [
{
"kind": "warm",
"milliseconds": 610,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 2263,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 276,
"TotalTokens": 1536,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1783,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 242,
"TotalTokens": 689,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 3,
"results": [
{
"kind": "warm",
"milliseconds": 834,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1575,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 210,
"TotalTokens": 1470,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1815,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 298,
"TotalTokens": 745,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
}
]
@@ -0,0 +1,86 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// runBlockedBatch runs one dry index batch where every arm's runOne fails
// before any model turn: the task's own directory is a regular file, so the
// fixture cannot be built. In production the same early return happens when the
// fixture build, the session load, or the answer-on-disk guard fails.
func runBlockedBatch(t *testing.T) string {
t.Helper()
tk := indexTasks()[0]
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, tk.ID), []byte("not a directory\n"), 0o644); err != nil {
t.Fatal(err)
}
if code := runExperiment("run-index", experimentIndex, root, true, []contextTask{tk}); code != 0 {
t.Fatalf("runExperiment exit = %d, want 0 (every run failed, but the batch finished)", code)
}
return root
}
func readResultLines(t *testing.T, root string) ([]contextMetrics, []string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join(root, "results.jsonl"))
if err != nil {
t.Fatal(err)
}
var runs []contextMetrics
var lines []string
for text := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") {
var m contextMetrics
if err := json.Unmarshal([]byte(text), &m); err != nil {
t.Fatalf("results.jsonl: %v", err)
}
runs = append(runs, m)
lines = append(lines, text)
}
return runs, lines
}
// A run that failed before the model turn produced no measurement. Recording it
// as a zero-valued contextMetrics puts it in the funnel's Scored denominator,
// in "Where it broke", and in the dose curve as a real zero.
func TestBatchDoesNotRecordAFailedRunAsAResult(t *testing.T) {
root := runBlockedBatch(t)
runs, lines := readResultLines(t, root)
if len(runs) == 0 {
t.Fatal("no runs recorded at all")
}
for i, m := range runs {
if m.Task == "" || m.Arm == "" || m.FailureStage == "" {
t.Fatalf("results.jsonl line %d records a run that never ran as a result "+
"(task=%q arm=%q failure_stage=%q):\n %s", i+1, m.Task, m.Arm, m.FailureStage, lines[i])
}
}
}
// The manifest is the artefact that says which runs counted, so a failed run
// must not be added to CompletedRuns or to the task id list.
func TestBatchManifestDoesNotReportFailedRunsAsCompleted(t *testing.T) {
root := runBlockedBatch(t)
var manifest batchManifest
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
for _, id := range manifest.TaskIDs {
if id == "" {
t.Fatalf("manifest lists an empty task id: task_ids=%q (completed=%d planned=%d)",
manifest.TaskIDs, manifest.CompletedRuns, manifest.PlannedRuns)
}
}
if manifest.CompletedRuns == manifest.PlannedRuns {
t.Fatalf("manifest reports every planned run completed although none produced evidence: "+
"completed=%d planned=%d task_ids=%q", manifest.CompletedRuns, manifest.PlannedRuns, manifest.TaskIDs)
}
}
+12 -2
View File
@@ -41,6 +41,10 @@ type batchManifest struct {
Arms []string `json:"arms"`
PlannedRuns int `json:"planned_runs"`
CompletedRuns int `json:"completed_runs"`
// FailedRuns counts the planned runs that ended before a transcript existed.
// They are rows in results.jsonl, never part of CompletedRuns, and the field
// is absent when there were none so an intact batch is unchanged.
FailedRuns int `json:"failed_runs,omitempty"`
// Usage totals are absent when nothing reported any: an unmeasured cost must
// not read as a measured zero. UsageReportedRuns stays, because zero there
// is a fact about the batch.
@@ -98,9 +102,15 @@ func sortedKeys(set map[string]bool) []string {
// last, so its digests are of what is already on disk.
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
ids, arms := map[string]bool{}, map[string]bool{}
var prompt, completion, cacheHit, cacheMiss, requests, reported int
var prompt, completion, cacheHit, cacheMiss, requests, reported, failed int
estimated := false
for _, m := range all {
// A failed run has no task, arm or evidence to contribute: it is counted
// as a failure below and is not part of what the batch measured.
if m.Failed {
failed++
continue
}
ids[m.Task] = true
arms[m.Arm] = true
if m.UsageReportedRequests > 0 {
@@ -125,7 +135,7 @@ func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error
GitCommit: commit, GitDirty: dirty,
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
PlannedRuns: info.Planned, CompletedRuns: len(all),
PlannedRuns: info.Planned, CompletedRuns: len(all) - failed, FailedRuns: failed,
UsageReportedRuns: reported, UsageReportedRequests: requests,
PromptTokens: prompt, CompletionTokens: completion,
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
@@ -0,0 +1,69 @@
package main
import (
"strings"
"testing"
)
// boundaryRuns builds the input reportBoundaries reads: one scored run for an
// index task's cue side, and one failed run for another index task's cue side.
// The runs are constructed because the report is a pure function of the slice;
// the batch path that produces them is covered by the failed-batch tests.
func boundaryRuns(t *testing.T) (all []contextMetrics, scoredTask, failedTask string) {
t.Helper()
index := indexTasks()
if len(index) < 2 {
t.Fatalf("need at least two index tasks, have %d", len(index))
}
scored, failed := index[0], index[1]
cue, noCue := boundaryPair(scored.CueTier)
if cue == "" || noCue == "" {
t.Fatalf("task %s has an empty boundary pair (%q, %q)", scored.ID, cue, noCue)
}
failedCue, _ := boundaryPair(failed.CueTier)
if failedCue == "" {
t.Fatalf("task %s has no cue side", failed.ID)
}
all = []contextMetrics{
{Task: scored.ID, Arm: "index-" + cue, AnswerRecovered: true, SearchCalls: 1, RecallReturnedTokens: 318},
{Task: scored.ID, Arm: "index-" + noCue, AnswerRecovered: false},
{Task: failed.ID, Arm: "index-" + failedCue, Failed: true, FailureStage: stageNoEvidence},
}
return all, scored.ID, failed.ID
}
// TestBoundaryReportExcludesFailedRuns: a run that never produced a transcript
// must not appear in the cue-boundary table, which was the last reporting exit
// that still folded it in as a zero measurement.
func TestBoundaryReportExcludesFailedRuns(t *testing.T) {
all, scoredTask, failedTask := boundaryRuns(t)
out := captureStdout(t, func() { reportBoundaries(all) })
if strings.Contains(out, failedTask) {
t.Fatalf("the boundary table printed the failed task %q as a measurement:\n%s", failedTask, out)
}
if !strings.Contains(out, scoredTask) {
t.Fatalf("the boundary table dropped the scored task %q, so the exclusion is not specific to failures:\n%s", scoredTask, out)
}
if !strings.Contains(out, "cue-present") || !strings.Contains(out, "cue-absent") {
t.Fatalf("the pooled comparison disappeared:\n%s", out)
}
}
// TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed: with no scored run
// the report must say so, rather than printing a comparison of zeros.
func TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed(t *testing.T) {
all, _, _ := boundaryRuns(t)
failedOnly := make([]contextMetrics, 0, len(all))
for _, m := range all {
m.Failed = true
m.FailureStage = stageNoEvidence
failedOnly = append(failedOnly, m)
}
out := captureStdout(t, func() { reportBoundaries(failedOnly) })
if !strings.Contains(out, "scored=0") || !strings.Contains(out, "failed=") {
t.Fatalf("an all-failed batch did not report scored=0 with a failed count:\n%s", out)
}
if strings.Contains(out, "cue-present") || strings.Contains(out, "cue-absent") {
t.Fatalf("an all-failed batch still printed a pooled comparison of zeros:\n%s", out)
}
}
+16 -2
View File
@@ -18,7 +18,8 @@ import (
// canonical position, and never the model's prose. "I will search the history"
// is not a search; a recall call with a query is.
// failure stages, in funnel order. Exactly one is reported per run.
// failure stages, in funnel order. Exactly one is reported per run, except for
// NoEvidence: that stage belongs to a run with no measurement at all.
const (
stageReadMissed = "NoSearch" // never asked, and never read the target either
stageSearchMiss = "SearchMiss" // searched, target never came back
@@ -26,6 +27,7 @@ const (
stageAnswerWrong = "ReadButAnswerWrong" // opened it and still answered wrong
stageRecovered = "Recovered"
stageContaminated = "Contaminated"
stageNoEvidence = "NoEvidence"
// Routing is judged by model round, not by the linear order of tool events:
// two calls in one round are one parallel fan-out, and calling that
@@ -116,6 +118,12 @@ type contextMetrics struct {
FailureStage string `json:"failure_stage"`
TimeoutReason string `json:"timeout_reason,omitempty"`
// Failed marks a run that ended before a transcript existed: it has no score
// and no place in the statistics. The row stays in results.jsonl with its
// task, arm and stage, so a failure is counted rather than dropped.
Failed bool `json:"failed,omitempty"`
Reason string `json:"reason,omitempty"`
// UnexpectedWorkTools are investigation tools used instead of recall.
// Reaching for the workspace is behaviour, not noise: how a model routes
// its uncertainty when memory is missing is part of what is measured.
@@ -462,6 +470,12 @@ type funnel struct {
func summarize(arm string, runs []contextMetrics) funnel {
f := funnel{Arm: arm, Runs: len(runs), Stages: map[string]int{}, Routes: map[string]int{}}
for _, r := range runs {
// A run that never produced a transcript is not a scored run and has no
// stage to place: counting it would put an empty name in the funnel, the
// stage table and the routes.
if r.Failed {
continue
}
if r.Contaminated {
f.Contaminated++
f.Stages[r.FailureStage]++
@@ -631,7 +645,7 @@ func reportStopping(all []contextMetrics) string {
var extraRounds, extraTokens []float64
sufficient, answeredNext := 0, 0
for _, m := range all {
if m.Contaminated {
if m.Failed || m.Contaminated {
continue
}
classes[m.StoppingClass]++
+1 -1
View File
@@ -121,7 +121,7 @@ var doseMetrics = []struct {
func reportDoseResponse(all []contextMetrics) string {
valid := map[string]map[string]contextMetrics{}
for _, m := range all {
if m.Contaminated || m.FailureStage == "RunError" {
if m.Failed || m.Contaminated || m.FailureStage == "RunError" {
continue
}
scale := strings.TrimPrefix(m.Arm, "index-")
@@ -0,0 +1,306 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// blockTask makes one task fail before its first model turn: its own directory
// is a regular file, so buildFixture cannot create the session under it. It is
// the same early exit as a failed fixture build, session load, or
// answer-on-disk guard, and it needs no provider.
func blockTask(t *testing.T, root, id string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, id), []byte("not a directory\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// mixedTaskPair is one task whose runs all fail and one whose runs all succeed.
// Both are drawn from the corpus, so the batch is the real pipeline.
func mixedTaskPair() (blocked, ok contextTask) {
blocked, ok = indexTasks()[0], indexTasks()[1]
for _, t := range indexTasks() {
if t.Role == roleEfficiency && len(armsFor(t)) == 2 {
ok = t
break
}
}
return blocked, ok
}
// runBlockedExperiment runs one dry index batch over the given tasks, with the
// named task failing before any model turn, and returns the batch directory, the
// runs, the manifest, and everything the run printed.
func runBlockedExperiment(t *testing.T, tasks []contextTask, blocked ...string) (string, []contextMetrics, batchManifest, string) {
t.Helper()
root := t.TempDir()
for _, id := range blocked {
blockTask(t, root, id)
}
out := captureStdout(t, func() {
if code := runExperiment("run-index", experimentIndex, root, true, tasks); code != 0 {
t.Errorf("runExperiment exit = %d, want 0 (the batch finished)", code)
}
})
runs, _ := readResultLines(t, root)
manifest, _ := readManifest(t, root)
return root, runs, manifest, out
}
// captureStdout reads everything the function prints. os.Pipe is unbuffered, so
// the reader must run beside the call.
func captureStdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
done := make(chan string, 1)
go func() {
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
b.Write(buf[:n])
if err != nil {
break
}
}
done <- b.String()
}()
f()
os.Stdout = old
w.Close()
text := <-done
r.Close()
return text
}
// funnelScored reads the "runs=N" column of the named arm's funnel line out of
// the printed tables, so the assertion is on the number a reader sees.
func funnelScored(t *testing.T, out, arm string) int {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != arm || !strings.HasPrefix(fields[1], "runs=") {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(fields[1], "runs="))
if err != nil {
t.Fatalf("funnel line for %s: %v\n%s", arm, err, line)
}
return n
}
t.Fatalf("no funnel line for arm %s:\n%s", arm, out)
return 0
}
// The batch report states both counts on one line, so a reader of the scrollback
// sees the scored set and the failures without counting funnel rows.
func reportCounts(t *testing.T, out string) (scored, failed int) {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
if !strings.Contains(line, " scored=") || !strings.Contains(line, " failed=") {
continue
}
for f := range strings.FieldsSeq(line) {
switch {
case strings.HasPrefix(f, "scored="):
scored, _ = strconv.Atoi(strings.TrimPrefix(f, "scored="))
case strings.HasPrefix(f, "failed="):
failed, _ = strconv.Atoi(strings.TrimPrefix(f, "failed="))
}
}
return scored, failed
}
t.Fatalf("no line reports scored= and failed=:\n%s", out)
return 0, 0
}
// A batch that mixes a task that never ran with one that did must read as its
// successful runs only: the failed task leaves Scored, the funnel, and the
// completed count, and is reported as a failure with its identity instead.
// Every assertion here is a number, not the absence of an empty row.
func TestMixedBatchCountsOnlyTheRunThatProducedEvidence(t *testing.T) {
blocked, ok := mixedTaskPair()
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked, ok}, blocked.ID)
wantScored, wantFailed := len(armsFor(ok)), len(armsFor(blocked))
if manifest.CompletedRuns != wantScored || manifest.FailedRuns != wantFailed {
t.Fatalf("manifest completed=%d failed=%d, want %d and %d (planned=%d)",
manifest.CompletedRuns, manifest.FailedRuns, wantScored, wantFailed, manifest.PlannedRuns)
}
if len(runs) != manifest.CompletedRuns+manifest.FailedRuns {
t.Fatalf("results.jsonl has %d rows but completed=%d failed=%d",
len(runs), manifest.CompletedRuns, manifest.FailedRuns)
}
for _, id := range manifest.TaskIDs {
if id == blocked.ID {
t.Fatalf("task_ids lists %s, which produced no evidence: %v", blocked.ID, manifest.TaskIDs)
}
}
scored, failures := 0, 0
for _, m := range runs {
if m.Failed {
failures++
if m.Task != blocked.ID || m.FailureStage == "" || m.Reason == "" {
t.Fatalf("failed row lost its identity or stage: %+v", m)
}
continue
}
scored++
if m.Task != ok.ID {
t.Fatalf("a run scored from the wrong task: %+v", m)
}
}
if scored != wantScored || failures != wantFailed {
t.Fatalf("rows scored=%d failed=%d, want %d and %d", scored, failures, wantScored, wantFailed)
}
// Each arm's funnel holds exactly the successful task's runs, and the two
// scored counts agree with each other and with the manifest.
total := 0
for _, arm := range armsFor(ok) {
total += funnelScored(t, out, arm.name)
}
if total != manifest.CompletedRuns {
t.Fatalf("funnels scored %d runs, manifest completed_runs = %d:\n%s", total, manifest.CompletedRuns, out)
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != manifest.FailedRuns {
t.Fatalf("report says scored=%d failed=%d, manifest says %d and %d",
gotScored, gotFailed, manifest.CompletedRuns, manifest.FailedRuns)
}
if strings.Contains(out, "## Where it broke") && strings.Contains(out, "=-\n") {
t.Fatalf("the stage table kept an empty stage:\n%s", out)
}
}
// A batch where every run fails must not read as an empty success: zero scored
// runs, a nonzero failed count, and both stated.
func TestAllFailedBatchReportsZeroScoredAndNonzeroFailed(t *testing.T) {
blocked, _ := mixedTaskPair()
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked}, blocked.ID)
if manifest.CompletedRuns != 0 {
t.Fatalf("completed_runs = %d, want 0 (nothing produced evidence)", manifest.CompletedRuns)
}
if manifest.FailedRuns != manifest.PlannedRuns || manifest.FailedRuns == 0 {
t.Fatalf("failed_runs = %d, planned_runs = %d: every planned run failed",
manifest.FailedRuns, manifest.PlannedRuns)
}
if len(manifest.TaskIDs) != 0 {
t.Fatalf("task_ids = %v, want none", manifest.TaskIDs)
}
for i, m := range runs {
if !m.Failed || m.FailureStage == "" || m.Task != blocked.ID {
t.Fatalf("results.jsonl line %d is not a marked failure: %+v", i+1, m)
}
}
total := 0
for _, arm := range armsFor(blocked) {
total += funnelScored(t, out, arm.name)
}
if total != 0 {
t.Fatalf("funnels scored %d runs although nothing ran:\n%s", total, out)
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != 0 || gotFailed != manifest.FailedRuns {
t.Fatalf("report says scored=%d failed=%d, want 0 and %d", gotScored, gotFailed, manifest.FailedRuns)
}
}
// The control: with nothing blocked, a batch is what it always was. Every run
// is completed with a stage, two identical batches write byte-identical results
// and the same manifest header, and the new failure field stays out of it.
func TestAllSuccessfulBatchKeepsItsArtifacts(t *testing.T) {
tasks := indexTasks()
first, runs, manifest, out := runBlockedExperiment(t, tasks)
second, again, repeat, _ := runBlockedExperiment(t, tasks)
if manifest.CompletedRuns != len(runs) || manifest.FailedRuns != 0 {
t.Fatalf("completed=%d failed=%d rows=%d, want every row completed",
manifest.CompletedRuns, manifest.FailedRuns, len(runs))
}
if manifest.CompletedRuns != manifest.PlannedRuns {
t.Fatalf("completed=%d planned=%d, want all planned", manifest.CompletedRuns, manifest.PlannedRuns)
}
for i, m := range runs {
if m.Failed || m.Task == "" || m.Arm == "" || m.FailureStage == "" {
t.Fatalf("results.jsonl line %d is not a completed run: %+v", i+1, m)
}
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != 0 {
t.Fatalf("report says scored=%d failed=%d, want %d and 0", gotScored, gotFailed, manifest.CompletedRuns)
}
if !reflect.DeepEqual(runs, again) {
t.Fatal("two identical successful batches scored differently")
}
// Byte-for-byte on the runs. Trajectories carry per-message wall-clock
// stamps, so they are compared field by field instead.
if got, want := readFile(t, second, "results.jsonl"), readFile(t, first, "results.jsonl"); got != want {
t.Fatalf("an unchanged batch wrote different results.jsonl:\n%s\n---\n%s", want, got)
}
gotTraj, wantTraj := withoutStamps(t, readFile(t, second, "trajectories.jsonl")),
withoutStamps(t, readFile(t, first, "trajectories.jsonl"))
if gotTraj != wantTraj {
t.Fatalf("an unchanged batch wrote different trajectories:\n%s\n---\n%s", wantTraj, gotTraj)
}
repeat.StartedAt, repeat.FinishedAt = manifest.StartedAt, manifest.FinishedAt
repeat.TrajectoriesSHA256 = manifest.TrajectoriesSHA256
if !reflect.DeepEqual(repeat, manifest) {
t.Fatalf("an unchanged batch wrote a different manifest:\n%+v\n---\n%+v", manifest, repeat)
}
if repeat.FailedRuns != 0 {
t.Fatalf("a batch with no failure reports failed_runs=%d", repeat.FailedRuns)
}
}
// withoutStamps drops the createdAt stamp every message carries, which is the
// one field a dry batch cannot reproduce between two runs.
func withoutStamps(t *testing.T, text string) string {
t.Helper()
var b strings.Builder
for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") {
var row map[string]any
if err := json.Unmarshal([]byte(line), &row); err != nil {
t.Fatal(err)
}
stripStamp(row)
out, err := json.Marshal(row)
if err != nil {
t.Fatal(err)
}
b.Write(out)
b.WriteByte('\n')
}
return b.String()
}
func stripStamp(v any) {
switch typed := v.(type) {
case map[string]any:
delete(typed, "createdAt")
delete(typed, "workDurationMs")
for _, child := range typed {
stripStamp(child)
}
case []any:
for _, child := range typed {
stripStamp(child)
}
}
}
func readFile(t *testing.T, root, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatal(err)
}
return string(data)
}
@@ -130,7 +130,7 @@ type queryAudit struct {
func auditQueries(runs []contextMetrics) queryAudit {
a := queryAudit{Reformulations: map[string]int{}, MissedProbeTerms: map[string]int{}}
for _, r := range runs {
if r.Contaminated {
if r.Failed || r.Contaminated {
continue
}
a.Runs++
+35 -1
View File
@@ -85,6 +85,7 @@ func runAdversarial(root string) int {
adversarialPrompts[t.Experiment])
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", id, err)
m = failedMetrics(t, "adversarial", err)
}
verdict := "isolated"
if m.EscapeFoundAnswer {
@@ -173,6 +174,16 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
return m, runErr
}
// failedMetrics is what a run that never reached a transcript leaves behind. It
// carries the run's identity and the stage that ended it, so the failure stays a
// countable fact; Failed keeps it out of every statistic.
func failedMetrics(t contextTask, armName string, err error) contextMetrics {
return contextMetrics{
Task: t.ID, Arm: armName, Failed: true,
FailureStage: stageNoEvidence, Reason: err.Error(),
}
}
// finalAnswer is the last assistant text the turn produced.
func finalAnswer(msgs []provider.Message) string {
for _, m := range slices.Backward(msgs) {
@@ -250,6 +261,7 @@ func runBoundaries(mode, root string) int {
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
m = failedMetrics(t, name, err)
}
all = append(all, m)
if side.has {
@@ -349,6 +361,7 @@ func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask)
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
m = failedMetrics(t, arm.name, err)
}
byArm[arm.name] = append(byArm[arm.name], m)
all = append(all, m)
@@ -430,11 +443,17 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
return 2
}
defer f.Close()
scored, failed := 0, 0
for _, m := range all {
if err := writeJSONLine(f, m); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
if m.Failed {
failed++
} else {
scored++
}
}
if err := writeTrajectories(root, all); err != nil {
fmt.Fprintln(os.Stderr, "write trajectories:", err)
@@ -444,7 +463,10 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
return 2
}
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
// Both counts are named: a reader who sees only the batch size cannot tell
// measurements from attempts.
fmt.Printf("\n%d runs written to %s: scored=%d failed=%d (with trajectories.jsonl and batch.json)\n",
len(all), path, scored, failed)
return 0
}
@@ -453,9 +475,21 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
// what a four-arm average cannot do.
func reportBoundaries(all []contextMetrics) {
byKey := map[string]contextMetrics{}
scored, failed := 0, 0
for _, m := range all {
if m.Failed {
// No transcript, no boundary to compare: count the failure instead of
// printing the zero it left behind as a measurement.
failed++
continue
}
scored++
byKey[m.Task+"|"+m.Arm] = m
}
if scored == 0 {
fmt.Printf("\n## Across each task's cue boundary\n no scored runs: scored=%d failed=%d (nothing to compare)\n", scored, failed)
return
}
var cueSide, noCueSide []contextMetrics
fmt.Println("\n## Across each task's cue boundary")
fmt.Printf(" %-24s %-16s %-4s %-10s %-9s %-12s %-11s %s\n",
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
// writeKilledAgentStub writes a stand-in for the reasonix binary that leaves
// exactly what a killed child leaves behind: no final metrics record, but the
// in-flight snapshot the metrics sink writes every two seconds.
func writeKilledAgentStub(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "stub-agent.sh")
script := "#!/bin/sh\n" +
"metrics=\"\"\n" +
"prev=\"\"\n" +
"for arg in \"$@\"; do\n" +
" if [ \"$prev\" = \"--metrics\" ]; then metrics=\"$arg\"; fi\n" +
" prev=\"$arg\"\n" +
"done\n" +
"printf '%s' '{\"prompt_tokens\":1200,\"completion_tokens\":80,\"steps\":9," +
"\"cost\":0.0123,\"currency\":\"USD\",\"complete\":false}' > \"$metrics.partial\"\n" +
"exit 137\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
func killedRun(t *testing.T) result {
t.Helper()
requireShellStub(t)
work := testenv.TempDir(t)
tk := task{ID: "stub-task", Prompt: "do the thing"}
r := result{task: tk}
cfg := suiteConfig{bin: writeKilledAgentStub(t, work)}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err == nil {
t.Fatal("the stub exits 137; a leg that died must be reported as failed")
}
return r
}
// writeSilentAgentStub writes a stand-in that leaves neither a final record nor
// a snapshot: what it spent is simply gone, so nothing can bound it.
func writeSilentAgentStub(t *testing.T, dir string) string {
t.Helper()
requireShellStub(t)
path := filepath.Join(dir, "silent-agent.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run recovered from its snapshot is still accounted: its numbers are real and
// stop at the last write, which is exactly what Partial says. Marking it
// Unaccounted would drop them from every total and hide the lower bound.
func TestSuiteSidecarRecoveredRunStaysAccountedAsALowerBound(t *testing.T) {
r := killedRun(t)
if r.Unaccounted || !r.Partial {
t.Fatalf("a run recovered from its snapshot was reported as unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 1 {
t.Fatalf("a recovered run changed category:\n"+
" unaccounted=%d accounted=%d partial=%d", stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 1200 || stats.cTok != 80 || stats.cost != 0.0123 {
t.Fatalf("the recovered numbers never reached the aggregates:\n"+
" tokens=%d/%d cost=%.4f, want 1200/80 at 0.0123", stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the lower-bound hint is unreachable:\n%s", out)
}
}
// A leg that leaves neither record has no surviving evidence at all, so the run
// is unmeasured and must stay out of the totals rather than count as zero.
func TestSuiteRunWithNoRecordAtAllIsUnaccounted(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "silent-task", Prompt: "do the thing"}
r := result{task: tk}
cfg := suiteConfig{bin: writeSilentAgentStub(t, work)}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("the stub exits 0; a silent leg must not fail the run: %v", err)
}
if !r.Unaccounted || r.Partial {
t.Fatalf("a run that left no record at all was not marked unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=false unaccounted=true", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 1 || stats.accounted != 0 {
t.Fatalf("a record-less run was counted as accounted:\n"+
" unaccounted=%d accounted=%d", stats.unaccounted, stats.accounted)
}
if stats.pTok != 0 || stats.cTok != 0 || stats.cost != 0 {
t.Fatalf("a record-less run contributed numbers: tokens=%d/%d cost=%.4f",
stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "Accounting incomplete for 1 of 1 instances") {
t.Fatalf("the record-less run was not disclosed as unaccounted:\n%s", out)
}
}
// The killed child did leave its numbers: the metrics sink snapshots in flight
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path must
// do the same rather than let the directory removal destroy the evidence.
func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
r := killedRun(t)
if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
t.Fatalf("the child's in-flight snapshot was dropped:\n"+
" prompt_tokens=%d completion_tokens=%d cost=%.4f partial=%v unaccounted=%v\n"+
" want the 1200/80 the <metrics>.partial sidecar carries",
r.PromptTokens, r.CompletionTokens, r.Cost, r.Partial, r.Unaccounted)
}
}
// The meter-vs-harness divergence line divides one side by the other. Whether a
// run has a harness side is the suite's Unaccounted state, not the token values:
// a run with no record at all has no side, and pricing it against the meter
// invents a divergence and blames the harness for it.
func TestMeterDivergenceDoesNotPriceAMetricslessRun(t *testing.T) {
r := result{task: task{ID: "killed"}, Unaccounted: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("a run with no harness-side number was priced as a self-report divergence:\n%s", got)
}
}
// A persisted record that says zero tokens is a measurement of zero, not an
// absence: the harness side exists, so it belongs on both sides of the ratio
// even when the number it reports disagrees with the meter.
func TestMeterAccountingKeepsAPersistedZeroOnBothSides(t *testing.T) {
r := result{task: task{ID: "zero"}, Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r})
if !strings.Contains(got, "-100.0% (harness 0 vs meter 1,280 over 1 runs)") {
t.Fatalf("a real zero-token measurement was dropped as if it were missing:\n%s", got)
}
}
+9 -5
View File
@@ -172,10 +172,11 @@ func scanMemoryRecall(path string, markers []string, markersInPrefix bool) memor
defer f.Close()
type record struct {
MemoryRecall *struct {
Hits []struct{ ID string } `json:"hits"`
UsedChars int `json:"used_chars"`
Suppressed string `json:"suppressed"`
ShadowHits []struct{ ID string } `json:"shadow_hits"`
Hits []struct{ ID string } `json:"hits"`
UsedChars int `json:"used_chars"`
Suppressed string `json:"suppressed"`
SuppressionCode string `json:"suppression_code"`
ShadowHits []struct{ ID string } `json:"shadow_hits"`
} `json:"memory_recall"`
Event *struct {
Kind string `json:"kind"`
@@ -205,7 +206,10 @@ func scanMemoryRecall(path string, markers []string, markersInPrefix bool) memor
if len(mr.ShadowHits) > 0 && mr.ShadowHits[0].ID == mr.Hits[0].ID {
stats.ShadowAgree++
}
} else if mr.Suppressed != "" {
} else if mr.SuppressionCode != "" || mr.Suppressed != "" {
// The typed code is the identity. A record written before the
// code existed carries only the sentence, whose presence still
// means recall stayed silent.
stats.Suppressed++
}
}
+4 -3
View File
@@ -17,15 +17,16 @@ func TestScanMemoryRecallCountsAndPointOfUse(t *testing.T) {
`{"seq":1,"event":{"kind":"tool_result","tool":{"args":"{\"command\":\"make check-fast --tag=MEMKEY-EARLY\"}"}}}`,
`{"seq":2,"memory_recall":{"hits":[{"id":"a"},{"id":"b"}],"used_chars":420}}`,
`{"seq":3,"event":{"kind":"tool_result","tool":{"args":"{\"path\":\"answer.txt\",\"content\":\"make check-fast --tag=MEMKEY-USED\"}"}}}`,
`{"seq":4,"memory_recall":{"suppressed":"generic user turn"}}`,
`{"seq":4,"memory_recall":{"suppression_code":"generic-turn","suppressed":"generic-turn: generic user turn"}}`,
`{"seq":5,"event":{"kind":"text","text":"done, MEMKEY-TEXT too"}}`,
`{"seq":6,"memory_recall":{"suppressed":"generic user turn"}}`,
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
stats := scanMemoryRecall(path, []string{"MEMKEY-USED", "MEMKEY-TEXT", "MEMKEY-EARLY", "MEMKEY-NEVER"}, false)
if stats.RecallEvents != 1 || stats.RecallHits != 2 || stats.RecallChars != 420 || stats.Suppressed != 1 {
t.Fatalf("stats = %+v, want 1 event / 2 hits / 420 chars / 1 suppressed", stats)
if stats.RecallEvents != 1 || stats.RecallHits != 2 || stats.RecallChars != 420 || stats.Suppressed != 2 {
t.Fatalf("stats = %+v, want 1 event / 2 hits / 420 chars / 2 suppressed", stats)
}
// MEMKEY-EARLY appears only BEFORE the recall: not point-of-use evidence.
if stats.MarkersUsed != 2 {
+8 -7
View File
@@ -116,14 +116,15 @@ func renderMeterAccounting(results []result) string {
miss += r.Meter.CacheMissTokens
unmeasured += r.Meter.WithoutUsage
injected += r.Meter.Injected
// A run whose metrics file never landed has no harness number, so it
// stays out of both sides of the comparison; folding its meter tokens
// against a silent zero would invent a divergence.
if !r.Unaccounted {
selfReported++
harnessTokens += r.PromptTokens + r.CompletionTokens
comparableTokens += r.Meter.PromptTokens + r.Meter.CompletionTokens
// Whether the harness has a side to compare is the suite's state, not a
// token count: Unaccounted has no record and Partial stops at its last
// snapshot, so neither side is whole. A persisted zero is a measurement.
if r.Unaccounted || r.Partial {
continue
}
selfReported++
harnessTokens += r.PromptTokens + r.CompletionTokens
comparableTokens += r.Meter.PromptTokens + r.Meter.CompletionTokens
}
if metered == 0 {
return ""
+32 -2
View File
@@ -21,10 +21,17 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
// call on the harness's own token counts, and another found this directory
// by grepping the temp root for its task — moving it only helps unnamed.
metricsDir, err := os.MkdirTemp("", "e2ebench-metrics-")
// A snapshot recovered from the sidecar is the only surviving evidence of
// what that leg spent, so its directory is kept instead of removed.
keepMetrics := false
if err != nil {
metricsDir = work
} else {
defer func() { _ = os.RemoveAll(metricsDir) }()
defer func() {
if !keepMetrics {
_ = os.RemoveAll(metricsDir)
}
}()
}
var runErr error
for _, seg := range segs {
@@ -42,7 +49,17 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
cmd.WaitDelay = 10 * time.Second
runErr = cmd.Run()
if m, err := readMetrics(metricsPath); err == nil {
m, partial, err := legMetrics(metricsPath)
if err != nil {
// The leg left no record at all: nothing survives to say what it
// spent, so the run is unmeasured rather than priced at zero.
r.Unaccounted = true
} else {
// A snapshot is real evidence, so the run stays accounted and only
// Partial says its numbers stop at the last write. It is sticky: a
// complete later leg cannot restore the tail an earlier one lost.
r.Partial = r.Partial || partial || !m.Complete
keepMetrics = keepMetrics || partial
foldSegmentMetrics(r, m, seg.index)
}
// A leg that died takes the run with it: resuming a session the child
@@ -55,6 +72,19 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
return runErr
}
// legMetrics reads one leg's accounting. The final record is authoritative and
// the in-flight snapshot is only read when the final one is absent, so the two
// can never be counted together.
func legMetrics(path string) (m runMetrics, partial bool, err error) {
if m, err = readMetrics(path); err == nil {
return m, false, nil
}
if m, err = readMetrics(path + ".partial"); err == nil {
return m, true, nil
}
return m, false, err
}
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
// the record of the one before it.
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
+214
View File
@@ -0,0 +1,214 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
// stubScript builds a stand-in agent that records the --metrics path it was
// given, writes the metrics body it was built with to that path plus suffix,
// and exits with code. A non-empty suffix is what a killed child leaves: the
// in-flight snapshot and no final record.
func stubScript(t *testing.T, name, body, suffix string, code int) string {
t.Helper()
requireShellStub(t)
script := fmt.Sprintf(`#!/bin/sh
metrics=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
prev="$arg"
done
printf '%%s' "$metrics" > metrics-path.txt
printf '%%s' '%s' > "$metrics%s"
exit %d
`, body, suffix, code)
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// recordedMetricsPath reads back the --metrics path the stub was handed.
func recordedMetricsPath(t *testing.T, work string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(work, "metrics-path.txt"))
if err != nil {
t.Fatalf("the stub did not record its metrics path: %v", err)
}
got := strings.TrimSpace(string(b))
if got == "" {
t.Fatal("the stub recorded an empty metrics path")
}
return got
}
// twoLegStub writes a stand-in whose legs differ: the first leaves only the
// in-flight snapshot, the second lands its final record. Both exit 0, which is
// what lets the run reach the second leg after the first one stopped short.
func twoLegStub(t *testing.T, name string) string {
t.Helper()
requireShellStub(t)
script := `#!/bin/sh
metrics=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
prev="$arg"
done
case "$metrics" in
*-1.json)
printf '%s' '{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,"currency":"USD","complete":false}' > "$metrics.partial"
;;
*)
printf '%s' '{"prompt_tokens":300,"completion_tokens":20,"steps":2,"cost":0.001,"currency":"USD","complete":true}' > "$metrics"
;;
esac
exit 0
`
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run that lands its final metrics record is untouched by the recovery
// path: it stays accounted, keeps its real numbers and is still not partial.
func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "healthy-task", Prompt: "do the thing"}
body := `{"prompt_tokens":900,"completion_tokens":60,"steps":4,"cost":0.02,` +
`"currency":"USD","complete":true}`
cfg := suiteConfig{bin: stubScript(t, "stub-ok.sh", body, "", 0)}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("a healthy leg must not fail the run: %v", err)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 0 {
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d partial=%d",
stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 900 || stats.cTok != 60 || stats.cost != 0.02 {
t.Fatalf("a healthy run's numbers changed: tokens=%d/%d cost=%.4f",
stats.pTok, stats.cTok, stats.cost)
}
if r.Partial || r.Unaccounted {
t.Fatalf("a healthy run was flagged: partial=%v unaccounted=%v", r.Partial, r.Unaccounted)
}
// The metrics directory is disposable once the final record was read.
dir := filepath.Dir(recordedMetricsPath(t, work))
if dirExists(dir) {
t.Fatalf("a healthy run left its metrics directory behind: %s", dir)
}
}
// The snapshot a killed child leaves is the only evidence of what it spent, so
// recovering the numbers must not also destroy the file they came from.
func TestSuiteRunKeepsTheSnapshotItRecovered(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "killed-task", Prompt: "do the thing"}
body := `{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,` +
`"currency":"USD","complete":false}`
cfg := suiteConfig{bin: stubScript(t, "stub-partial.sh", body, ".partial", 137)}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err == nil {
t.Fatal("the stub exits 137; a leg that died must be reported as failed")
}
path := recordedMetricsPath(t, work)
if fileExists(path) {
t.Fatalf("a killed child cannot have written a final record: %s", path)
}
if !fileExists(path + ".partial") {
t.Fatalf("the recovered snapshot was deleted with the metrics directory: %s.partial", path)
}
if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
t.Fatalf("the snapshot was kept but not recovered: tokens=%d/%d",
r.PromptTokens, r.CompletionTokens)
}
// Recovering the numbers must not also mark the run unmeasured: the snapshot
// is evidence, so the run stays accounted and Partial marks it a lower bound.
if r.Unaccounted || !r.Partial {
t.Fatalf("a recovered snapshot was not reported as accounted but partial: partial=%v unaccounted=%v",
r.Partial, r.Unaccounted)
}
}
// A lower bound belongs to the run, not to its last leg: a complete second leg
// cannot restore the tail a killed first leg stopped short of, so once any leg
// ends at a snapshot the whole run stays partial — and still counts.
func TestSuitePartialFirstLegMakesTheWholeRunALowerBound(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "two-leg-partial", Prompt: "do the thing", MaxSteps: 10}
cfg := suiteConfig{bin: twoLegStub(t, "stub-two-leg.sh"), segments: 2}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("both legs exit 0; the run must not fail: %v", err)
}
if r.Segments != 2 {
t.Fatalf("the run did not take both legs: segments=%d", r.Segments)
}
// The second leg's completeness says nothing about the first leg's tail.
if !r.Partial || r.Unaccounted {
t.Fatalf("a complete last leg erased the first leg's lower bound:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
// Partial bounds the account; it does not exclude it. Both legs are counted.
if r.PromptTokens != 1500 || r.CompletionTokens != 100 {
t.Fatalf("the legs' spend was not folded together: tokens=%d/%d, want 1500/100",
r.PromptTokens, r.CompletionTokens)
}
stats := gatherSuiteStats([]result{r})
if stats.accounted != 1 || stats.unaccounted != 0 || stats.partial != 1 || stats.pTok != 1500 {
t.Fatalf("a two-leg partial run changed category:\n"+
" accounted=%d unaccounted=%d partial=%d pTok=%d, want 1/0/1/1500",
stats.accounted, stats.unaccounted, stats.partial, stats.pTok)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the run was counted but not disclosed as a lower bound:\n%s", out)
}
}
// TestMeterAccountingSkipsAnUnaccountedRunWithTokens pins the guard: a run the
// suite marked unmeasured stays out of the ratio even when a harness number is
// present, so a run with no record is not priced.
func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) {
r := result{
task: task{ID: "killed"},
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
Unaccounted: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("an unaccounted run with a harness number was priced as a self-report divergence:\n%s", got)
}
}
// The same guard has to cover a lower bound: a Partial run's harness side stops
// at its last snapshot while the meter saw the whole leg, so pricing it reads as
// a divergence that is only the missing tail.
func TestMeterAccountingSkipsAPartialRunWithTokens(t *testing.T) {
r := result{
task: task{ID: "lower-bound"},
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
Partial: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("a partial run's missing tail was priced as a self-report divergence:\n%s", got)
}
// Its meter tokens are still a real measurement, so the totals keep them.
if !strings.Contains(got, "tokens 1,280") {
t.Fatalf("skipping the ratio also dropped the run's metered tokens:\n%s", got)
}
}
+38 -3
View File
@@ -19,6 +19,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -156,14 +157,47 @@ func main() {
}
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell, driver)
releaseFatalLog()
releaseFatalLog, exitedFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
logs := &lastLine{w: os.Stderr}
code := run(parentLease(os.Stdin), os.Stdout, logs, *page, shell, driver)
if code == 0 {
releaseFatalLog()
} else {
exitedFatalLog(code, logs.last())
}
os.Exit(code)
}
// logsDirOf is the shell's logs directory: the crash directory it passed is
// the "crash" folder inside it.
// lastLine passes everything through and remembers the last line written, which
// is where run says why it is returning a failure code.
type lastLine struct {
w io.Writer
mu sync.Mutex
line string
}
func (l *lastLine) Write(p []byte) (int, error) {
for line := range strings.SplitSeq(strings.TrimRight(string(p), "\r\n"), "\n") {
if strings.HasPrefix(line, "reasonix-studio-host:") {
l.mu.Lock()
l.line = strings.TrimSpace(line)
l.mu.Unlock()
}
}
return l.w.Write(p)
}
func (l *lastLine) last() string {
l.mu.Lock()
defer l.mu.Unlock()
if l.line == "" {
return "no reason was reported"
}
return l.line
}
func logsDirOf(crashDir string) string {
if strings.TrimSpace(crashDir) == "" {
return ""
@@ -448,6 +482,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
AccountsURL: os.Getenv("REASONIX_ACCOUNTS_URL"),
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
+1
View File
@@ -15,6 +15,7 @@
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"focus-live": "electron test/focus_live.js",
"freeze-live": "electron test/freeze_live.js",
"windowstate-live": "electron test/windowstate_live.js",
"computer-live": "electron test/computer_live.js"
+17 -2
View File
@@ -3,6 +3,7 @@ const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typed } = require("./browserguard");
const { KeyboardKeeper } = require("./keyboard");
// The size a page lays out at while nobody is looking at it: the kernel drives
// pages whether or not the panel is open, and a page sized to nothing reads as
@@ -30,6 +31,7 @@ class BrowserViews {
// for this run only: a restart, or a different certificate, asks again.
this.trusted = new Set();
this.shown = "";
this.keyboard = new KeyboardKeeper({ win });
// Moves with every show and hide, so a freeze that finishes after one does
// not put away a page shown since.
this.placed = 0;
@@ -106,7 +108,8 @@ class BrowserViews {
}
onEvent(method, params);
});
const entry = { view, onDownload, typed: 0 };
const entry = { view, onDownload, typed: 0, synthUntil: 0 };
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
this.watchLoads(targetId, entry);
let closed = false;
const close = () => {
@@ -125,7 +128,10 @@ class BrowserViews {
void contents.loadURL(url).catch(() => {});
return {
targetId,
send: (method, params) => contents.debugger.sendCommand(method, params),
send: (method, params) => {
if (method.startsWith("Input.")) entry.synthUntil = Date.now() + 250;
return contents.debugger.sendCommand(method, params);
},
close,
mainFrame: () => mainFrame,
};
@@ -159,6 +165,14 @@ class BrowserViews {
this.win.contentView.addChildView(entry.view);
entry.view.setBounds(at);
entry.at = at;
this.keepKeyboard();
}
// Attaching a view can leave nobody holding the keyboard, and the platform
// settles that a moment later, so the page is handed it back twice.
keepKeyboard() {
this.keyboard.reclaim();
setTimeout(() => this.keyboard.reclaim(), 150);
}
reattach() {
@@ -168,6 +182,7 @@ class BrowserViews {
this.win.contentView.addChildView(entry.view);
if (id !== this.shown) this.putAway(entry.view, entry);
}
this.keepKeyboard();
}
// freeze answers with a picture of the page on screen, taken before it is put
+17
View File
@@ -0,0 +1,17 @@
"use strict";
// A person needs longer than this to read a panel and choose in it. A panel
// that closes sooner was never drawn: on Linux a missing file-chooser portal
// answers the same way as a dismissal, so the page would be told the person
// declined and would offer nothing else.
const UNDRAWN_MS = 400;
// folderAnswer is what the page is told about a folder panel: the path chosen,
// "" for a panel the person closed, and null for a panel that could not be shown,
// which the page answers by asking for the path in words.
function folderAnswer(result, elapsedMs, platform) {
if (!result.canceled && result.filePaths.length > 0) return result.filePaths[0];
return platform === "linux" && elapsedMs < UNDRAWN_MS ? null : "";
}
module.exports = { folderAnswer, UNDRAWN_MS };
+63
View File
@@ -0,0 +1,63 @@
"use strict";
// Who owns the keyboard while the agent drives a page. A person typing in the
// app page keeps typing there: a page the agent opens, navigates or puts on
// screen is given the keyboard by the platform, and nothing the agent does is a
// reason for it to have it. The one reason that counts is the person's own: a
// click or a key that reached the page from the window, which is theirs to
// have chosen. The agent's own input arrives over the debugging protocol and
// is recognised as such, because it reaches the page the same way a click does.
const PERSON_MS = 400;
class KeyboardKeeper {
constructor({ win, now = Date.now }) {
this.win = win;
this.now = now;
this.owner = win.webContents.isFocused() ? "app" : "none";
win.webContents.on("focus", () => {
this.owner = "app";
});
}
// watch follows one page. synthetic() says whether input reaching it right now
// is the agent's own.
watch(contents, synthetic = () => false) {
let personAt = -Infinity;
contents.on("before-input-event", () => {
personAt = this.now();
});
contents.on("input-event", (_event, input) => {
if (input && input.type === "mouseDown" && !synthetic()) personAt = this.now();
});
contents.on("focus", () => {
if (this.now() - personAt < PERSON_MS) {
this.owner = "view";
return;
}
if (this.owner !== "app" || !this.live()) {
this.owner = "view";
return;
}
// The platform is still handing the page the keyboard while this event is
// delivered, and a focus asked for from inside it is lost; one a turn later
// is not, as long as nothing of the person's has arrived by then.
setTimeout(() => {
if (this.now() - personAt < PERSON_MS || this.owner !== "app" || !this.live()) return;
if (contents.isFocused()) this.win.webContents.focus();
}, 0);
});
}
// reclaim gives the keyboard back to the app page when attaching or moving a
// view left nobody holding it, while the window itself is the one in front.
reclaim() {
if (this.owner === "app" && this.live() && !this.win.webContents.isFocused()) this.win.webContents.focus();
}
live() {
return !this.win.isDestroyed() && this.win.isFocused();
}
}
module.exports = { KeyboardKeeper, PERSON_MS };
+5 -3
View File
@@ -22,6 +22,7 @@ const { reveal, revealWorkspace, revealLogs, openWorkspace, openFile } = require
const { appIcon } = require("./appicon");
const { setUnreadBadge } = require("./badge");
const layout = require("./layout");
const { folderAnswer } = require("./folderpanel");
const { offerCleanup } = require("./legacy");
const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
@@ -472,17 +473,18 @@ ipcMain.handle("dialog:save-bytes", (event, name, bytes) =>
// createDirectory is the half that carries meaning: a panel that can only open
// what exists reads as an app that cannot start a project, which is what the
// Wails picker was reported as before it said so. A dismissed panel answers ""
// like the save dialogs, and startIn is dropped when it names nothing.
// like the save dialogs, a panel that could not be drawn answers null, and startIn
// is dropped when it names nothing.
ipcMain.handle("dialog:pick-folder", async (event, startIn) => {
const target = fromWindow(event);
if (!target) return "";
const shownAt = Date.now();
const picked = await dialog.showOpenDialog(target, {
defaultPath: startIn || undefined,
// Tools keep their working folders behind a leading dot, so those are listed too.
properties: ["openDirectory", "createDirectory", "showHiddenFiles"],
});
if (picked.canceled || !picked.filePaths.length) return "";
return picked.filePaths[0];
return folderAnswer(picked, Date.now() - shownAt, process.platform);
});
// Named before any path is derived from it: userData hangs off the app name,
+141
View File
@@ -0,0 +1,141 @@
"use strict";
// Who owns the keyboard while the agent drives its browser. A person typing in
// the app page must still be typing there after the agent opens a page, shows
// it, clicks in it, types into it or asks for it to come to the front: the
// agent's commands arrive over the debugging protocol, and nothing it sends may
// move the focus out of the page the person is in. Run with `pnpm focus-live`;
// it needs no network and no kernel.
const { app, BrowserWindow } = require("electron");
const { BrowserProtocol } = require("../src/browserprotocol");
const { BrowserViews } = require("../src/browserviews");
const { checker, listen, wait } = require("./livekit");
const APP = `<!doctype html><title>app</title><textarea id="compose" autofocus></textarea>`;
const PAGE = `<!doctype html><title>page</title><input id="name"><button id="go" onclick="document.title='clicked'">Go</button>`;
const { check, failures } = checker();
async function main() {
await app.whenReady();
const page = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
const win = new BrowserWindow({ width: 1000, height: 700, show: true, webPreferences: { contextIsolation: true } });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(APP));
win.show();
app.focus({ steal: true });
win.focus();
win.webContents.focus();
await wait(800);
if (!win.webContents.isFocused()) {
process.stdout.write(" SKIP this desktop would not give the test window the keyboard, so there is nothing to keep\n");
app.exit(0);
return;
}
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const frames = [];
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
frames.push(frame);
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message, sessionId) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message, ...(sessionId ? { sessionId } : {}) }) });
setTimeout(() => resolve({ timedOut: true }), 4000);
});
const where = async () => ({
app: await win.webContents.executeJavaScript("document.activeElement && document.activeElement.id"),
appFocused: win.webContents.isFocused(),
viewFocused: [...views.entries.values()].some((e) => e.view.webContents.isFocused()),
});
const keepsTheKeyboard = async (what) => {
const w = await where();
check(`${what}: the composer keeps the keyboard`, w.app === "compose" && w.appFocused && !w.viewFocused, w);
};
protocol.receive({ conn: "c", open: "focus-live" });
await keepsTheKeyboard("before the agent does anything");
const created = await send({ method: "Target.createTarget", params: { url: page.url } });
const targetId = created.result.targetId;
const attached = await send({ method: "Target.attachToTarget", params: { targetId } });
const session = attached.result.sessionId;
await wait(1200);
await keepsTheKeyboard("opening a page");
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(500);
await keepsTheKeyboard("showing it over the page");
await send({ method: "Page.bringToFront" }, session);
await wait(300);
await keepsTheKeyboard("asking for the page to come to the front");
await send({ method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.dispatchMouseEvent", params: { type: "mouseReleased", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await wait(300);
await keepsTheKeyboard("a click inside the agent's page");
await send({ method: "Input.insertText", params: { text: "hello" } }, session);
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyDown", key: "a", text: "a" } }, session);
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyUp", key: "a" } }, session);
await wait(300);
await keepsTheKeyboard("typing into the agent's page");
await send({ method: "Runtime.evaluate", params: { expression: "document.querySelector('#name').focus()" } }, session);
await wait(300);
await keepsTheKeyboard("the page focusing one of its own fields");
await send({ method: "Page.navigate", params: { url: page.url + "/next" } }, session);
await wait(1200);
await keepsTheKeyboard("navigating the agent's page");
views.hide();
await wait(300);
await keepsTheKeyboard("putting the page away");
// The other half: a person who clicks into the agent's page is typing there,
// and the agent navigating it again does not take that away from them.
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(500);
const theirs = [...views.entries.values()][0].view.webContents;
// A click that reaches the page from the window, and the focus the platform
// gives the page because of it; sendInputEvent alone does not move focus.
theirs.sendInputEvent({ type: "mouseDown", x: 40, y: 20, button: "left", clickCount: 1 });
theirs.focus();
theirs.sendInputEvent({ type: "mouseUp", x: 40, y: 20, button: "left", clickCount: 1 });
await wait(500);
const chosen = await where();
check("a click of the person's own into the agent's page gives it the keyboard", chosen.viewFocused && !chosen.appFocused, chosen);
await send({ method: "Page.navigate", params: { url: page.url + "/again" } }, session);
await wait(1200);
const stays = await where();
check("and the agent navigating it again does not pull the keyboard back", stays.viewFocused && !stays.appFocused, stays);
win.webContents.focus();
await wait(300);
win.minimize();
await wait(400);
win.restore();
await wait(600);
await keepsTheKeyboard("the window being minimized and restored");
page.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL focus-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+130
View File
@@ -14,6 +14,7 @@ const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
const { groundFor, themeSourceFor, GROUND } = require("../src/ground.js");
const { folderAnswer } = require("../src/folderpanel.js");
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
const TOKEN = "a".repeat(64);
@@ -2259,6 +2260,8 @@ test("a parked page is attached again when it is shown, and a page already on sc
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
};
@@ -2441,3 +2444,130 @@ test("the window capture is scaled down past the upload-friendly width and refus
const blank = captureHandler(() => ({ webContents: { capturePage: async () => fakeImage(0, { empty: true }) } }));
assert.equal(await blank({}), null);
});
test("a folder panel that closes before anyone could read it is reported as never drawn on Linux", () => {
const closed = { canceled: true, filePaths: [] };
assert.equal(folderAnswer(closed, 20, "linux"), null);
assert.equal(folderAnswer(closed, 5000, "linux"), "");
assert.equal(folderAnswer(closed, 20, "darwin"), "");
assert.equal(folderAnswer(closed, 20, "win32"), "");
assert.equal(folderAnswer({ canceled: false, filePaths: ["/home/a/p"] }, 20, "linux"), "/home/a/p");
});
// Who keeps the keyboard while the agent drives a page.
const { KeyboardKeeper, PERSON_MS } = require("../src/keyboard.js");
function keyboardWindow({ appFocused = true, windowFocused = true } = {}) {
const contents = Object.assign(new EventEmitter(), { focused: appFocused, focus() { this.focused = true; this.emit("focus"); }, isFocused() { return this.focused; } });
const win = { webContents: contents, isDestroyed: () => false, isFocused: () => windowFocused };
return { win, contents };
}
function keyboardPage() {
return new EventEmitter();
}
const turn = () => new Promise((resolve) => setTimeout(resolve, 5));
test("a page the agent opens does not take the keyboard from the app page", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
assert.equal(contents.focused, false, "not from inside the platform's own focus event");
await turn();
assert.equal(contents.focused, true);
assert.equal(keeper.owner, "app");
});
test("a click or a key from the person into the page is theirs to have chosen", () => {
for (const arrive of [(page) => page.emit("before-input-event", {}, { type: "keyDown" }), (page) => page.emit("input-event", {}, { type: "mouseDown" })]) {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
keeper.watch(page);
arrive(page);
contents.focused = false;
page.emit("focus");
assert.equal(contents.focused, false);
assert.equal(keeper.owner, "view");
}
});
test("a click the agent itself sent over the protocol is not the person's", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page, () => true);
page.emit("input-event", {}, { type: "mouseDown" });
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, true);
});
test("the person's choice stands until they go back to the app page", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
page.emit("input-event", {}, { type: "mouseDown" });
page.emit("focus");
assert.equal(keeper.owner, "view");
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, false, "the agent navigating again does not pull it back");
contents.focus();
assert.equal(keeper.owner, "app");
});
test("nothing is taken back when the app page did not have the keyboard, or the window is not in front", async () => {
let { win, contents } = keyboardWindow({ appFocused: false });
let keeper = new KeyboardKeeper({ win });
let page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
page.emit("focus");
await turn();
assert.equal(contents.focused, false);
({ win, contents } = keyboardWindow({ windowFocused: false }));
keeper = new KeyboardKeeper({ win });
page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, false);
({ win, contents } = keyboardWindow());
keeper = new KeyboardKeeper({ win });
page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
page.emit("before-input-event", {}, { type: "keyDown" });
await turn();
assert.equal(contents.focused, false, "a key from the person in the meantime is theirs");
});
test("reclaim hands the keyboard back only when nobody holds it and the app page owned it", () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
contents.focused = false;
keeper.reclaim();
assert.equal(contents.focused, true);
keeper.owner = "view";
contents.focused = false;
keeper.reclaim();
assert.equal(contents.focused, false);
});
test("a person's click is only a click for a moment", () => {
assert.ok(PERSON_MS > 0 && PERSON_MS < 2000);
});
+3 -1
View File
@@ -1,6 +1,8 @@
node_modules
dist
dist-perf
dist-perf*
*.png
!public/icons/*.png
tsconfig.tsbuildinfo
dist-relay*
relay/bin
+1
View File
@@ -78,6 +78,7 @@ node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏
node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶部、有自己的底(浅/深)
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
@@ -0,0 +1,53 @@
// The product mark in the sidebar's top-left is one row: the wordmark and a bordered uppercase badge, vertically centred on
// each other. Where the badge was hidden before it stays hidden.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
let seen = null;
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 700]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height: 800 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".studio-brand-lockup");
await page.waitForTimeout(500);
const m = await page.evaluate(() => {
const logo = document.querySelector(".studio-brand-logo").getBoundingClientRect();
const sub = document.querySelector(".studio-brand-sub");
const r = sub.getBoundingClientRect();
const cs = getComputedStyle(sub);
return {
shown: r.width > 0,
left: r.left,
top: r.top,
dy: Math.abs(logo.top + logo.height / 2 - (r.top + r.height / 2)),
after: r.left >= logo.right - 1,
fill: cs.backgroundColor, ink: cs.color, radius: cs.borderTopLeftRadius, upper: cs.textTransform, text: sub.textContent,
};
});
const tag = `${scheme} ${width}px`;
if (width >= 760) {
check(`${tag}: the badge is on the wordmark's row, vertically centred on it`, m.shown && m.dy <= 1.5 && m.after, JSON.stringify(m));
check(`${tag}: it is a filled uppercase badge in both themes and the text is untouched`, m.fill !== "rgba(0, 0, 0, 0)" && m.fill !== m.ink && m.radius !== "0px" && m.upper === "uppercase" && m.text === "studio", JSON.stringify(m));
if (!seen) seen = { left: m.left, top: m.top };
check(`${tag}: it sits in the same place whichever theme is on`, Math.abs(m.left - seen.left) <= 0.5 && Math.abs(m.top - seen.top) <= 0.5, `${m.left},${m.top} vs ${seen.left},${seen.top}`);
} else {
check(`${tag}: the badge stays hidden on a narrow window`, !m.shown);
}
await ctx.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,57 @@
// A call brought to the end of the transcript is the same row it is where it happened, drawn open: no box, no icon
// square, no vertical track — the status glyph, the title, the subject and the meta on one row over a hairline.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1100, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "run" });
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}' } });
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}', output: "ok\n", durationMs: 900 } });
window.__feed({ kind: "text", text: "done" });
window.__feed({ kind: "turn_done" });
});
await page.waitForTimeout(400);
await page.evaluate(() => [...document.querySelectorAll('[data-action="tool.bring"]')].pop()?.click());
await page.waitForSelector(".brought-item .call");
await page.mouse.move(5, 5);
await page.waitForTimeout(200);
const m = await page.evaluate(() => {
const call = document.querySelector(".brought-item .call");
const g = call.querySelector(":scope > .g");
if (!call.closest(".flow")) return { iconColumn: "outside .flow" };
const c = call.querySelector(":scope > .c");
const cs = getComputedStyle(c);
return {
iconColumn: g ? getComputedStyle(g).display : "absent",
sides: [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth].join(","),
radius: cs.borderTopLeftRadius,
bg: cs.backgroundColor,
caption: !!document.querySelector(".brought-bar span"),
open: !!call.querySelector("details[open]"),
};
});
check(`${scheme}: no icon column or vertical track`, m.iconColumn === "none" || m.iconColumn === "absent", m.iconColumn);
check(`${scheme}: no box around the row`, m.sides === "0px,0px,0px" && m.radius === "0px" && (m.bg === "rgba(0, 0, 0, 0)" || m.bg === "transparent"), JSON.stringify(m));
check(`${scheme}: opened, with its caption above`, m.open && m.caption);
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+64
View File
@@ -0,0 +1,64 @@
// Cards the reader reads or acts on share one shell: the same radius, border and ground, and a header with no fill of its
// own. A kind that draws its own (a different radius, a state-tinted border, a filled strip) reads as another family.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "跑一遍" });
f({ kind: "text", text: "我来处理。" });
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: '{}' } });
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "标题", description: "一句话", scope: "project", activation: "always", body: "正文" }) } });
f({ kind: "guardian_assessment", guardian: { id: "gd", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "越界", duration_ms: 900 } });
f({ kind: "compaction_done", compaction: { trigger: "capacity", messages: 40, summary: "折叠了前 40 条。", sourceTokens: 90000, projectionTokens: 6000, coverageRequired: 6, coverageMissing: 2, boundary: "capacity", triggerTokens: 90000 } });
f({ kind: "extension_surface", extension: { pluginId: "cov", surfaceId: "c", kind: "card", card: { title: "覆盖率", text: "78.4%", fields: [{ key: "语句", value: "78.4%" }], progress: 0.78, actions: [{ actionId: "o", label: "打开" }] } } });
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: [], unverified: [], saysSomething: true } });
f({ kind: "turn_started" });
f({ kind: "approval_request", approval: { id: "a1", tool: "bash", subject: "rm -rf build && make" } });
f({ kind: "ask_request", ask: { id: "q1", questions: [{ id: "q", header: "选一个", prompt: "走哪条路", options: [{ label: "方案 A" }, { label: "方案 B" }] }] } });
});
await page.waitForTimeout(800);
await page.mouse.move(995, 5);
const rows = await page.evaluate(() =>
[...document.querySelectorAll(".flow .call")]
.filter((c) => ["mem", "host", "extension"].includes(c.getAttribute("data-k")) || c.hasAttribute("data-prompt"))
.map((c) => {
const cs = getComputedStyle(c.querySelector(":scope > .c"));
const hl = c.querySelector(":scope > .c > .hl");
return {
kind: c.getAttribute("data-k"),
radius: cs.borderTopLeftRadius,
border: cs.borderTopColor,
bg: cs.backgroundColor,
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
};
}));
check(`${scheme}: four kinds of card found`, rows.length === 4, rows.map((r) => r.kind).join(","));
const same = (key) => new Set(rows.map((r) => r[key])).size === 1;
check(`${scheme}: one radius`, rows.length > 0 && same("radius"), [...new Set(rows.map((r) => r.radius))].join(" / "));
check(`${scheme}: one border colour, whatever the state`, rows.length > 0 && same("border"), [...new Set(rows.map((r) => r.border))].join(" / "));
check(`${scheme}: one ground`, rows.length > 0 && same("bg"), [...new Set(rows.map((r) => r.bg))].join(" / "));
check(`${scheme}: no header fill`, rows.length > 0 && rows.every((r) => r.head === "rgba(0, 0, 0, 0)"), [...new Set(rows.map((r) => r.head))].join(" / "));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+6
View File
@@ -101,6 +101,12 @@ check("回执没有重复根容器", await page.locator(".rc > .rc").count() ===
// between any two glyphs, so it is the one that must not give up its width.
const rc = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: "zh-CN", colorScheme: "dark" });
rc.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await rc.addInitScript(() => {
try {
localStorage.setItem("rx-turn-receipt", "on");
} catch {
}
});
await rc.goto(PAGE, { waitUntil: "networkidle" });
await rc.waitForSelector(".compose");
await rc.evaluate(() => {
@@ -0,0 +1,79 @@
// The composer keeps the 1.x feel: a ring of light sweeps its border once when
// it is hovered or focused, nothing sweeps while a turn runs or when motion is
// reduced, and the run state is announced rather than printed inside the box.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const sweep = (page) => page.evaluate(() => getComputedStyle(document.querySelector(".compose"), "::before").animationName);
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const running of [false, true]) {
const label = `${scheme} ${running ? "running" : "idle"}`;
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(BASE + (running ? "&running=1" : ""), { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (running) {
await page.waitForFunction(() => typeof window.__feed === "function");
await page.waitForTimeout(400);
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
}
await page.waitForSelector(running ? ".studio-runstate[data-running]" : ".studio-runstate[data-idle]");
await page.mouse.move(5, 5);
await page.waitForTimeout(150);
const state = await page.evaluate(() => {
const status = document.querySelector(".compose .studio-runstate");
const word = status?.querySelector(".studio-runlabel > span.sr-only");
const r = word?.getBoundingClientRect();
const clock = status?.querySelector(".studio-runclock");
return {
role: status?.getAttribute("role"),
live: status?.getAttribute("aria-live"),
said: word?.textContent ?? "",
wordBox: r ? [Math.round(r.width), Math.round(r.height)] : null,
clockShown: !!clock && clock.getBoundingClientRect().width > 0,
mark: !!status?.querySelector(".rmark"),
};
});
check(`${label}: the state is announced`, state.role === "status" && state.live === "polite" && state.said.length > 0, state.said);
check(`${label}: the word is not printed in the box`, state.wordBox && state.wordBox[0] <= 2 && state.wordBox[1] <= 2, JSON.stringify(state.wordBox));
check(`${label}: the mark stays`, state.mark);
if (running) check(`${label}: the clock stays`, state.clockShown);
check(`${label}: nothing sweeps at rest`, (await sweep(page)) === "none");
await page.hover(".compose textarea");
await page.waitForTimeout(300);
const hovered = await sweep(page);
check(`${label}: ${running ? "no sweep on hover while running" : "a sweep on hover"}`, running ? hovered === "none" : hovered === "compose-sweep", hovered);
if (shotDir && !running) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-hover.png` });
await page.mouse.move(5, 5);
await page.waitForTimeout(100);
await page.focus(".compose textarea");
await page.waitForTimeout(300);
const focused = await sweep(page);
check(`${label}: ${running ? "no sweep on focus while running" : "a sweep on focus"}`, running ? focused === "none" : focused === "compose-sweep", focused);
if (shotDir) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-${running ? "running" : "idle"}.png` });
await page.close();
}
const calm = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
await calm.goto(BASE, { waitUntil: "networkidle" });
await calm.waitForSelector(".compose");
await calm.hover(".compose textarea");
await calm.focus(".compose textarea");
await calm.waitForTimeout(300);
check(`${scheme}: no sweep when motion is reduced`, (await sweep(calm)) === "none");
await calm.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
@@ -0,0 +1,49 @@
// The context card opens on hover and holds buttons (compress now, manage MCP).
// Reaching them means crossing the gap between the meter and the card; a card
// that closes while the pointer is in that gap can never be used. Hover is a
// layout question, so it is asked of a real browser, with the pointer moving
// the way a hand does: in steps, through the gap.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 }, colorScheme: scheme, locale: "zh-CN" });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".studio-meter-context", { timeout: 20000 });
const trigger = page.locator(".studio-meter-context").first();
const tb = await trigger.boundingBox();
await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2);
await page.waitForTimeout(250);
const card = page.locator(".studio-context-anchor .chrome-context-card");
check(`${scheme}: the card opens while the pointer is on the meter`, await card.isVisible());
const target = card.locator("button, a").first();
const cb = await target.boundingBox();
// Up through the gap in small steps, then onto the card's own control.
await page.mouse.move(cb.x + cb.width / 2, cb.y + cb.height / 2, { steps: 40 });
await page.waitForTimeout(250);
check(`${scheme}: the card is still open after crossing the gap`, await card.isVisible());
const hit = await target.evaluate((el) => {
const r = el.getBoundingClientRect();
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return at === el || el.contains(at);
});
check(`${scheme}: its first control takes the pointer`, hit);
await ctx.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,46 @@
// An opened step shows its detail as a panel in place, bordered on all four sides by one hairline. A rule on one side only
// is a rail, and the transcript has no rails.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
f({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}' } });
f({ kind: "tool_result", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}', output: "ok\n", durationMs: 900 } });
f({ kind: "text", text: "done" });
f({ kind: "turn_done" });
});
await page.waitForTimeout(500);
await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
await page.waitForTimeout(400);
await page.mouse.move(995, 5);
const m = await page.evaluate(() =>
[...document.querySelectorAll(".flow .tool-disclosure > .out")].map((el) => {
const cs = getComputedStyle(el);
return { sides: [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].join(","), color: [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor] };
}));
check(`${scheme}: an opened step has a detail panel`, m.length > 0, `${m.length} found`);
check(`${scheme}: it is bordered on all four sides`, m.length > 0 && m.every((x) => x.sides === "1px,1px,1px,1px" && new Set(x.color).size === 1), JSON.stringify(m[0] ?? {}));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+15 -24
View File
@@ -1,4 +1,4 @@
// A docked browser column starts at the top of the pane, beside the navigation row, and gives the rows back when it
// A docked browser column starts at the top of the pane, and gives the rows back when it
// is undocked. Where things start is a layout question, so it is asked of a real browser.
import { chromium } from "playwright";
@@ -19,7 +19,7 @@ const geometry = (page) =>
};
const card = document.querySelector('.pane > .scroll[data-pane="browser"] > .workbench');
const divider = card ? parseFloat(getComputedStyle(card).borderLeftWidth) : 0;
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
return { pane: box(".pane"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
});
const browser = await chromium.launch();
@@ -40,26 +40,24 @@ for (const scheme of ["light", "dark"]) {
check(`${tag} ${round}: the browser column is drawn`, !!open.side);
if (open.side) {
if (label === "wide") {
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
check(`${tag} ${round}: it starts at the top of the pane`, Math.abs(open.side.top - open.pane.top) <= 1, `pane ${open.pane.top} column ${open.side.top}`);
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
check(`${tag} ${round}: its strip ends on the navigation row's line, so the two rows read as one`, !!open.strip && Math.abs(open.strip.bottom - open.tabs.bottom) <= 1, JSON.stringify([open.tabs, open.strip]));
check(`${tag} ${round}: the workbench card's own edge separates it from the conversation`, open.divider >= 1, String(open.divider));
} else {
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
check(`${tag} ${round}: the page takes the pane's full width when it stacks`, open.side.left <= open.pane.left + 1 && open.side.right >= open.pane.right - 1, JSON.stringify([open.pane, open.side]));
}
}
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
}
await page.locator('[data-action="pane.view"][data-value="flow"]').first().click();
await page.locator('[data-action="pane.view"][data-value="browser"][aria-pressed="true"]').first().click();
await page.waitForTimeout(700);
const shut = await geometry(page);
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
check(`${tag} undocked: the column is gone`, !shut.side || shut.side.left >= shut.pane.right - 1, JSON.stringify([shut.side, shut.pane]));
await page.close();
}
}
// Every room between phone and wide, not two samples: the left column shrinks with the window, and the tabs it holds
// must still fit and stay clear of the browser column.
// Every room between phone and wide, not two samples: the conversation shrinks with the window, and it must
// stay clear of the browser column, which stacks over it once the window is narrow.
{
const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
const bad = [];
@@ -70,22 +68,15 @@ for (const scheme of ["light", "dark"]) {
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
await page.waitForTimeout(400);
const g = await page.evaluate(() => {
const tabs = document.querySelector(".pane > .tabs");
const side = document.querySelector('.pane > .scroll[data-pane="browser"]');
const t = tabs.getBoundingClientRect();
const last = [...tabs.querySelectorAll(".tab")].at(-1)?.getBoundingClientRect();
const s = side?.getBoundingClientRect();
const stacked = !!s && s.top >= t.bottom - 1;
const first = tabs.querySelector(".tab")?.getBoundingClientRect();
const pad = parseFloat(getComputedStyle(tabs).paddingLeft) * 2;
const need = last && first ? last.right - first.left + pad : 0;
const pane = document.querySelector(".pane").getBoundingClientRect().width;
return { fits: tabs.scrollWidth <= tabs.clientWidth + 1, clear: stacked || !s || !last || last.right <= s.left + 1, stacked, need, pane };
const side = document.querySelector('.pane > .scroll[data-pane="browser"]')?.getBoundingClientRect();
const body = document.querySelector(".pane > .pbody")?.getBoundingClientRect();
const pane = document.querySelector(".pane").getBoundingClientRect();
const stacked = !!side && side.left <= pane.left + 40;
return { stacked, clear: stacked || !side || !body || body.right <= side.left + 1 };
});
const should = g.pane < g.need + 320 || width <= 640;
if (!g.fits || !g.clear || g.stacked !== should) bad.push(`${width}:${g.fits ? "" : "overflow "}${g.clear ? "" : "overlap "}${g.stacked !== should ? `stacked=${g.stacked} pane=${Math.round(g.pane)} need=${Math.round(g.need)}` : ""}`);
if (!g.clear || (width <= 640 && !g.stacked) || (width >= 1000 && g.stacked)) bad.push(`${width}:${g.clear ? "" : "overlap "}stacked=${g.stacked}`);
}
check("from 560 to 1440 the tabs fit their row, stay clear of the browser column, and the column stacks exactly when tabs plus its 320px floor no longer fit", bad.length === 0, bad.join(" "));
check("from 560 to 1440 the conversation stays clear of the browser column, which stacks only in a narrow window", bad.length === 0, bad.join(" "));
await page.close();
}
await browser.close();
+55
View File
@@ -0,0 +1,55 @@
// Keyboard focus has to be seen. Every button the keyboard reaches in the workbench must look different when focused for a
// reason other than fading in: an outline, a shadow, a border or a background. A control that only changes opacity is
// invisible to anyone who cannot tell .45 from 1.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(700);
await page.mouse.move(1270, 5);
await page.evaluate(() => document.activeElement?.blur());
const look = (el) => {
const cs = getComputedStyle(el);
return [cs.outlineStyle === "none" ? "" : `${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.borderColor, cs.backgroundColor, cs.textDecorationLine].join("|");
};
const seen = new Set();
let stops = 0;
const bad = [];
for (let i = 0; i < 90; i++) {
await page.keyboard.press("Tab");
const r = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body || el.tagName !== "BUTTON") return null;
const cs = getComputedStyle(el);
const focused = [cs.outlineStyle === "none" ? "" : `${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.borderColor, cs.backgroundColor, cs.textDecorationLine].join("|");
const id = (el.getAttribute("aria-label") || el.textContent || el.getAttribute("data-action") || "button").trim().slice(0, 40);
el.blur();
const css2 = getComputedStyle(el);
const plain = [css2.outlineStyle === "none" ? "" : `${css2.outlineWidth} ${css2.outlineColor}`, css2.boxShadow, css2.borderColor, css2.backgroundColor, css2.textDecorationLine].join("|");
const r = el.getBoundingClientRect();
el.focus({ focusVisible: true });
return { id, key: id + Math.round(r.left) + Math.round(r.top), same: focused === plain, shown: r.width > 0 && r.height > 0 };
});
if (!r) continue;
if (seen.has(r.key)) break;
seen.add(r.key);
stops++;
if (r.shown && r.same) bad.push(r.id);
}
console.log(`${bad.length ? "FAIL" : " ok"} ${scheme}: ${stops} buttons reached by Tab${bad.length ? ", no visible focus on: " + bad.join("; ") : ""}`);
if (bad.length) fails.push(`${scheme}: ${bad.join("; ")}`);
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+90
View File
@@ -0,0 +1,90 @@
// Interface text has one size dial, --fs-scale, and the transcript has its own,
// --read. Every piece of text must answer to one of them: text that moves with
// neither is a size written into a stylesheet as a bare pixel count, which the
// text-size setting could never reach.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const SCENES = [
{ name: "conversation", q: "?pref=zh&ws=2&sess=6&turns=3" },
{ name: "files panel", q: "?pref=zh&ws=2&sess=6&turns=2", act: async (p) => { await p.locator('.navbtn[aria-label="文件"]').first().click(); await p.waitForSelector(".workbench-filelist button"); } },
{ name: "side panel", q: "?pref=zh&turns=2", act: async (p) => { await p.locator('[data-action="side.toggle"]').click(); await p.waitForSelector(".sidepanel"); } },
{ name: "narrow", q: "?pref=zh&ws=2&sess=6&turns=2", w: 390 },
{ name: "settings", q: "?pref=zh&turns=2", tabs: true },
];
const sizes = (page) => page.evaluate(() => {
window.__texts ??= (() => {
const out = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
let n;
while ((n = walker.nextNode())) {
if (![...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim())) continue;
const r = n.getBoundingClientRect();
if (r.width < 2 || r.height < 2 || !n.checkVisibility({ checkVisibilityCSS: true, contentVisibilityAuto: true })) continue;
out.push(n);
}
return out;
})();
return window.__texts.map((el) => parseFloat(getComputedStyle(el).fontSize));
});
const browser = await chromium.launch();
for (const sc of SCENES) {
const page = await browser.newPage({ viewport: { width: sc.w ?? 1400, height: 900 }, locale: "zh-CN", reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(BASE + sc.q, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
const views = [];
if (sc.tabs) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
const n = await page.evaluate(() => document.querySelectorAll(".prefs-nav button").length);
for (let i = 0; i < n; i++) views.push(async () => { await page.evaluate((k) => document.querySelectorAll(".prefs-nav button")[k].click(), i); });
} else views.push(async () => { await sc.act?.(page); });
let total = 0;
const holes = new Map();
for (const show of views) {
await show();
await page.waitForTimeout(500);
await page.evaluate(() => { window.__texts = undefined; document.documentElement.style.setProperty("--fs-scale", "1"); });
const base = await sizes(page);
await page.evaluate(() => document.documentElement.style.setProperty("--fs-scale", "1.25"));
await page.waitForTimeout(80);
const scaled = await sizes(page);
await page.evaluate(() => {
const root = document.documentElement.style;
window.__read = root.getPropertyValue("--read");
root.setProperty("--fs-scale", "1");
root.setProperty("--read", `${parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--read")) * 1.25}px`);
});
await page.waitForTimeout(80);
const reading = await sizes(page);
await page.evaluate(() => {
const root = document.documentElement.style;
if (window.__read) root.setProperty("--read", window.__read);
else root.removeProperty("--read");
});
const names = await page.evaluate(() => window.__texts.map((el) => (el.className && typeof el.className === "string" ? el.className.trim().split(/\s+/).slice(0, 2).join(".") : el.tagName.toLowerCase()) + " " + el.textContent.trim().slice(0, 10)));
base.forEach((b, i) => {
if (b <= 0) return;
total++;
const ok = Math.abs(scaled[i] / b - 1.25) < 0.03 || Math.abs(reading[i] / b - 1.25) < 0.03;
if (!ok) holes.set(`${names[i]} (${b}px)`, (holes.get(`${names[i]} (${b}px)`) ?? 0) + 1);
});
}
check(`${sc.name}: every piece of text answers to the text size or the reading size`, holes.size === 0, `${total} text nodes${holes.size ? "; stuck: " + [...holes.keys()].slice(0, 6).join(", ") : ""}`);
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+71 -3
View File
@@ -7,9 +7,11 @@ import "../src/styles/app.css";
import "../src/styles/dock.css";
import "../src/styles/studio.css";
import "../src/styles/changedpill.css";
import "../src/styles/askskip.css";
import "../src/styles/pastekeep.css";
import "../src/styles/ctxitems.css";
import "../src/styles/sidepanel.css";
import "../src/styles/contentmatches.css";
import "../src/styles/scrolljump.css";
import "../src/styles/chart.css";
import "../src/styles/community.css";
@@ -25,9 +27,12 @@ import "../src/styles/dialogcenter.css";
import "../src/styles/treerows.css";
import "../src/styles/workbench-changes.css";
import "../src/styles/gitgraph.css";
import "../src/styles/vthumb.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
import "../src/styles/bring.css";
import "../src/styles/line.css";
import "../src/styles/notice.css";
import "../src/styles/peers.css";
import "../src/styles/jsontext.css";
import "../src/styles/skillpin.css";
import "../src/styles/narrow.css";
@@ -36,13 +41,16 @@ import "../src/styles/workbench-launcher.css";
import "../src/styles/feedback.css";
import "../src/styles/toasts.css";
import "../src/styles/modelmenu.css";
import "../src/styles/composer-menu.css";
import "../src/styles/runsignals.css";
import "../src/styles/overview.css";
import "../src/styles/themes.css";
import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
import { fromHistory } from "../src/state/session";
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port";
import type { UpdateProgress } from "../src/port/version";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
import type { WireEvent } from "../src/port/wire";
@@ -86,18 +94,35 @@ function benchPack(): ThemePack | null {
}
class BenchPort extends MockPort {
// ?checkfail=<name> makes a plain test of that source answer 401.
protected async answerCheck(name: string) {
if (query.get("checkfail") !== name) return super.answerCheck(name);
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
}
async browserTabs() {
if (!query.has("agentpage")) return super.browserTabs();
return [{ id: "t1", target: "view-a", url: "https://example.com/docs/install", title: "Install guide", active: true }];
}
async workspaceFile(path: string) {
// ?grant=1 grants the workspace a folder, /library, holding one document.
async workspaceRoots() {
const roots = await super.workspaceRoots();
return query.has("grant") ? { ...roots, extra: ["/library"] } : roots;
}
async workspaceFile(path: string, root?: string) {
if (root) {
const content = "# Release plan\n\nThe plan lives in the library folder you granted this project.\n\n- freeze on Friday\n- tag after the checks are green\n- announce the next morning\n";
return { path, content, revision: "r1", root, readOnly: true };
}
if (!query.has("doc") || !path.endsWith(".md")) return super.workspaceFile(path);
const content = "# Install guide\n\nDownload the package for your system, then run the installer. It places the program in your user folder and does not need administrator rights.\n\nAfter the first start, open Settings and add a model service before you begin a conversation.";
return { path, content, revision: "r1" };
}
async versions() {
if (query.has("update")) return { current: "2.42.0", pinned: "", stalePin: false, latest: "2.43.0", newer: true, versions: [] };
if (!query.has("versions")) return super.versions();
const names = ["2.38.0", "2.37.0", "2.36.0", "2.35.0", "2.34.0", "2.33.0", "2.32.0", "2.31.0", "2.30.0", "2.29.0"];
return {
@@ -135,6 +160,15 @@ class BenchPort extends MockPort {
return ONBOARDING ? { required: true, noProvider: true, provider: "", model: "" } : null;
}
async peers() {
if (!query.has("peers")) return super.peers();
return {
enabled: true,
peers: [{ key: "s-1a2b3c4d", title: "拾音", project: "rx-archive" }, { key: "s-5e6f7a8b", title: "文档整理", project: "docs" }],
held: [{ id: "m-1", fromSession: "s-1a2b3c4d", fromTitle: "拾音", fromProject: "rx-archive", sentAt: "2026-10-16T12:00:00Z", body: "迁移脚本跑完了,结果在 out/report.md,请你核对第三节再继续。" }],
};
}
async foldWorkSettings() {
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
}
@@ -210,19 +244,42 @@ class BenchPort extends MockPort {
private readonly jobsSince = Date.now() - 739_000;
async status() {
const st = await super.status();
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}) };
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
if (!JOBS) return st;
const jobs = Array.from({ length: JOBS }, (_, i) => ({
id: `job-${i}`,
kind: "bash",
label: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i}`,
// ?jobnames=1 gives each job the name its caller wrote and the full command.
...(query.has("jobnames") ? {
description: i === 0 ? "运行桌面端测试并只看最后 40 行输出" : `跑第 ${i} 组内核测试`,
detail: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i} -count=1 -timeout 10m`,
} : {}),
status: this.stopped.has(`job-${i}`) ? "killed" : "running",
startedAt: this.jobsSince,
}));
return { ...st, jobs };
}
async switchBranch(name: string) {
if (SWITCH_MS > 0) await new Promise((r) => setTimeout(r, SWITCH_MS));
return super.switchBranch(name);
}
async goToVersion() {}
onUpdateProgress(cb?: (p: UpdateProgress) => void) {
if (cb && query.has("update")) queueMicrotask(() => cb({ version: "2.43.0", phase: "ready", received: 0, total: 0 }));
return () => {};
}
async jobOutput(jobId: string) {
if (!JOBS) return super.jobOutput(jobId);
const lines = Array.from({ length: 28 }, (_, i) => `[${String(i).padStart(2, "0")}] compiling module ${i * 7 + 3} … ok`);
return { text: lines.join("\n") + "\nwatching for changes\n", status: this.stopped.has(jobId) ? "killed" : "running", bytes: 1100, truncated: false };
}
async cancelJob(jobId: string) {
if (!JOBS) return super.cancelJob(jobId);
this.stopped.add(jobId);
@@ -261,6 +318,7 @@ const SESSIONS = Number(query.get("sess") ?? 0);
// ?turns= is how long the conversation being opened already is.
const TURNS = Number(query.get("turns") ?? 0);
const STATUS_MS = Number(query.get("statusms") ?? 0);
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");
@@ -274,6 +332,16 @@ const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
// ?search=1 answers every query with two conversations that say it.
async searchHistory(q: string) {
if (!query.has("search")) return super.searchHistory(q);
return [
{ path: "/sessions/mock.jsonl", id: "mock", snippet: `…上周讨论过要不要把 ${q} 的轮换放进发布流程,结论是每月一次…`, role: "assistant", messageIndex: 4, score: 2 },
{ path: "/sessions/older.jsonl", id: "older", snippet: `怎么把 ${q} 配到预发环境里?`, role: "user", messageIndex: 0, score: 1 },
];
}
private readonly settingsPort = new BenchPort();
machinePort(): AgentPort {
+16 -6
View File
@@ -14,10 +14,15 @@ const CONTROLS = [".studio-mode-picker", ".studio-branch", ".model-picker", ".co
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
for (const scheme of ["light", "dark"]) for (const turns of [2, 0]) {
const context = `${scheme}, ${turns ? "existing conversation" : "new conversation"}`;
const url = new URL(BASE);
url.searchParams.set("turns", String(turns));
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1100, height: 760 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose .studio-branch");
await page.goto(url.href, { waitUntil: "networkidle" });
await page.waitForSelector(turns ? ".compose .studio-branch" : ".compose .worktree-trigger");
const inline = await page.locator(".compose .studio-branch").first().evaluate((e) => !!e.closest(".turntools"));
check(`${context}: workspace control stays in the toolbar`, inline);
const seen = [];
for (const sel of CONTROLS) {
const el = page.locator(`.compose ${sel}`.replace(".compose .compose", ".compose")).first();
@@ -26,11 +31,16 @@ try {
await page.waitForTimeout(300);
seen.push([sel, await el.evaluate((e) => ({ radius: getComputedStyle(e).borderRadius, bg: getComputedStyle(e).backgroundColor }))]);
}
check(`${scheme}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
check(`${context}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
const radii = new Set(seen.map(([, v]) => v.radius));
const grounds = new Set(seen.map(([, v]) => v.bg));
check(`${scheme}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
check(`${scheme}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
check(`${context}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
check(`${context}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(url.href, { waitUntil: "networkidle" });
const chip = page.locator(turns ? ".compose .studio-branch" : ".compose .worktree-trigger").first();
await chip.click();
check(`${context}: a narrow-pane pointer click opens the workspace menu`, await chip.getAttribute("aria-expanded") === "true");
await page.close();
}
} finally {
@@ -0,0 +1,64 @@
// Interface wording the catalogue test cannot see: a Chinese label kept in a table and drawn without t() passes every
// source scan and still shows up in the English window. This opens each settings section in English, fires the probes
// that fill in more of the page, and fails on any Chinese the reader would see that is not on the fixture list below.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
// Names the demo data itself carries, and names that are written in Chinese on purpose.
const FIXTURE = [
/并行会话演示/, /上一次的会话/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
];
const fails = [];
const browser = await chromium.launch();
const ctx = await browser.newContext({ locale: "en-US", viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(800);
const scan = () => {
const out = [];
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n = w.nextNode(); n; n = w.nextNode()) {
const text = n.textContent.trim();
if (text && /[一-鿿]/.test(text) && !["SCRIPT", "STYLE"].includes(n.parentElement?.tagName)) out.push(text);
}
for (const el of document.querySelectorAll("[aria-label],[title],[placeholder],[alt]"))
for (const a of ["aria-label", "title", "placeholder", "alt"]) {
const v = el.getAttribute(a);
if (v && /[一-鿿]/.test(v)) out.push(v);
}
return out;
};
const seen = new Map();
const take = async (where) => {
for (const text of await page.evaluate(scan)) if (!FIXTURE.some((re) => re.test(text)) && !seen.has(text)) seen.set(text, where);
};
await take("workbench");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForTimeout(500);
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /Back up|repair/i.test(x.textContent))?.click());
await page.waitForTimeout(400);
const sections = await page.evaluate(() => [...new Set([...document.querySelectorAll('[data-action="settings.section"]')].map((b) => b.getAttribute("data-value")))]);
if (sections.length < 8) fails.push(`only ${sections.length} settings sections found; the guard is looking at the wrong page`);
for (const value of sections) {
await page.evaluate((v) => document.querySelector(`[data-action="settings.section"][data-value="${v}"]`)?.click(), value);
await page.waitForTimeout(450);
await page.evaluate(() => document.querySelector('[data-action="network.diagnose"]')?.click());
await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
await page.waitForTimeout(500);
await take(`settings:${value}`);
}
await browser.close();
for (const [text, where] of seen) fails.push(`${where}: ${text.slice(0, 80)}`);
if (fails.length) {
console.log("Chinese in the English window:\n " + fails.join("\n "));
process.exit(1);
}
console.log(`ok ${sections.length} settings sections, no unlisted Chinese in the English window`);
+52
View File
@@ -0,0 +1,52 @@
// A background job is named for a person: the list shows the name its caller gave it, and the whole name and the full command
// appear in a tip that stays inside the window, can be selected, and opens from the keyboard as well as the pointer.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [width, height] of [[1280, 860], [400, 760]]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?jobs=3&jobnames=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(800);
await page.evaluate(() => [...document.querySelectorAll("button")].find((b) => /后台任务/.test(b.textContent))?.click());
await page.waitForSelector(".jobs .job .cmd");
const tag = `${scheme} ${width}px`;
const shown = await page.evaluate(() => [...document.querySelectorAll(".jobs .job .cmd")].map((e) => e.textContent));
check(`${tag}: the list shows the names, not the commands`, shown.length === 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
await page.locator(".jobs .job .cmd").first().hover();
await page.waitForSelector(".job-tip");
const tip = await page.evaluate(() => {
const el = document.querySelector(".job-tip");
const r = el.getBoundingClientRect();
return { inside: r.left >= 0 && r.top >= 0 && r.right <= innerWidth + 1 && r.bottom <= innerHeight + 1, lines: el.children.length, select: getComputedStyle(el).userSelect, cmd: el.querySelector(".jt-cmd")?.textContent ?? "" };
});
check(`${tag}: the tip has the name and the full command, inside the window`, tip.inside && tip.lines === 2 && /npm run tauri:test/.test(tip.cmd), JSON.stringify(tip));
check(`${tag}: its text can be selected`, tip.select === "text", tip.select);
await page.mouse.move(width - 5, 5);
await page.waitForTimeout(200);
check(`${tag}: it closes when the pointer leaves`, (await page.locator(".job-tip").count()) === 0);
await page.locator(".jobs .job .cmd").nth(1).focus();
await page.waitForSelector(".job-tip");
check(`${tag}: focus from the keyboard opens it too`, /内核测试/.test(await page.locator(".job-tip .jt-name").textContent()));
await page.keyboard.press("Escape");
await page.waitForTimeout(150);
check(`${tag}: Escape closes it`, (await page.locator(".job-tip").count()) === 0);
await ctx.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+46
View File
@@ -0,0 +1,46 @@
// The two floating buttons are named for the ends they go to ("back to top", "jump to latest"),
// so one click has to arrive there, whatever expanded blocks lie between. Where a scroller ends up
// is a property of the render, so it is read from the browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=40";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1100, height: 760 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector('[data-pane="flow"]');
const geometry = () => page.locator('[data-pane="flow"]').evaluate((el) => ({ top: Math.round(el.scrollTop), room: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop) }));
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
await page.waitForSelector('.scrolljump button[data-value="up"]');
await page.waitForSelector('.scrolljump button[data-value="down"]');
await page.evaluate(() => document.querySelectorAll("details.tool-disclosure, details.think").forEach((d) => { d.open = true; }));
await page.waitForTimeout(200);
const open = await page.locator("details.tool-disclosure[open], details.think[open]").count();
check("there are expanded blocks between the middle and each end", open >= 2, `${open} expanded blocks`);
check("the conversation is long enough to scroll", (await geometry()).top > 0 && (await geometry()).room > 0);
await page.click('.scrolljump button[data-value="up"]');
await page.waitForTimeout(400);
const atTop = await geometry();
check("one click on the top button reaches the top", atTop.top <= 1, `scrollTop ${atTop.top}`);
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
await page.waitForSelector('.scrolljump button[data-value="down"]');
await page.click('.scrolljump button[data-value="down"]');
await page.waitForTimeout(800);
const atEnd = await geometry();
check("one click on the latest button reaches the end", atEnd.room <= 2, `${atEnd.room}px from the end`);
await page.close();
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
+55
View File
@@ -0,0 +1,55 @@
// An allowed guardian review at low risk and a remembered fact are Lines, not cards: one row over a hairline with no box.
// A refusal, or an allow that carried real risk, keeps the card.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: "{}" } });
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "A fact", description: "why", scope: "project", activation: "always", body: "b" }) } });
f({ kind: "guardian_assessment", guardian: { id: "g1", tool: "bash", subject: "go test", outcome: "allow", risk_level: "low", user_authorization: "high", rationale: "read only", duration_ms: 5 } });
f({ kind: "guardian_assessment", guardian: { id: "g2", tool: "bash", subject: "git push -f", outcome: "allow", risk_level: "high", user_authorization: "low", rationale: "rewrites history", duration_ms: 5 } });
f({ kind: "guardian_assessment", guardian: { id: "g3", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "no", duration_ms: 5 } });
f({ kind: "turn_done" });
});
await page.waitForTimeout(600);
await page.mouse.move(995, 5);
const m = await page.evaluate(() => {
const rows = [...document.querySelectorAll(".flow .line-row")];
const box = (el) => { const cs = getComputedStyle(el); return [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth].join(","); };
return {
lines: rows.length,
boxes: rows.map(box),
radius: rows.map((r) => getComputedStyle(r).borderTopLeftRadius),
cards: [...document.querySelectorAll('.flow .call[data-k="host"]')].length,
forget: !!document.querySelector('.flow .line-row [data-action="memory.forget"]'),
oldMem: document.querySelectorAll('.flow .call[data-k="mem"]').length,
};
});
check(`${scheme}: the remembered fact and the low-risk allow are rows`, m.lines === 2, `${m.lines} rows`);
check(`${scheme}: rows carry no box`, m.lines > 0 && m.boxes.every((b) => b === "0px,0px,0px") && m.radius.every((r) => r === "0px"), JSON.stringify(m));
check(`${scheme}: the high-risk allow and the refusal keep their cards`, m.cards === 2, `${m.cards} cards`);
check(`${scheme}: the take-back sits in the row, and no memory card remains`, m.forget && m.oldMem === 0);
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,58 @@
// A dialog that declares itself modal keeps the keyboard inside it: Tab from the last control goes back to the first, and
// never reaches the sidebar, the transcript or the composer the sheet is covering.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
const outside = async (selector, presses) => {
const out = [];
for (let i = 0; i < presses; i++) {
await page.keyboard.press("Tab");
const where = await page.evaluate((sel) => {
const el = document.activeElement;
return el && el !== document.body && !el.closest(sel) ? (el.getAttribute("aria-label") || el.textContent || el.tagName).trim().slice(0, 30) : "";
}, selector);
if (where) out.push(where);
}
return out;
};
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-sheet");
await page.waitForTimeout(400);
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
await page.waitForTimeout(300);
await page.evaluate(() => document.activeElement?.blur());
const settingsOut = await outside(".prefs-sheet", 60);
check(`${scheme}: Tab never leaves the settings sheet`, settingsOut.length === 0, settingsOut.slice(0, 3).join("; "));
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
await page.evaluate(() => document.querySelector('[data-action="workspace.search"]')?.click());
await page.waitForSelector(".palette");
await page.waitForTimeout(300);
const paletteOut = await outside(".palette", 30);
check(`${scheme}: Tab never leaves the command palette`, paletteOut.length === 0, paletteOut.slice(0, 3).join("; "));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,59 @@
// The connection form is the widest thing in settings. At phone widths its headings stay on one line, rows that cannot fit
// wrap instead of overlapping, and nothing passes the right edge of the window.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [360, 390, 430]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height: 844 }, colorScheme: scheme, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForTimeout(500);
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
await page.waitForTimeout(400);
await page.evaluate(() => [...document.querySelectorAll('[data-action="settings.section"]')].find((x) => x.textContent.includes("模型服务"))?.click());
await page.waitForSelector(".pdetail", { state: "attached" });
await page.evaluate(() => [...document.querySelectorAll("button,li,a")].find((x) => /myrelay-work/.test(x.textContent) && x.textContent.length < 80)?.click());
await page.waitForSelector(".pdetail .mlhead", { state: "attached" });
const m = await page.evaluate(() => {
const live = (el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null;
};
const head = document.querySelector(".pdetail .mlhead");
const kids = [...head.children].map((el) => [el, live(el)]).filter(([, r]) => r);
const overlaps = [];
for (let i = 0; i < kids.length; i++)
for (let j = i + 1; j < kids.length; j++) {
const a = kids[i][1], b = kids[j][1];
if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1) overlaps.push(i + "/" + j);
}
const ttl = head.querySelector(".ttl");
const lineH = parseFloat(getComputedStyle(ttl).lineHeight) || parseFloat(getComputedStyle(ttl).fontSize) * 1.4;
const out = [...document.querySelectorAll(".pdetail *")].filter((el) => live(el) && live(el).right > innerWidth + 1 && !el.closest("[hidden]")).map((el) => String(el.className).split(" ")[0] || el.tagName).slice(0, 4);
return { ttlLines: Math.round(ttl.getBoundingClientRect().height / lineH), overlaps, out, hscroll: document.documentElement.scrollWidth > innerWidth + 1 };
});
check(`${scheme} ${width}px: the model list heading stays on one line`, m.ttlLines === 1, JSON.stringify(m));
check(`${scheme} ${width}px: the model list header wraps instead of overlapping`, m.overlaps.length === 0, m.overlaps.join(", "));
check(`${scheme} ${width}px: nothing in the connection form passes the window edge`, m.out.length === 0 && !m.hscroll, m.out.join(", "));
await ctx.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+32 -7
View File
@@ -43,7 +43,6 @@ const probe = (page) =>
}
return hits;
};
const tabbar = document.querySelector(".tabs");
const bar = document.querySelector(".chrome");
const row = document.querySelector(".compose .row");
const tools = document.querySelector(".compose .turntools");
@@ -54,9 +53,11 @@ const probe = (page) =>
return {
bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) },
row: { overlaps: overlaps(row), outside: outside(row) },
tabs: flatOverlaps([...tabbar.querySelectorAll(".vtabs > .tab, .plan-peek, .sidetoggle")]),
tabsOutside: [...tabbar.querySelectorAll(".vtabs > .tab, .plan-peek, .sidetoggle")].filter((el) => live(el).bottom > tabbar.getBoundingClientRect().bottom + 1).map(name),
viewSwitch: [...bar.querySelectorAll(".view-action")].map((el) => el.getAttribute("data-value")),
sideAsk: (() => { const el = tools.querySelector('[data-action="side.toggle"]'); const r = el && live(el); return r ? { w: r.width, h: r.height } : null; })(),
toolsOverlaps: flatOverlaps([...tools.children]),
toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
toolsOverlap: flatOverlaps([...tools.querySelectorAll("button")]),
titleShown: t.width,
titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
crumbWidth: clip.width,
@@ -64,7 +65,7 @@ const probe = (page) =>
};
});
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action"];
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".view-action"];
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
async function reachable(page) {
@@ -107,8 +108,30 @@ const browser = await chromium.launch();
check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
await page.close();
}
// A running turn swaps Send for Stop, which is wider; the toolbar must still sit on one line at every column width.
{
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.fill('textarea[role="combobox"]', "hello");
await page.keyboard.press("Enter");
await page.waitForSelector(".btn.stop");
const bad = [];
for (let room = 420; room <= 760; room += 20) {
await page.setViewportSize({ width: room, height: 800 });
await page.waitForTimeout(150);
const lines = await page.evaluate(() => {
const seen = [...document.querySelectorAll(".compose .row button")].filter((el) => el.getBoundingClientRect().width > 0 && el.checkVisibility());
const mids = seen.map((el) => { const r = el.getBoundingClientRect(); return r.top + r.height / 2; }).sort((a, b) => a - b);
return mids.filter((m, i) => i === 0 || m - mids[i - 1] > 6).length;
});
if (lines !== 1) bad.push(`${room}:${lines}`);
}
check("while a turn runs, the composer toolbar stays on one line from 420 to 760", bad.length === 0, bad.join(" "));
await page.close();
}
for (const scheme of ["light", "dark"]) {
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [320, 1], [1280, 1]]) {
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [390, 1], [320, 1.8], [320, 1], [1280, 1]]) {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
@@ -120,10 +143,12 @@ for (const scheme of ["light", "dark"]) {
const m = await probe(page);
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", "));
check(`${tag}: view tabs and their neighbours do not overlap`, m.tabs.length === 0, m.tabs.join(", "));
check(`${tag}: the tab bar grows to hold a wrapped row`, m.tabsOutside.length === 0, m.tabsOutside.join(", "));
check(`${tag}: the workbench switch is on the bar`, m.viewSwitch.includes("browser"), JSON.stringify(m.viewSwitch));
check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", "));
check(`${tag}: the side question icon is drawn in the toolbar`, !!m.sideAsk && m.sideAsk.w > 0 && m.sideAsk.h > 0, JSON.stringify(m.sideAsk));
check(`${tag}: composer toolbar controls do not overlap`, m.toolsOverlaps.length === 0, m.toolsOverlaps.join(", "));
check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
check(`${tag}: composer toolbar buttons do not overlap`, m.toolsOverlap.length === 0, m.toolsOverlap.join(", "));
check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
const hint = await page.evaluate(() => {
@@ -0,0 +1,50 @@
// A host notice is a bar, not a card: one sentence with a glyph, no header strip, no box and no state wash. Severity is
// the glyph's ink, so warn and error differ only there.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 760 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
for (const level of ["info", "warn", "error"]) window.__feed({ kind: "notice", level, text: `a ${level} notice`, detail: "the diagnostic" });
window.__feed({ kind: "turn_done" });
});
await page.waitForTimeout(500);
await page.mouse.move(990, 10);
const m = await page.evaluate(() => {
const read = (el) => {
const cs = getComputedStyle(el);
const g = el.querySelector(".nb-ic, .nb-dot");
return {
border: [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth, cs.borderBottomWidth].join(","),
bg: cs.backgroundColor,
header: !!el.querySelector(".hl, .tag"),
glyph: g ? getComputedStyle(g).color : "",
};
};
return [...document.querySelectorAll(".notice-bar")].map(read);
});
check(`${scheme}: three notices drawn as bars`, m.length === 3, `${m.length} found`);
check(`${scheme}: no box, wash or header strip`, m.length > 0 && m.every((x) => x.border === "0px,0px,0px,0px" && (x.bg === "rgba(0, 0, 0, 0)" || x.bg === "transparent") && !x.header), JSON.stringify(m[0] ?? {}));
check(`${scheme}: severity is the glyph ink alone`, m.length === 3 && new Set(m.map((x) => x.glyph)).size === 3, m.map((x) => x.glyph).join(" / "));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,75 @@
// Tab-switch and sidebar-filter cost with 1/2/4/8 panes that each hold a long transcript. A measurement, not a guard:
// the numbers depend on the machine, so it prints a table instead of failing. CPU=4 emulates a slow laptop.
import { chromium } from "playwright";
const CPU = Number(process.env.CPU ?? 4);
const TURNS = Number(process.env.TURNS ?? 60);
const REPS = Number(process.env.REPS ?? 3);
const b = await chromium.launch();
const rows = [];
const pct = (a, p) => { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(s.length * p))]; };
for (const n of [1, 2, 4, 8]) {
const ctx = await b.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
await page.goto(`${process.env.PERF_URL ?? "http://localhost:4399/perf.html"}?panes=${n}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app"); await page.waitForTimeout(800);
await page.evaluate(async (k) => {
const frame = () => new Promise((r) => requestAnimationFrame(r));
for (let i = 0; i < k; i++) {
window.__feed({ kind: "turn_started" });
const id = `t${i}`;
window.__feed({ kind: "tool_dispatch", tool: { id, name: "bash", args: JSON.stringify({ command: `go test ./pkg${i}/...` }) } });
window.__feed({ kind: "tool_result", tool: { id, name: "bash", args: JSON.stringify({ command: `go test ./pkg${i}/...` }), output: "ok\n".repeat(20) } });
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n\`\`\`go\nfunc f${i}() {}\n\`\`\`\n` });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
if (i % 10 === 9) await frame();
}
await frame();
}, TURNS);
await page.waitForTimeout(800);
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
await cdp.send("Performance.enable");
const nodes = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "Nodes")?.value;
// tab switch: click each session row in turn, time until two frames have painted
const switches = [];
const m0 = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
for (let r = 0; r < REPS; r++) {
const count = await page.locator(".sessrow").count();
for (let i = 0; i < count; i++) {
const ms = await page.evaluate(async (i) => {
const el = document.querySelectorAll(".sessrow")[i];
const t0 = performance.now();
el.click();
await new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)));
return performance.now() - t0;
}, i);
switches.push(ms);
await page.waitForTimeout(80);
}
}
const m1 = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
const per = (k) => (((m1[k] - m0[k]) * 1000) / switches.length).toFixed(0);
// sidebar: type into the rail find box and measure each keystroke to paint
const keys = [];
const box = page.locator(".wsfind input");
await page.evaluate(() => document.querySelector(".wsfind input")?.focus());
for (const ch of "perf") {
const ms = await page.evaluate(async (ch) => {
const input = document.querySelector(".wsfind input");
const t0 = performance.now();
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
set.call(input, input.value + ch);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)));
return performance.now() - t0;
}, ch);
keys.push(ms);
}
rows.push({ N: n, 节点: nodes, "切换 p50": pct(switches, 0.5).toFixed(0), "切换 p95": pct(switches, 0.95).toFixed(0), "切换 max": Math.max(...switches).toFixed(0), "脚本/次": per("ScriptDuration"), "样式/次": per("RecalcStyleDuration"), "布局/次": per("LayoutDuration"), "侧栏筛选 p50": pct(keys, 0.5).toFixed(0), "侧栏筛选 max": Math.max(...keys).toFixed(0) });
await ctx.close();
}
await b.close();
console.log(`CPU 降频 ${CPU}x,每窗格 ${TURNS} 轮历史(含 bash 工具卡),切换 ${REPS} 轮;单位 ms(点击到两帧之后)\n`);
console.table(rows);
@@ -1,33 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
// The small magnifier beside the session filters opens the rail's find box and
// closes it again: a second press must not reopen it.
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
for (const theme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${base}?pref=zh`, { waitUntil: "networkidle" });
await page.waitForSelector(".studio-filter-search");
const shown = () => page.evaluate(() => Number(getComputedStyle(document.querySelector(".rail .wsfind")).opacity) > 0.5);
const press = () => page.locator(".studio-filter-search").click();
assert.equal(await shown(), false, `${theme}: the find box starts hidden`);
await press();
await page.waitForTimeout(250);
assert.equal(await shown(), true, `${theme}: the first press shows the find box`);
await press();
await page.waitForTimeout(250);
assert.equal(await shown(), false, `${theme}: a second press on the magnifier must hide the find box, not show it again`);
await press();
await page.waitForTimeout(250);
assert.equal(await shown(), true, `${theme}: the third press shows it again`);
assert.deepEqual(errors, []);
await page.close();
}
console.log("ok search-toggle");
} finally {
await browser.close();
}
+81
View File
@@ -0,0 +1,81 @@
// The side question opens from the button in the composer, above the composer
// and inside the conversation's own column — never over what sits beside or
// above it, whether the file panel is open or not and at any width.
import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
if (shotDir) await mkdir(shotDir, { recursive: true });
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL });
for (const scheme of ["light", "dark"]) {
for (const [width, files, height = 800, zoom = 1] of [[1400, false], [1400, true], [760, false], [760, true], [390, false, 700], [900, false, 480], [760, false, 800, 1.3]]) {
const label = `${scheme} ${width}x${height} zoom ${zoom}${files ? " with the file panel" : ""}`;
const page = await browser.newPage({ viewport: { width, height }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${PAGE}&zoom=${zoom}`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (files) {
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-filelist button");
}
await page.locator('[data-action="side.toggle"]').click();
await page.waitForSelector(".sidepanel");
await page.waitForTimeout(250);
const r = await page.evaluate(() => {
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
const panel = document.querySelector(".sidepanel");
const compose = panel.closest(".compose");
const pane = compose.closest(".pane");
const beside = [...document.querySelectorAll(".workbench-filelist, .workbench-explorer-head")].filter((el) => !pane.contains(el)).map(box);
return { panel: box(panel), compose: box(compose), pane: box(pane), beside, vw: innerWidth, vh: innerHeight, button: box(document.querySelector('[data-action="side.toggle"]')) };
});
const p = r.panel;
check(`${label}: it sits above the composer`, p.bottom <= r.compose.top + 1 && r.compose.top - p.bottom < 24, `bottom ${Math.round(p.bottom)}, composer top ${Math.round(r.compose.top)}`);
check(`${label}: it stays inside its pane`, p.left >= r.pane.left - 1 && p.right <= r.pane.right + 1 && p.top >= r.pane.top - 1, `panel ${Math.round(p.left)}–${Math.round(p.right)}, pane ${Math.round(r.pane.left)}–${Math.round(r.pane.right)}`);
check(`${label}: it stays inside the window`, p.left >= 0 && p.right <= r.vw && p.top >= 0 && p.bottom <= r.vh);
check(`${label}: it does not cover the file panel`, r.beside.every((b) => p.right <= b.left || p.left >= b.right || p.bottom <= b.top || p.top >= b.bottom), files ? `${r.beside.length} file panel parts` : "no file panel");
check(`${label}: it opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`);
const draft = page.locator('[data-action-change="side.draft"]');
await draft.fill("解释这个函数如何处理错误。".repeat(50));
await page.locator('[data-action="side.send"]').click();
await page.locator('.sideturn[data-role="side"]').waitFor();
await draft.fill("keep this draft");
const collapse = page.locator('[data-action="side.collapse"]');
await collapse.click();
const hidden = await draft.isHidden();
const compact = await page.locator(".sidepanel").evaluate((el) => {
const b = el.getBoundingClientRect();
const button = el.querySelector('[data-action="side.collapse"]');
const c = button.getBoundingClientRect();
const hit = document.elementFromPoint(c.x + c.width / 2, c.y + c.height / 2);
const flow = el.closest(".pane").querySelector('[data-pane="flow"]').getBoundingClientRect();
return { height: b.height, hit: hit === button || button.contains(hit), readable: b.top - flow.top, flowHeight: flow.height };
});
check(`${label}: collapse leaves a reachable header and hides inputs`, hidden && compact.hit && compact.height < 72, JSON.stringify(compact));
check(`${label}: collapse exposes the transcript`, compact.readable > 100 && compact.readable > compact.flowHeight / 2, JSON.stringify(compact));
await collapse.focus();
await page.keyboard.press("Tab");
check(`${label}: Tab skips the collapsed controls`, await page.locator('[data-action="side.close"]').evaluate((el) => el === document.activeElement));
if (shotDir && [1400, 390].includes(width) && !files) await page.screenshot({ path: `${shotDir}/${scheme}-${width}.png` });
await collapse.click();
await draft.waitFor({ state: "visible" });
check(`${label}: expansion keeps the draft`, await draft.inputValue() === "keep this draft");
await page.keyboard.press("Escape");
await page.locator(".sidepanel").waitFor({ state: "detached" });
check(`${label}: Escape returns focus`, await page.locator('[data-action="side.toggle"]').evaluate((el) => el === document.activeElement));
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+46
View File
@@ -0,0 +1,46 @@
// The session tab's dot is the only at-a-glance sign of a working session: it
// breathes while the run is live and stays still in every other state, and a
// person who asked for reduced motion gets it still. Only the browser computes it.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const [label, reduced] of [["normal", "no-preference"], ["reduced motion", "reduce"]]) {
const ctx = await browser.newContext({ viewport: { width: 1200, height: 760 }, reducedMotion: reduced });
const page = await ctx.newPage();
await page.goto(`${BASE}?pack=photo`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const names = await page.evaluate(() => {
const bar = document.createElement("div");
bar.className = "panetabs";
bar.style.cssText = "position:fixed;left:0;top:0;opacity:0;pointer-events:none";
bar.innerHTML = ["idle", "running", "halt", "done"].map((r) => `<div class="ptab" data-run="${r}"><i class="pip"></i></div>`).join("");
bar.innerHTML += `<div class="ptab" data-run="done"><i class="pip" data-unread></i></div>`;
document.body.appendChild(bar);
const out = {};
for (const r of ["idle", "running", "halt", "done"]) out[r] = getComputedStyle(bar.querySelector(`[data-run="${r}"] .pip`)).animationName;
const read = getComputedStyle(bar.querySelector('[data-run="done"] .pip:not([data-unread])'));
const unread = getComputedStyle(bar.querySelector('[data-run="done"] .pip[data-unread]'));
out.unreadDiffers = unread.backgroundColor !== read.backgroundColor && unread.backgroundColor !== "rgba(0, 0, 0, 0)";
return out;
});
check(`${label}: a running session's dot ${reduced === "reduce" ? "does not move" : "breathes"}`, reduced === "reduce" ? names.running === "none" : names.running === "breathe", names.running);
check(`${label}: idle, halted and finished dots stay still`, names.idle === "none" && names.halt === "none" && names.done === "none", JSON.stringify(names));
check(`${label}: an unread finished session is told apart by its own dot, not an extra one`, names.unreadDiffers === true);
await ctx.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+59
View File
@@ -0,0 +1,59 @@
// The interface text size enlarges type and nothing else: the window keeps its
// layout, no column spills sideways at the largest step, and the control that
// sets it reaches the page.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const STEPS = [["标准", 1], ["大", 1.15], ["更大", 1.3]];
const measure = (page) => page.evaluate(() => {
const px = (sel, prop = "fontSize") => parseFloat(getComputedStyle(document.querySelector(sel))[prop]);
const box = (sel) => document.querySelector(sel).getBoundingClientRect();
const spill = [".rail", ".compose", ".panetabs", ".chrome"].map((s) => document.querySelector(s)).filter(Boolean).filter((el) => el.scrollWidth > el.clientWidth + 1).map((el) => el.className.split(" ")[0]);
return {
sessionTitle: px(".sesstitle"),
newTask: px(".studio-new-task b"),
railWidth: Math.round(box(".rail").width),
composeWidth: Math.round(box(".compose").width),
zoom: getComputedStyle(document.documentElement).getPropertyValue("--zoom"),
scale: document.documentElement.style.getPropertyValue("--fs-scale"),
spill,
};
});
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".sesstitle");
const base = await measure(page);
for (const [name, scale] of STEPS) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: "外观" }).first().click();
await page.locator('[data-action="appearance.text-size"]', { hasText: new RegExp(`^${name}$`) }).click();
await page.waitForTimeout(200);
if (shotDir && scale === 1.3) await page.screenshot({ path: `${shotDir}/${scheme}-settings-1.3.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
const m = await measure(page);
const tag = `${scheme} ${name}`;
check(`${tag}: the control reaches the page`, scale === 1 ? m.scale === "" : Number(m.scale) === scale, `--fs-scale "${m.scale}"`);
check(`${tag}: type grows by the step`, Math.abs(m.sessionTitle / base.sessionTitle - scale) < 0.03 && Math.abs(m.newTask / base.newTask - scale) < 0.03, `${base.sessionTitle}→${m.sessionTitle}, ${base.newTask}→${m.newTask}`);
check(`${tag}: the layout keeps its room`, m.railWidth === base.railWidth && m.composeWidth === base.composeWidth && m.zoom === base.zoom, `rail ${m.railWidth}/${base.railWidth}, composer ${m.composeWidth}/${base.composeWidth}`);
check(`${tag}: nothing spills sideways`, m.spill.length === 0, m.spill.join(","));
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-${scale}.png` });
}
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
@@ -0,0 +1,53 @@
// A picture sent with a message is a 240x160 thumbnail whatever its shape. A
// tall phone screenshot used to keep its own aspect ratio at the thumbnail's
// width and spill far below its box, over the message text.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?ws=1&sess=1&turns=1&pref=zh";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shape = { tall: [390, 700], wide: [1600, 400], square: [500, 500], small: [60, 40] };
const svg = (w, h) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#e8eef5"/></svg>`;
const text = "@.reasonix/attachments/clipboard-tall.png\[email protected]/attachments/clipboard-wide.png\n管理员长按成员头像弹出的踢出弹窗,成员名字下方修改为岗位\n移除成员界面成员也要显示岗位";
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1400, 520]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.route(/fixture/, (r) => {
const name = Object.keys(shape).find((k) => r.request().url().includes(k)) ?? "square";
return r.fulfill({ contentType: "image/svg+xml", body: svg(...shape[name]) });
});
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForFunction(() => window.__panes?.() > 0);
await page.evaluate((text) => window.__feed({
kind: "turn_started", authoredTurn: 9, msgIndex: 90, text,
attachments: ["tall", "wide", "square", "small"].map((n) => ({ path: `.reasonix/attachments/${n}.png` })),
}), text);
await page.locator(".call[data-k='me'] .user-image img").last().waitFor();
await page.waitForTimeout(400);
const rows = await page.evaluate(() => {
const me = [...document.querySelectorAll(".call[data-k='me']")].at(-1);
const rect = (e) => { const b = e.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
const words = [...me.querySelectorAll(".out, .txt, .bubble, p, div")].filter((e) => !e.closest(".user-images") && e.textContent.includes("管理员"));
const bubble = words.at(-1);
return { imgs: [...me.querySelectorAll(".user-image img")].map((i) => ({ box: rect(i.closest(".user-image")), img: rect(i) })), text: bubble ? rect(bubble) : null };
});
const tag = `${scheme} ${width}px`;
check(`${tag}: every picture stays inside a 240x160 thumbnail`, rows.imgs.length === 4 && rows.imgs.every(({ img }) => img.w <= 241 && img.h <= 161), JSON.stringify(rows.imgs.map((x) => [Math.round(x.img.w), Math.round(x.img.h)])));
check(`${tag}: no picture leaves its own box`, rows.imgs.every(({ box, img }) => img.t >= box.t - 1 && img.b <= box.b + 1 && img.l >= box.l - 1 && img.r <= box.r + 1));
check(`${tag}: the message text is not covered by a picture`, rows.text !== null && rows.imgs.every(({ img }) => img.b <= rows.text.t + 1 || img.t >= rows.text.b - 1 || img.r <= rows.text.l + 1 || img.l >= rows.text.r - 1));
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+1 -1
View File
@@ -123,7 +123,7 @@ await page.waitForTimeout(400);
const trajRows = await page.locator(".run-analysis").count();
check("运行分析页有内容", trajRows > 0, `${trajRows} 个`);
await page.screenshot({ path: `${SHOTS}/3-运行分析页.png` });
await page.locator('[role="tab"]').nth(0).click();
await page.locator('[data-action="pane.view"][aria-pressed="true"]').first().click();
await page.waitForTimeout(400);
const after = await page.locator('[data-pane="flow"] .call').count();
check("切回后卡片仍在", after > 0, `${after} 张`);
-40
View File
@@ -1,40 +0,0 @@
// The pane's view tabs share one highlight that moves to the chosen tab, so a
// switch reads as one motion. Where it lands is geometry, asked of a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en&turns=5`, { waitUntil: "networkidle" });
await page.waitForSelector(".pane > .tabs [role=tab]");
const tabs = page.locator(".pane > .tabs [role=tab]");
const count = await tabs.count();
for (let i = 0; i < count; i++) {
await tabs.nth(i).click();
await page.waitForTimeout(400);
const g = await page.evaluate(() => {
const thumbs = document.querySelectorAll(".pane:not([data-off]) > .tabs .vthumb");
const on = document.querySelector('.pane:not([data-off]) > .tabs [role=tab][aria-selected="true"]');
const a = thumbs[0]?.getBoundingClientRect();
const c = on?.getBoundingClientRect();
return { thumbs: thumbs.length, thumb: a && [Math.round(a.left), Math.round(a.width), Math.round(a.top), Math.round(a.height)], tab: c && [Math.round(c.left), Math.round(c.width), Math.round(c.top), Math.round(c.height)], tabBg: on && getComputedStyle(on).backgroundColor };
});
check(`${scheme} tab ${i}: one highlight, lying exactly under the chosen tab`, g.thumbs === 1 && JSON.stringify(g.thumb) === JSON.stringify(g.tab), JSON.stringify(g));
check(`${scheme} tab ${i}: the chosen tab draws no box of its own`, g.tabBg === "rgba(0, 0, 0, 0)", g.tabBg);
}
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+10
View File
@@ -1,2 +1,12 @@
# The shell names the build that is current, and the page's own out-of-date
# check reads this same file over no-store. A cached or revalidated copy makes
# an open page look behind and keeps reloading onto it. Hashed bundles under
# /assets are the opposite: they are named by content, so they never change.
/
Cache-Control: no-store
/index.html
Cache-Control: no-store
/assets/*
Cache-Control: public, max-age=31536000, immutable
+31
View File
@@ -0,0 +1,31 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-17
---
# Relay harness
A hosted page driving a real Studio through a stand-in relay.
- Real: the production page bundle, its encrypted channel, `reasonix-studio-host` with its kernel, and Chromium.
- Replaced: the accounts API and the message relay (`tools/relayfake`), and the model provider (a scripted stream in `stack.mjs`).
The page still dials `id.reasonix.io` and `remote.reasonix.io`; the browser
context answers those two hosts with the stand-ins.
```
go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host
go build -o relay/bin/relayfake ../../tools/relayfake
pnpm exec vite build --outDir dist-relay --emptyOutDir
RELAY_HOST_BIN=$PWD/relay/bin/studio-host RELAY_FAKE_BIN=$PWD/relay/bin/relayfake \
RELAY_DIST=$PWD/dist-relay node relay/live.mjs
```
`relay/storm.mjs` is the other scenario: a page served by the Studio itself (no relay), nine panes on the shared stream, and the browser going offline and back across three turns. Run it the same way, with `RELAY_DIST` pointing at the same build.
`CHROMIUM_PATH` selects a browser other than the one Playwright installed.
A change to how events travel between a page and a kernel is judged by this
run, not only by a unit test of one side. Add a scenario to `live.mjs` rather
than a second harness.
+61
View File
@@ -0,0 +1,61 @@
// A hosted page driving a real Studio through a stand-in relay. What a shipped
// page does over that link is the claim: the production bundle, the encrypted
// channel, the Studio's own host and a real kernel are all real; only the two
// Reasonix services between them are replaced (tools/relayfake).
import { chromium } from "playwright";
import { startStack, routeServices, until } from "./stack.mjs";
const need = (name) => {
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
return process.env[name];
};
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST") });
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
const failures = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
if (!ok) failures.push(name);
};
try {
const context = await browser.newContext({ viewport: { width: 1300, height: 850 } });
await routeServices(context, stack);
const page = await context.newPage();
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(stack.pageUrl());
await page.evaluate(() => { window.__survivor = true; });
await page.keyboard.press("Escape");
const pane = await stack.api("/runtimes", "POST", { root: stack.workspace });
check("a pane opens on the Studio", pane.status === 200, `status ${pane.status}`);
const composer = page.getByLabel("Task input").first();
await composer.waitFor({ timeout: 30_000 });
const text = () => page.evaluate(() => document.body.innerText);
const count = async (word) => ((await text()).match(new RegExp(word, "g")) ?? []).length;
const send = async (message) => {
await composer.fill(message);
await composer.press("Enter");
};
await send("first");
const live = await until("the reply to stream onto the page", async () => (await count("word11")) >= 1, 20_000).then(() => true, () => false);
check("a reply streams onto the page over the relay", live, `provider saw ${stack.provider.requests.length} request(s)`);
const dropped = await stack.ctl("drop-controllers", "POST");
check("the relay drops the page's socket", dropped.dropped === 1, JSON.stringify(dropped));
await until("the page to reconnect", async () => (await stack.ctl("state")).controllers === 1, 30_000).catch(() => {});
await send("second");
const after = await until("a reply after the reconnect", async () => (await count("word11")) >= 2, 40_000).then(() => true, () => false);
check("a reply streams onto the page after the relay drops the socket", after);
check("the page was never reloaded", await page.evaluate(() => window.__survivor === true));
check("the page threw no error", pageErrors.length === 0, pageErrors.join(" | "));
} catch (error) {
check("the scenario ran to the end", false, String(error));
} finally {
await browser.close();
await stack.stop();
}
process.exit(failures.length ? 1 : 0);
+172
View File
@@ -0,0 +1,172 @@
import http from "node:http";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function until(what, probe, ms = 20_000) {
const end = Date.now() + ms;
for (;;) {
const value = await probe();
if (value) return value;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await wait(100);
}
}
function firstLine(child, what) {
return new Promise((resolve, reject) => {
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk;
const at = buffer.indexOf("\n");
if (at >= 0) resolve(buffer.slice(0, at));
});
child.once("exit", (code) => reject(new Error(`${what} exited (${code}) before it was ready`)));
});
}
function scriptedProvider(words, gapMs) {
const requests = [];
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
if (!req.url.includes("chat/completions")) {
res.writeHead(404);
res.end("{}");
return;
}
requests.push({ at: Date.now(), body });
res.writeHead(200, { "content-type": "text/event-stream" });
const chunk = (delta) =>
`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta }] })}\n\n`;
let n = 0;
const timer = setInterval(() => {
if (n < words) {
res.write(chunk({ content: `word${n++} ` }));
return;
}
clearInterval(timer);
res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } })}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}, gapMs);
res.on("close", () => clearInterval(timer));
});
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, requests, port: server.address().port })));
}
function pageServer(dist) {
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".json": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png" };
const server = http.createServer((req, res) => {
const target = path.join(dist, decodeURIComponent(new URL(req.url, "http://x").pathname));
const file = target.startsWith(dist) && fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(dist, "index.html");
res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" });
fs.createReadStream(file).pipe(res);
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, origin: `http://127.0.0.1:${server.address().port}` })));
}
/**
* Starts everything between a hosted page and a Studio except the page's browser:
* the real studio-host binary on a fresh data home, a stand-in accounts API and
* relay, a scripted model provider, and a static server for the built page.
*/
export async function startStack({ hostBin, fakeBin, dist, servePage = false, words = 12, gapMs = 120 }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "relay-stack-"));
const home = path.join(root, "home");
const workspace = path.join(root, "workspace");
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(workspace, { recursive: true });
const provider = await scriptedProvider(words, gapMs);
fs.writeFileSync(path.join(home, "config.toml"), [
'language = "en"',
'default_model = "fake"',
`providers = [{ name = "fake", kind = "openai", base_url = "http://127.0.0.1:${provider.port}/v1", model = "fake-m", api_key_env = "FAKE_KEY" }]`,
"",
].join("\n"));
fs.writeFileSync(path.join(home, ".env"), "FAKE_KEY=fixture\n");
const fake = spawn(fakeBin, [], { stdio: ["ignore", "pipe", "inherit"] });
const urls = JSON.parse(await firstLine(fake, "relayfake"));
const ctl = async (route, method = "GET") => (await fetch(`${urls.accounts}/ctl/${route}`, { method })).json();
const host = spawn(hostBin, servePage ? ["-page", dist] : [], {
stdio: ["pipe", "pipe", "inherit"],
env: {
...process.env, HOME: root, USERPROFILE: root, REASONIX_HOME: home, REASONIX_STATE_HOME: home,
REASONIX_ACCOUNTS_URL: urls.accounts, REASONIX_REMOTE_URL: urls.relay, REASONIX_ACCOUNT_TOKEN: "fixture-token",
FAKE_KEY: "fixture",
},
});
const handshake = JSON.parse(await firstLine(host, "studio-host"));
const state = await until("the Studio to reach the relay", async () => {
const s = await ctl("state");
return s.deviceId && s.deviceOnline ? s : null;
});
const page = await pageServer(dist);
const api = async (route, method = "GET", body) => {
const response = await fetch(handshake.origin + route, {
method,
headers: { cookie: `reasonix_token=${handshake.token}`, origin: handshake.origin, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, json: await response.json().catch(() => null) };
};
return {
urls, deviceId: state.deviceId, pageOrigin: page.origin, hostOrigin: handshake.origin, hostToken: handshake.token, workspace, provider, ctl, api,
pageUrl: () => `${page.origin}/?device=${state.deviceId}`,
async stop() {
const gone = new Promise((resolve) => host.once("exit", resolve));
host.stdin.end();
host.kill();
await gone;
fake.kill();
page.server.close();
provider.server.close();
fs.rmSync(root, { recursive: true, force: true });
},
};
}
/**
* Points a browser context's two Reasonix services at the stand-ins. The page is
* the production build, so it still dials id.reasonix.io and remote.reasonix.io;
* the requests and the socket are answered here. The socket is a plain pipe:
* the page and the Studio speak their own encrypted protocol across it.
*/
export async function routeServices(context, stack) {
await context.route("https://id.reasonix.io/**", async (route) => {
const request = route.request();
const cors = {
"access-control-allow-origin": request.headers().origin ?? "*",
"access-control-allow-credentials": "true",
"access-control-allow-headers": "content-type",
};
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: cors });
return;
}
const answer = await fetch(stack.urls.accounts + new URL(request.url()).pathname, {
method: request.method(),
headers: { "content-type": "application/json" },
body: request.postData() ?? undefined,
});
await route.fulfill({ status: answer.status, headers: { ...cors, "content-type": "application/json" }, body: await answer.text() });
});
await context.routeWebSocket(/remote\.reasonix\.io/, (page) => {
const upstream = new WebSocket(`${stack.urls.relay}/v1/sessions/connect?fixture=1`, "reasonix.remote.v1");
const queued = [];
upstream.onopen = () => queued.splice(0).forEach((message) => upstream.send(message));
upstream.onmessage = (event) => page.send(String(event.data));
upstream.onclose = (event) => page.close({ code: event.code >= 1000 && event.code < 5000 ? event.code : 1011, reason: event.reason });
page.onMessage((message) => (upstream.readyState === 1 ? upstream.send(message) : queued.push(message)));
page.onClose(() => upstream.close());
});
}
+109
View File
@@ -0,0 +1,109 @@
// A real page on a real Studio while its network flaps. Nine panes stream on one
// multiplexed connection while the browser keeps going offline and back across
// three turns, so the flaps land on turn starts and ends. What is judged is what
// the person sees on the pane they have open, read without navigating: raw frames
// are not the claim, because a page may recover a lost one from a snapshot.
import fs from "node:fs";
import path from "node:path";
import { chromium } from "playwright";
import { startStack, until } from "./stack.mjs";
const need = (name) => {
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
return process.env[name];
};
const PANES = 9;
const TURNS = 3;
const WORDS = 15;
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST"), servePage: true, words: WORDS, gapMs: 100 });
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
const failures = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
if (!ok) failures.push(name);
};
try {
const panes = [];
for (let i = 0; i < PANES; i++) {
const root = path.join(stack.workspace, `p${i}`);
fs.mkdirSync(root);
panes.push((await stack.api("/runtimes", "POST", { root })).json);
}
const context = await browser.newContext({ viewport: { width: 1300, height: 1400 } });
await context.addCookies([{ name: "reasonix_token", value: stack.hostToken, url: stack.hostOrigin }]);
await context.addInitScript(() => {
const Native = window.EventSource;
window.__streams = [];
window.EventSource = class extends Native {
constructor(url, options) {
super(url, options);
window.__streams.push(String(url));
}
};
});
const page = await context.newPage();
if (process.env.RELAY_DEBUG) {
page.on("console", (m) => console.log("[console]", m.type(), m.text().slice(0, 160)));
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
page.on("framenavigated", (f) => console.log("[navigated]", f.url()));
}
await page.goto(stack.hostOrigin + "/");
await page.keyboard.press("Escape");
await until("the sidebar to list every project", async () => (await page.getByText(/^p\d$/).count()) >= PANES, 30_000).catch(() => {});
const turnOn = async (turn) => {
await Promise.all(panes.map((p, i) => stack.api(`${p.base}/submit`, "POST", { input: `pane${i} turn ${turn}` })));
return until(`turn ${turn} to finish on every pane`, async () => {
const states = await Promise.all(panes.map(async (p) => (await stack.api(`${p.base}/status`)).json));
return states.every((st) => st && st.running === false);
}, 60_000).then(() => true, () => false);
};
let settled = await turnOn(0);
await page.getByText(/^pane0 turn/).first().click({ timeout: 15_000 }).catch(async () => {
await page.getByText("p0", { exact: true }).first().click();
await page.getByText(/^pane0 turn/).first().click();
});
await page.waitForTimeout(1500);
if (process.env.RELAY_SHOT) await page.screenshot({ path: process.env.RELAY_SHOT.replace('.png', '-before.png') });
let flapping = true;
const flaps = (async () => {
while (flapping) {
await context.setOffline(true);
await new Promise((resolve) => setTimeout(resolve, 200));
await context.setOffline(false);
await new Promise((resolve) => setTimeout(resolve, 400));
}
})();
for (let turn = 1; turn <= TURNS && settled; turn++) settled = await turnOn(turn);
flapping = false;
await flaps;
await context.setOffline(false);
check("every pane finished every turn on the Studio", settled);
const streams = await page.evaluate(() => window.__streams);
check("no pane held a stream of its own", streams.every((u) => !/\/rt\/.*\/events/.test(u)), streams.filter((u) => /\/rt\//.test(u)).join(" "));
// The open pane is read where it stands, with no navigation: opening a pane
// reloads it from its transcript, which would hide a live stream that stopped.
// Recovery after the last flap has a bounded time to finish.
const read = () => page.evaluate(() => document.body.innerText);
const done = await until("the open pane to settle", async () => /\bIdle\b|Done/.test(await read()), 30_000).then(() => true, () => false);
const text = await read();
const replies = (text.match(new RegExp(`word${WORDS - 1}`, "g")) ?? []).length;
check("the open pane shows every reply it was sent while the network flapped", replies >= TURNS + 1, `${replies} of ${TURNS + 1} replies`);
check("the open pane returns to idle", done);
if (process.env.RELAY_SHOT) {
await page.mouse.move(700, 700);
await page.waitForTimeout(2500);
await page.screenshot({ path: process.env.RELAY_SHOT });
}
} catch (error) {
check("the scenario ran to the end", false, String(error));
} finally {
await browser.close();
await stack.stop();
}
process.exit(failures.length ? 1 : 0);
+47 -3
View File
@@ -62,10 +62,18 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.live-grace", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.legacy-sessions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.open-session-limit", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.live-stream-budget", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.configure", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-search", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-navigate", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-select", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-open", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
@@ -104,6 +112,7 @@ export const ACTIONS: UIAction[] = [
// Closing a conversation's pane ends its runtime and keeps the transcript;
// refused while a turn runs, so it never stops work.
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.branch", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
@@ -131,7 +140,14 @@ export const ACTIONS: UIAction[] = [
// live turn the first steers and the second cancels the current turn.
{ id: "session.send", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.stop", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "tool.bring", kind: "view", target: "entity", proof: "interaction" },
{ id: "tool.unbring", kind: "view", target: "entity", proof: "interaction" },
{ id: "tool.stop", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.open-match", kind: "navigation", target: "entity", proof: "interaction" },
{ id: "job.cancel", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "job.tip", kind: "view", target: "entity", proof: "interaction" },
{ id: "job.view", kind: "view", target: "entity", proof: "interaction" },
{ id: "job.close", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
// A rewrite is asked of the kernel but changes nothing there; taking it only
// replaces what is in the box.
@@ -221,6 +237,7 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
{ id: "side.unquote", kind: "view", target: "none", proof: "interaction" },
@@ -245,6 +262,10 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
@@ -381,6 +402,7 @@ export const ACTIONS: UIAction[] = [
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workspace.open-with", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.open-with-menu", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -481,6 +503,7 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-args", kind: "view", target: "none", proof: "interaction" },
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "hooks.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "hooks.run-once", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -522,6 +545,7 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
@@ -558,6 +582,7 @@ export const ACTIONS: UIAction[] = [
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
{ id: "workbench.rename-tab", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.open-file", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
@@ -598,6 +623,28 @@ export const ACTIONS: UIAction[] = [
{ id: "sandbox.add-write-root", kind: "kernel-mutation", target: "optional", proof: "authority-effect" },
{ id: "sandbox.remove-write-root", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "size.set", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "size.keep", kind: "view", target: "none", proof: "interaction" },
{ id: "size.restore", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "tree.retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "update.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "update.restart", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "update.later", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "skills.update-close", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-overwrite", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-reload", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-apply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "subagent-claims.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "write-lease.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "plan-approval.auto", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.target", kind: "view", target: "none", proof: "interaction" },
{ id: "peers.text", kind: "view", target: "none", proof: "interaction" },
{ id: "peers.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "peers.send", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.approve", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.deny", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
{ id: "auto-archive.notice-dismiss", kind: "view", target: "none", proof: "interaction" },
@@ -619,9 +666,6 @@ export const ACTIONS: UIAction[] = [
{ id: "stall.dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.remove", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "provider.protocol", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "provider.web-search", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "provider.thinking", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "provider.continuation", kind: "kernel-mutation", target: "entity", proof: "interaction" },
// ── The two side panels. Reached from the keyboard here, and from each
// gutter's own grip, which this pass has not annotated yet.
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { HttpError } from "../port/http_error";
import { STORAGE, boot } from "./index";
import { reason } from "./kernel";
const refused = new HttpError(409, "config does not parse", { code: "config.unparsed", params: { path: "C:\\Users\\a\\.reasonix\\config.toml", line: 12 } });
describe("a settings save refused because the config file does not parse", () => {
it("says where to see and repair it, not only that it failed", () => {
expect(reason(refused)).toContain("设置");
});
it("names the file that did not parse", () => {
expect(reason(refused)).toContain("C:\\Users\\a\\.reasonix\\config.toml");
});
it("says the same in English", () => {
localStorage.setItem(STORAGE, "en");
boot();
try {
expect(reason(refused)).toContain("Settings");
expect(reason(refused)).toContain("config.toml");
} finally {
localStorage.setItem(STORAGE, "zh");
boot();
}
});
});
+63 -5
View File
@@ -22,6 +22,7 @@ import { EN_COMMUNITY } from "./en_community";
import { EN_SPONSOR } from "./en_sponsor";
export const EN: Record<string, string> = {
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
...EN_SURFACES,
"重命名聊天": "Rename chat",
"保持简短且易于识别": "Keep it short and recognizable",
@@ -62,6 +63,7 @@ export const EN: Record<string, string> = {
"收回 {rule}": "Take back {rule}",
"放大第 {n} 张": "Enlarge image {n}",
"工具截图 {n}": "Tool screenshot {n}",
"视频 {name}({detail})已原生发送给 {model}": "Video {name} ({detail}) sent to {model} as a video file",
"查看图片 {name}": "Preview image {name}",
"关闭图片预览": "Close image preview",
"放大的工具截图": "The enlarged tool screenshot",
@@ -135,6 +137,12 @@ export const EN: Record<string, string> = {
// ── 运行状态 ─────────────────────────────────────────────────────
"空闲": "Idle",
"运行中": "Running",
"只停止这一条调用": "Stop only this call",
"把结果带到末尾": "Show this result again at the end",
"带到末尾的结果": "Brought to the end",
"正在搜索对话内容…": "Searching conversation text…",
"对话内容暂时搜不了,稍后再试": "Conversation text cannot be searched right now; try again shortly",
"对话内容匹配": "Matches in conversation text",
"声明推理档位": "Declare reasoning levels",
"这个端点没有报告推理档位,中转站通常不转发这项能力。将打开该来源的编辑表单,可以选择思考参数或直接填写档位。": "This endpoint reported no reasoning levels; relays often do not forward them. Opens this source’s editor, where you can pick a thinking parameter or list the levels yourself.",
"本轮已发送 / 已接收的 token": "Tokens sent and received this turn",
@@ -321,6 +329,7 @@ export const EN: Record<string, string> = {
// ── 会话树 ───────────────────────────────────────────────────────
"新会话": "New session",
"标签数已到上限 {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}",
"从左栏选择,或在当前文件夹新建": "Pick one on the left, or start a new one in this folder",
@@ -352,6 +361,8 @@ export const EN: Record<string, string> = {
"删除主题「{name}」": "Delete theme \"{name}\"",
"删除这份恢复副本?": "Delete this recovery copy?",
"连同其记录一并删除": "Deletes it together with its record",
"还有 {n} 份恢复副本带着这个会话没有的内容,直接删除会保留它们": "{n} recovery copies hold content this conversation lacks; a plain delete keeps them",
"连同 {n} 份副本删除": "Delete with {n} copies",
"恢复副本": "Recovery",
"早先版本 · {n} 轮": "Earlier version · {n} turns",
"删除这个早先版本?": "Delete this earlier version?",
@@ -427,7 +438,9 @@ export const EN: Record<string, string> = {
"更大": "Larger",
"小": "Small",
"大": "Large",
"「界面」会同时缩放边距与控件,「正文」仅调整对话中的文字大小,两者独立设置。": "Interface scales spacing and controls together; Body text changes only the text in the conversation. The two are set independently.",
"「界面」会同时缩放边距与控件,「界面文字」只放大侧栏、设置等处的文字,「正文」仅调整对话中的文字大小,三者独立设置。": "Interface scales spacing and controls together; Interface text enlarges only the text in the sidebar, settings and the like; Body text changes only the text in the conversation. The three are set independently.",
"界面文字": "Interface text",
"界面文字大小": "Interface text size",
"字体": "Typeface",
"等宽": "Monospace",
"清除": "Clear",
@@ -600,6 +613,13 @@ export const EN: Record<string, string> = {
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
"在代码编辑器中打开工作区": "Open the workspace in a code editor",
"在系统文件管理器中显示工作区": "Show the workspace in the system file manager",
"用其他程序打开项目": "Open the project in another program",
"没有找到可用的程序": "No supported program found",
"文件管理器": "File manager",
"终端": "Terminal",
"在系统文件管理器中显示此文件": "Show this file in the system file manager",
"只读 · 来自你授权的文件夹": "Read-only · from a folder you granted",
"重命名页签": "Rename tab",
"显示隐藏文件": "Show hidden files",
"在访达中显示": "Reveal in Finder",
"在文件资源管理器中显示": "Reveal in File Explorer",
@@ -646,6 +666,11 @@ export const EN: Record<string, string> = {
"编辑": "Edit",
"测试中…": "Testing…",
"测试连接": "Test it",
"代理": "Proxy",
"域名解析": "Name resolution",
"建立连接": "Connection",
"加密握手": "TLS handshake",
"凭据": "Credentials",
"保存失败": "Save failed",
"已保存,尚未生效": "Saved, not applied yet",
"读取模型列表失败": "Could not read the model list",
@@ -712,12 +737,16 @@ export const EN: Record<string, string> = {
"指定一个可执行文件": "Point at an executable",
"取消指定": "Clear it",
"可执行文件路径": "Path to the executable",
"启动命令": "Launch command",
"保存的启动参数现在无法运行,已回退到自动检测。": "The saved launcher arguments no longer run, so auto-detection is in use.",
"启动参数(可选,每行一项)": "Launcher arguments (optional, one per line)",
"例如 wsl.exe 需要 -d <发行版> -- /bin/bash 才会进入 shell。参数按原样传给程序,不经过任何 shell;保存前会带着它们运行一次来验证。": "For example wsl.exe needs -d <distro> -- /bin/bash to reach a shell. Arguments are passed to the program as written, through no shell; they are run once to verify before saving.",
"自行编译的 bash、MSYS2,或安装在其他位置的 pwsh 均可填写于此。保存前会实际执行一条命令进行验证,无法运行则不会写入配置。":
"A bash you built yourself, MSYS2, a pwsh installed elsewhere — all go here. Before saving, one command is really run through it; if that fails, nothing is written to the config.",
"切换失败": "Not changed",
"够不着这个工作目录。": "cannot reach this working directory.",
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项 —— WSL 中的 bash 不适用,它看到的是 /mnt 下的另一套路径,无法访问当前工作目录。":
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here — the one inside WSL does not count: it sees a different set of paths under /mnt and cannot reach this working directory.",
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项。要用 WSL,请在下方指定 wsl.exe 并填写启动参数;其中的路径是 Linux 写法。":
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here. To use WSL, point at wsl.exe below and add its launcher arguments; paths inside it are Linux paths.",
"无法读取 shell 配置。": "Could not read the shell config.",
// ── 卡片:批准、还原、收工 ───────────────────────────────────────
@@ -1069,6 +1098,9 @@ export const EN: Record<string, string> = {
"外部服务 {name} 提供的工具": "A tool provided by the external service {name}",
"这次调用解析到的能力目录条目": "The capability catalogue entry this call resolved to",
"在这段对话里查找": "Find in this conversation",
"显示对话": "Show conversation",
"显示运行分析": "Show run analysis",
"显示工作台": "Show workbench",
"无结果": "No matches",
"上一处": "Previous match",
"下一处": "Next match",
@@ -1159,6 +1191,7 @@ export const EN: Record<string, string> = {
// ── 回合中的通知(notices.ts 按 code 查表,扫描器看不到)──────
"这一轮模型没有给出回答,正在让它重说一次": "The model gave no answer this round; asking it again",
"模型直接给了答案却没动手,正在要求它先用工具": "The model answered without acting; asking it to use its tools first",
"分支保存的模型已不可用,本次改用其他已配置的模型;可以在模型设置中重新选择": "The model saved for this branch is unavailable, so another configured model is in use; choose a model again in settings",
"同一个检查连着几轮都报同样的结果,是否继续由你定":
"The same check has reported the same thing for several rounds; whether to keep at it is yours to call",
"另一个会话正在写这个工作区,等它写完会自动继续":
@@ -1291,12 +1324,30 @@ export const EN: Record<string, string> = {
"置顶会话": "Pin session",
"删除会话": "Delete session",
"会话范围": "Session scope",
"按名称筛选会话": "Filter sessions by name",
"运行统计": "Run metrics",
"本轮": "This turn",
"详情": "Details",
"分离状态": "detached",
"新版本 {v} 已下载。重启后使用,不会自动重启。": "Version {v} is downloaded. Restart to use it; Studio never restarts by itself.",
"重启并更新": "Restart and update",
"有 {n} 个会话还在运行,等它们结束后再重启。": "{n} sessions are still running; restart after they finish.",
"仍在读取(会话很多时首次较慢)": "Still reading (the first read is slow with many sessions)",
"界面是不是偏小?可以在这里放大,之后也能在「外观」里调整。": "Is the interface too small? You can enlarge it here and change it later under Appearance.",
"界面已改为 {n}%。合适吗?": "The interface is now {n}%. Does that look right?",
"恢复 {n}%": "Restore {n}%",
"不用了": "No thanks",
"好": "OK",
"后台任务输出:{name}": "Background task output: {name}",
"查看输出:{cmd}": "View output: {cmd}",
"查看输出": "View output",
"停止此任务": "Stop this task",
"复制输出": "Copy output",
"这个任务已经结束,没有可显示的输出了。": "This task has ended; there is no output left to show.",
"只显示最后一部分(共 {total})。": "Showing only the last part ({total} in all).",
"还没有输出": "No output yet",
"当前 Git 分支:{branch}": "Current Git branch: {branch}",
"正在切换到 {branch}…": "Switching to {branch}…",
"这张图片在工作区之外,不会加载": "This picture is outside the workspace and is not loaded",
"{n} 个变更": "{n} changes",
"1 个文件已更改": "1 file changed",
"{n} 个文件已更改": "{n} files changed",
@@ -1356,8 +1407,10 @@ export const EN: Record<string, string> = {
"复制选中内容": "Copy selected text",
"选中内容": "Selected text",
"重新生成": "Regenerate",
"分支为新会话": "Branch into a new conversation",
"创建对话分支": "Create a conversation branch",
"分支": "branch",
"从最近回复分叉": "Branch from the latest reply",
"还没有已完成的回复可以分叉": "There is no finished reply to branch from yet",
"当前回复会留在运行历史里": "The current reply stays in the run history",
"按当前配置重试": "Retry as configured",
"先调整模型与强度": "Adjust the model and effort first",
@@ -1508,12 +1561,17 @@ export const EN: Record<string, string> = {
"后台任务已结束:{name}": "Background job finished: {name}",
"后台任务已终止:{name}": "Background job killed: {name}",
"后台任务 {name} 失败,需要处理": "Background job {name} failed: needs attention",
"后台任务 {name} 已有 {quiet} 没往自己的输出里写任何内容:可能是在写文件,也可能卡住了;可以用 wait 或 bash_output 查看,或用 kill_shell 结束它":
"Background job {name} has written nothing to its own output for {quiet}: it may be writing to a file, or stuck; check it with wait or bash_output, or stop it with kill_shell",
"被信号 {signal} 终止": "Ended by signal {signal}",
"退出码 {code}": "Exit code {code}",
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
"这个会话保存的压缩结果没有被复用:它所总结的历史之后发生了变化,现在发送的是完整历史,可能超出模型容量": "This conversation's saved compaction was not reused: the history it summarised has changed since. The full history is being sent, which may exceed the model's window.",
"这个会话保存的压缩结果是在另一个模型或工作区下生成的,与当前历史对不上,没有被复用,现在发送的是完整历史": "This conversation's saved compaction was made under another model or workspace and does not match its history, so it was not reused. The full history is being sent.",
"这些工作区在同一个 Git 仓库 {repo} 内,同一仓库共用一个写入域;把项目移出该仓库,或在用户配置里设 write_lease = \"optimistic\",才能并行写入": "These workspaces are inside the same Git repository, {repo}, and one repository is one write domain; move the project out of it, or set write_lease = \"optimistic\" in your user config, to write in parallel",
"配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个": "The question wait time in the config file is not valid (it must be a whole number, 0 or 10 to 86400), so questions wait for you as long as it takes; set it again in Settings.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
"扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展": "The companion background program of extension {ext} is not running, so the extension was skipped this time (at {point}). Open Tools and integrations to check and start it, or disable the extension.",
+25 -1
View File
@@ -1,6 +1,23 @@
// The turn starts here: drafting, attachments, run posture, and the two actions
// that remain available while an agent is live.
export const EN_COMPOSER: Record<string, string> = {
"配置工作目录": "Configure workspace",
"独立 worktree · {branch}": "New worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}",
"新建 worktree": "New worktree",
"为此会话创建独立的代码目录": "Create a separate code folder for this session",
"起始分支": "Source branch",
"任务分支": "Task branch",
"自动创建": "Create automatically",
"基于所选分支的已提交版本创建,不包含未提交修改": "Start from the selected branch's committed version, without uncommitted changes",
"使用当前目录,与该目录中的其他会话共享文件": "Use the current folder, sharing files with other sessions in it",
"选择起始分支": "Choose source branch",
"搜索分支": "Search branches",
"搜索分支…": "Search branches…",
"远程分支": "Remote branches",
"当前分支": "Current branch",
"没有匹配的分支": "No matching branches",
"分支读取失败,请关闭后重试": "Could not read branches. Close and try again",
"不思考": "No reasoning",
"最轻量": "Minimal",
"比快速档更轻;GLM-5.2 会跳过思考": "Lighter than Fast; GLM-5.2 skips thinking",
@@ -43,6 +60,13 @@ export const EN_COMPOSER: Record<string, string> = {
"当前分支 · {branch}": "Current branch · {branch}",
"非 Git 仓库": "No Git repository",
"此工作区未受版本控制": "This workspace is not version-controlled",
"Git 拒绝打开": "Git refused to open it",
"Git 未响应": "Git did not respond",
"这个文件夹有 Git 仓库,但 Git 拒绝打开它": "This folder has a Git repository, but Git refused to open it",
"这个文件夹有 Git 仓库,但 Git 没有及时响应": "This folder has a Git repository, but Git did not respond in time",
"Git 说:{detail}": "Git said: {detail}",
"请在终端里运行 git status 查看原因": "Run git status in a terminal to see why",
"分支暂时无法显示。重新打开这个文件夹,或重启 Studio 后再试": "Branches cannot be shown for now. Reopen this folder, or restart Studio, and try again",
"这里没有 Git 仓库,因此没有分支可显示或切换": "There is no Git repository here, so there is no branch to show or switch to",
"HEAD 分离 · {sha}": "Detached HEAD · {sha}",
"本地分支": "Local branches",
@@ -93,6 +117,6 @@ export const EN_COMPOSER: Record<string, string> = {
"账号:{name}": "Account: {name}",
"目录": "folder",
"资源": "resource",
"Tab 补全或进入目录 · ↑↓ 后回车确认 · Esc 关闭": "Tab completes or enters a folder · ↑↓ then Enter confirms · Esc closes",
"↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭": "↑↓ selects · Enter references · Tab completes or enters a folder · Esc closes",
"↑↓ 选择 · Tab 或回车补全 · Esc 关闭": "↑↓ selects · Tab or Enter completes · Esc closes",
};
@@ -1,6 +1,11 @@
// English for the in-app feedback dialog and its refusals.
export const EN_FEEDBACK: Record<string, string> = {
"反馈": "Feedback",
"新回复": "New replies",
"未结束": "Open",
"已结束": "Closed",
"按进展筛选": "Filter by progress",
"这一组里没有反馈。": "No reports in this group.",
"已完成:": "Done: ",
"当前:": "Current: ",
"尚未开始:": "Not yet: ",
@@ -144,6 +149,8 @@ export const EN_FEEDBACK: Record<string, string> = {
"{n} 项待查看": "{n} to review",
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
"回复已发送。": "Reply sent.",
"也可以直接粘贴。PNG 或 JPEG,最多 {n} 张,每张不超过 {size} MB。": "You can also paste. PNG or JPEG, up to {n}, each up to {size} MB.",
"截图先只发给维护者,由维护者决定是否公开。": "Screenshots go to the maintainers only, who decide whether to make them public.",
"反馈等级": "Feedback level",
"升级进度": "Progress to the next level",
"新种": "New seed",
@@ -176,4 +183,20 @@ export const EN_FEEDBACK: Record<string, string> = {
"约 {n} 分钟后重置。": "It resets in about {n} min.",
"约 {n} 小时后重置。": "It resets in about {n} h.",
"这份反馈的回复次数已到上限,不会自动重置。如有新的情况,可以另外提交一条反馈。": "This report has reached its reply limit, and it does not reset. If something new comes up, send another report.",
"账号关联": "Account link",
"关联后,这台电脑的反馈等级会显示在网页账号页上。关联不会改变额度,随时可以解除。": "Once linked, this computer's feedback level shows on your account page on the web. Linking does not change your limits, and you can unlink at any time.",
"先在设置里登录账号,才能关联。": "Sign in to your account in Settings first, then you can link.",
"发出第一条反馈之后才能关联这台电脑。": "You can link this computer after you have sent your first report.",
"这台电脑已关联": "This computer is linked",
"解除关联": "Unlink",
"把这台电脑关联到我的账号": "Link this computer to my account",
"账号下的其他电脑": "Other computers on this account",
"解除": "Unlink",
"没能连上反馈服务,请稍后重试。": "Could not reach the feedback service. Please try again later.",
"需要先登录账号。登录状态可能已过期,请在设置里重新登录。": "Sign in to your account first. Your sign-in may have expired; sign in again in Settings.",
"这台电脑已经关联到另一个账号。请先用那个账号解除关联。": "This computer is already linked to another account. Unlink it from that account first.",
"这个账号关联的电脑已达上限。请先解除一台不用的电脑。": "This account has reached its limit of linked computers. Unlink one you no longer use.",
"这台电脑还没有发过反馈,发出第一条之后才能关联账号。": "This computer has not sent a report yet. You can link it to an account after the first one.",
"账号关联暂时不可用,请稍后再试。": "Account linking is unavailable right now. Please try again later.",
"操作没有完成,请稍后重试。": "That did not finish. Please try again later.",
};
+119 -3
View File
@@ -3,11 +3,16 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"起始分支已不存在,请重新选择分支": "The source branch no longer exists. Choose another branch",
"会话或工作目录已改变,请新建会话后重新选择 worktree": "The session or workspace changed. Start a new session to select a worktree",
"请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
"未配置文件访问策略,无法执行此文件操作。": "File access policy is not configured; this file operation cannot run.",
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "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 target file: permission denied.",
"读视频需要本机装有 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.",
"视频文件太大,超过了能读取的上限。": "The video file is larger than the limit for reading.",
"无法覆盖目标文件:没有权限读取原文件内容。": "Cannot overwrite target file: permission to read existing contents is required.",
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
"无法连接内核,这次操作可能没有送达,重试前请先确认": "The kernel cannot be reached; the action may not have been delivered - check before retrying",
@@ -33,6 +38,9 @@ export const EN_KERNEL: Record<string, string> = {
"该会话还没有可用于命名的用户消息": "This chat has no user message to name it from",
"未配置可用于自动命名的模型,请先配置模型": "Configure a model before using automatic naming",
"自动命名失败,原标题未更改,请重试": "Automatic naming failed. The original title is unchanged. Please try again",
"自动命名超时:标题模型没有及时回答,原标题未更改,请稍后重试": "Automatic naming timed out: the title model did not answer in time. The original title is unchanged. Please try again later",
"自动命名失败:标题模型没有返回任何文字,原标题未更改": "Automatic naming failed: the title model returned no text. The original title is unchanged",
"自动命名失败:标题模型的接口拒绝了请求(HTTP {status}){detail}": "Automatic naming failed: the title model's endpoint refused the request (HTTP {status}) {detail}",
"会话标题保存失败,请重试": "The session title could not be saved. Please try again",
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
@@ -46,6 +54,10 @@ export const EN_KERNEL: Record<string, string> = {
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
"这份反馈现在不接收回复": "This report takes no reply right now",
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
"需要先登录账号": "Sign in to your account first",
"这台电脑已关联到另一个账号": "This computer is already linked to another account",
"这个账号关联的电脑已达上限": "This account has reached its limit of linked computers",
"这台电脑还没有发过反馈": "This computer has not sent a report yet",
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
"反馈内容过大,请缩短文字或减少截图": "That feedback is too large - shorten the text or attach fewer screenshots",
@@ -69,7 +81,14 @@ export const EN_KERNEL: Record<string, string> = {
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
// 待送达:条目、队列、这份存档各自会拒
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
"事件流连接已经关闭,需要重新打开并重新订阅各窗格": "The event stream has closed; open a new one and subscribe each pane again",
"这个窗格由另一台机器驱动,使用它自己的事件流": "This pane is driven from another machine and keeps its own event stream",
"这个后台任务已经不在运行了": "That background job is no longer running",
"这条调用已经结束了": "This call has already finished",
"你停止了这条调用,它在外部已经做了什么无法确定": "You stopped this call; what it had already done outside the workspace is unknown",
"请输入要搜索的内容": "Enter something to search for",
"对话内容索引暂时无法回答,稍后再试": "The conversation index cannot answer right now; try again shortly",
"已经请求停止这个后台任务,它的进程还没有退出,仍在等待": "A stop was already requested for this background job; its process has not exited yet, so it is still shutting down",
"该条当前状态不允许此操作": "That entry's current state does not allow this",
"待送达已暂停,请先恢复派发再进行操作": "The pending queue is on hold - resume it first",
"待送达条数已达上限,请先发送部分内容": "The pending queue is full - let a few go out first",
@@ -80,7 +99,7 @@ export const EN_KERNEL: Record<string, string> = {
"该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here",
"该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content",
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时
"配置文件无法读取,因此本次未保存": "The config file does not parse, so nothing was saved",
"配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
"这台服务器未开放配置编辑": "This server does not allow editing its config",
"该文件需手动修改:{detail}": "This file needs an edit by hand: {detail}",
"设置已写入,但运行时未能按新设置重建:{detail}": "The settings were written, but the runtime could not be rebuilt on them: {detail}",
@@ -89,6 +108,14 @@ export const EN_KERNEL: Record<string, string> = {
"沙箱设置未能保存:{detail}": "The sandbox settings were not saved: {detail}",
"内置浏览器设置未能保存:{detail}": "The built-in browser setting was not saved: {detail}",
"本次请求未说明开关状态,未做任何修改": "The request did not say on or off, so nothing changed",
"这台服务器未开放子代理写入范围设置": "This server does not open the sub-agent write scope setting",
"这台服务器未开放多会话写入设置": "This server does not open the multi-session write setting",
"多会话写入设置未能保存:{detail}": "The multi-session write setting was not saved: {detail}",
"这台服务器未开放计划批准设置": "This server does not open the plan approval setting",
"计划批准设置未能保存:{detail}": "The plan approval setting was not saved: {detail}",
"子代理写入范围设置未能保存:{detail}": "The sub-agent write scope setting was not saved: {detail}",
"本次请求未说明选择,未做任何修改": "The request did not say which, so nothing changed",
"这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写": "This path is in use by another parallel sub-agent or write. This write did not run; choose another path, or wait until it finishes",
"无法解析该服务器声明:{detail}": "That server declaration could not be read: {detail}",
"未能安装该服务器:{detail}": "That server could not be installed: {detail}",
"未能移除该服务器:{detail}": "That server could not be removed: {detail}",
@@ -96,6 +123,30 @@ export const EN_KERNEL: Record<string, string> = {
"该钩子未能运行:{detail}": "That hook did not run: {detail}",
"未能删除该条记忆:{detail}": "That memory could not be removed: {detail}",
"网络设置未能保存:{detail}": "The network settings were not saved: {detail}",
"不走代理(已关闭)": "Not using a proxy (turned off)",
"这个地址直连,不走代理": "This address is reached directly, without a proxy",
"经由 {proxy}": "Through {proxy}",
"代理配置本身不合法,先改这里": "The proxy setting itself is not valid; fix it here first",
"读不懂这个地址:{endpoint}": "This address cannot be read: {endpoint}",
"解析不了 {host}:{error}": "Cannot resolve {host}: {error}",
"域名解析就失败了,通常是 DNS 或者根本没有网": "Name resolution failed: usually DNS, or no network at all",
"连不上 {host}:{port}:{error}": "Cannot connect to {host}:{port}: {error}",
"{host}:{port} 通了": "{host}:{port} is reachable",
"解析得到地址但连不上代理,检查代理是不是没开、端口对不对": "The name resolved but the proxy is not reachable; check that it is running and the port is right",
"解析得到地址但连不上,可能被防火墙挡了,或者需要走代理": "The name resolved but the connection fails; a firewall may be blocking it, or it may need a proxy",
"握手成功 · HTTP {status}": "Handshake succeeded · HTTP {status}",
"证书不被信任 —— 公司网络里的解密代理常有这种情况,需要把它的根证书装进系统信任": "The certificate is not trusted — common behind a company decrypting proxy; install its root certificate into the system trust store",
"对面不像是一个 HTTPS 端点,检查地址或者代理协议是不是填错了": "The other side does not look like an HTTPS endpoint; check the address and the proxy protocol",
"读不到配置:{error}": "Cannot read the configuration: {error}",
"没有配置 provider,没有可以测的目标": "No provider is configured, so there is nothing to test",
"没有可用的模型配置": "No usable model configuration",
"没有找到 API key": "No API key was found",
"网络这一侧是通的,缺的是 key —— 去「模型」那页填": "The network side works; the key is missing — add it on the Models page",
"key 被拒了 — HTTP {status}": "The key was rejected — HTTP {status}",
"网络通了,是 key 的问题,去「模型」那页换一个": "The network works and the key is the problem; replace it on the Models page",
"对面出错了 — HTTP {status}": "The other side failed — HTTP {status}",
"不是你的配置问题,是服务端;过一会儿再试": "This is not your configuration, it is the server; try again in a while",
"key 可用 — HTTP {status}": "The key works — HTTP {status}",
"shell 设置未能保存:{detail}": "The shell settings were not saved: {detail}",
"扩展未执行该动作:{detail}": "The extension did not carry that action out: {detail}",
"扩展未接受本次提交:{detail}": "The extension did not accept that submission: {detail}",
@@ -159,6 +210,7 @@ export const EN_KERNEL: Record<string, string> = {
"这个文件夹已经在当前工作区里": "This folder is already inside the current workspace.",
"这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)": "This folder is too broad: it is a drive root or contains your home or Reasonix data folder.",
"这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限": "This folder holds or sits inside keys, credentials or Reasonix's own data, so it cannot be granted write access.",
"这里是 Reasonix 保存已安装技能的位置,不能授予写入权限。想让 AI 持续改进自己的技能:把技能的源文件夹用“登记”(register)方式安装,之后在源文件夹里修改。": "This is where Reasonix keeps installed skills, so it cannot be granted write access. To let the AI keep improving a skill of yours, install the skill's source folder in register mode and edit it there.",
"这个文件夹不属于当前用户,不能授予写入权限": "This folder is not owned by the current user, so it cannot be granted write access.",
"这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠": "This folder overlaps a location that is already writable (temp, a tool cache or another extra folder).",
"没有这个预设": "No curated preset has this id.",
@@ -281,7 +333,19 @@ export const EN_KERNEL: Record<string, string> = {
"这个会话不支持辅助提问": "Side questions are not available in this session",
"这个辅助提问已经关闭,请重新打开": "That side question is closed; open it again",
"上一个问题还在回答,请稍候再问": "The previous question is still being answered; wait a moment",
"同时打开的辅助提问太多,请先关闭几个": "Too many side questions are open; close some first",
"同时打开的辅助提问已到你设置的上限(agent.max_side_sessions),请先关闭几个": "The side questions open at once have reached your limit (agent.max_side_sessions); close some first",
"会话间消息未开启": "Messages between sessions are not turned on",
"这个客户端不能收发会话间消息": "This client cannot send or decide messages between sessions",
"本机没有叫这个名字的会话": "No session on this machine has that name",
"有多个会话叫这个名字,请改用「项目/会话名」": "More than one session has that name; use project/session",
"不能给自己发消息": "A session cannot message itself",
"对方不接收消息": "That session does not accept messages",
"发得太快了,请稍后再发": "Sending too fast; try again shortly",
"请先写下要发的内容": "Write the message first",
"这条消息太长": "That message is too long",
"这条消息已经处理过了": "That message was already decided",
"会话间消息没能完成:{detail}": "The message between sessions did not go through: {detail}",
"地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥": "The address moved to another origin, so the saved key is not sent there; enter the key for this address",
"请先写下要问的内容": "Write the question first",
"辅助提问没能完成,请重试": "The side question could not be completed; try again",
"尚未填写服务地址": "No endpoint address yet",
@@ -364,7 +428,9 @@ export const EN_KERNEL: Record<string, string> = {
"这份备份已不存在": "That backup no longer exists",
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
"项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加": "The project list is at the limit you set (desktop.max_workspaces); remove one before adding another",
"{model} 的价格必须是不小于 0 的数字,并选择货币": "The price for {model} must be a number not below 0, with a currency chosen",
"{model} 不在已选模型中,未保存价格": "{model} is not one of the selected models, so no price was saved",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
"不存在该预设": "There is no such preset",
"没有找到这个模型,默认模型没有改动": "That model was not found, so the default model is unchanged",
@@ -411,7 +477,18 @@ export const EN_KERNEL: Record<string, string> = {
"这个内核没有窗口外壳,没有可以指出的日志目录": "This kernel has no window shell, so there is no log directory to point to",
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
"这不是一个技能名": "That is not a skill name.",
"这个技能没有安装来源的记录,无法从源文件夹更新": "This skill has no recorded source, so it cannot be updated from a folder.",
"这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)": "This skill was not copied from a local folder, so it cannot be updated from one (reinstall a linked or remote skill).",
"找不到来源文件夹,可能被移动或删除了": "The source folder cannot be found; it may have been moved or deleted.",
"自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新": "The source folder or the installed copy changed after you opened the preview. Review it again before updating.",
"已安装的副本被改过,更新会覆盖这些改动,需要你确认": "The installed copy was edited, and updating overwrites those edits; confirm to continue.",
"这个技能太大,无法更新(文件数或总大小超过上限)": "This skill is too large to update (too many files or too many bytes).",
"更新没有完成,已安装的副本保持原样:{detail}": "The update did not complete and the installed copy was left as it was: {detail}",
"请先查看更新预览,再确认更新": "Open the update preview first, then confirm.",
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
"不支持这种更新方式,没有改动。": "That update mode is not supported; nothing was changed.",
"更新方式未能保存:{detail}": "The update mode was not saved: {detail}",
"这不是一个已发布的版本号": "That is not a published version number",
"这个版本没有发布更新内容": "This version has no published notes",
"暂时取不到更新内容,请检查网络后重试": "What's new cannot be reached right now. Check the network and try again",
@@ -433,6 +510,7 @@ export const EN_KERNEL: Record<string, string> = {
"找不到该文件,它可能已被移动或删除": "The file was not found; it may have been moved or deleted",
"文件操作失败,请重试": "The file operation failed; try again",
"该路径不在当前工作区内": "That path is outside the current workspace",
"这个文件夹没有授权给当前工作区": "That folder has not been granted to this workspace",
"写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹": "The target is outside the workspace write scope. This is the file tools' write scope, not an OS sandbox; to allow the write, add the target folder under Settings → Sandbox → Also writable",
"无法读取工作区文件列表": "The workspace file list could not be read",
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
@@ -451,4 +529,42 @@ export const EN_KERNEL: Record<string, string> = {
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
"这个项目自带的钩子等程序要你批准后才会运行":
"This project ships hooks or other programs that run only after you approve them",
"这个项目的程序在批准后被改动过,本次没有运行,需要重新批准":
"A program in this project changed after you approved it; it did not run this time and needs approval again",
"这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令":
"This project declares an MCP server that runs only after you approve it; the detail shows the command it would start",
"一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用":
"What an enabled project MCP server would start has changed; it did not run this time - review the command and enable it again",
"有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子":
"A hook could not be evaluated (invalid matcher, could not start, or payload could not be serialized), so this action was blocked to be safe; fix or remove the hook in the hook settings",
"一条外部内容看起来在向智能体下指令,已提醒它只当资料看待":
"Some external content looks like it is giving the agent instructions; the agent was told to treat it as data only",
"配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动":
"The default model saved in your configuration is no longer among the configured providers, so the first available model is used this time; pick a default model again in Settings to replace it - the configuration file was not changed",
"搜索模型的格式应为 服务/模型,搜索已关闭;到模型偏好里重新选择,或恢复自动":
"The search model must be written as service/model, so search is off; choose one again in Model preferences, or set it back to automatic",
"所选搜索模型所在的连接已被移出,搜索已关闭;到模型偏好里重新选择,或恢复自动":
"The connection of the chosen search model has been removed, so search is off; choose one again in Model preferences, or set it back to automatic",
"所选搜索模型已不在配置里,搜索已关闭;到模型偏好里重新选择,或恢复自动":
"The chosen search model is no longer in the configuration, so search is off; choose one again in Model preferences, or set it back to automatic",
"所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动":
"The chosen search model's protocol does not support native search, or search was turned off, so search is off; choose one again in Model preferences, or set it back to automatic",
"网页抓取后端没有返回内容,已换用链里的下一个后端;详情见通知":
"A fetch backend did not answer; the next one in the chain was tried. See the notice for details",
"配置的网页抓取后端无法使用,已跳过;检查用户配置里的 web_fetch_backends":
"A configured fetch backend cannot be used and was skipped; check web_fetch_backends in your user config",
"搜索后端没有返回结果,已换用链里的下一个后端;详情见通知":
"A search backend did not answer; the next one in the chain was tried. See the notice for details",
"配置的搜索后端无法使用,已跳过;检查用户配置里的 web_search_backends 和 searxng_url":
"A configured search backend cannot be used and was skipped; check web_search_backends and searxng_url in your user config",
"所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动":
"The connection of the chosen search model has no credentials, so search is off; choose one again in Model preferences, or set it back to automatic",
"配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换":
"The default approval level in your configuration is not recognized by this version and is treated as ask every time; pick a level in the interface to replace it",
"旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因":
"The old memory file could not be backed up first, so it was not migrated and was left as it is; the detail gives the reason",
"会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发":
"The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again",
};
@@ -124,6 +124,9 @@ export const EN_REMOTE: Record<string, string> = {
"查看并管理正在操作这台电脑的设备": "See and manage devices controlling this computer",
"手机扫码即可进入这台电脑的 Studio": "Scan with your phone to open this computer's Studio",
"远程连接已断开": "Remote connection ended",
"刷新页面": "Refresh page",
"这个页面不是最新版": "This page is not the latest version",
"它是之前打开的旧版本,继续用可能出现莫名的报错。刷新一下就能换成最新版。": "It is an older build that was opened earlier, and may show confusing errors. Refreshing switches to the latest.",
"这台电脑已停止网页控制。重新连接需要再次验证设备状态。": "This computer has stopped web control. Reconnecting will verify the device again.",
"重新连接": "Reconnect",
"这个浏览器版本太旧,不支持远程控制需要的加密。请改用较新版本的 Chrome、Edge、Safari 或 Firefox 打开。": "This browser is too old for the encryption remote control needs. Open it in a recent version of Chrome, Edge, Safari or Firefox.",
+136 -3
View File
@@ -2,13 +2,30 @@
// 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> = {
"「{from}」发来一条消息,等你批准": "\u201c{from}\u201d sent a message and is waiting for your approval",
"来自 {from} 的消息": "Message from {from}",
"来自另一个会话「{from}」的文字,不是你写的。批准后才会交给这个会话的模型。": "Text from another session, \u201c{from}\u201d; you did not write it. It reaches this session\u2019s model only after you approve it.",
"批准并交给模型": "Approve and give it to the model",
"等你决定的会话间消息": "Session messages waiting for your decision",
"还有 {n} 条等你决定": "{n} more waiting for your decision",
"无法读取会话间消息的状态。": "Could not read the state of messages between sessions.",
"会话间消息没有开启。在用户配置里写入 [peers] enabled = true,重新打开会话后生效;默认关闭。": "Messages between sessions are off. Write [peers] enabled = true in your user config and reopen the session; it is off by default.",
"已送到「{to}」,对方的用户批准后才会读到。": "Delivered to \u201c{to}\u201d; it is read only after that session\u2019s user approves it.",
"本机没有别的会话在监听。": "No other session on this machine is listening.",
"发给": "Send to",
"要发的内容": "Message",
"写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。": "Write what to tell the other session. It reaches that session\u2019s model only after its user approves.",
"发送中…": "Sending\u2026",
"会话间消息(实验)": "Messages between sessions (experimental)",
"同一台机器上的会话互相发文字。收到的文字先挂起,只有你批准才会交给模型;发出去的文字也要对方的用户批准。没有给模型用的工具,默认关闭。": "Sessions on one machine send each other text. What you receive is held until you approve it; what you send needs the other user\u2019s approval too. There is no tool for the model, and it is off by default.",
"浏览器标签": "Browser tabs",
"辅助会话": "Side question",
"只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed",
"收起辅助会话": "Collapse the side question",
"展开辅助会话": "Expand the side question",
"关闭辅助会话": "Close the side question",
"移除引用 {n}": "Remove quote {n}",
"向辅助会话提问": "Ask the side question",
"向辅助会话提问(只读,不写入主会话)": "Ask a side question (read-only, kept out of the main session)",
"提问…": "Ask…",
"先在对话里选中一段文字": "Select some text in the conversation first",
"正在读取并回答…": "Reading and answering…",
@@ -16,6 +33,8 @@ export const EN_SETTINGS: Record<string, string> = {
"用 {name} 打开": "Open with {name}",
"回到会话顶部": "Back to top",
"跳到最新": "Jump to latest",
"回到会话顶部(Shift+点击:上一个展开的块)": "Back to top (Shift+click: previous expanded block)",
"跳到最新(Shift+点击:下一个展开的块)": "Jump to latest (Shift+click: next expanded block)",
"首字时间": "Time to first token",
"关闭其他浏览器标签": "Close other browser tabs",
"关闭右侧浏览器标签": "Close browser tabs to the right",
@@ -56,7 +75,10 @@ export const EN_SETTINGS: Record<string, string> = {
"步骤 1 · 连接服务": "Step 1 · Connect service",
"先连接服务,再选择这个来源要启用的模型。": "Connect the service, then choose which models to enable from it.",
"来源名称": "Source name",
"例如:公司中转站": "For example: Company gateway",
"上次测试失败:{why}": "Last test failed: {why}",
"上次测试失败": "Last test failed",
"上次测试通过": "Last test passed",
"连接后自动生成,可改": "Made after connecting; editable",
"API Key 仅保存在运行内核的这台机器上。": "The API key is stored only on the machine running the kernel.",
"检测并继续": "Test and continue",
"自动识别协议与模型": "Protocol and models are detected automatically",
@@ -132,6 +154,8 @@ export const EN_SETTINGS: Record<string, string> = {
"重建运行时": "Rebuilds the runtime",
"重启后生效": "Takes effect after a restart",
"已保存 · 重启后生效": "Saved · takes effect after a restart",
"已保存 · 新开的会话生效": "Saved · applies to sessions opened from now on",
"新开的会话生效": "Applies to new sessions",
// ── 设置:分区与导航 ─────────────────────────────────────────────
"设置分类": "Settings sections",
@@ -189,6 +213,10 @@ export const EN_SETTINGS: Record<string, string> = {
"推理强度": "Reasoning effort",
"子代理声明": "Subagent declarations",
"{name} 的子代理声明": "Subagent declarations for {name}",
"技能信息": "Skill information",
"{name} 的技能信息": "Skill information for {name}",
"许可": "License",
"运行要求": "Requirements",
"声明的模型": "Declared model",
"声明的推理强度": "Declared reasoning effort",
"声明的工具": "Declared tools",
@@ -360,6 +388,30 @@ export const EN_SETTINGS: Record<string, string> = {
"agent 用来打开和操作网页的工具。该开关单独保存,不受命令行版本浏览器设置的影响。修改会重建运行时,任务运行期间无法变更。": "The tools the agent uses to open and operate web pages. This switch is stored on its own and is not affected by the command-line version's browser setting. Changing it rebuilds the runtime and cannot be done while a task is running.",
"无法读取内置浏览器设置。": "Could not read the built-in browser setting.",
"启用内置浏览器": "Enable the built-in browser",
"乐观模式(效率优先)": "Optimistic mode (efficiency first)",
"同时保留的会话标签数": "Session tabs kept open",
"保持实时连接的会话标签数": "Session tabs kept live",
"默认留空:自动。浏览器对同一个地址只开约 6 个连接,会话列表占一个,所以只让少数标签保持实时连接,其余的标签在显示时从断点接着读,运行中的后台标签每 5 秒读一次状态。调高会让更多后台标签实时更新,但开太多会让停止、发送排不上队。": "Empty means automatic. A browser opens only about six connections to one address and the session list takes one, so only a few tabs stay live; the others resume from where they stopped when shown, and a running background tab has its status read every 5 seconds. A higher number updates more background tabs live, but too many makes Stop and Send queue behind them.",
"实时连接的标签数": "Tabs with a live connection",
"留空表示自动;可填 1 到 {max}": "Empty is automatic; 1 to {max}",
"默认 0:不自动收起,打开的会话都保留。设成一个数字后,打开的会话超过它时,会收起最久没动的空闲会话;对话仍在列表里,随时可以重新打开。保留得越多,占用的内存越多。": "Default 0: nothing is folded away and every open session stays. Set a number and, once more sessions than that are open, the idle one untouched longest is folded away; its conversation stays in the list and can be opened again. The more you keep, the more memory it uses.",
"会话标签数上限": "Limit on session tabs",
"0 表示不限制": "0 means no limit",
"个": "tabs",
"无法读取多会话写入设置。": "Could not read the multi-session write setting.",
"无法读取计划批准设置。": "Could not read the plan approval setting.",
"计划批准": "Plan approval",
"计划模式提交计划后,默认等你点批准才开始执行。长时间无人值守的任务可以改成自动批准。": "After Plan mode submits a plan, it waits for your approval by default. For long unattended runs it can be set to approve automatically.",
"自动批准计划": "Approve plans automatically",
"计划提交后不再等你点批准,直接开始执行,对话里会留下一条说明。只放行这一次点击:工具批准、沙箱和拒绝规则照常生效。默认关闭,只写在你自己的用户配置里,项目配置不能替你打开。": "A submitted plan starts without waiting for your click, and the transcript notes it. Only that click is released: tool approvals, the sandbox and deny rules still apply. Off by default, written only to your own user config; a project config cannot turn it on for you.",
"多个会话同时写一个项目": "Several sessions writing one project",
"多个会话在同一个项目里工作时如何互相等待。默认严格:没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)会占住整个工作区,其他会话要等它结束。只影响之后新开的会话,已在运行的会话保持它启动时的设置。": "How sessions working in one project wait for each other. Strict by default: an operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) holds the whole workspace and other sessions wait for it. It affects sessions opened afterwards; a session already running keeps the setting it started with.",
"乐观模式(多会话并行)": "Optimistic mode (sessions in parallel)",
"没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)不再占住整个工作区,所以同一个项目里的多个会话可以同时工作,不必互相等待。代价:声明了路径的写入(如编辑文件)仍按路径互斥,但 bash 和 MCP 的写入没有事前的路径围栏,只在事后观察,两个会话用它们改同一个文件时可能互相覆盖。默认关闭(严格模式);只对之后新开的会话生效。": "An operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) no longer holds the whole workspace, so several sessions in one project can work at the same time without waiting. The cost: writes that declare a path (such as editing a file) still exclude each other by path, but bash and MCP writes have no path fence beforehand and are only observed afterwards, so two sessions using them on one file can overwrite each other. Off by default (strict mode); applies only to sessions opened afterwards.",
"无法读取子代理写入范围设置。": "Could not read the sub-agent write scope setting.",
"子代理的写入范围": "Sub-agent write scope",
"后台子代理写文件时如何占用工作区。默认严格:没有声明 write_paths 的子代理占住整个工作区,其他写入要等它结束。修改会重建运行时,任务运行期间无法变更。": "How a background sub-agent holds the workspace while it writes. Strict by default: a sub-agent that names no write_paths holds the whole workspace and other writers wait for it. Changing it rebuilds the runtime and cannot be done while a task runs.",
"没有声明 write_paths 的子代理不再占住整个工作区,只在写某个文件的那一刻占用该路径,所以可以和主会话、其他子代理并发。代价:它的 bash 和 MCP 写入不受路径围栏约束,只在事后观察;两边同时改同一个文件时,后写的一方会被拒绝。默认关闭(严格模式)。": "A sub-agent that names no write_paths no longer holds the whole workspace; it holds a path only while it writes that file, so it can run beside the main conversation and other sub-agents. The cost: its bash and MCP writes are not confined by a path fence and are only observed afterwards; if two sides edit the same file at once, the later write is refused. Off by default (strict mode).",
"agent 可以打开网页、读取内容并操作页面;浏览器在第一次使用时才启动。关闭后这些工具不会出现在工具列表中": "The agent can open web pages, read them and operate them; the browser only starts the first time it is used. When off, these tools are not in the tool list",
"当前项目的配置文件开启了它,此工作区仍会提供内置浏览器。": "This project's config file turns it on, so this workspace still has the built-in browser.",
"当前项目的配置文件关闭了它,此工作区不会提供内置浏览器。": "This project's config file turns it off, so this workspace has no built-in browser.",
@@ -387,6 +439,16 @@ export const EN_SETTINGS: Record<string, string> = {
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
"{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置价格": "Price by model",
"每百万 token 的单价。留空表示未知,用量页会如实显示「未知」,而不是 0;填 0 表示免费。缓存命中价留空时按输入价计算。": "Price per million tokens. Left empty it is unknown, and the usage page says unknown rather than 0; enter 0 for free. An empty cache-hit price is charged at the input price.",
"{model} 的输入单价": "{model} input price",
"{model} 的输出单价": "{model} output price",
"{model} 的缓存命中单价": "{model} cache-hit price",
"{model} 的货币": "{model} currency",
"未知": "Unknown",
"同输入价": "Same as input",
"缓存命中": "Cache hit",
"输入价和输出价需要都填写,才会保存这一行。": "Fill in both input and output to save this row.",
"按模型设置限制": "Limits per model",
"留空的模型沿用上面的值,灰字是它当前继承到的值;填写后只对该模型生效。":
"A blank field follows the values above; the grey text is what that model inherits now. A value applies to that model only.",
@@ -535,6 +597,9 @@ export const EN_SETTINGS: Record<string, string> = {
"允许这一次": "Allow once",
"本会话允许此类操作,不再询问": "Allow this kind for this session, stop asking",
"始终允许此类操作,不再询问": "Always allow this kind, stop asking",
"“本会话允许”只在这次会话内有效,不写入磁盘;“始终允许”写入用户配置的权限规则,可在设置 → 工具与权限里撤销。": "“Allow for this session” lasts only for this session and is not written to disk; “Always allow” is saved as a permission rule in your user config and can be undone in Settings → Tools and permissions.",
"“始终允许”写入用户配置的权限规则,可在设置 → 工具与权限里撤销。": "“Always allow” is saved as a permission rule in your user config and can be undone in Settings → Tools and permissions.",
"“本会话允许”只在这次会话内有效,不写入磁盘。": "“Allow for this session” lasts only for this session and is not written to disk.",
"内核已记入会话授权,不写入磁盘。": "The kernel records it for this session only; nothing is written to disk.",
"agent 已收到拒绝,将改用其他方式或终止。": "The agent takes the refusal and either finds another way or stops.",
"下次同样的操作仍会请求确认。": "The same operation will ask again next time.",
@@ -546,6 +611,7 @@ export const EN_SETTINGS: Record<string, string> = {
"确认(还有 {n} 个没答)": "Confirm ({n} left)",
"确认": "Confirm",
"先不选择,直接回复": "Skip the choice and reply instead",
"{time} 后自动跳过这个问题,点任意处保留": "This question is skipped in {time}; click anywhere to keep it",
"需要你决定": "Need your decision",
"需要你补充信息": "Need more information from you",
"MCP 服务器 {name} 请求信息": "MCP server {name} is asking for information",
@@ -604,6 +670,9 @@ export const EN_SETTINGS: Record<string, string> = {
"两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace",
"它们要写的路径重叠": "they want to write the same path",
"上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}": "The context holds about {total}, over the {at} compaction threshold, and nothing more can be folded. Largest: {top}. {active}",
"折叠后仍有 {n}:{top}": "{n} still held after the fold: {top}",
"最大的一条是 {tool} 的结果 {n}": "the largest is a {tool} result, {n}",
"最大的一条是 {role} 消息 {n}": "the largest is a {role} message, {n}",
"正在进行的这一轮占 {n},折叠动不了它。": "The turn in progress holds {n} of it, and a fold cannot take it.",
"用户消息": "User messages",
"自动压缩暂缓,上次尝试没有完成:{why}": "Automatic compaction is paused, the last attempt did not finish: {why}",
@@ -708,7 +777,70 @@ export const EN_SETTINGS: Record<string, string> = {
"安装失败": "Install failed",
"无法读取 {v} 的发布信息": "Could not read the release details for {v}",
"版本目录暂时连不上,可能是网络或代理问题。当前版本未被改动,稍后重试即可。": "The version index cannot be reached right now, likely a network or proxy problem. The running version was not touched; try again later.",
"源文件夹有更新": "Source has changed",
"来源文件夹里的内容比已安装的副本新:{path}": "The source folder has newer content than the installed copy: {path}",
"副本被改过": "Copy was edited",
"已安装的副本与安装时不同,来源文件夹没有变化:{path}": "The installed copy differs from what was installed; the source folder has not changed: {path}",
"两边都改过": "Both changed",
"已安装的副本和来源文件夹各自都改过:{path}": "The installed copy and the source folder have each changed: {path}",
"源文件夹不见了": "Source folder is gone",
"找不到来源文件夹:{path}": "The source folder cannot be found: {path}",
"从源文件夹更新…": "Update from source folder…",
"文件太大,这里不显示内容。请到来源文件夹里查看:{path}": "This file is too large to show here. Read it in the source folder: {path}",
"不是文本文件,这里不显示内容。": "Not a text file, so its content is not shown here.",
"(没有可显示的差异)": "(no difference to show)",
"更新 {name}:来源文件夹有 {n} 处变化": "Update {name}: {n} change(s) in the source folder",
"{name} 与来源文件夹的内容一致,没有可更新的内容": "{name} matches its source folder; there is nothing to update",
"来源:{path}。更新后,已安装的副本会与来源文件夹完全一致。只有你点击“更新”才会执行,AI 不能触发。": "Source: {path}. After the update the installed copy is identical to the source folder. It runs only when you click Update; the AI cannot trigger it.",
"已安装的副本在安装后被改过": "The installed copy was edited after it was installed",
"更新会让副本与来源文件夹完全一致,覆盖这些本地改动。": "The update makes the copy identical to the source folder, overwriting those local edits.",
"我知道会覆盖本地改动": "I understand this overwrites local edits",
"以下文件不是说明文字,需要逐项展开看过": "These files are not plain instructions; open each one to read it",
"还有 {n} 个文件没有展开": "{n} file(s) not opened yet",
"以下文件是说明文字": "These files are plain instructions",
"有 {n} 个文件在这里无法显示,我已在来源文件夹里看过": "{n} file(s) cannot be shown here; I have read them in the source folder",
"替换「我的」里已安装的副本,所有项目均可使用": "Replaces the installed copy under Mine, available to every project",
"重新查看": "Review again",
"修改": "Changed",
"{name} 的内容已与来源文件夹一致,只是安装记录还停在旧版本": "{name} already matches its source folder; only its install record is behind",
"查看会话概览": "Open the session overview",
"{h} 小时 {m} 分": "{h} h {m} min",
"{s} 秒": "{s} s",
"主代理": "Main agent",
"会话标题": "Session title",
"分类器": "Classifier",
"目标评估": "Goal evaluation",
"部分请求没有价格信息": "Some requests carried no price",
"价格未知:{reason}": "Price unknown: {reason}",
"价格未知": "Price unknown",
"还没有计费的请求": "Nothing billed yet",
"会话概览": "Session overview",
"下一次请求会带上的内容,是当前值,不是累计": "What the next request would carry: a current reading, not a running total",
"自动压缩于 {n}": "Folds automatically at {n}",
"自本次打开此会话起;恢复或分叉会重新计数": "Since this session was opened in this run; resuming or forking counts again",
"这个会话还没有计费的请求": "This session has no billed requests yet",
"返回了用量与价格的模型响应": "Model responses that reported usage and a price",
"Token": "Tokens",
"输入 {a} · 输出 {b}": "In {a} · out {b}",
"命中 ÷ (命中 + 未命中);不含服务商未区分的 {n} tokens": "hit ÷ (hit + miss); leaves out {n} tokens the provider did not split",
"命中 ÷ (命中 + 未命中)": "hit ÷ (hit + miss)",
"工作时长": "Work time",
"给出最终回答的回合耗时之和,含审批与工具等待": "Sum of the turns that gave a final answer, approval and tool waits included",
"费用": "Cost",
"请求 · Token · 缓存 · 费用": "Requests · Tokens · Cache · Cost",
"{v} 下载中断": "Downloading {v} was interrupted",
"更新方式": "Update mode",
"后台检查新版本": "Check for new versions in the background",
"有新版本 {v},在设置的「版本」里查看": "Version {v} is available; see Settings, Version",
"决定窗口自己做不做事:两种方式都不会替你安装新版本,重启始终由你决定。": "Chooses whether the window does anything on its own. Neither mode installs a new version for you; restarting is always your call.",
"提醒": "Notify",
"不会在后台联网查新版本。要更新时在上面的版本列表里自己下载并重启。这是默认。": "Nothing is checked in the background. To update, download and restart yourself from the version list above. This is the default.",
"每天在后台读取一次版本目录,有更新的版本时在设置图标上显示一个小点。不会下载,也不会安装或重启。": "Reads the version catalog once a day in the background and shows a dot on the Settings icon when a newer release exists. It never downloads, installs or restarts.",
"自动下载": "Download",
"在提醒的基础上,后台下载并校验新版本。下载完成后在没有会话运行时提示你,由你点「重启并更新」;不会安装,也不会自动重启。": "On top of Notify, downloads and verifies the new release in the background. When it is ready and no session is running you get a notice; you press Restart and update. It never installs or restarts by itself.",
"下载暂停了:连接长时间没有数据,已重连 {n} 次…": "Download paused: the connection delivered nothing for a while, reconnected {n} time(s)…",
"{v} 下载卡住了": "Downloading {v} got stuck",
"下载连接长时间没有数据,重连几次仍未恢复。当前版本未被改动,可以稍后重试,或下载完整安装包手动安装。": "The download connection delivered nothing for a long time and several reconnects did not help. The running version was not touched; try again later, or download the full installer.",
"网络中断或超时。当前版本未被改动,可以重试。": "The connection dropped or timed out. The running version was not touched; you can try again.",
"{v} 未通过签名校验": "{v} failed its signature check",
"下载到的文件与发布签名不符,已丢弃,没有安装。多为下载损坏或网络被篡改,请重试;反复出现请反馈。": "The downloaded file does not match the release signature, so it was discarded and nothing was installed. This is usually a corrupted download or a tampered network; try again, and report it if it keeps happening.",
@@ -894,6 +1026,7 @@ export const EN_SETTINGS: Record<string, string> = {
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
"压缩简报": "Compaction brief",
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
"收起并预览首句": "Folded, with first sentence",
"进行中展开": "Open while running",
"失败时展开": "Open on failure",
"改动时展开": "Open on file change",
@@ -1086,7 +1219,7 @@ export const EN_SETTINGS: Record<string, string> = {
"提问等待超时": "Question wait time",
"长任务中途遇到提问时,不必一直停在那里等人。": "A long task need not stop at a question and wait for someone.",
"提问超时后自动跳过": "Skip a question nobody answers",
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). The terminal reads the same setting; change it there with /ask-timeout.",
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。模型把任务清单交还给你、等你输入时,同样等这么久,之后宿主让它按自己的判断继续并写明假设。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). When the model hands its task list back and waits for you, it waits this long too, then the host has it continue on its own judgement and state its assumptions. The terminal reads the same setting; change it there with /ask-timeout.",
"等待时长": "Wait time",
"配置文件里的提问等待时长无效(须为 0 或 {min} 到 {max} 之间),现在按“一直等待”处理:{path}": "The question wait in the config file is not valid (it must be 0 or between {min} and {max}), so questions wait as long as it takes: {path}",
"从内核发出问题时开始计时。默认 {n} 秒。": "Counted from when the kernel asks the question. Default {n} seconds.",
@@ -7,6 +7,10 @@ export const EN_STORAGE: Record<string, string> = {
"选择旧版的数据文件夹。扫描只会复制会话,不会移动或删除原文件;找不到原工作区的会话会归入当前工作区。":
"Choose the older data folder. The scan only copies sessions and never moves or deletes the originals; sessions whose workspace no longer exists are placed in the current workspace.",
"选择旧版数据目录…": "Choose older data folder…",
"无法打开文件夹选择器。请输入旧版数据文件夹的路径(里面有 sessions、projects 或 desktop-sessions-v5)。":
"The folder panel could not be opened. Type the path of the older data folder (the one holding sessions, projects or desktop-sessions-v5).",
"旧版数据目录路径": "Older data folder path",
"导入此文件夹": "Import this folder",
"正在扫描…": "Scanning…",
"已找回 {n} 个会话。": "Recovered {n} sessions.",
"没有发现尚未导入的旧会话。": "Nothing new to import.",
@@ -1,8 +1,10 @@
import { t } from ".";
import { elapsed } from "./format";
export const JOB_FINISHED = "job_finished";
export const JOB_KILLED = "job_killed";
export const JOB_FAILED = "job_failed";
export const JOB_STALLED = "job_stalled";
// The typed payload a background-job notice carries as its detail, read as the
// variables its sentence is worded from. Anything that does not parse reads as no
@@ -18,6 +20,21 @@ export function jobNoticeVars(detail: string | undefined): { name: string } | un
}
}
// A stalled job's sentence also says how long its own output has been silent:
// without that span the sentence would be half-filled, so it reads as no payload.
export function jobStalledVars(detail: string | undefined): { name: string; quiet: string } | undefined {
const named = jobNoticeVars(detail);
if (!named || !detail) return undefined;
try {
const p = JSON.parse(detail) as { quietSeconds?: unknown };
return typeof p.quietSeconds === "number" && Number.isInteger(p.quietSeconds) && p.quietSeconds > 0
? { ...named, quiet: elapsed(p.quietSeconds * 1000) }
: undefined;
} catch {
return undefined;
}
}
// How a failed process ended, worded from the typed fields the producer wrote:
// a signal first, since an exit code is then only the shell's echo of it. The
// job function's own error text stands in when the producer did not type it.
@@ -98,10 +98,10 @@ describe("what a reader is told a refusal was", () => {
// authority never actually left the kernel.
it("explains the project limit in Chinese and English", () => {
const error = coded("storage limit", "workspace.limit_reached");
expect(reason(error)).toBe("项目列表已满(32 个),请先移除一个项目再添加");
expect(reason(error)).toBe("项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加");
localStorage.setItem(STORAGE, "en");
boot();
expect(reason(error)).toBe("The project list is full (32 projects); remove one before adding another");
expect(reason(error)).toBe("The project list is at the limit you set (desktop.max_workspaces); remove one before adding another");
});
it("does not move when the kernel rewords the same code", () => {
+83 -3
View File
@@ -49,6 +49,9 @@ const SAID: Record<string, string> = {
"session.title_no_message": "该会话还没有可用于命名的用户消息",
"session.title_unavailable": "未配置可用于自动命名的模型,请先配置模型",
"session.title_failed": "自动命名失败,原标题未更改,请重试",
"session.title_timeout": "自动命名超时:标题模型没有及时回答,原标题未更改,请稍后重试",
"session.title_no_text": "自动命名失败:标题模型没有返回任何文字,原标题未更改",
"session.title_refused": "自动命名失败:标题模型的接口拒绝了请求(HTTP {status}){detail}",
"session.title_save_failed": "会话标题保存失败,请重试",
// ── 忙:不是出错,是「现在不行」 ─────────────────────────────────
@@ -67,9 +70,13 @@ const SAID: Record<string, string> = {
"workspace.file_missing": "找不到该文件,它可能已被移动或删除",
"workspace.file_failed": "文件操作失败,请重试",
"workspace.path_outside_tree": "该路径不在当前工作区内",
"workspace.root_not_granted": "这个文件夹没有授权给当前工作区",
"workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。",
"workspace.read_forbidden": "无法读取目标文件:没有权限访问。",
"workspace.read_outside_scope": "无法读取目标文件:没有权限访问。",
"video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。",
"video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。",
"video.too_large": "视频文件太大,超过了能读取的上限。",
"workspace.overwrite_read_forbidden": "无法覆盖目标文件:没有权限读取原文件内容。",
"workspace.overwrite_read_outside_scope": "无法覆盖目标文件:没有权限读取原文件内容。",
"workspace.write_outside_scope": "写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹",
@@ -157,8 +164,12 @@ const SAID: Record<string, string> = {
"backup.not_found": "这份备份已不存在",
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
"workspace.limit_reached": "项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加",
"provider.bad_model_price": "{model} 的价格必须是不小于 0 的数字,并选择货币",
"provider.model_prices_unlisted": "{model} 不在已选模型中,未保存价格",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
"settings.unknown_preset": "不存在该预设",
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
@@ -251,7 +262,7 @@ const SAID: Record<string, string> = {
"side.unavailable": "这个会话不支持辅助提问",
"side.unknown": "这个辅助提问已经关闭,请重新打开",
"side.busy": "上一个问题还在回答,请稍候再问",
"side.limit": "同时打开的辅助提问太多,请先关闭几个",
"side.limit": "同时打开的辅助提问已到你设置的上限(agent.max_side_sessions),请先关闭几个",
"side.empty": "请先写下要问的内容",
"side.failed": "辅助提问没能完成,请重试",
@@ -375,7 +386,14 @@ const SAID: Record<string, string> = {
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
"inbox.not_found": "该条已不在待送达队列中",
"tool_call.not_running": "这条调用已经结束了",
"tool.cancelled_by_user": "你停止了这条调用,它在外部已经做了什么无法确定",
"history.query_required": "请输入要搜索的内容",
"history.search_failed": "对话内容索引暂时无法回答,稍后再试",
"job.not_running": "这个后台任务已经不在运行了",
"mux.unknown_connection": "事件流连接已经关闭,需要重新打开并重新订阅各窗格",
"mux.pane_not_local": "这个窗格由另一台机器驱动,使用它自己的事件流",
"job.stop_in_progress": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待",
"inbox.invalid_state": "该条当前状态不允许此操作",
"inbox.paused": "待送达已暂停,请先恢复派发再进行操作",
"inbox.capacity_items": "待送达条数已达上限,请先发送部分内容",
@@ -387,7 +405,7 @@ const SAID: Record<string, string> = {
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
"config.unparsed": "配置文件无法读取,因此本次未保存",
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
"changes.path_outside_tree": "{path} 不在当前工作树中,无法查看其改动",
"changes.diff_failed": "无法读取该文件的改动 —— git 未返回结果",
"trajectory.unreadable": "无法读取本次运行的轨迹记录,因此无法确定其覆盖范围",
@@ -401,6 +419,28 @@ const SAID: Record<string, string> = {
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"subagent_claims.editing_disabled": "这台服务器未开放子代理写入范围设置",
"subagent_claims.save_failed": "子代理写入范围设置未能保存:{detail}",
"subagent_claims.no_choice": "本次请求未说明选择,未做任何修改",
"write_lease.editing_disabled": "这台服务器未开放多会话写入设置",
"write_lease.save_failed": "多会话写入设置未能保存:{detail}",
"plan_approval.editing_disabled": "这台服务器未开放计划批准设置",
"plan_approval.save_failed": "计划批准设置未能保存:{detail}",
"plan_approval.no_choice": "本次请求未说明选择,未做任何修改",
"peer.disabled": "会话间消息未开启",
"peer.editing_disabled": "这个客户端不能收发会话间消息",
"peer.not_found": "本机没有叫这个名字的会话",
"peer.ambiguous": "有多个会话叫这个名字,请改用「项目/会话名」",
"peer.self": "不能给自己发消息",
"peer.refused": "对方不接收消息",
"peer.rate_limited": "发得太快了,请稍后再发",
"peer.empty": "请先写下要发的内容",
"peer.too_large": "这条消息太长",
"peer.not_held": "这条消息已经处理过了",
"peer.send_failed": "会话间消息没能完成:{detail}",
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
@@ -423,6 +463,30 @@ const SAID: Record<string, string> = {
"hooks.dry_run_failed": "该钩子未能运行:{detail}",
"memory.forget_failed": "未能删除该条记忆:{detail}",
"network.rejected": "网络设置未能保存:{detail}",
"net.proxy.off": "不走代理(已关闭)",
"net.proxy.direct": "这个地址直连,不走代理",
"net.proxy.via": "经由 {proxy}",
"net.proxy.invalid": "代理配置本身不合法,先改这里",
"net.dns.unreadable": "读不懂这个地址:{endpoint}",
"net.dns.failed": "解析不了 {host}:{error}",
"net.dns.advice": "域名解析就失败了,通常是 DNS 或者根本没有网",
"net.connect.failed": "连不上 {host}:{port}:{error}",
"net.connect.ok": "{host}:{port} 通了",
"net.connect.proxy_down": "解析得到地址但连不上代理,检查代理是不是没开、端口对不对",
"net.connect.blocked": "解析得到地址但连不上,可能被防火墙挡了,或者需要走代理",
"net.tls.ok": "握手成功 · HTTP {status}",
"net.tls.untrusted": "证书不被信任 —— 公司网络里的解密代理常有这种情况,需要把它的根证书装进系统信任",
"net.tls.not_https": "对面不像是一个 HTTPS 端点,检查地址或者代理协议是不是填错了",
"net.config.unreadable": "读不到配置:{error}",
"net.no_provider": "没有配置 provider,没有可以测的目标",
"net.auth.no_model": "没有可用的模型配置",
"net.auth.no_key": "没有找到 API key",
"net.auth.no_key_advice": "网络这一侧是通的,缺的是 key —— 去「模型」那页填",
"net.auth.rejected": "key 被拒了 — HTTP {status}",
"net.auth.rejected_advice": "网络通了,是 key 的问题,去「模型」那页换一个",
"net.auth.server_error": "对面出错了 — HTTP {status}",
"net.auth.server_error_advice": "不是你的配置问题,是服务端;过一会儿再试",
"net.auth.ok": "key 可用 — HTTP {status}",
"shell.rejected": "shell 设置未能保存:{detail}",
"extension.action_failed": "扩展未执行该动作:{detail}",
"extension.form_rejected": "扩展未接受本次提交:{detail}",
@@ -486,6 +550,7 @@ const SAID: Record<string, string> = {
"workspace_roots.covered_by_primary": "这个文件夹已经在当前工作区里",
"workspace_roots.too_broad": "这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)",
"workspace_roots.protected": "这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限",
"workspace_roots.skill_store": "这里是 Reasonix 保存已安装技能的位置,不能授予写入权限。想让 AI 持续改进自己的技能:把技能的源文件夹用“登记”(register)方式安装,之后在源文件夹里修改。",
"workspace_roots.not_owned": "这个文件夹不属于当前用户,不能授予写入权限",
"workspace_roots.overlaps_write_root": "这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠",
"provider.preset_unknown": "没有这个预设",
@@ -517,6 +582,10 @@ const SAID: Record<string, string> = {
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
"feedback.not_replyable": "这份反馈现在不接收回复",
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
"feedback.account_required": "需要先登录账号",
"feedback.link_conflict": "这台电脑已关联到另一个账号",
"feedback.link_limit": "这个账号关联的电脑已达上限",
"feedback.no_install": "这台电脑还没有发过反馈",
// ── 远程连接停下来问的那一句 ───────────────────────────────────
"ask.not_found": "不存在该待回答的问题",
@@ -532,7 +601,18 @@ const SAID: Record<string, string> = {
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
"studio.no_logs": "这个内核没有窗口外壳,没有可以指出的日志目录",
"skills.update_bad_name": "这不是一个技能名",
"skills.update_not_recorded": "这个技能没有安装来源的记录,无法从源文件夹更新",
"skills.update_not_local": "这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)",
"skills.update_source_missing": "找不到来源文件夹,可能被移动或删除了",
"skills.update_plan_stale": "自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新",
"skills.update_needs_confirm": "已安装的副本被改过,更新会覆盖这些改动,需要你确认",
"skills.update_too_large": "这个技能太大,无法更新(文件数或总大小超过上限)",
"skills.update_failed": "更新没有完成,已安装的副本保持原样:{detail}",
"skills.update_plan_required": "请先查看更新预览,再确认更新",
"studio.pin_rejected": "版本固定未能保存:{detail}",
"studio.update_mode_invalid": "不支持这种更新方式,没有改动。",
"studio.update_mode_save_failed": "更新方式未能保存:{detail}",
"studio.notes_bad_version": "这不是一个已发布的版本号",
"studio.notes_absent": "这个版本没有发布更新内容",
"studio.notes_unreachable": "暂时取不到更新内容,请检查网络后重试",
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { EN } from "./en";
import { NOTICE_TEXT } from "./notices";
import { NOTICE_VAR_CODES, leftoverDetail, noticeWording } from "./notice_wording";
@@ -11,6 +12,7 @@ const PAYLOAD: Record<string, string | undefined> = {
job_finished: JSON.stringify({ id: "j", label: "build" }),
job_killed: JSON.stringify({ id: "j", label: "build" }),
job_failed: JSON.stringify({ id: "j", label: "build" }),
job_stalled: JSON.stringify({ id: "j", label: "build", quietSeconds: 900 }),
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 }),
display_currency: "USD",
@@ -20,6 +22,12 @@ const PAYLOAD: Record<string, string | undefined> = {
};
describe("notice wording", () => {
it.each(Object.entries(NOTICE_TEXT))("%s reads in English too", (code, sentence) => {
const english = EN[sentence];
expect(english, `${code}: no English for "${sentence}" - an English window would show the Chinese`).toBeTruthy();
expect(/[\u4e00-\u9fff]/.test(english), `${code}: the English still holds Chinese`).toBe(false);
});
const withSlots = Object.entries(NOTICE_TEXT).filter(([, s]) => placeholders(s).length > 0);
it.each(withSlots)("%s registers a reader for every placeholder it has", (code, sentence) => {
@@ -56,6 +64,9 @@ describe("notice wording", () => {
["job_finished", '{"label":"build"}'],
["job_killed", '{"id":""}'],
["job_failed", '{"id":5}'],
["job_stalled", '{"id":"j"}'],
["job_stalled", '{"id":"j","quietSeconds":"900"}'],
["job_stalled", '{"id":"j","quietSeconds":0}'],
["compact_failed", "no_such_reason"],
])("%s reads valid JSON of the wrong shape (%s) as no payload", (code, detail) => {
expect(noticeWording(code, detail)).toBeUndefined();
@@ -4,7 +4,7 @@ import { CONTEXT_COMPOSITION, contextCompositionVars } from "./context_compositi
import { SUBAGENT_WAITING, subagentWaitingVars } from "./subagent_waiting";
import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, jobErrorText, jobNoticeVars } from "./job_notice";
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice";
import { NOTICE_TEXT } from "./notices";
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
@@ -24,6 +24,7 @@ const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefine
[JOB_FINISHED]: jobNoticeVars,
[JOB_KILLED]: jobNoticeVars,
[JOB_FAILED]: jobNoticeVars,
[JOB_STALLED]: jobStalledVars,
[CONTEXT_COMPOSITION]: contextCompositionVars,
[SUBAGENT_WAITING]: subagentWaitingVars,
display_currency: (detail) => ({ mode: detail || "auto" }),
@@ -22,11 +22,16 @@ export const NOTICE_TEXT: Record<string, string> = {
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
branch_model_unavailable: "分支保存的模型已不可用,本次改用其他已配置的模型;可以在模型设置中重新选择",
web_search_model_bad_ref: "搜索模型的格式应为 服务/模型,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_not_added: "所选搜索模型所在的连接已被移出,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_removed: "所选搜索模型已不在配置里,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_unsupported: "所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_no_credentials: "所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_backend_fallback: "搜索后端没有返回结果,已换用链里的下一个后端;详情见通知",
web_fetch_backend_fallback: "网页抓取后端没有返回内容,已换用链里的下一个后端;详情见通知",
web_fetch_backend_unusable: "配置的网页抓取后端无法使用,已跳过;检查用户配置里的 web_fetch_backends",
web_search_backend_unusable: "配置的搜索后端无法使用,已跳过;检查用户配置里的 web_search_backends 和 searxng_url",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
@@ -37,6 +42,7 @@ export const NOTICE_TEXT: Record<string, string> = {
job_finished: "后台任务已结束:{name}",
job_killed: "后台任务已终止:{name}",
job_failed: "后台任务 {name} 失败,需要处理",
job_stalled: "后台任务 {name} 已有 {quiet} 没往自己的输出里写任何内容:可能是在写文件,也可能卡住了;可以用 wait 或 bash_output 查看,或用 kill_shell 结束它",
compacted: "已压缩",
compact_declined: "无需压缩:{why}",
compact_failed: "压缩失败:{why}",
@@ -46,5 +52,7 @@ export const NOTICE_TEXT: Record<string, string> = {
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
extension_skipped: "扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展",
ask_timeout_invalid: "配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个",
fold_not_reused_history: "这个会话保存的压缩结果没有被复用:它所总结的历史之后发生了变化,现在发送的是完整历史,可能超出模型容量",
fold_not_reused_lineage: "这个会话保存的压缩结果是在另一个模型或工作区下生成的,与当前历史对不上,没有被复用,现在发送的是完整历史",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
@@ -22,4 +22,18 @@ describe("write claim attribution", () => {
expect(say({ code: "workspace.write_conflict" }, "")).not.toBe("");
});
}
for (const language of ["zh", "en"]) {
it(`says a repository folded the folders into one write domain, with the way out, in ${language}`, () => {
localStorage.setItem(STORAGE, language);
boot();
const folded = { contended: 0, heldMs: 0, idleMs: 0, holder: "A", holderSessionId: "s", paths: ["d:/home/ws"], requestedPaths: ["d:/home/ws"], repository: "d:/home/ws" };
const detail = workspaceLeaseDetail(folded);
expect(detail).toContain("d:/home/ws");
expect(detail).toContain(language === "en" ? "same Git repository" : "同一个 Git 仓库");
expect(detail).toContain("write_lease");
expect(workspaceLeaseDetail({ ...folded, repository: undefined })).not.toContain("write_lease");
expect(workspaceLeaseDetail(folded, false)).not.toContain("write_lease");
});
}
});
@@ -4,5 +4,6 @@ import { t } from "./index";
export function workspaceLeaseDetail(claim: WorkspaceLease, showHolder = true): string {
return [showHolder && (claim.holder || claim.holderSessionId) ? t("会话 {holder}({session})持有写入范围:{paths}", {
holder: claim.holder ?? "", session: claim.holderSessionId ?? "", paths: JSON.stringify(claim.paths ?? []),
}) : "", claim.requestedPaths?.length ? t("所需写入范围:{paths}", { paths: JSON.stringify(claim.requestedPaths) }) : ""].filter(Boolean).join(" · ");
}) : "", claim.requestedPaths?.length ? t("所需写入范围:{paths}", { paths: JSON.stringify(claim.requestedPaths) }) : "",
showHolder && claim.repository ? t("这些工作区在同一个 Git 仓库 {repo} 内,同一仓库共用一个写入域;把项目移出该仓库,或在用户配置里设 write_lease = \"optimistic\",才能并行写入", { repo: claim.repository }) : ""].filter(Boolean).join(" · ");
}
+19 -2
View File
@@ -7,14 +7,17 @@ import "./styles/tokens.css";
import "./styles/app.css";
import "./styles/dock.css";
import "./styles/studio.css";
import "./styles/planpeek.css";
import "./styles/vthumb.css";
import "./styles/dialogcenter.css";
import "./styles/stophint.css";
import "./styles/phonecomposer.css";
import "./styles/tips.css";
import "./styles/skillpin.css";
import "./styles/waitnote.css";
import "./styles/bring.css";
import "./styles/line.css";
import "./styles/notice.css";
import "./styles/peers.css";
import "./styles/contentmatches.css";
import "./styles/jsontext.css";
import "./styles/narrow.css";
import "./styles/workfold.css";
@@ -26,6 +29,7 @@ import "./styles/sponsor.css";
import "./styles/chart.css";
import "./styles/versions.css";
import "./styles/changedpill.css";
import "./styles/askskip.css";
import "./styles/pastekeep.css";
import "./styles/ctxitems.css";
import "./styles/sidepanel.css";
@@ -39,8 +43,10 @@ import "./styles/treerows.css";
import "./styles/workbench-changes.css";
import "./styles/gitgraph.css";
import "./styles/modelmenu.css";
import "./styles/composer-menu.css";
import "./styles/runsignals.css";
import "./styles/toasts.css";
import "./styles/overview.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
@@ -52,6 +58,7 @@ import { settled } from "./boot/gate";
import { failureView, type FailureView } from "./port/cloud_failure";
import { RemoteLinkError } from "./port/cloud_link";
import { RemoteNotice } from "./ui/RemoteNotice";
import { useStalePage } from "./ui/stale_page";
import { onRemoteConnectionEnded, remoteConnectionEnded, openRemoteHome, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
const failureAction = (view: FailureView) => (view.action === "home" ? openRemoteHome : () => location.reload());
@@ -63,11 +70,21 @@ function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }
if (!remote) return;
return onRemoteConnectionEnded(setEnded);
}, [remote]);
const stale = useStalePage(!!remote);
const reauth = ended?.kind === "reauth";
const specific = !reauth && ended?.failure ? failureView(ended.failure) : null;
return (
<>
<App hub={hub} />
{stale && !ended && (
<RemoteNotice
title={t("这个页面不是最新版")}
body={t("它是之前打开的旧版本,继续用可能出现莫名的报错。刷新一下就能换成最新版。")}
action={t("刷新页面")}
info
onAction={() => location.reload()}
/>
)}
{ended && remote && (
<RemoteNotice
title={reauth ? t("需要重新登录") : specific?.title ?? t("远程连接已断开")}
@@ -99,6 +99,55 @@ export interface BrowserToolsSettings {
path: string;
}
// How a sub-agent that declared no write_paths holds its claim, as the user
// file records it. A project file cannot set it, so there is one value.
export interface SubagentClaimsSettings {
optimistic: boolean;
path: string;
}
// How this machine's sessions hold the workspace write lease against one
// another, as the user file records it. A project file cannot set it. A running
// session keeps the lease it was built with, so a change reaches sessions
// opened after it.
export interface WriteLeaseSettings {
optimistic: boolean;
path: string;
}
// Whether a submitted plan starts executing without the approval click, as the
// user file records it. A project file cannot set it.
export interface PlanApprovalSettings {
auto: boolean;
path: string;
}
export interface PeerView {
key: string;
title: string;
project?: string;
}
export interface PeerHeld {
id: string;
fromSession: string;
fromTitle?: string;
fromProject?: string;
sentAt: string;
body: string;
}
export interface PeersState {
enabled: boolean;
peers: PeerView[];
held: PeerHeld[];
}
export interface PeerReceipt {
id: string;
to: PeerView;
}
export interface AutoArchiveSettings {
enabled: boolean;
days: number;
@@ -66,7 +66,9 @@ describe("remote Studio binary framing", () => {
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
await vi.advanceTimersByTimeAsync(0);
expect(String(fetchMock.mock.calls[0][0])).toBe(`/rt/one/events/replay?lastEventId=${Number.MAX_SAFE_INTEGER}`);
// No cursor is an attach, and an attach is answered with whatever a run is
// stopped on rather than with the stream's history.
expect(String(fetchMock.mock.calls[0][0])).toBe("/rt/one/events/replay");
expect(seen).toEqual([{ kind: "stream_watermark", seq: 7 }]);
await vi.advanceTimersByTimeAsync(1500);
@@ -78,6 +80,33 @@ describe("remote Studio binary framing", () => {
vi.useRealTimers();
});
it("hands an attaching page the question a run is waiting on", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response(JSON.stringify({
frames: [{ kind: "ask_request", ask: { id: "ask-1" } }],
complete: true,
watermark: 3,
}), { status: 200 }))
.mockResolvedValue(new Response(JSON.stringify({ frames: [], complete: true, watermark: 3 }), { status: 200 }));
const seen: Array<Record<string, unknown>> = [];
const source = new RemoteEventSource("/rt/one/events");
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
await vi.advanceTimersByTimeAsync(0);
// What a run is blocked on is not in the ordinary history a client resumes
// past, so a page that read only the log would draw the run as stopped with
// nothing to answer.
expect(seen).toEqual([{ kind: "ask_request", ask: { id: "ask-1" } }]);
await vi.advanceTimersByTimeAsync(200);
expect(String(fetchMock.mock.calls[1][0])).toBe("/rt/one/events/replay?lastEventId=3");
source.close();
fetchMock.mockRestore();
vi.useRealTimers();
});
it("resumes from the cursor its URL carries", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
@@ -129,7 +158,9 @@ describe("remote Studio binary framing", () => {
await vi.advanceTimersByTimeAsync(STREAM_SILENCE_MS * 3);
stop();
expect(opened).toBe(1);
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay\?/);
// Every read goes to the replay route: the attach carries no cursor, and
// every later read resumes from the watermark.
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay(\?|$)/);
fetchMock.mockRestore();
vi.unstubAllGlobals();
vi.useRealTimers();
+26 -8
View File
@@ -173,21 +173,27 @@ interface ReplayAnswer {
}
// Stands in for EventSource over the relay by polling /events/replay. A cursor
// the URL carries is where a replacement stream resumes; without one it attaches
// at the watermark, and asking past it returns no frames, not the whole log.
// Frames the SSE transport writes on its own (the watermark, a gap) are said
// here too, or the stream reads as silent to whoever watches it.
// the URL carries is where a replacement stream resumes. Frames the SSE
// transport writes on its own (the watermark, a gap) are said here too, or the
// stream reads as silent to whoever watches it.
export class RemoteEventSource {
onmessage: ((event: MessageEvent<string>) => void) | null = null;
private closed = false;
private after: number | null;
private readonly replay: string;
// What an attach asks for. Only the main event stream carries connection-local
// state to hand back — the pending prompt — so only its attach asks for
// nothing and is answered with that prompt. The tree stream reads the tree,
// not history, so its attach asks past the watermark as it always has:
// replaying the tree's history would only re-read the tree.
private readonly attachQuery: string;
constructor(url: string) {
const target = new URL(url, location.origin);
const cursor = Number(target.searchParams.get("lastEventId") ?? "");
this.after = target.searchParams.has("lastEventId") && Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : null;
this.replay = `${target.pathname}/replay`;
this.attachQuery = target.pathname.endsWith("/tree/events") ? `?lastEventId=${Number.MAX_SAFE_INTEGER}` : "";
queueMicrotask(() => void this.poll());
}
@@ -203,12 +209,15 @@ export class RemoteEventSource {
while (!this.closed) {
let active = false;
try {
const response = await fetch(`${this.replay}?lastEventId=${this.after ?? Number.MAX_SAFE_INTEGER}`, { credentials: "same-origin" });
// No cursor yet is an attach; the main stream's attach asks for nothing
// so the kernel answers with the waiting prompt, the tree stream's asks
// past the watermark. Every later read resumes from where it stands.
const query = this.after !== null ? `?lastEventId=${this.after}` : this.attachQuery;
const response = await fetch(`${this.replay}${query}`, { credentials: "same-origin" });
if (response.ok && !this.closed) {
const body = await response.json() as ReplayAnswer;
const watermark = typeof body.watermark === "number" ? body.watermark : null;
const attaching = this.after === null;
const frames = attaching ? [] : body.frames ?? [];
const frames = body.frames ?? [];
active = frames.length > 0;
if (this.after === null) this.after = watermark ?? 0;
else if (body.complete === false) {
@@ -303,6 +312,12 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
}
}
// ownFetch is the page's own fetch, bound before remoteHub replaces
// globalThis.fetch with one that routes same-origin requests over the tunnel.
// A hosted page has to read its own origin — the build it is running — not the
// kernel's, so anything that reads the page itself uses this and not the global.
export const ownFetch: typeof fetch = globalThis.fetch.bind(globalThis);
export async function remoteHub(deviceId: string): Promise<HubPort> {
const nativeFetch = globalThis.fetch.bind(globalThis);
let first: RemoteConnection;
@@ -321,7 +336,10 @@ export async function remoteHub(deviceId: string): Promise<HubPort> {
return new URL(request.url).origin === location.origin ? link.fetch(request) : nativeFetch(request);
}) as typeof fetch;
globalThis.EventSource = RemoteEventSource as unknown as typeof EventSource;
return new SseHub();
// RemoteEventSource is a polled stand-in for one stream. The multiplexed
// stream also needs an open frame and per-pane subscriptions, which polling
// cannot carry, so every pane here reads its own /events instead.
return new SseHub({ mux: false });
}
export const remoteCodec = {
@@ -0,0 +1,10 @@
// What holds the tokens of a prompt: the six classes by size, largest first,
// and the biggest single messages. active is the running turn, which a fold
// cannot take.
export interface ContextComposition {
total: number;
at: number;
active: number;
parts: { name: string; tokens: number }[];
largest?: { role: string; tool?: string; tokens: number; active?: boolean }[];
}
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SseHub, type RuntimeView } from "./hub";
import type { WireEvent } from "./wire";
// Every way a hub can be built has to put a frame the kernel sends into the
// pane's reducer. A change to the event transport is held to this table, which
// is where "the page stays connected and goes silent" gets caught.
const rt = { id: "r1", base: "/rt/r1", root: "/w", name: "w" } as RuntimeView;
interface Source { url: string; closed: boolean; say: (data: object) => void }
function kernel(opts: { muxAnswers: boolean }) {
const sources: Source[] = [];
const puts: string[] = [];
vi.stubGlobal(
"EventSource",
class {
onmessage: ((m: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly s: Source;
constructor(url: string) {
this.s = { url, closed: false, say: (data) => this.onmessage?.({ data: JSON.stringify(data) }) };
sources.push(this.s);
const answers = url.endsWith("/events/mux") ? opts.muxAnswers : true;
// A kernel that answers the mux path with the SPA shell opens a stream
// that never says anything, which is what an older kernel does.
if (answers && url.endsWith("/events/mux")) queueMicrotask(() => this.s.say({ kind: "mux_open", conn: "c1" }));
}
close() {
this.s.closed = true;
}
},
);
vi.stubGlobal("fetch", async (url: string, init: RequestInit = {}) => {
if (init.method === "PUT") puts.push(url);
return { ok: true, status: 204, json: async () => ({}) } as Response;
});
return {
sources,
puts,
live: (suffix: string) => sources.filter((s) => !s.closed && s.url.includes(suffix)),
};
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
const tick = (ms = 0) => vi.advanceTimersByTimeAsync(ms);
describe("a pane receives what its kernel sends", () => {
it("on a local hub, through the shared stream", async () => {
const k = kernel({ muxAnswers: true });
const got: WireEvent[] = [];
const stop = new SseHub().portFor(rt).subscribe((ev) => got.push(ev));
await tick();
expect(k.puts).toEqual(["/events/mux/c1/panes/r1"]);
k.live("/events/mux")[0].say({ pane: "r1", frame: { kind: "text", seq: 1, text: "hi" } });
expect(got.map((e) => e.kind)).toEqual(["text"]);
expect(k.live("/rt/r1/events")).toHaveLength(0);
stop();
});
it("on a hub reached over a relay, through the pane's own stream and never the shared one", async () => {
const k = kernel({ muxAnswers: true });
const got: WireEvent[] = [];
const stop = new SseHub({ mux: false }).portFor(rt).subscribe((ev) => got.push(ev));
await tick(20_000);
expect(k.sources.some((s) => s.url.endsWith("/events/mux"))).toBe(false);
k.live("/rt/r1/events")[0].say({ kind: "text", seq: 1, text: "hi" });
expect(got.map((e) => e.kind)).toEqual(["text"]);
stop();
});
it("on a kernel that answers the shared path with something else, through the pane's own stream", async () => {
const k = kernel({ muxAnswers: false });
const got: WireEvent[] = [];
const stop = new SseHub().portFor(rt).subscribe((ev) => got.push(ev));
await tick(11_000);
expect(k.live("/events/mux")).toHaveLength(0);
k.live("/rt/r1/events")[0].say({ kind: "text", seq: 1, text: "hi" });
expect(got.map((e) => e.kind)).toEqual(["text"]);
stop();
});
it("on every one of them, a second frame follows the first in order", async () => {
for (const [hub, muxAnswers] of [[new SseHub(), true], [new SseHub({ mux: false }), true], [new SseHub(), false]] as const) {
const k = kernel({ muxAnswers });
const got: number[] = [];
const stop = hub.portFor({ ...rt, id: `r${got.length}` } as RuntimeView).subscribe((ev) => got.push(ev.seq ?? 0));
await tick(11_000);
const source = k.live("/events/mux")[0] ?? k.live("/rt/")[0];
const say = (seq: number) => source.say(source.url.endsWith("/events/mux") ? { pane: "r0", frame: { kind: "tick", seq } } : { kind: "tick", seq });
say(1);
say(2);
expect(got).toEqual([1, 2]);
stop();
vi.unstubAllGlobals();
}
});
});
@@ -159,6 +159,15 @@ export interface FeedbackMine {
profile?: FeedbackProfile | null;
}
// One install an account holds, as the service lists it: the short id, when it
// was linked, and whether it is this machine. The install's token never comes
// with it.
export interface FeedbackLinkedInstall {
id: string;
linkedAt: string;
current: boolean;
}
// The window a refusal came from, as the service names it. A refusal carries
// these in its params; an identifier not listed here is still a window.
export const FEEDBACK_LIMIT = {
@@ -187,6 +196,10 @@ export const FEEDBACK_CODE = {
replyLimit: "feedback.reply_limit",
notReplyable: "feedback.not_replyable",
challengeRequired: "feedback.challenge_required",
accountRequired: "feedback.account_required",
linkConflict: "feedback.link_conflict",
linkLimit: "feedback.link_limit",
noInstall: "feedback.no_install",
offline: "feedback.offline",
unavailable: "feedback.unavailable",
internal: "feedback.internal",
@@ -442,6 +442,7 @@ export const SCRIPT: Beat[] = [
projectionTokens: 31_200,
coverageRequired: 12,
coverageMissing: 2,
remains: { total: 31_200, at: 160_000, active: 6_400, parts: [{ name: "tool_output", tokens: 14_800 }, { name: "reply", tokens: 9_100 }, { name: "user", tokens: 4_300 }], largest: [{ role: "tool", tool: "browser_read", tokens: 7_200 }] },
},
},
},
@@ -74,3 +74,15 @@ describe("host capabilities", () => {
expect(calls).toEqual([["GET", "/host/capabilities"]]);
});
});
describe("closing a pane the kernel no longer has", () => {
it("counts as closed, so a stale pane id never reaches the person as an error", async () => {
answer(404, JSON.stringify({ code: "request.not_found", error: "no runtime named r1", params: { kind: "runtime", name: "r1" } }));
await expect(new SseHub().close("r1")).resolves.toBeUndefined();
});
it("still surfaces every other refusal", async () => {
answer(500, JSON.stringify({ code: "internal", error: "teardown failed" }));
await expect(new SseHub().close("r1")).rejects.toBeInstanceOf(HttpError);
});
});
+33 -4
View File
@@ -142,11 +142,25 @@ export function treeQuery(window: TreeWindow | undefined): string {
// HubPort is the window's view of the kernel: which panes it drives and which
// folders it can open. One AgentPort hangs off each pane.
/** One conversation whose words matched, with the passage that matched. */
export interface HistoryHit {
path: string;
id: string;
title?: string;
workspace?: string;
snippet: string;
role: string;
messageIndex: number;
score: number;
}
export interface HubPort extends SharePort {
runtimes(): Promise<RuntimeView[]>;
open(req: { root?: string; sessionPath?: string }): Promise<RuntimeView>;
close(id: string): Promise<void>;
tree(window?: TreeWindow): Promise<TreeWorkspace[]>;
/** Conversations, in every workspace, that say these words. */
searchHistory(query: string): Promise<HistoryHit[]>;
onTreeChanged(listener: () => void): () => void;
addWorkspace(path: string): Promise<TreeWorkspace>;
removeWorkspace(path: string): Promise<void>;
@@ -229,7 +243,8 @@ export class SseHub implements HubPort {
// last list. The default matches the kernel's own until one arrives.
// Bindings the shell exposes are pane-independent; this reaches them without
// pretending to belong to a runtime.
private readonly shell = new SsePort();
private readonly shell: SsePort;
private readonly mux: boolean;
private readonly settings = new Map<string, AgentPort>();
// Questions seen this session, kept so an answer can carry the identities the
// kernel will check it against.
@@ -238,6 +253,11 @@ export class SseHub implements HubPort {
private dialing = 0;
private askTimer: ReturnType<typeof setInterval> | null = null;
constructor(options: { mux?: boolean } = {}) {
this.mux = options.mux !== false;
this.shell = new SsePort("", "", this.mux);
}
// A refusal carries a code, and it is the code that has words in the reader's
// language. Flattening the body into the message threw that away: what
// reached the screen was the raw JSON, or a filesystem error naming a path.
@@ -290,7 +310,12 @@ export class SseHub implements HubPort {
}
async close(id: string) {
await this.post<void>(`/runtimes/${encodeURIComponent(id)}/close`);
try {
await this.post<void>(`/runtimes/${encodeURIComponent(id)}/close`);
} catch (e) {
// A pane the kernel no longer holds is already closed.
if (!(e instanceof HttpError && e.reason?.code === "request.not_found")) throw e;
}
this.ports.delete(id);
this.settings.delete(id);
}
@@ -303,6 +328,10 @@ export class SseHub implements HubPort {
return this.get<TreeWorkspace[]>("/tree" + treeQuery(window));
}
searchHistory(query: string) {
return this.get<HistoryHit[]>("/history/search?q=" + encodeURIComponent(query));
}
addWorkspace(path: string) {
return this.post<TreeWorkspace>("/tree/workspaces", { path });
}
@@ -590,7 +619,7 @@ export class SseHub implements HubPort {
const held = this.settings.get(runtimeId);
if (held) return held;
const base = runtimeId ? `/host/settings/runtime/${encodeURIComponent(runtimeId)}` : "/host/settings";
const port = new SsePort(base);
const port = new SsePort(base, "", this.mux);
this.settings.set(runtimeId, port);
return port;
}
@@ -598,7 +627,7 @@ export class SseHub implements HubPort {
portFor(rt: RuntimeView): AgentPort {
const held = this.ports.get(rt.id);
if (held) return held;
const port = new SsePort(rt.base, rt.id);
const port = new SsePort(rt.base, rt.id, this.mux);
this.ports.set(rt.id, port);
return port;
}
@@ -82,3 +82,61 @@ describe("a stream that goes silent", () => {
stop();
});
});
describe("a stream that gives its connection up", () => {
it("closes the connection, keeps its place, and resumes from the last frame it folded", () => {
const opened = stubStreams();
vi.stubGlobal("fetch", async () => ({ ok: true, json: async () => ({ frames: [], complete: true }) }));
const seen: number[] = [];
const sub = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
opened[0].say('{"kind":"stream_watermark","seq":0}');
opened[0].say('{"kind":"turn_started","seq":1}');
sub.pause!();
expect(opened).toHaveLength(1);
expect(opened[0].closed).toBe(true);
// Silence while paused is not a dead stream: nothing replaces it.
vi.advanceTimersByTime(STREAM_SILENCE_MS * 3);
expect(opened).toHaveLength(1);
sub.resume!();
expect(opened).toHaveLength(2);
expect(opened[1].url).toBe("/events?lastEventId=1");
opened[1].say('{"kind":"turn_started","seq":1}');
opened[1].say('{"kind":"turn_done","seq":2}');
expect(seen).toEqual([1, 2]);
sub();
});
it("recovers the frames that went by while it was paused, in order", async () => {
const opened = stubStreams();
vi.stubGlobal("fetch", async (url: string) => {
expect(url).toContain("/events/replay?lastEventId=1");
return { ok: true, json: async () => ({ frames: [{ kind: "text", seq: 2 }, { kind: "turn_done", seq: 3 }], complete: true }) };
});
vi.useRealTimers();
const seen: number[] = [];
const sub = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
opened[0].say('{"kind":"stream_watermark","seq":0}');
opened[0].say('{"kind":"turn_started","seq":1}');
sub.pause!();
sub.resume!();
// The server's first word on the new connection states where it is: past the
// cursor, so the missing frames are fetched back before anything newer folds.
opened[1].say('{"kind":"stream_watermark","seq":3}');
opened[1].say('{"kind":"turn_started","seq":4}');
await new Promise((r) => setTimeout(r, 20));
expect(seen).toEqual([1, 2, 3, 4]);
sub();
});
it("says it has a hole when it paused before the stream ever stated a position", () => {
const opened = stubStreams();
const gaps: number[] = [];
const sub = new SsePort("", "r1").subscribe(() => {}, () => gaps.push(1));
sub.pause!();
expect(gaps).toEqual([]);
sub.resume!();
expect(gaps).toEqual([1]);
expect(opened[1].url).toBe("/events");
sub();
});
});
+38 -15
View File
@@ -5,27 +5,34 @@
export const STREAM_SILENCE_MS = 45_000;
const CHECK_MS = 5_000;
/** The disposer of a live stream, which can also give its connection back and
* take one again. A paused stream keeps whatever cursor `url` reads, so the
* connection that replaces it asks for exactly what went by in between. */
export type LiveStream = (() => void) & { pause: () => void; resume: () => void; holdsConnection?: () => boolean };
/** Holds one EventSource open and replaces it once it has gone silent. `url`
* names where to resume, so a replacement asks for what the dead one missed.
* Timers stop while a phone sleeps, so waking and coming back online check at
* once rather than on the next tick. */
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): () => void {
let es: EventSource;
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): LiveStream {
let es: EventSource | null = null;
let paused = false;
let heard = 0;
const open = () => {
heard = Date.now();
es = new EventSource(url(), { withCredentials: true });
es.onopen = () => {
const next = new EventSource(url(), { withCredentials: true });
es = next;
next.onopen = () => {
heard = Date.now();
};
es.onmessage = (m) => {
next.onmessage = (m) => {
heard = Date.now();
onData(m.data);
};
};
const check = () => {
if (Date.now() - heard < silenceMs) return;
es.close();
if (paused || Date.now() - heard < silenceMs) return;
es?.close();
open();
};
const wake = () => {
@@ -38,12 +45,28 @@ export function openLiveStream(url: () => string, onData: (raw: string) => void,
document.addEventListener("visibilitychange", wake);
addEventListener("online", check);
}
return () => {
clearInterval(timer);
if (dom) {
document.removeEventListener("visibilitychange", wake);
removeEventListener("online", check);
}
es.close();
};
const stop: LiveStream = Object.assign(
() => {
clearInterval(timer);
if (dom) {
document.removeEventListener("visibilitychange", wake);
removeEventListener("online", check);
}
es?.close();
},
{
pause: () => {
if (paused) return;
paused = true;
es?.close();
es = null;
},
resume: () => {
if (!paused) return;
paused = false;
open();
},
},
);
return stop;
}
+3
View File
@@ -75,6 +75,9 @@ export interface Appearance {
// rather than carrying a copy.
zoomRange?: ZoomRange;
readSize?: number;
// Interface text alone, as a scale over the stylesheet's sizes; zoom moves
// spacing and controls with it, this does not.
textSize?: number;
fontUi?: string;
fontMono?: string;
// How far prose may run: "standard" holds it to a reading line length,
+19 -28
View File
@@ -1,9 +1,10 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { PlanAction } from "./session";
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 { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace } from "./mock_workspace";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
@@ -11,8 +12,6 @@ import { MEMORIES } from "./mock_memory";
import { mockModels } from "./mock_models";
import { mockUsage } from "./mock_usage";
export class MockPort extends MockWorkspace implements AgentPort {
private listeners = new Set<(ev: WireEvent) => void>();
private log: WireEvent[] = [];
@@ -226,15 +225,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
async accountLogout(): Promise<void> {}
async workspaces(): Promise<WorkspaceInfo> {
return {
current: this.state.workspaceRoot ?? "",
canSwitch: true,
canIsolate: true,
recents: [
{ path: "~/projects/reasonix-site", name: "reasonix-site" },
{ path: "~/work/notes", name: "notes" },
],
};
return mockWorkspaces(this.state.workspaceRoot ?? "");
}
async setWorkspace(path: string) {
@@ -243,8 +234,13 @@ export class MockPort extends MockWorkspace implements AgentPort {
await this.newSession();
}
async isolateWorkspace() {
async worktreeSources(): Promise<import("./workspace").WorktreeSources> {
return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch);
}
async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) {
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
this.branchState.branch = "reasonix/delivery-demo";
}
async openInEditor() {
@@ -299,7 +295,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
return `目标:${draft.trim()}\n\n约束:不改变现有行为。\n\n完成标准:相关测试通过。`;
}
async complete(line: string, cursor: number): Promise<Completion> {
async complete(line: string, cursor: number, _signal?: AbortSignal): Promise<Completion> {
const before = line.slice(0, cursor);
const at = before.lastIndexOf("@");
if (at >= 0 && !/\s/.test(before.slice(at + 1)) && (at === 0 || /\s/.test(line[at - 1]))) {
@@ -322,7 +318,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
insert: "@" + dir + n,
descend: n.endsWith("/"),
kind: n.endsWith("/") ? "dir" : "file",
}));
})).filter((it) => it.insert !== line.slice(at, to));
return { kind: "ref", from: at, to, query: frag.slice(dir.length), items };
}
if (line.startsWith("/") && !/\s/.test(line)) {
@@ -385,7 +381,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] };
return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] };
}
async workspaceFile(path: string) { return { path, content: `// ${path}\n`, revision: "fixture" }; }
async workspaceFile(path: string, _root?: string): Promise<import("./port").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
workspaceImageURL(path: string) { return `/fixture/${path}`; }
async saveWorkspaceFile(file: import("./port").WorkspaceFile) { return { ...file, revision: file.revision + "-saved" }; }
@@ -655,6 +651,12 @@ export class MockPort extends MockWorkspace implements AgentPort {
this.state = { ...this.state, jobs: this.state.jobs?.map((j) => (j === job ? { ...j, status: "killed" } : j)) };
}
async jobOutput(jobId: string): Promise<JobOutput> {
const job = this.state.jobs?.find((j) => j.id === jobId);
if (!job || job.status !== "running") throw new HttpError(409, "no running background job with this id", { code: "job.not_running" });
return { text: "> dev\n> vite\n\n VITE ready in 312 ms\n Local: http://localhost:5173/\n", status: job.status, bytes: 66, truncated: false };
}
async retryQueued(itemId: string) {
void itemId;
}
@@ -787,14 +789,3 @@ export class MockPort extends MockWorkspace implements AgentPort {
this.state.goal = text;
}
}
// Which owner answers a pending approval. The kernel decides it from the
// approval itself — a recovery gate, the plan tool, otherwise the tool gate —
// and the fixture reads the same fields the card does rather than inventing a
// third rule.
function approvalKind(ev: WireEvent): "plan_approval" | "recovery_approval" | "tool_approval" {
const a = ev.approval;
if (a?.kind === "plan" || a?.tool === "exit_plan_mode") return "plan_approval";
if (a?.kind === "recovery") return "recovery_approval";
return "tool_approval";
}
@@ -0,0 +1,12 @@
import type { WireEvent } from "./wire";
// Which owner answers a pending approval. The kernel decides it from the
// approval itself — a recovery gate, the plan tool, otherwise the tool gate —
// and the fixture reads the same fields the card does rather than inventing a
// third rule.
export function approvalKind(ev: WireEvent): "plan_approval" | "recovery_approval" | "tool_approval" {
const a = ev.approval;
if (a?.kind === "plan" || a?.tool === "exit_plan_mode") return "plan_approval";
if (a?.kind === "recovery") return "recovery_approval";
return "tool_approval";
}
@@ -1,4 +1,4 @@
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
@@ -137,6 +137,55 @@ export class MockBoundary extends MockShell {
return { ...this.browser };
}
private claims: SubagentClaimsSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
async subagentClaims(): Promise<SubagentClaimsSettings> {
return { ...this.claims };
}
async saveSubagentClaims(optimistic: boolean): Promise<SubagentClaimsSettings> {
this.claims = { ...this.claims, optimistic };
return { ...this.claims };
}
private plans: PlanApprovalSettings = { auto: false, path: "/Users/you/.reasonix/config.toml" };
async planApproval(): Promise<PlanApprovalSettings> {
return { ...this.plans };
}
async savePlanApproval(auto: boolean): Promise<PlanApprovalSettings> {
this.plans = { ...this.plans, auto };
return { ...this.plans };
}
private lease: WriteLeaseSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
async writeLease(): Promise<WriteLeaseSettings> {
return { ...this.lease };
}
async saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings> {
this.lease = { ...this.lease, optimistic };
return { ...this.lease };
}
protected peerState: PeersState = { enabled: false, peers: [], held: [] };
async peers(): Promise<PeersState> {
return { ...this.peerState, peers: [...this.peerState.peers], held: [...this.peerState.held] };
}
async sendPeerMessage(to: string, text: string): Promise<PeerReceipt> {
const target = this.peerState.peers.find((p) => p.key === to || p.title === to);
if (!target) throw new Error("no session answers to that name");
return { id: `m-${text.length}`, to: target };
}
async resolvePeerMessage(id: string, _approve: boolean): Promise<void> {
this.peerState = { ...this.peerState, held: this.peerState.held.filter((h) => h.id !== id) };
}
private archive: AutoArchiveSettings = { enabled: false, days: 30, defaultDays: 30, path: "/Users/you/.reasonix/config.toml" };
async autoArchive(): Promise<AutoArchiveSettings> {
@@ -251,4 +300,8 @@ export class MockBoundary extends MockShell {
this.broken = null;
return { backup, problem: null };
}
private mode: UpdateMode = "manual";
async updateMode(): Promise<UpdateMode> { return this.mode; }
async setUpdateMode(mode: UpdateMode): Promise<UpdateMode> { this.mode = mode; return mode; }
}
+7 -1
View File
@@ -1,5 +1,5 @@
import { download } from "./download";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry } from "./port";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry, SkillUpdatePlan } from "./port";
import { MockMarket } from "./mock_market";
// The extension half of the fixture: skills, and the packages that bring them.
@@ -22,6 +22,7 @@ export class MockExtensions extends MockMarket {
{
name: "release-notes", description: "从提交历史起草发布说明",
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
license: "Apache-2.0", compatibility: "需要 git,并能访问网络", metadata: { author: "example-org", version: "1.0" },
},
{
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
@@ -71,6 +72,11 @@ export class MockExtensions extends MockMarket {
// real store keys them by repository, which a browser fixture cannot do.
protected localSkills = new Set<string>();
async planSkillUpdate(name: string): Promise<SkillUpdatePlan> {
return { name, source: "/home/demo/skills/" + name, state: "source_changed", changes: [], overwritesLocal: false, planId: "demo" };
}
async applySkillUpdate(): Promise<void> {}
async setSkillEnabled(name: string, enabled: boolean, scope: ScopeLayer = "project") {
const sk = this.skillList.find((x) => x.name === name);
if (!sk) return;
@@ -1,6 +1,6 @@
import { HttpError } from "./http_error";
import { MockCommit } from "./mock_commit";
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
@@ -80,6 +80,7 @@ const STATUS: Record<string, number> = {
[FEEDBACK_CODE.tooLarge]: 413, [FEEDBACK_CODE.rateLimited]: 429, [FEEDBACK_CODE.disabled]: 503,
[FEEDBACK_CODE.duplicate]: 409, [FEEDBACK_CODE.badToken]: 409, [FEEDBACK_CODE.offline]: 502, [FEEDBACK_CODE.unavailable]: 502, [FEEDBACK_CODE.internal]: 500, [FEEDBACK_CODE.busy]: 503, [FEEDBACK_CODE.imageMetadata]: 400,
[FEEDBACK_CODE.replyLimit]: 429, [FEEDBACK_CODE.notReplyable]: 409, [FEEDBACK_CODE.challengeRequired]: 403,
[FEEDBACK_CODE.accountRequired]: 401, [FEEDBACK_CODE.linkConflict]: 409, [FEEDBACK_CODE.linkLimit]: 409, [FEEDBACK_CODE.noInstall]: 409,
};
// A fault is a code, optionally followed by "@" and the window it names:
@@ -135,7 +136,10 @@ export class MockFeedback extends MockCommit {
this.announced += 1;
}
async replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt> {
readonly replyImages: number[] = [];
async replyFeedback(receipt: string, body: string, images: FeedbackImage[] = []): Promise<FeedbackReplyReceipt> {
this.replyImages.push(images.length);
const code = fault(REPLY_FAULT);
if (code === "feedback.invalid") {
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "empty" } });
@@ -153,6 +157,33 @@ export class MockFeedback extends MockCommit {
return { replyId, createdAt };
}
// The demo keeps its links in the tab: signed-in is the same switch account() reads.
private links: FeedbackLinkedInstall[] = [{ id: "ba9876543210", linkedAt: ago(9), current: false }];
private requireAccount(): void {
if (!localStorage.getItem("reasonix.mock.account")) throw refusal(FEEDBACK_CODE.accountRequired);
}
async feedbackLinks(): Promise<FeedbackLinkedInstall[]> {
this.requireAccount();
return this.links.map((l) => ({ ...l }));
}
async linkFeedbackAccount(): Promise<void> {
this.requireAccount();
if (!this.links.some((l) => l.current)) this.links = [...this.links, { id: "0123456789ab", linkedAt: new Date().toISOString(), current: true }];
}
async unlinkFeedbackAccount(): Promise<void> {
this.requireAccount();
this.links = this.links.filter((l) => !l.current);
}
async unlinkFeedbackInstall(id: string): Promise<void> {
this.requireAccount();
this.links = this.links.filter((l) => l.id !== id);
}
async feedbackSeen(receipt: string, upTo: number): Promise<void> {
if (this.rows.some((i) => i.receipt === receipt)) this.seen.set(receipt, Math.max(this.seen.get(receipt) ?? 0, upTo));
}
+5 -1
View File
@@ -1,5 +1,5 @@
import { MockMcp } from "./mock_mcp";
import type { HookCatalog, HookDryRun, HookEntry } from "./port";
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
// Hooks, and what a dry run of one reports back.
export class MockHook extends MockMcp {
@@ -8,6 +8,10 @@ export class MockHook extends MockMcp {
{ event: "PreToolUse", match: "bash", command: "./scripts/guard.sh", scope: "global", blocking: true, usesMatch: true },
];
async stopToolCall(_callId: string): Promise<void> {
throw new HttpError(409, "no running tool call with this id", { code: "tool_call.not_running" });
}
async hooks(): Promise<HookCatalog> {
return {
hooks: this.hookList.map((h) => ({ ...h })),
+5 -1
View File
@@ -1,5 +1,5 @@
import type { AgentPort } from "./port";
import type { HubPort, RuntimeView, TreeWindow, TreeWorkspace } from "./hub";
import type { HistoryHit, HubPort, RuntimeView, TreeWindow, TreeWorkspace } from "./hub";
import type { RemoteHost, RemoteListing, RemoteProbe } from "./remote";
import type { ShareStatus } from "./share";
import { MockPort } from "./mock";
@@ -210,6 +210,10 @@ export class MockHub implements HubPort {
return Promise.resolve();
}
searchHistory(_query: string): Promise<HistoryHit[]> {
return Promise.resolve([]);
}
placeWorkspace(path: string, before: string) {
const at = this.roots.indexOf(path);
if (at < 0) return Promise.resolve();
Loaded 100 of 1163 files, more files were not shown because too many files have changed in this diff. Show more