Compare commits

...
1946 Commits
Author SHA1 Message Date
YHH 31f230cf49 Merge pull request #13673 from esengine/docs/studio-2.44.0-notes
docs(release): studio 2.44.0 notes
2026-10-17 16:19:24 -07:00
esengine adc02158fb docs(release): studio 2.44.0 notes 2026-10-17 16:11:02 -07:00
YHH 481aa4a0eb Merge pull request #13672 from esengine/fix/shell-probe-test-hedge
test(boot): count launches of the planted bash, not hedged candidates, in the proven-shell effect test
2026-10-17 15:46:19 -07:00
esengine 7bf97efccb test(boot): count launches of the planted bash, not hedged candidates, in the proven-shell effect test 2026-10-17 15:27:48 -07:00
YHH b50bb0c29e Merge pull request #13470 from esengine/fix/13197-slash-catalog-memo
fix(skill): memoize slash-list discovery for two seconds
2026-10-17 15:21:14 -07:00
esengine 1c718f8547 test(skill): a skill added outside the store shows once the slash-list memo has aged out 2026-10-17 15:08:07 -07:00
YHH 1339e794d8 Merge pull request #13530 from esengine/fix/12900-adopted-hint
feat(desktop): say what a shipped report is on the feedback level card
2026-10-17 15:04:52 -07:00
YHH 6a6342446d Merge pull request #13668 from esengine/feat/open-document-default-app
feat(studio): open a local HTML, SVG or PDF file in the system default program
2026-10-17 15:04:23 -07:00
YHH f743552038 Merge pull request #13671 from esengine/feat/13526-provider-retries
feat(agent): agent.max_provider_retries sets how often a failed provider connection is retried, 0 until cancelled
2026-10-17 15:03:52 -07:00
esengine 188f100cc5 fix(serve): check an opened document's path against the roots as strings before touching the disk 2026-10-17 14:37:08 -07:00
YHH e1211b7715 Merge branch 'studio' into fix/12900-adopted-hint 2026-10-17 14:34:21 -07:00
YHH c926b5306b Merge branch 'studio' into fix/13197-slash-catalog-memo 2026-10-17 14:34:17 -07:00
esengine 0fe1bbc31b test(boot): name the status the retry probe returns 2026-10-17 13:17:16 -07:00
esengine 3b0ab63f4a feat(agent): agent.max_provider_retries sets how often a failed provider connection is retried, 0 until cancelled 2026-10-17 13:07:38 -07:00
YHH df91037c1c Merge pull request #13670 from esengine/feat/peers-wire-parity
test(repolint): hold the desktop's PeersSettings mirror to the kernel's
2026-10-17 12:59:33 -07:00
esengine 1bb970d834 test(repolint): hold the desktop's PeersSettings mirror to the kernel's 2026-10-17 12:48:51 -07:00
YHH 1593ac424b Merge pull request #13661 from esengine/a5/acp-mcp-session-control
feat(acp): reconnect and disconnect MCP servers per session, submit extension forms
2026-10-17 12:40:31 -07:00
esengine a4781fa524 Merge origin/studio into a5/acp-mcp-session-control 2026-10-17 12:26:20 -07:00
YHH 1d4c43e98b Merge pull request #13669 from esengine/test/machine-list-explicit-times
test(cli): the machine-list fixture keeps the update times it asks for
2026-10-17 12:23:12 -07:00
esengine 47addbe945 test(opendoc): hold the Windows opener's argument list whole, and make the opener tests path-portable 2026-10-17 12:15:49 -07:00
esengine c0cf65568c test(cli): the machine-list fixture keeps the update times it asks for 2026-10-17 12:10:41 -07:00
YHH 987c13b4cb Merge pull request #13667 from esengine/a5/goal-usage
feat(goal): show a goal's usage and stop cause in the desktop and the terminal
2026-10-17 12:05:31 -07:00
YHH 414bec6f4d Merge pull request #13664 from esengine/fix/pricecheck-deepseek-cny
feat(pricecheck): check DeepSeek's yuan page as well as the dollar one
2026-10-17 12:01:36 -07:00
esengine 42d4313907 feat(studio): open a local HTML, SVG or PDF file in the system default program 2026-10-17 11:56:49 -07:00
esengine 813884f9d2 feat(goal): show a goal's usage and stop cause in the desktop and the terminal 2026-10-17 11:38:18 -07:00
esengine d6b6acb309 feat(pricecheck): check DeepSeek's yuan page as well as the dollar one 2026-10-17 11:35:16 -07:00
YHH b2f9c25ae7 Merge pull request #13656 from esengine/fix/skill-warnings-once
fix(skill): write each distinct discovery diagnostic once per store
2026-10-17 11:31:31 -07:00
YHH 504465f8de Merge pull request #13657 from esengine/fix/narrow-line-limit
refactor(config): side conversations and queued prompts are held with the other agent limits
2026-10-17 11:14:40 -07:00
esengine 2301cb0fd7 feat(acp): scope the persistent MCP capabilities away, with the trust gate documented 2026-10-17 11:14:37 -07:00
esengine c1617dfc69 feat(acp): reconnect and disconnect MCP servers for one session, submit extension forms 2026-10-17 11:14:37 -07:00
YHH 876a4ae39d Merge pull request #13658 from esengine/docs/win-port-note
docs(release): word the Windows port note for users
2026-10-17 11:14:18 -07:00
esengine 299a10f39a docs(release): word the Windows port note for users 2026-10-17 11:03:48 -07:00
esengine 7c9d16c82f refactor(config): side conversations and queued prompts are held with the other agent limits 2026-10-17 10:59:04 -07:00
esengine fc501c8219 fix(skill): write each distinct discovery diagnostic once per store 2026-10-17 10:55:42 -07:00
YHH ee92b3f4d7 Merge pull request #13654 from esengine/a5/narrow-size
refactor(config): move the workspace grants out of heldScope.narrow
2026-10-17 10:53:54 -07:00
esengine e6cb790416 fix(skill): memoize slash-list discovery for two seconds 2026-10-17 10:48:44 -07:00
esengine fcb4264282 refactor(config): move the workspace grants out of heldScope.narrow 2026-10-17 10:43:07 -07:00
YHH 34efa76dd3 Merge pull request #13572 from esengine/test/resource-diag
test(boot): a red resource-tool run names what the schema cache held
2026-10-17 10:38:45 -07:00
YHH 43a515dc27 Merge pull request #13599 from esengine/fix/subagent-done-status
fix(studio): a finished sub-agent says it is handed back, not only how long it took
2026-10-17 10:38:34 -07:00
YHH 1caab22b6a Merge pull request #13651 from esengine/fix/subagent-view-image-by-own-model
fix(delegation): a child on an image model gets view_image whatever the parent's model reads
2026-10-17 10:36:02 -07:00
YHH af3e7e973b Merge pull request #13633 from esengine/feat/13526-queued-inputs
feat(inbox): the session queue holds as many prompts as asked unless the user sets agent.max_queued_inputs
2026-10-17 10:32:56 -07:00
YHH b4c126d66e Merge pull request #13648 from esengine/fix/tree-open-pane-identity
fix(serve): a pane belongs to a folder however the folder is spelled, and a pane with no row is logged
2026-10-17 10:28:49 -07:00
YHH 5e6fe553c7 Merge pull request #13649 from esengine/fix/win-port-retry-eacces
fix(cli): a Windows bind refused as forbidden moves the web server to the next port
2026-10-17 10:27:51 -07:00
YHH f0ab419902 Merge pull request #13646 from esengine/a5/acp-session-info
feat(acp): report session info and configured MCP servers
2026-10-17 10:22:41 -07:00
YHH d4f5a36c1b Merge pull request #13647 from esengine/test/pipe-status-product-bash
test(builtin): the pipe-status probe runs under the bash the product resolves
2026-10-17 10:20:41 -07:00
YHH 73dbd8e8c4 Merge pull request #13645 from esengine/fix/13565-versions-memo
fix(update): share one version-catalog read between Settings, the versions panel and the update check
2026-10-17 10:16:43 -07:00
esengine e7b2cbd8f5 fix(delegation): a child on an image model gets view_image whatever the parent's model reads 2026-10-17 10:14:32 -07:00
esengine c6b36276a8 fix(cli): a Windows bind refused as forbidden moves the web server to the next port 2026-10-17 10:08:40 -07:00
esengine 2fa53d3cc4 test(builtin): the pipe-status probe runs under the bash the product resolves, not the first bash on PATH 2026-10-17 10:07:06 -07:00
YHH 6f653cbfaf Merge pull request #13643 from esengine/a5/video-windows-skip
test(model): skip the swap-after-open video tests on Windows
2026-10-17 10:06:42 -07:00
esengine 2f1c6648b1 feat(acp): report session info and configured MCP servers, scope four lifecycle capabilities 2026-10-17 10:04:56 -07:00
YHH 75883d7f04 Merge pull request #13641 from esengine/chore/release-fragments-2.44
docs(release): merge the two session-messages fragments and rewrite the scratch-spill one
2026-10-17 10:04:00 -07:00
esengine 544d17f6dd fix(serve): a pane belongs to a folder however the folder is spelled, and a pane with no row is logged 2026-10-17 10:02:45 -07:00
esengine fafb92b935 test(model): skip the swap-after-open tests on Windows, which cannot replace an open file 2026-10-17 09:54:22 -07:00
YHH 9696d762ed Merge pull request #13602 from esengine/feat/mcp-signin-follow-ui
feat(desktop): sign in to an MCP server that answered 401, and follow a server's tool changes
2026-10-17 09:50:55 -07:00
esengine 9f1d20cd46 fix(update): share one version-catalog read between Settings, the versions panel and the update check 2026-10-17 09:50:12 -07:00
esengine 70e5060ec3 docs(release): merge the two session-messages fragments and rewrite the scratch-spill one 2026-10-17 09:49:20 -07:00
esengine b3b39feefe Merge origin/studio into feat/mcp-signin-follow-ui 2026-10-17 09:35:51 -07:00
YHH 9374e1eda7 Merge pull request #13617 from esengine/a5/video-native
feat: let read_video hand an MP4 to a model that watches video natively
2026-10-17 09:34:52 -07:00
YHH 37ccec6449 Merge pull request #13595 from esengine/feat/12356-follow-tool-changes
feat(mcp): follow a server's tool changes instead of accepting each one
2026-10-17 09:18:23 -07:00
YHH a75e30b5de Merge pull request #13637 from xxoingr/fix/desktop-account-panel-language
fix(desktop): the account panel says its failures in the window's language
2026-10-17 09:15:38 -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
xxoingr 783584d3f1 fix(desktop): the account panel says its failures in the window's language
Account caught with String(e), so a coded refusal such as
account.signin_disabled printed "Error: " and the kernel's English
fallback instead of the wording kernel.ts already has, and its
expired-request message was set without t() and had no English. The
port's accountPoll also hand-rolled its fetch and threw the raw
response text, a JSON envelope with no code to word.

Catch with reason(e) like every other panel, put the expiry message
through t() with an English entry, and send accountPoll through post0.
2026-10-18 00:01:00 +08:00
esengine 1010be9737 refactor(plugin): copy the approval map with maps.Copy 2026-10-17 09:00:29 -07:00
esengine 8d7870b067 feat(mcp): follow a server's tool changes instead of accepting each one 2026-10-17 09:00:29 -07:00
YHH d7707f5183 Merge pull request #13597 from esengine/feat/web-access-settings-ui
feat(desktop): web search and fetch backends are set from Settings, and consent to be billed is its own switch
2026-10-17 08:37:04 -07:00
YHH cb2059a3df Merge pull request #13634 from esengine/fix/brand-badge-outline
fix(studio): draw the STUDIO badge as an outline
2026-10-17 08:29:31 -07:00
YHH 26fcf1134a Merge pull request #13632 from esengine/fix/spxx-file-image
test(desktop): live checks that a local image and the side question are seen with the built-in browser open
2026-10-17 08:24:53 -07:00
esengine efc38208bd fix(perf): restore the web-access harness override's closing brace after the merge 2026-10-17 08:21:37 -07:00
esengine e644290329 fix(studio): draw the STUDIO badge as an outline 2026-10-17 08:03:05 -07:00
esengine 00fcacca11 Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/i18n/en.ts
#	desktop/frontend-next/src/styles/peers.css
2026-10-17 08:01:04 -07:00
YHH 4d3b21e333 Merge pull request #13571 from esengine/fix/queue-row-image-reason
fix(inbox): say why a queued line with a picture waits for the turn to end
2026-10-17 07:59:09 -07:00
YHH ac3085a257 Merge pull request #13583 from esengine/feat/peers-settings-ui
feat(desktop): session messages are switched on from Settings, and a held message can be let through for its project
2026-10-17 07:48:05 -07:00
esengine 4f8852dbd2 Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 07:33:06 -07:00
esengine 80dbb55361 test(desktop): the refusal is worded from the kernel's code now that its wording is on studio 2026-10-17 07:32:34 -07:00
esengine 5ffb22c1f5 Merge remote-tracking branch 'origin/studio' into feat/peers-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/port/mock_boundary.ts
#	desktop/frontend-next/src/port/port.ts
#	desktop/frontend-next/src/port/sse_boundary.ts
2026-10-17 07:24:27 -07:00
YHH a30c58ff94 Merge pull request #13631 from esengine/fix/price-fallback-explained
feat(studio): say why a price cannot be shown in the pinned currency, and offer the official one
2026-10-17 06:58:33 -07:00
esengine b225a3889f feat(inbox): the session queue holds as many prompts as asked unless the user sets agent.max_queued_inputs 2026-10-17 06:55:59 -07:00
esengine a56f1241e8 test(desktop): live checks that a local image and the side question are seen with the built-in browser open 2026-10-17 06:55:58 -07:00
YHH a4da20d96a Merge pull request #13629 from esengine/fix/feedback-reply-while-received-app
fix(studio): offer a reply on a released report that is still received
2026-10-17 06:48:06 -07:00
YHH 23708e88cb Merge pull request #13438 from chuiwenwei/fix/sessioninbox-read-failure-not-empty-queue
fix(sessioninbox): a queue that cannot be read is not an empty queue
2026-10-17 06:38:16 -07:00
YHH ef40245efc Merge pull request #13628 from esengine/fix/13605-ime-composition
fix(desktop): the agent's browser loads and navigates out of the window, so a composition in progress is not cut off
2026-10-17 06:32:52 -07:00
esengine 879c1c8ae6 feat(studio): say why a price cannot be shown in the pinned currency, and offer the official one 2026-10-17 06:29:27 -07:00
YHH abf120c2ce Merge pull request #13590 from esengine/feat/12357-mcp-signin
feat(mcp): sign-in asks which host the server sends you to before the browser opens
2026-10-17 06:22:13 -07:00
YHH b21ae6ee3c Merge pull request #13579 from esengine/fix/13560-cache-by-spec
fix(plugin): the MCP handshake cache keeps one entry per server name and spec
2026-10-17 06:20:59 -07:00
YHH 21ae289cc9 Merge pull request #13586 from SivanCola/fix/group-worktree-sessions
fix(studio): group worktree chats under their source project / 将隔离会话归入原项目
2026-10-17 06:18:59 -07:00
Will 3e9dcd0e53 test(sessioninbox): hold the transaction lock through filelock, not unix.Flock
Review follow-up: the audit helper used unix.Flock with no build tag, so lint's
typecheck and test (windows-latest) failed with 'undefined: unix.Flock'. Take the
lock through internal/base/filelock's TryAcquire, which has the same unix and
windows implementations the store uses, so the held-lock tests run on every platform.
2026-10-17 21:15:12 +08:00
YHH d46fa91cb5 Merge pull request #13585 from esengine/feat/peers-settings
feat(peers): a real switch for messages between sessions, applied at the next turn
2026-10-17 06:13:40 -07:00
esengine 3d69508aca test(desktop): drop the leftover debugging from the hung-navigation check 2026-10-17 06:09:11 -07:00
esengine ae8397df0a test(desktop): do not wait on a navigation that never answers 2026-10-17 06:07:07 -07:00
esengine 164bc5d258 wip debug3 2026-10-17 06:06:26 -07:00
esengine 1bd238e593 wip debug2 2026-10-17 06:05:44 -07:00
esengine f78b2a86fe fix(studio): offer a reply on a released report that is still received 2026-10-17 06:05:22 -07:00
esengine c3f2befaa3 wip debug 2026-10-17 06:05:05 -07:00
esengine 1ddfa7206a fix(desktop): a page held out of the window for a navigation comes back within a fixed limit 2026-10-17 06:04:23 -07:00
esengine 335ab3ad8b fix(plugin): forget sign-in consent through a root opened on the server's state directory 2026-10-17 06:00:37 -07:00
esengine 0a804c33ad docs(release): note for the input-method fix 2026-10-17 05:59:35 -07:00
esengine f8a6d19980 fix(desktop): the agent's browser loads and navigates out of the window, so the app page keeps the keyboard and its composition 2026-10-17 05:59:35 -07:00
esengine fd822b3623 test(desktop): a live check that an IME composition survives the agent's browser 2026-10-17 05:59:35 -07:00
esengine 1ac226ac6d fix(plugin): list a server's cache entries from the fixed directory, not a pattern holding the name 2026-10-17 05:59:28 -07:00
YHH 771427b639 Merge branch 'studio' into feat/peers-settings 2026-10-17 05:58:36 -07:00
esengine addbb5bf05 fix(model): hash the opened video before sending a byte of it 2026-10-17 05:48:17 -07:00
YHH 06c4340cc0 Merge pull request #13608 from esengine/a5/web-access-settings
feat(serve): read and save the web search and fetch settings over HTTP
2026-10-17 05:47:27 -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
YHH 55e73ec8fa Merge pull request #13612 from esengine/fix/13610-prefs-nav-selected-hover
fix(studio): keep the selected settings section's look while it is hovered
2026-10-17 05:44:37 -07:00
esengine c472732883 feat(desktop): web access settings read as read-only in a window that was not granted editing 2026-10-17 05:40:29 -07:00
esengine 957c90e3e8 fix(studio): keep the selected settings section's look while it is hovered 2026-10-17 05:33:10 -07:00
YHH b0c6cf2dd8 Merge pull request #13621 from xxoingr/fix/desktop-settings-refusal-codes
fix(desktop): keep the refusal's code when network or wallpaper settings fail
2026-10-17 05:32:13 -07:00
esengine 9a45df0c8b Merge remote-tracking branch 'origin/a5/web-access-settings' into feat/web-access-settings-ui 2026-10-17 05:31:51 -07:00
esengine abcf61d321 fix(serve): web access settings are written only with the edit grant 2026-10-17 05:30:56 -07:00
YHH ea7be33211 Merge pull request #13436 from chuiwenwei/fix/e2ebench-unsplit-runs-not-in-medians
fix(e2ebench): keep runs without a recorded split out of the boundary medians
2026-10-17 05:29:03 -07:00
YHH 610a6a99d9 Merge pull request #13623 from esengine/fix/13604-running-count
fix(desktop): a status read lost to the network is asked again, so a pane nobody is looking at stops reading as running
2026-10-17 05:24:29 -07:00
YHH 08198846ef Merge pull request #13622 from esengine/fix/scratch-spill-race
fix(sessionstore): scratch spill sweep keeps its directory so a concurrent spill is not truncated
2026-10-17 05:22:38 -07:00
YHH 1bf59ce841 Merge pull request #13618 from xxoingr/fix/desktop-port-shadowed-faces
fix(desktop): stop the real port's own copies from hiding its faces
2026-10-17 05:20:57 -07:00
YHH c4825325f6 Merge pull request #13620 from esengine/fix/flaky-oversize-spill-scratch
test(agent): give the scratch-spill test its own temp directory
2026-10-17 05:17:25 -07:00
YHH 7a1a9ef810 Merge pull request #13619 from esengine/fix/win-flake-walk-lock
test: Windows waits sized for a loaded runner
2026-10-17 05:13:59 -07:00
xxoingr b8ddd82efb fix(desktop): keep the refusal's code when network or wallpaper settings fail
saveNetwork, uploadWallpaper and clearWallpaper hand-rolled their fetch
and threw a plain Error, so the code the kernel sent never reached
reason(): an unsupported wallpaper read as the kernel's English fallback
in a Chinese window, a failed clear as a path and a status, and the
existing wording for network.rejected and wallpaper.* went unused. They
now go through post0 and del, whose fail() keeps it.

SaveNetworkSettings also stops prefixing its refusal with a Chinese
sentence: the frontend frames network.rejected itself, and the prefix
would read twice in one language and as a fragment in the other.
2026-10-17 20:09:15 +08:00
esengine c1992ffe7f fix(sessionstore): the scratch spill sweep leaves its directory in place so a concurrent spill is not truncated 2026-10-17 05:08:12 -07:00
esengine 7b726d70fa fix(desktop): a status read lost to the network is asked again, so a pane nobody is looking at stops reading as running 2026-10-17 05:04:26 -07:00
esengine a8bfcd0944 test(agent): give the scratch-spill test its own temp directory 2026-10-17 05:02:48 -07:00
xxoingr 8d267f265a fix(desktop): stop the real port's own copies from hiding its faces
SsePort still declared exportPlugin, dryRunHook, trustMcp, reconnectMcp,
parseMcp and probeProvider after they were split into its faces, and a
method on the outer class hides the face's. So the faces' dryRunHook and
probeProvider, which go through post0 to keep a refusal's code, never
ran: probeProvider threw the raw response text, and onboarding showed a
refusal envelope as its message and could never offer the vendor-console
link it keys on provider.probe.unauthorized. exportPlugin was the other
way round, the face holding the stale copy.

Drop the six copies, move the current exportPlugin into SseExtensions,
and guard the chain so no face redeclares one below it.
2026-10-17 20:02:29 +08:00
YHH 022b82d2d5 Merge pull request #13606 from esengine/a5/acp-paid-persist
fix(acp): offer a saved choice for paid search consent and keep it on a replayed card
2026-10-17 04:57:39 -07:00
Will d8c0c249cb fix(e2ebench): keep a run with no recorded split out of the correct-boundary medians
renderSolveProfiles folded every run that reached a correct checkpoint into
the boundary medians, including runs whose trajectory was never read. With
-checkpoints and no -trajectories those runs carry zero round and
verification tallies, so the report printed "rounds before 0 · rounds after
0 · verifications after 0" as if they had been measured.

Only runs whose split was actually recorded now fold in, and a suite where no
run recorded one says the boundary was never split instead of printing zeros.
The all-measured medians are unchanged.
2026-10-17 19:55:50 +08:00
esengine f40b87d144 test: Windows waits sized for a loaded runner (walk re-stat window, grant-lock helper retry) 2026-10-17 04:55:39 -07:00
YHH 132deb676b Merge pull request #13611 from esengine/a5/title-wait
fix(serve): a stale title result must not overwrite a newer one, and a changed first message is a new request
2026-10-17 04:53:46 -07:00
YHH 75a4e7e2fe Merge pull request #13598 from SivanCola/codex/workspace-flow
Complete workspace preparation and branch switching / 补齐工作目录准备与分支切换流程
2026-10-17 04:47:59 -07:00
YHH a45f8abe4d Merge pull request #13552 from esengine/feat/peer-threads
feat(desktop): an icon on each session this one has written to, saying where the exchange stands
2026-10-17 04:42:48 -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
esengine f11d657c32 Merge remote-tracking branch 'origin/studio' into feat/peer-threads
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 04:26:24 -07:00
SivanCola 5284c18764 fix: restore worktree preparation focus and truthful terminal states
Problem: creating a worktree dropped keyboard focus when the composer became
inert, returning to editing did not restore it, and terminal pages still
reported waiting or mapped cleanup failure to a confirmation state.

Root cause: draft restoration attempted focus before React removed inert;
the preparation page had no focus owner, phase fallbacks conflated terminal
states, and details/edit actions shared a configuration identity.

Fix: focus the preparation heading on entry, recover focus when a removed
phase action leaves the body active, preserve surviving detail focus, and
restore the composer only after the stage-clearing commit. Use distinct
view action IDs and explicit terminal labels, and show elapsed waiting only
for active phases without changing existing typography or button recipes.

Verification: failure/cancellation interaction regressions, phase/disclosure
unit tests, real-browser focus and draft-retention checks, English dark-page
labels and shared button hierarchy, frontend typecheck/build/census, and
repository lint/check; full frontend: 634 files and 5,257 tests passed.
No Git or backend behavior changed.
2026-10-17 19:25:03 +08: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
esengine 71cd039be4 Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 04:10:40 -07: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
SivanCola 865399363f fix: preserve MCP shutdown metadata and confine worktree rollback
Problem: hosted atomic coverage intermittently lost the MCP resource tool,
and CodeQL reported an unchecked filesystem probe during worktree cleanup.

Root cause: a queued cache callback read clients after Host.Close cleared
handshake metadata. Cleanup accepted a result path without independently
checking the trusted managed root used by its construction owner.

Fix: snapshot advertised capabilities and instructions before releasing the
deferred spawn; keep only persistence asynchronous. Capture the managed root
once for creation and rollback, validate a local relative descendant before
probing the filesystem, and retain registration and unchanged-head checks.

Verification: deterministic shutdown/cache and wrong-boundary regressions;
plugin atomic coverage and race tests; boot atomic coverage; worktree/serve
race checks; native Windows rollback cases; local CodeQL path query; root
lint, Windows lint and changed-file repository checks.
2026-10-17 19:05:38 +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
Will fd9792e536 i18n(desktop): translate inbox.foreign_run 2026-10-17 19:00:33 +08:00
esengine df88dde9ba fix(serve): a stale title result must not overwrite a newer one, and a changed first message is a new request 2026-10-17 03:45:53 -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
SivanCola f58bd3866b fix(worktree): normalize Windows rollback paths and explain recovery
Problem: failed or cancelled worktrees remain registered on Windows, and the
cleanup-failed screen leaves users without a concrete recovery procedure.
Root cause: native backslash paths are compared directly with Git porcelain's
slash paths. Cleanup uncertainty deliberately fences replacement creation.
Fix: normalize both registered and expected paths while preserving symlink
resolution and the unchanged-head guard. Cover native rollback and changed
branches, wire the Windows regressions into PR CI, and explain draft retention,
manual reconciliation, runtime closure and retry from the source folder.
Restore the existing design QA content and metadata, append only workspace
acceptance, and remove all eight screenshots from the final PR tree.
Verification: native Windows regression fails before the fix; the fixed worktree
package passes 36 tests with 6 platform skips. macOS worktree suite, focused
serve/worktree race tests, Windows lint, workflow contracts, 5,245 frontend tests,
typecheck/build, census and repository lint pass. Hosted CI still needs approval.
2026-10-17 18:37:23 +08:00
esengine a10b5f6758 Merge remote-tracking branch 'origin/studio' into feat/peers-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 03:28:54 -07:00
esengine f0a9ce4720 feat(serve): read and save the web search and fetch settings over HTTP 2026-10-17 03:28:46 -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
esengine b5c2f8216d Merge remote-tracking branch 'origin/studio' into feat/peer-threads
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 03:27:24 -07:00
esengine 8b536210dd fix(acp): offer a saved choice for paid search consent and keep it on a replayed card 2026-10-17 03:25:57 -07:00
SivanCola 595f9cbbef test(boot): await discovery before disabled MCP assertions
Problem: Windows smoke reported an empty live catalog in the disabled-MCP
effect test's fresh always-loaded phase; the fixture expected one read tool.

Root cause: Build returns while cold discovery and always-loaded preconnection
can still be in flight. The fixture immediately issued an explicit connection,
leaving competing handshakes and initial catalog publication in its setup.
The adjacent always-load effect tests already await the boot-owned connection.

Fix: Wait for that connection in cold and always-loaded phases before explicit
registration. Keep cached deferred startup explicit and retain all filtering,
restart, catalog and provider assertions. Report held tools and server catalog
diagnostics if the allowed tool is still missing.

Verification: Related disabled/always-loaded/held-definition effect tests pass
normally and under the race detector. go vet, make lint and make check pass.
Full builtin and boot suites pass as well.
Native Windows CI must confirm the replacement candidate.
2026-10-17 18:24:33 +08: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
SivanCola fdae95f92e fix(studio): fence stale worktree source reads and update palette guard
Problem: an explicit source refresh can repopulate the palette after switching
workspace, port or session, and an older initial read can overwrite a later read.
Root cause: only the initial fetch had a lifetime fence; manual reads published
without a shared generation. The browser guard still awaited a retired hover card.
Fix: put initial and manual reads behind one latest-read generation, invalidate
it on scope changes, and verify the unified palette before and after task delivery.
Verification: four deferred-response regressions fail without the fix and pass
with it; 633 frontend files / 5,245 tests pass, typecheck/build, census invariants
and fixture verification, repository checks, and browser palette interactions.
Upstream workflows remain pending repository-admin approval, not passed CI.
2026-10-17 18:17:44 +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
esengine 13e6f6fed0 Merge remote-tracking branch 'origin/feat/12356-follow-tool-changes' into feat/mcp-signin-follow-ui 2026-10-17 03:10:59 -07:00
esengine 21c9ecfec8 chore(desktop): leave followsChanges to the kernel PR that sends it 2026-10-17 03:10:58 -07:00
esengine 476c301df9 feat(desktop): sign in to an MCP server that answered 401, and follow a server's tool changes 2026-10-17 03:10:28 -07:00
YHH 42a50deb7e Merge branch 'studio' into feat/0b-peer-threads 2026-10-17 03:09:06 -07:00
SivanCola cc0c4beacf Merge latest Studio base into workspace preparation flow
Problem: the PR overlaps the current Studio font-scaling update.
Root cause: both changes replace the workspace stylesheet, and hub additions
bring its combined source over the repository file-size ceiling.
Fix: retain the new palette with shared, scalable typography tokens; preserve
all upstream behavior and extract hub close/shutdown ownership together.
Verification: 633 frontend files and 5,241 tests; typecheck/build; affected
worktree, serve, control and boot suites and race tests; Desktop Go suite;
make lint/check; fresh production browser palette with no console errors.
2026-10-17 18:05:08 +08:00
SivanCola 2d65cb07b0 feat(studio): complete branch and worktree preparation flow
Problem: workspace configuration disappeared after the first task and creating
an isolated worktree left the composer without visible progress or recovery.

Root cause: workspace selection, Git branch switching and runtime construction
had separate UI lifetimes, with no cancellable preparation/publication boundary.

Fix: keep one searchable workspace palette across conversation states; expose
real preparation phases with operation-ID reconciliation, retry and cancellation.
Serialize cancellation against publication, terminate checkout descendants and
clean unpublished resources before acknowledging cancellation. Preserve task
and attachment drafts, and reuse existing typography throughout status screens.

Verification: 5,231 frontend tests, typecheck and production build; root and
Desktop Go suites; affected backend race tests; lint and repository checks;
real Git browser checks for switch, failure/retry, cancellation and handoff.
Include light/dark and wide/narrow before-and-after screenshots and design QA.
2026-10-17 17:59:47 +08:00
esengine e0577c3381 fix(studio): a finished sub-agent says it is handed back, not only how long it took
The sub-agent list replaced the status with the duration whenever one was recorded. It now shows the status word and the duration, in the ok ink.
2026-10-17 02:59:28 -07:00
esengine 907aba3e90 Merge remote-tracking branch 'origin/studio' into feat/11577-text-size 2026-10-17 02:56:59 -07:00
SivanCola bc47cd69e9 fix: confine worktree registration reads to Git metadata
Problem: CodeQL still reported a path-injection alert at the worktree
registration read, and a registration symlink could read outside its metadata
directory before assigning the checkout to a project.

Root cause: Validating checkout identity and the registration backlink did not
constrain the filesystem operation that reads that backlink. The plain
os.ReadFile call followed symlinks outside the selected Git directory.

Fix: Validate the checkout metadata path relative to the shared repository,
open that directory through os.Root, and read the fixed gitdir entry through a
second root confined to this checkout's metadata. Preserve relative backlinks
and internal symlinks while refusing escaping registrations.

Verification: A real-Git symlink-escape regression fails on the original code
and passes with the fix. Worktree/workspace identity suites, grouped-project
HTTP regressions, focused race tests, Windows test compilation, go vet,
make lint and make check pass. Local CodeQL CLI 2.27.1 with go-queries 1.6.11
reproduces the exact remote alert before the change; afterward that alert is
removed and the same path-injection query introduces no new results.
Builtin and boot integration suites also pass.
2026-10-17 17:46:10 +08:00
esengine b2d1b1aa5a docs(release): name the change the web-access note belongs to 2026-10-17 02:41:56 -07:00
esengine ba51af6781 feat(desktop): web search and fetch backends are set from Settings, and consent to be billed is its own switch 2026-10-17 02:41:13 -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
esengine 0d13220cc2 refactor(plugin): copy the approval map with maps.Copy 2026-10-17 02:35:53 -07:00
SivanCola 76ee9db5e9 fix: keep checkout labels inside narrow title bars
Problem: Checkout labels could push a conversation title beyond its breadcrumb
at phone widths and high interface scale.

Root cause: The fixed-width directory badge consumed the title's reserved
space even after project and folder controls were hidden by the narrow layout.

Fix: Group project and checkout context in a shrinkable clipped flex child,
allow labels to ellipsize, and preserve the title's existing four-em floor.
Document the shared layout constraint without weakening browser guards.

Verification: The eight failing light/dark width-and-zoom cases now pass in
perf/narrow.mjs. Title spacing and overflow, intrinsic layout and hover guards,
12 related interaction tests, typecheck, production build, vet and repository
checks pass. The Go implementation is unchanged by this layout correction.
2026-10-17 17:33:20 +08:00
SivanCola dbc39493d6 fix: harden grouped checkout ownership and sync studio
Problem: Grouped projects could open the source after an explicit worktree
selection, hide source-alias history, omit inferred launcher exclusions, or
remain unusable when their source folder disappeared. Copied Git metadata
could also claim another checkout's project identity.

Root cause: Display identity was reused as execution and catalog authority.
Project inference duplicated an unbounded marker walk without validating
managed checkout containment or the reverse Git registration.

Fix: Preserve selected directories and all catalog members, choose a surviving
project directory, and use complete member roots for editor discovery and
launch. Reuse shared checkout identity and validate its managed boundary,
local prefix and registration backlink. Merge studio, move root resolution to
its project owner to preserve the file-size budget, and align the title guard
with the intended checkout label while retaining spacing and overflow limits.

Verification: Failing original-head HTTP/path regressions pass after the fix.
Frontend tests (629 files, 5,221 tests), production build, focused race tests,
go vet, make lint and make check pass. Added a 100-root tree benchmark and a
deterministic delayed-header test; repository suites and browser guards cover
adjacent behavior. Hosted checks must validate the replacement PR head.
2026-10-17 17:28:59 +08:00
esengine 29e3125d07 Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui 2026-10-17 02:27:36 -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 4db4e8b319 feat(mcp): follow a server's tool changes instead of accepting each one 2026-10-17 02:13:47 -07: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 a3337ebbf7 feat(mcp): sign-in asks which host the server sends you to before the browser opens 2026-10-17 01:52:04 -07:00
SivanCola 86c3630d17 fix: group managed worktree chats under their source project
Problem: Studio lists a managed worktree and its source checkout as two
same-named sidebar projects, scattering one project's conversations.

Root cause: Sidebar ownership, session catalogs, and project operations use
checkout paths as project identities even when they share a Git repository.

Fix: Resolve project ownership from managed checkout Git identity while
retaining each transcript and runtime working directory. Merge session
catalogs before paging and keep registration, ordering, capacity, and removal
consistent with the project group. Show the checkout type in the chat header
and mark isolated history without migrating persisted data.

Verification: Serve/worktree/workspace identity and builtin/boot suites,
focused race tests, Desktop Go tests, frontend tests and production build,
go vet, make lint, and make check passed. Browser interactions and an installed
macOS package verified grouping and original/isolated conversation navigation.
2026-10-17 16:51:22 +08: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
esengine b334cd31d9 fix(desktop): session message settings follow the kernel's refusal codes and its available flag 2026-10-17 01:42:53 -07:00
esengine 747c79dcc8 test(peers): compare wrapped errors with errors.Is 2026-10-17 01:27:16 -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 c127511bf2 feat(peers): English wording for the new refusal codes 2026-10-17 01:22:34 -07:00
esengine 5c27e9c6f3 docs(release): shorten the session-messages note 2026-10-17 01:16:32 -07:00
esengine 978914283b feat(desktop): session messages are switched on from Settings, and a held message can be let through for its project 2026-10-17 01:16:32 -07:00
esengine a0a1a860e8 feat(peers): a real switch for messages between sessions, applied at the next turn 2026-10-17 01:16:13 -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
esengine 14db96264c fix(plugin): the MCP handshake cache keeps one entry per server name and spec 2026-10-17 00:48:38 -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 0648b575d0 feat(serve): read and save the web search and fetch settings over HTTP 2026-10-17 00:30:03 -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
esengine 28af7a784f feat(inbox): say why a queued line with a picture is waiting for the turn to end
A queued message that froze an image cannot be admitted as mid-turn guidance. The manifest now records that (images), and the queue row says so instead of 'not delivered yet'.
2026-10-17 00:16:06 -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 a2d77f97f5 test(boot): a red resource-tool run names what the schema cache held 2026-10-17 00:07:14 -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 1dffc620bb Merge remote-tracking branch 'origin/studio' into feat/peer-threads
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/main.tsx
2026-10-16 21:28:53 -07:00
esengine 38c0e02514 refactor(desktop): the first-run read leaves App so the file stays inside its budget 2026-10-16 21:28:41 -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
esengine 955040469a feat(desktop): an icon on each session this one has written to, saying where the exchange stands 2026-10-16 21:20:53 -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 7ad1771a91 feat(desktop): say what a shipped report is on the feedback level card
The card showed "已落地 N 条" without saying what counts. One muted line
under the progress bar now states it.
2026-10-16 18:48:35 -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
esengine 6ca0c2cd53 fix(skill): memoize slash-list discovery for two seconds 2026-10-16 08:43:10 -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
Will 6ac611bdf1 fix(sessioninbox): the lock-free read may not adopt another run's queue
The transaction-free fallback loaded the disk manifest without the RunID
isolation the locked path applies, so a queue another run (or a previous run)
left behind could be dispatched while the lock was momentarily unavailable.
Refuse a foreign manifest with ErrForeignRun, which the dispatcher already maps
to a retry, and give the frontend a coded refusal for it.
2026-10-16 20:16:21 +08: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
Will 457c68e30d fix(sessioninbox): surface a queue read failure instead of an empty one
NextQueued returns (InboxItemMeta, bool, error) and, when the disk
transaction is unavailable, reads the published manifest without the lock
(commits land by atomic rename, so that snapshot is complete). The
dispatcher maps a read error to inboxDispatchRetry instead of reporting
the inbox idle.

Narrowed per review: the permanent 1s long stop (armInboxDispatchLongStop,
its constant, its arming and its stop/reset wiring) is removed, so the
retry schedule past the existing bounded burst is exactly what it was
before this branch. The dispatcher audit case is rewritten to assert the
in-scope contract - a failed queue read schedules a retry and does not
report idle, and the durable item survives - rather than delivery after
the burst was exhausted, which was the removed policy.
2026-10-16 19:29:33 +08: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
Will 71c7cacf55 fix(sessioninbox): report a queue that could not be read instead of an empty one
NextQueued discarded beginDiskTransactionLocked's error and answered from
the in-memory snapshot, so a transaction that could not run — another
process holding the lock, EACCES, ENOSPC, a transient EIO — looked exactly
like an empty queue. dispatchInboxOnce read that as inboxDispatchIdle and
stopped with the durable item still queued, permanently.

NextQueued now reports that failure as an error, and before it will claim
the queue is empty it falls back to reading the published manifest without
the transaction: commits land by atomic rename, so that snapshot is
complete, and repair work stays behind the lock. A genuinely empty or
paused queue is still idle.

The bounded burst (three attempts, ~750ms) used to abandon work its own
scans had seen. Past the burst the dispatcher now parks on a slow
production timer that keeps re-checking until the item advances or the
queue reads empty; reset and Close cancel the pending timer.

A read error maps to inboxDispatchRetry, so an unreadable queue retries
instead of ending dispatch.
2026-10-16 19:07:03 +08: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
YHH 5d784ed6d6 Merge pull request #13174 from esengine/release/2.41.0
docs(release): Studio 2.41.0 notes
2026-10-14 17:07:43 -07:00
esengine 3524a9fb6a docs(release): Studio 2.41.0 notes 2026-10-14 16:57:39 -07:00
YHH 16a07336fa Merge pull request #13173 from esengine/fix/stream-failure-recovery-flake
test(session): bound the stream-failure recovery turns by the hang budget
2026-10-14 15:40:44 -07:00
esengine 372acad69d test(session): bound the stream-failure recovery turns by the hang budget 2026-10-14 15:31:45 -07:00
YHH 0694a186ae Merge pull request #13172 from esengine/sweep/390-leftovers
fix(studio): narrow-width step rows, meter rail and config error block
2026-10-14 15:18:01 -07:00
esengine 690681194a fix(studio): narrow-width step rows, meter rail and config error block 2026-10-14 15:09:35 -07:00
YHH e5f5b75801 Merge pull request #13171 from esengine/docs/frag-13169
docs(release): fragment for the narrow-width layout fixes
2026-10-14 14:48:12 -07:00
YHH ca121eaf9b Merge pull request #13170 from esengine/sweep/tools-args
test(studio): i18n and fault sweeps take a viewport and wait for the settings control
2026-10-14 14:47:25 -07:00
esengine 90effbe6b2 docs(release): fragment for the narrow-width layout fixes 2026-10-14 14:40:43 -07:00
esengine 0d9e678ea8 test(studio): i18n and fault sweeps take a viewport, wait for the settings control and print it 2026-10-14 14:37:04 -07:00
YHH e0ca9a4779 Merge pull request #13169 from esengine/sweep/narrow
fix(studio): narrow-width layout and contrast defects found by the sweep
2026-10-14 14:29:57 -07:00
esengine 47831de6bf fix(studio): narrow-width layout and contrast defects found by the sweep 2026-10-14 14:22:05 -07:00
YHH 1345212f38 Merge pull request #13165 from esengine/fix/bwrap-probe-net
fix(sandbox): probe bubblewrap with the network flag the launch will use
2026-10-14 13:50:56 -07:00
YHH 34ebef5524 Merge pull request #13168 from esengine/perf/11885-effect-tests
test(boot): build SDK examples against the host go build cache
2026-10-14 13:08:40 -07:00
YHH b661446471 Merge pull request #13167 from esengine/docs/frag-13166
docs(release): fragment for keeping a folded paste as a file in the terminal
2026-10-14 13:05:50 -07:00
esengine ddedaffdf0 test(boot): build SDK examples against the host go build cache 2026-10-14 12:56:51 -07:00
esengine 0c45db5968 docs(release): fragment for keeping a folded paste as a file in the terminal 2026-10-14 12:55:33 -07:00
YHH 399e232141 Merge pull request #13166 from esengine/feat/13148-tui-paste
feat(tui): keep a folded paste as a file with Ctrl+G
2026-10-14 12:51:39 -07:00
esengine 108f41e16d feat(tui): keep a folded paste as a file with Ctrl+G 2026-10-14 12:43:24 -07:00
esengine 5418317cf8 test(plugin): pin that a confined MCP starts unconfined when bwrap cannot unshare the network 2026-10-14 12:37:47 -07:00
YHH ce7460d071 Merge pull request #13164 from esengine/docs/guide-allow-devices
docs(guide): document the sandbox device allowlist
2026-10-14 12:34:21 -07:00
esengine cda2915da4 fix(sandbox): probe bubblewrap with the network flag the launch will use 2026-10-14 12:32:52 -07:00
esengine 845edced30 docs(guide): document the sandbox device allowlist 2026-10-14 12:27:00 -07:00
YHH 4636880a69 Merge pull request #13163 from esengine/docs/frag-13162
docs(release): fragment for the keep-a-paste-as-a-file chip action
2026-10-14 12:02:55 -07:00
esengine 6dd6a23388 docs(release): fragment for the keep-a-paste-as-a-file chip action 2026-10-14 11:55:47 -07:00
YHH de12671c0f Merge pull request #13162 from esengine/feat/13148-paste-to-file
feat(studio): keep a long paste as a file on request
2026-10-14 11:37:55 -07:00
esengine d519e83100 test(paste): range over an integer 2026-10-14 11:26:20 -07:00
esengine abf8ec53b2 refactor(paste): move the paste chip's keep action into its own module 2026-10-14 11:26:20 -07:00
esengine 2fa2487e0d feat(paste): style the save action 2026-10-14 11:26:20 -07:00
esengine d6b5e34bc8 feat(paste): keep a long paste as a file on request 2026-10-14 11:26:20 -07:00
esengine 94dc1671c1 wip(paste): keep a long paste as a file on request 2026-10-14 11:26:20 -07:00
YHH 75f2d3c38f Merge pull request #13161 from esengine/feat/13157-allow-devices
feat(sandbox): bind user-allowed host device nodes into the Linux bash sandbox
2026-10-14 11:25:37 -07:00
YHH a7bf4b1205 Merge pull request #13160 from esengine/feat/13140-x25519-fallback
feat(remote): fall back to a software X25519 when the browser has none
2026-10-14 11:22:22 -07:00
esengine 9ed45e1e9f feat(sandbox): bind user-allowed host device nodes into the Linux bash sandbox 2026-10-14 11:11:32 -07:00
esengine 69400e57ac feat(remote): fall back to a software X25519 when the browser has none 2026-10-14 11:06:06 -07:00
YHH adb7bf8cc7 Merge pull request #13159 from esengine/fix/13152-say-which-path-check
fix(serve): resume says whether the transcript is gone, outside, or unreadable and why
2026-10-14 10:54:58 -07:00
YHH b3523730f5 Merge pull request #13158 from esengine/docs/frag-11851
docs(release): fragment for the naming model and per-role effort
2026-10-14 10:50:03 -07:00
YHH 922c1bfe85 Merge pull request #13155 from esengine/feat/13141-ideas-entry
feat(studio): point ideas and questions from the feedback dialog to Discussions
2026-10-14 10:46:18 -07:00
esengine a5e966e98e fix(serve): resume says whether the transcript is gone, outside, or unreadable and why 2026-10-14 10:44:14 -07:00
YHH f690a0537c Merge pull request #13156 from esengine/docs/release-fragments-2-41
docs(release): fragments for the user-visible changes merged since 2.40.0
2026-10-14 10:42:31 -07:00
esengine ffe680d3f5 docs(release): fragment for the naming model and per-role effort 2026-10-14 10:40:08 -07:00
YHH b9a87f8bda Merge pull request #11851 from Guan0923/codex/model-role-preferences
feat(studio): add an independent naming model and per-role reasoning effort
2026-10-14 10:37:43 -07:00
YHH 5247d0acdc Merge pull request #13154 from esengine/fix/oobe-skip-contrast
fix(studio): lift the intro hint and the changed-files counts over AA contrast
2026-10-14 10:37:18 -07:00
esengine 1ff55e8026 docs(release): fragments for the user-visible changes merged since 2.40.0 2026-10-14 10:32:41 -07:00
esengine e788d83765 feat(studio): point ideas and questions from the feedback dialog to Discussions 2026-10-14 10:28:02 -07:00
YHH fd9c4e44dd Merge branch 'studio' into codex/model-role-preferences 2026-10-14 10:23:10 -07:00
esengine 9d34d5ecab fix(studio): lift the intro hint and the changed-files counts over AA contrast 2026-10-14 10:18:47 -07:00
YHH f8c262da03 Merge pull request #13151 from esengine/feat/13148-precheck
feat(compaction): refuse a fold that cannot fit before paying for its summary
2026-10-14 10:04:07 -07:00
YHH be0305d665 Merge pull request #13150 from esengine/feat/13110-spec-name
feat(skill): report front matter that departs from the Agent Skills spec
2026-10-14 09:57:17 -07:00
esengine f5a442c133 feat(compaction): refuse a fold that cannot fit before paying for its summary 2026-10-14 09:55:00 -07:00
esengine 7d15f23c63 feat(skill): report front matter that departs from the Agent Skills spec 2026-10-14 09:47:08 -07:00
YHH 4bee13505c Merge pull request #13149 from esengine/fix/13148-compaction-accept
feat(compaction): say what holds a rejected candidate above the trigger
2026-10-14 09:41:47 -07:00
esengine ce60d21610 feat(compaction): say what holds a rejected candidate above the trigger 2026-10-14 09:33:56 -07:00
YHH 4db0f505d0 Merge pull request #13147 from esengine/fix/12948-recall-stale-fact
fix(memory): re-read the facts the last recall returned before recalling again
2026-10-14 09:29:39 -07:00
YHH bc6f415d98 Merge pull request #13144 from xxoingr/docs/guide-tui-keys-as-built
docs(guide): describe the terminal keys 2.x has
2026-10-14 09:27:59 -07:00
YHH 5d985236c2 Merge pull request #13146 from esengine/feat/bwrap-unrunnable
feat(sandbox): a bwrap that cannot be started is not reported as a denied namespace
2026-10-14 09:25:35 -07:00
esengine 99fe3dbbf1 fix(memory): re-read the facts the last recall returned before recalling again 2026-10-14 09:19:58 -07:00
YHH d7fa4c3b0f Merge pull request #13143 from esengine/feat/13052-context-parts
feat(studio): open a context class to the messages that made it heavy
2026-10-14 09:18:58 -07:00
YHH f1003c74a0 Merge pull request #13139 from esengine/feat/13108-list-ttl
feat(mcp): re-read a tool catalog when the server's ttlMs runs out
2026-10-14 09:10:19 -07:00
YHH 98ebd85258 Merge pull request #13145 from esengine/fix/13140-x25519-fallback
fix(remote): say the browser is too old when it has no X25519
2026-10-14 09:09:31 -07:00
esengine ee27c844d7 feat(sandbox): a bwrap that cannot be started is not reported as a denied namespace 2026-10-14 09:06:03 -07:00
YHH e591109795 Merge pull request #13138 from esengine/feat/bwrap-typed-reason
feat(sandbox): say why bubblewrap is unusable: missing, or installed but denied a namespace
2026-10-14 09:03:16 -07:00
esengine 612f4ad550 feat(mcp): re-read a tool catalog when the server's ttlMs runs out 2026-10-14 09:00:45 -07:00
esengine 7668609b08 fix(remote): say the browser is too old when it has no X25519 2026-10-14 08:59:37 -07:00
xxoingr 717baf5ecd docs(guide): describe the terminal keys 2.x has
The CLI/TUI shortcut tables in docs/GUIDE.md and docs/GUIDE.zh-CN.md still
described four 1.x behaviours the 2.x terminal UI does not have (#12144
lists them as missing):

- Up/Down "navigate queued follow-up feedback" while a turn runs; in 2.x
  they recall history there too (recallAtEdge has no running case), and
  queued items are managed with /queue.
- Esc "un-sends a just-submitted turn before any reply"; escape() cancels a
  running turn, clears input, or leaves ! shell mode, and nothing un-sends.
- Composer text selection by drag with replace-on-type; onMouse ignores a
  left click below the transcript viewport, so only transcript text is
  selectable, and Ctrl+C copies only that.
- The rewind picker's f (fork); rewindActions has b c d s u.

Two tests pin the rows as now written: Up recalls while a turn runs, and
Esc on an empty composer leaves shell mode.
2026-10-14 23:54:03 +08:00
esengine efdbf04694 feat(studio): open a context class to the messages that made it heavy 2026-10-14 08:54:02 -07:00
esengine 7d75708986 feat(sandbox): say why bubblewrap is unusable: missing, or installed but denied a namespace 2026-10-14 08:39:42 -07:00
YHH 33730cb13c Merge pull request #13137 from esengine/feat/13132-row-quick-actions
feat(studio): opt-in hover Archive and Delete buttons on session rows
2026-10-14 08:39:03 -07:00
YHH 258269c30c Merge pull request #13136 from esengine/perf/12533-migrations-once
perf(boot): read the user config once for the startup migrations and leave an untouched config unlocked
2026-10-14 08:29:34 -07:00
esengine 212388b682 feat(studio): opt-in hover Archive and Delete buttons on session rows 2026-10-14 08:28:25 -07:00
YHH 2af6ef5422 Merge pull request #13135 from esengine/feat/13110-allowed-tools-spec
feat(skill): accept space-separated allowed-tools through one shared splitter
2026-10-14 08:24:39 -07:00
esengine cc6c305649 perf(boot): read the user config once for the startup migrations, and leave an untouched config unlocked 2026-10-14 08:21:07 -07:00
YHH 351c1fa9a3 Merge pull request #13128 from esengine/feat/12927-side-session
feat(session): a read-only side question beside the main conversation
2026-10-14 08:18:21 -07:00
esengine ebca87f6ec feat(skill): accept space-separated allowed-tools through one shared splitter 2026-10-14 08:14:23 -07:00
YHH c7715a4a7a Merge pull request #13133 from esengine/fix/13125-missing-file
fix(studio): a requested file that is not there is said so, not opened as a blank preview
2026-10-14 08:04:44 -07:00
esengine d9424dacdc fix(side): refusal copy, dev proxy route and the direct-runner entry for side conversations 2026-10-14 08:03:53 -07:00
esengine c812fbc2e5 feat(session): a read-only side question beside the main conversation 2026-10-14 08:03:48 -07:00
YHH 59fc67f43a Merge pull request #13016 from SivanCola/fix/12950-memory-directory-errors
fix(memory): report unreadable store directories / 修复记忆目录读取错误被忽略
2026-10-14 07:58:17 -07:00
YHH fdee835f9e Merge pull request #13131 from esengine/fix/13129-wallpaper-side
fix(studio): the sidebar footer takes the rail's tint over a wallpaper instead of a plate of its own
2026-10-14 07:56:44 -07:00
esengine ecbbbcf428 fix(studio): a requested file that is not there is said so, not opened as a blank preview 2026-10-14 07:54:22 -07:00
YHH 38620addcc Merge pull request #13130 from esengine/feat/13114-acp-tool-name
feat(acp): name the invoked tool on tool_call updates
2026-10-14 07:47:15 -07:00
esengine 106ee3670a fix(studio): the sidebar footer takes the rail's tint over a wallpaper instead of a plate of its own 2026-10-14 07:46:30 -07:00
esengine 8f137304cf feat(acp): name the invoked tool on tool_call updates 2026-10-14 07:36:33 -07:00
YHH 20e300871f Merge pull request #13127 from esengine/fix/13107-home-root
fix(serve): do not list a launch folder nobody picked and nothing was said in
2026-10-14 07:32:13 -07:00
YHH 2688a5b34b Merge pull request #13123 from xxoingr/test/plugin-oauth-removal-budget
test(plugin): bound the mid-browser OAuth removal by the hang budget
2026-10-14 07:27:53 -07:00
YHH ebe1858ad2 Merge pull request #13122 from xxoingr/test/session-lock-release-budget
test(session): let a released save lock be waited for under load
2026-10-14 07:27:28 -07:00
YHH 6de37f872e Merge pull request #13121 from xxoingr/test/plugin-writable-roots-windows
test(plugin): give the writable-roots host-context tests absolute paths on Windows
2026-10-14 07:27:02 -07:00
esengine b3e683ad75 fix(serve): do not list a launch folder nobody picked and nothing was said in 2026-10-14 07:18:44 -07:00
YHH a12e814476 Merge pull request #13124 from esengine/fix/openwith-test-windows
test(openwith): skip fakes of a POSIX disk on a Windows host
2026-10-14 06:52:46 -07:00
esengine 5c7fc811a1 test(openwith): skip fakes of a POSIX disk on a Windows host 2026-10-14 06:43:23 -07:00
xxoingr c587f9e7dc test(plugin): bound the mid-browser OAuth removal by the hang budget
TestAuthorizeHTTPMCPDoesNotHoldStateLockDuringBrowser failed on Windows
CI (studio run 38416026313) with "removal during browser flow blocked on
OAuth state lock" after 1.09 s. The test runs ReconcileHTTPMCPOAuthAfterRemoval
from inside openURL and gave it one second. AuthorizeHTTPMCP holds no lock
there (captureMCPOAuthGeneration releases its lock before the browser
opens), so a slow removal on a loaded runner read as a held lock. Starting
the removal 1.2 s late reproduces the message.

The wait is now testenv.Budget(t). A lock really held across the browser
is still caught: the removal's own lock wait gives up after five seconds
and returns an error, which fails the test.
2026-10-14 21:39:58 +08:00
xxoingr d2bf651d53 test(session): let a released save lock be waited for under load
TestSaveSnapshotSucceedsWhenCrossProcessFileLockReleasesBeforeDeadline
failed on Windows CI (studio run 38502171680) with "lock session file:
session file lock held" after 1.28 s. A goroutine releases the competing
lock after 30 ms and the save waits at most 500 ms for it; on a loaded
runner the release came later than that. Releasing after 600 ms
reproduces the message.

The save returns as soon as the lock is free, so the wait now is
testenv.Budget(t). TestSaveSnapshotBoundsCrossProcessFileLockWait keeps
its 40 ms, since it asserts that a lock never released makes the save
fail.
2026-10-14 21:33:21 +08:00
xxoingr 64501d34bf test(plugin): give the writable-roots host-context tests absolute paths on Windows
TestHostContextCarriesTheExtraWritableFoldersToLocalServersOnly and
TestHostContextCapsHowManyWritableFoldersAreTold fail on every Windows run
since e6f70622b (studio run 38537039922): their roots are written as
"/data/shared" and "/data/000", which have no drive and so are not
absolute on Windows, and metaWritableRoots drops every root that is not.
The key is never sent and both tests see nil.

The roots are now built from the temp directory's volume, the repo's
usual way to spell an absolute path on any OS. The same change makes
TestHostContextSaysNothingOfWritableFoldersWhenThereAreNone exercise the
workspace exclusion on Windows: its "/w" was dropped as relative there,
so it passed whether or not the workspace was excluded.
2026-10-14 21:30:34 +08:00
YHH ce2a950525 Merge pull request #13112 from esengine/release/2.40.0
docs(release): Studio 2.40.0 notes
2026-10-14 05:55:26 -07:00
esengine f207c88732 docs(release): Studio 2.40.0 notes 2026-10-14 05:46:49 -07:00
YHH e89d7be726 Merge pull request #13101 from esengine/feat/12788-turn-narration
feat(agent): a setting for how much the model says between tool calls
2026-10-14 05:44:34 -07:00
esengine dc0274076d feat(agent): a setting for how much the model says between tool calls 2026-10-14 05:34:37 -07:00
Guan09 24f9b73cd7 feat(studio): add an independent naming model and per-role reasoning effort
Add a separately selectable naming model and adjustable reasoning preferences
for chat roles, with typed roles/efforts responses and user-only role settings.

Keep title generation fixed off with temperature zero and a 60-token budget;
reject explicit title effort instead of storing an unused setting. Use the
configured startup chat model when no naming model is selected, including
servers without a running controller.

Integrate the on-demand naming and rename paths from #11591 with grouped title
provider state. Preserve default-model persistence, per-profile model overrides,
busy-save notices, and inherited effort validation against the executing model.

Share the model-preference row's existing layout declarations to keep the CSS
line count below the studio baseline without changing rendered layout.

Preserve each provider's saved effort when a role has no stored preference;
use the model default only for explicitly selected auto. Add a real boot
assembly effect test for planner and guardian-derived reviewer/evaluator
providers, while leaving subagent effort inheritance unchanged.
2026-10-14 20:27:50 +08:00
YHH 3892810d1d Merge pull request #13100 from esengine/feat/12858-open-with
feat(studio): open a project in another program (#12858)
2026-10-14 05:17:26 -07:00
YHH d33f88bba5 Merge pull request #13104 from esengine/fix/13094-page-assets-fixed-mime
fix(serve): the page's files keep their declared type whatever the machine registered
2026-10-14 05:08:51 -07:00
esengine dcaa53ba07 Merge studio into feat/12858-open-with 2026-10-14 05:02:11 -07:00
YHH faa0d5bc6e Merge pull request #13103 from esengine/feat/12959-shared-meter
feat(studio): a shared meter, with the queue's capacity limits on it
2026-10-14 05:00:48 -07:00
esengine 0423298c95 fix(serve): the page's files keep their declared type whatever the machine registered 2026-10-14 04:59:05 -07:00
esengine 2e5e4f9bec feat(studio): a shared meter, with the queue's capacity limits on it 2026-10-14 04:50:40 -07:00
esengine 71f648e2b6 fix(openwith): resolve links before judging whether a program is inside a project 2026-10-14 04:50:23 -07:00
YHH 3df18bdb5f Merge pull request #13096 from esengine/feat/12736-local-config-export
feat(studio): export and import the configuration as an encrypted file (#12736)
2026-10-14 04:45:55 -07:00
YHH b9db827dd4 Merge pull request #13098 from esengine/feat/fault-report-prefill
feat(studio): report a page defect with its error name and where it came from (#13055)
2026-10-14 04:45:36 -07:00
YHH acd58cdcaf Merge pull request #13091 from esengine/feat/13077-scroll-jump
feat(studio): jump-to-top / jump-to-latest buttons in the conversation (#13077)
2026-10-14 04:45:26 -07:00
esengine 7f60b15571 Merge studio into feat/fault-report-prefill 2026-10-14 04:36:56 -07:00
esengine e6ac8c9add feat(studio): open a project in another program
Refs #12858
2026-10-14 04:32:29 -07:00
YHH 0633035468 Merge pull request #13097 from esengine/feat/12964-hide-legacy-sessions
feat(studio): a setting to keep 1.x sessions out of the sidebar lists
2026-10-14 04:25:29 -07:00
YHH 7b10eefb3d Merge pull request #13095 from esengine/feat/13067-one-workbench-entry
feat(studio): one way into the workbench (#13067)
2026-10-14 04:19:37 -07:00
esengine 298bb73476 test(configbackup): drop a fixture assumption 2026-10-14 04:19:13 -07:00
YHH dbf2764c14 Merge pull request #13093 from esengine/fix/13055-no-null-lists
fix(serve): answer an empty list as [] in branches, event replay and the queue (#13055)
2026-10-14 04:18:55 -07:00
esengine 9702283b4d fix(configbackup): ask the disk only about plain absolute words in a backup 2026-10-14 04:18:54 -07:00
esengine 61e43ef64d Merge studio into feat/13077-scroll-jump 2026-10-14 04:18:02 -07:00
esengine be15d7a6d0 feat(studio): report a page defect with its error name and where it came from
Refs #13055
2026-10-14 04:16:20 -07:00
esengine bbcf3dff69 feat(studio): a setting to keep 1.x sessions out of the sidebar lists 2026-10-14 04:12:30 -07:00
esengine 72e4c58b63 feat(studio): an imported file starts with only what this machine lacks ticked 2026-10-14 04:11:43 -07:00
esengine c88d079723 feat(studio): export and import the configuration as an encrypted file
Refs #12736
2026-10-14 04:10:39 -07:00
esengine c044e821c0 test(serve): build the read list in one append 2026-10-14 03:59:40 -07:00
YHH 4f7b9fd280 Merge pull request #13088 from esengine/feat/13076-add-project-opens-session
feat(studio): adding a project selects it and opens a new session (#13076)
2026-10-14 03:57:25 -07:00
esengine aeb46aeb4e feat(studio): one way into the workbench
The title-bar Browser button and the Workbench tab opened the same column but
the button also asked for a blank browser page. The tab is now the only entry.
2026-10-14 03:56:09 -07:00
YHH 4c10ec72de Merge pull request #13089 from esengine/feat/13075-ttft
feat(agent): report time to first token and show it in the speed detail (#13075)
2026-10-14 03:54:08 -07:00
YHH 32132d3bf2 Merge pull request #13090 from esengine/fix/13086-nil-transport-in-interface
fix(plugin): a failed transport build is a nil interface, not a typed nil
2026-10-14 03:52:43 -07:00
esengine 8bd7dccc14 fix(studio): keep the jump buttons and the dock tab off the scroll rail
The buttons sit left of the rail, and the shut dock's tab sits flush with the
window edge instead of over the rail's ticks. The browser guard asserts no two
of the buttons, rail, tab and composer intersect at four widths.
2026-10-14 03:46:54 -07:00
esengine 3dad9ceb74 fix(serve): answer an empty list as [] in branches, event replay and the queue
Refs #13055
2026-10-14 03:41:38 -07:00
esengine c34c9c31b4 Merge studio into feat/13076-add-project-opens-session 2026-10-14 03:35:00 -07:00
esengine 312e7e9247 feat(studio): floating jump-to-top / jump-to-latest buttons in the conversation
Refs #13077
2026-10-14 03:34:26 -07:00
YHH a22ee52eaa Merge pull request #13087 from esengine/fix/13055-list-reads-tolerate-missing
fix(studio): treat a missing list in three reads as empty (#13055)
2026-10-14 03:32:39 -07:00
esengine 06eb4c34a6 fix(plugin): a failed transport build is a nil interface, not a typed nil 2026-10-14 03:28:01 -07:00
esengine 1bd4ddfe38 feat(agent): report each round's time to first token and show it in the speed detail
Refs #13075
2026-10-14 03:24:38 -07:00
esengine 6ca9b32695 feat(studio): adding a project selects it and opens a new session in it
Refs #13076
2026-10-14 03:15:29 -07:00
YHH 7a92d0cb30 Merge pull request #13084 from esengine/feat/13065-skip-setup
feat(studio): let first-run setup be put off (#13065)
2026-10-14 03:12:19 -07:00
esengine 30d5487c71 docs(release-notes): keep the stack-logging note, add the list-read note separately 2026-10-14 03:08:07 -07:00
esengine 73e4709340 fix(studio): treat a missing list in three reads as empty
Refs #13055
2026-10-14 03:07:27 -07:00
YHH 9fe33e78b3 Merge pull request #13062 from esengine/feat/13047-corner-notices
feat(studio): show non-blocking failures as dismissible corner notices with a report action
2026-10-14 02:55:14 -07:00
esengine 80c2b8dea6 feat(studio): let first-run setup be put off, with a way back above the input box
Refs #13065
2026-10-14 02:47:15 -07:00
YHH 877f021c16 Merge pull request #13080 from esengine/feat/13056-chunked-upload
feat(remote): carry a request too large for one relay message as numbered parts
2026-10-14 02:40:24 -07:00
YHH 37f320a4ef Merge pull request #13082 from esengine/feat/release-notes-language-switch
feat(studio): switch a release's notes between Chinese and English
2026-10-14 02:37:35 -07:00
esengine 7959dfe77e Merge studio into feat/13047-corner-notices 2026-10-14 02:35:16 -07:00
YHH 60f28d0405 Merge pull request #13073 from esengine/feat/12052-wait-names-holder
feat(delegation): say whose write claim a queued sub-agent is waiting on
2026-10-14 02:34:40 -07:00
YHH 0f119c9c5c Merge pull request #13074 from esengine/fix/13043-add-folder-visible
fix(studio): keep the add-folder button on the machine row visible without hover
2026-10-14 02:32:51 -07:00
esengine 5436dc9ae1 feat(studio): switch a release's notes between Chinese and English 2026-10-14 02:28:39 -07:00
YHH 574f69bdde Merge pull request #13079 from esengine/fix/13068-session-row-quiet
fix(studio): drop the hover archive and delete icons from session rows
2026-10-14 02:14:27 -07:00
esengine 3dc9249204 feat(remote): carry a request too large for one relay message as numbered parts 2026-10-14 02:06:04 -07:00
esengine 6e2647b8f6 fix(studio): drop the hover archive and delete icons from session rows 2026-10-14 02:04:15 -07:00
esengine 5eb6691ae0 Merge studio into feat/13047-corner-notices
# Conflicts:
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/i18n/en_composer.ts
2026-10-14 01:56:48 -07:00
esengine 9be7ea6cbc fix(studio): keep the add-folder button on the machine row visible without hover 2026-10-14 01:51:47 -07:00
YHH 7b13eaef56 Merge pull request #13071 from esengine/chore/13055-fault-stack
chore(studio): log the stack of a page fault that reaches the pane's error bar
2026-10-14 01:42:31 -07:00
esengine 9eea85f892 feat(delegation): say whose write claim a queued sub-agent is waiting on 2026-10-14 01:39:40 -07:00
YHH 4ac6a344a8 Merge pull request #13070 from esengine/fix/13056-remote-upload-limit
fix(remote): refuse a request too large for the relay link instead of dropping the link
2026-10-14 01:37:06 -07:00
YHH 2701be6327 Merge pull request #13069 from esengine/feat/12291-chart-render-p0
feat(studio): write values beside sparse chart marks
2026-10-14 01:32:23 -07:00
esengine 478131f826 chore(studio): log the stack of a page fault that reaches the pane's error bar 2026-10-14 01:30:41 -07:00
esengine 566ff0121b fix(remote): refuse a request too large for the relay link instead of dropping the link 2026-10-14 01:26:58 -07:00
esengine 4ce75ea335 feat(studio): write values beside sparse chart marks 2026-10-14 01:22:55 -07:00
YHH e7f55a8415 Merge pull request #13064 from esengine/feat/12926-model-menu-filter
feat(studio): set provider headings apart and filter the model menu by provider
2026-10-14 01:16:14 -07:00
YHH cc5d129293 Merge pull request #13063 from esengine/fix/13060-outside-workspace-write-is-a-gap
fix(checkpoint): a write outside the workspace is a coverage gap, not a recording failure
2026-10-14 01:12:10 -07:00
esengine 18e2a47e19 fix(studio): anchor the corner notices above the composer and guard it in a browser 2026-10-14 01:10:25 -07:00
esengine 751a697589 feat(studio): show non-blocking failures as dismissible corner notices with a report action 2026-10-14 01:10:25 -07:00
YHH 4a4718c7bf Merge pull request #13058 from esengine/feat/13053-writable-roots-meta
feat(mcp): tell a local stdio server the extra writable folders
2026-10-14 01:04:33 -07:00
esengine 431a87652e feat(studio): set provider headings apart and filter the model menu by provider 2026-10-14 01:04:18 -07:00
YHH fb99d78c33 Merge pull request #13059 from esengine/fix/13047-errbar-clears
fix(studio): clear a status-read error when that read next succeeds
2026-10-14 01:04:05 -07:00
esengine 186b2ee2df fix(checkpoint): a write outside the workspace is a coverage gap, not a recording failure 2026-10-14 01:00:22 -07:00
YHH 79597b19db Merge pull request #13031 from esengine/feat/13027-open-agent-page-externally
feat(studio): open the page the agent is working on in the default browser
2026-10-14 00:54:31 -07:00
YHH 7a1113d5e6 Merge pull request #13018 from esengine/fix/context-calibration-per-model
fix(agent): the gauge follows the provider's count, not the larger estimate
2026-10-14 00:46:34 -07:00
esengine 426b16f95a fix(studio): clear a status-read error when that read next succeeds 2026-10-14 00:45:50 -07:00
esengine e6f70622b5 feat(mcp): tell a local stdio server the extra writable folders 2026-10-14 00:40:52 -07:00
esengine f757912b74 Merge studio into feat/13027-open-agent-page-externally
# Conflicts:
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/i18n/en.ts
2026-10-14 00:37:11 -07:00
esengine c0b0770a4a test(boot): report prompt tokens the way a provider counts them in the not-a-digest fixture 2026-10-14 00:36:47 -07:00
YHH e7ce2480c3 Merge pull request #13054 from esengine/fix/13044-branch-tip-over-menu
fix(studio): hide the branch hover card while the branch menu is open
2026-10-14 00:36:39 -07:00
YHH af6d91a9bb Merge pull request #13050 from esengine/fix/13045-settings-nav-quiet
fix(studio): quiet the settings sidebar's selected row and separate section titles from items
2026-10-14 00:32:12 -07:00
esengine cd2a779d3a Merge studio into fix/context-calibration-per-model 2026-10-14 00:31:54 -07:00
YHH cdf8fad699 Merge pull request #13049 from esengine/fix/13041-search-toggle
fix(studio): make the session-filter magnifier a real toggle
2026-10-14 00:23:25 -07:00
esengine b1d5f81459 fix(studio): quiet the settings sidebar's selected row and separate section titles from items 2026-10-14 00:22:29 -07:00
esengine a6eeef486b fix(studio): hide the branch hover card while the branch menu is open 2026-10-14 00:15:19 -07:00
YHH 9da4afb553 Merge pull request #13032 from esengine/codex/cua-installed-launch
fix(computer): recover hidden applications through CUA launch
2026-10-14 00:09:25 -07:00
esengine a318c609b2 fix(studio): make the session-filter magnifier a real toggle 2026-10-14 00:05:55 -07:00
YHH da5e098968 Merge pull request #13042 from esengine/fix/13036-rowcount-covers-buttons
fix(studio): let the faded row count pass the pointer through to the row actions
2026-10-13 23:56:44 -07:00
esengine 3bd031f4ea fix(studio): let the faded row count pass the pointer through to the row actions 2026-10-13 23:46:42 -07:00
esengine 820e25e333 fix(computer): compare launched executables by file, not by 8.3 or long spelling on Windows 2026-10-13 23:43:43 -07:00
YHH a88cb60824 Merge pull request #13040 from esengine/fix/13034-signal-rail-merge
fix(studio): merge crowded signal marks into one counted marker
2026-10-13 23:27:21 -07:00
YHH ab30b0c994 Merge pull request #13037 from esengine/fix/13013-stuck-compaction
feat(agent): say what holds an over-threshold context that cannot fold
2026-10-13 23:23:28 -07:00
YHH 829ca39c79 Merge pull request #12962 from hasandenizuk/fix/12915-remove-last-provider
test(config): cover removing the last provider
2026-10-13 23:14:28 -07:00
esengine 44761d3444 fix(studio): merge crowded signal marks into one counted marker 2026-10-13 23:10:09 -07:00
esengine 04d6f39378 test(computer): cover the abort transport, window and cancellation bounds of Act, and launchable listing 2026-10-13 23:05:01 -07:00
esengine d257e677c3 chore(memory): annotate the host-built directory reads for CodeQL 2026-10-13 23:01:37 -07:00
esengine 5d4e335e9e feat(agent): say what holds an over-threshold context that cannot fold 2026-10-13 22:53:44 -07:00
YHH 5ae84e2b89 Merge pull request #13033 from esengine/feat/11965-capture-window
feat(studio): capture the app window into a feedback report
2026-10-13 22:50:41 -07:00
esengine 16de6cfdde Merge studio into fix/12950-memory-directory-errors 2026-10-13 22:43:00 -07:00
esengine e0c8ac0723 fix(memory): report a regular file in a directory's place on Windows too
Windows answers "not found" for a file where a directory should be, or
beneath one, so the not-exist shortcut hid the very case the directory read
error exists for. The stat of the entry and its parent tells them apart.
2026-10-13 22:42:53 -07:00
YHH ebca63af9f Merge pull request #13028 from esengine/feat/12913-git-graph
feat(studio): show the commit graph in the workbench explorer
2026-10-13 22:42:29 -07:00
esengine 47c44e9298 feat(studio): capture the app window into a feedback report 2026-10-13 22:33:40 -07:00
esengine f4cdcba372 fix(studio): word the git.history_failed refusal in the kernel table 2026-10-13 22:33:01 -07:00
YHH 1c6550f75f Merge pull request #13029 from esengine/feat/12771-quote-file-selection
feat(studio): add text selected in an open file to the conversation from the right-click menu
2026-10-13 22:06:08 -07:00
yhh b0e85b06cf fix(computer): restore installed applications through CUA 2026-10-13 22:00:14 -07:00
esengine 2a8b0c4f03 feat(studio): open the page the agent is working on in the default browser 2026-10-13 21:58:49 -07:00
esengine 33b017c710 fix(studio): a quoted file's name cannot end the host line or close its bracket 2026-10-13 21:50:06 -07:00
esengine 32a641107f test(gitcmd): pin that the history log runs no repository-configured program 2026-10-13 21:48:04 -07:00
esengine 49b3aa4d53 feat(studio): add text selected in an open file to the conversation from the right-click menu 2026-10-13 21:44:13 -07:00
esengine 2b47ae1fbd feat(studio): show the commit graph in the workbench explorer
A read-only History read in gitstatus, a /workspace/git/log endpoint, and a
collapsed Commit history section that lays the commits out in lanes.
2026-10-13 21:29:23 -07:00
YHH 55ab4e8d92 Merge pull request #13021 from esengine/codex/cua-driver-performance
fix(computer): reduce CUA latency and normalize Windows app identities
2026-10-13 21:28:46 -07:00
YHH 5f8eae49ec Merge pull request #13022 from esengine/feat/image-preview-13012
fix(studio): share original image previews across conversation entrances
2026-10-13 21:25:55 -07:00
YHH 078359533b Merge pull request #13014 from esengine/fix/13002-subagent-row-title
feat(studio): name each sub-agent row by its task
2026-10-13 21:25:43 -07:00
YHH 8761026022 Merge pull request #13024 from esengine/feat/12300-project-order
feat(studio): reorder projects by dragging a row
2026-10-13 21:25:23 -07:00
YHH 62814aae7c Merge pull request #13026 from esengine/fix/13017-smaller-unread-dot
fix(desktop): draw the Windows unread dot smaller, smooth and ringed
2026-10-13 21:21:10 -07:00
YHH ebbddbac5a Merge pull request #13025 from esengine/fix/13020-explorer-bigger
fix(studio): make the workbench explorer's text and icons easier to read and hit
2026-10-13 21:19:04 -07:00
esengine f5867f5103 fix(desktop): draw the Windows unread dot smaller, smooth and ringed 2026-10-13 21:12:05 -07:00
esengine b98b4f7201 fix(studio): make the workbench explorer's text and icons easier to read and hit 2026-10-13 21:08:46 -07:00
esengine c5e6a5c3ae feat(studio): reorder projects by dragging a row
The move endpoint also takes a place to land before; the row turns pointer
events into that place and shows a landing line while dragging.
2026-10-13 21:07:29 -07:00
esengine b1a67e195d wip: workspace place test 2026-10-13 21:07:29 -07:00
esengine 4309227e31 fix(agent): the gauge follows the provider's count, not the larger estimate
With an anchor the reading is the reported prompt size plus the growth since.
The estimate can sit far above the provider's count for a whole session.
2026-10-13 21:00:42 -07:00
YHH 2bd6a19565 Merge pull request #13019 from esengine/fix/13011-surface-replay-truncation
fix(serve): a log kept to its cap is not a failed surface restore
2026-10-13 20:58:51 -07:00
esengine b522bd0b8b docs(studio): keep the image preview notes, drop the embedded screenshots
Screenshots belong in the pull request; add the release note.
2026-10-13 20:53:28 -07:00
yhh 65d6442012 fix(computer): reduce CUA discovery and observation overhead 2026-10-13 20:51:27 -07:00
SivanCola 5910c148f8 fix(studio): share original image previews across conversation entrances
Problem: Studio user-message and composer images only showed thumbnails,
while tool screenshots used a separate enlargement overlay.

Root cause: The three entrances did not share a preview interaction owner.

Fix: Use a common image trigger and body-portaled viewer with original
sources, keyboard access, focus restoration, safe double clicking, explicit
closing and unavailable-image handling. Remove the tool-only overlay and
document the restored attachment parity with main-v2.

Verification: 541 frontend test files and 4611 tests pass; typecheck,
production build, census verification, make lint, make check and go vet
pass. Builtin and boot Go tests pass. Chromium checks cover light/dark,
wide/narrow windows and 150% interface zoom. The browser guard is wired
into Studio CI. Correct the perf fixture to await committed language state.
2026-10-13 20:50:18 -07:00
esengine d0be627024 fix(serve): a log kept to its cap is not a failed surface restore
The wire log records whether a surface frame was dropped; the surfaces route
answers 200 with an incomplete mark only then, and an unreadable history has a
code of its own.
2026-10-13 20:48:11 -07:00
esengine 732ec45bfe feat(studio): name each sub-agent row by its task
The kernel puts the task description's first line on the delegation profile and
the popover row shows it, truncated, with the full text on hover.
2026-10-13 20:37:22 -07:00
YHH fcc9363a17 Merge pull request #13015 from esengine/carry/13004-black-gold-icons
fix(studio): restore black-and-gold application and tray icons (carries #13004)
2026-10-13 20:32:03 -07:00
SivanCola 39a1e8b432 fix(memory): surface unreadable store directories
Problem: permission and storage failures can hide existing memory revisions,
make restores report them as missing, and compute recovery revisions from an
incomplete history.

Root cause: directory discovery conflated every ReadDir error with absence.

Fix: unify all eleven store walks behind a typed reader that treats only
missing directories as empty. Propagate errors through reads, save guards,
recovery, recall, controller commands, and HTTP responses. Complete recovery
and legacy duplicate discovery before writing. Preserve persisted formats.

Verification: failing-first permission fixture; eleven-site permission,
I/O, and non-directory regressions; unchanged files on failed recovery or
save; missing-directory compatibility; tool/controller/HTTP and bilingual
error coverage; Go tests, vet, lint, repository checks, frontend tests and
typecheck, and Windows memory test cross-compilation.

Fixes #12950
2026-10-14 11:23:56 +08:00
SivanCola c184d41602 docs(studio): announce restored black-and-gold icons
Problem: the pending icon restoration was missing from the next-release
notes, so users would not be told why the application artwork changed.

Root cause: the original restoration commit only included icon assets.

Fix: add a bilingual release-note fragment covering the application,
taskbar/Dock, launcher and tray icons without changing any artwork.

Verification: the 16 release-note rendering/publication tests, repository
lint, Go vet, changed-file checks and diff whitespace checks pass.
2026-10-13 20:23:24 -07:00
SivanCola 0d92555c24 fix(studio): restore black-and-gold application and tray icons
Problem: Studio 2.39.0 replaced the expected black-and-gold application,
taskbar, Dock, launcher, and tray artwork with blue-and-yellow icons.

Root cause: the shared SVG and all generated platform resources were
recoloured together in #12897.

Fix: restore the black-and-gold SVG and its ten generated icon files,
preserving the platform crop, ICO/ICNS sizes, and Windows tray DPI scales.

Verification: regenerate all assets and compare their bytes with the
pre-#12897 artwork; decode and visually inspect all 26 raster sizes;
run the Electron unit suite, Go vet, repository lint, and changed-file
checks, plus the builtin and boot suites. No application or packaging
logic changes.
2026-10-13 20:23:24 -07:00
YHH 96c3da197f Merge pull request #13008 from esengine/fix/13001-degenerate-repeat
fix(agent): flag a stream that collapses to a few tiny lines
2026-10-13 20:11:02 -07:00
YHH d1eeef5318 Merge pull request #13003 from esengine/fix/12993-find-exec-readonly
fix(shellsafe): treat find -exec of a known read-only program as a read
2026-10-13 20:07:17 -07:00
YHH f4d88a3142 Merge pull request #13005 from esengine/fix/12747-commit-draft-failure
fix(studio): let a person write the commit message when the model cannot draft one
2026-10-13 20:06:07 -07:00
esengine 4c192248d5 fix(agent): flag a stream that collapses to a few tiny lines
The guard only saw byte-periodic blocks. A second structural reading of the same
tail - many short lines from very few distinct ones - catches a degraded stream
that never repeats a block.
2026-10-13 20:00:36 -07:00
esengine abf44246c5 fix(studio): let a person write the commit message when the model cannot draft one
ProposeCommit returns the staged set with a typed draftFailure instead of an
error, and the card opens its message box with the reason.
2026-10-13 19:49:59 -07:00
esengine 1dd0b8a247 fix(shellsafe): treat find -exec of a known read-only program as a read
The program find runs decides: a bare read-only name whose own arguments do not
write. Anything else, -ok, -delete and the -fprint family stay writers, so a
read-only find no longer takes the workspace write lease.
2026-10-13 19:43:39 -07:00
YHH ffd136f463 Merge pull request #12999 from esengine/feat/12985-tui-theme-preview
feat(tui): preview each theme as the /theme picker moves
2026-10-13 19:39:23 -07:00
YHH c1a243d46c Merge pull request #12998 from esengine/fix/npm-publish-wait-longer
fix(release): wait up to 15 minutes for npm to show a published CLI package
2026-10-13 19:32:13 -07:00
YHH 31ba7705ed Merge pull request #12996 from esengine/feat/12063-shadow-token
feat(theme): let a pack set the shadow menus, popovers and dialogs cast
2026-10-13 19:30:27 -07:00
esengine b5a79cda33 feat(tui): preview each theme as the /theme picker moves
A bare /theme opens the quick picker; moving repaints, Enter keeps and stores the
row, Escape restores the original. /theme list keeps the old listing.
2026-10-13 19:29:07 -07:00
YHH 903aa09cdc Merge pull request #12997 from esengine/fix/12925-files-toggle
fix(studio): fold the files panel when the Files button is pressed again
2026-10-13 19:27:43 -07:00
YHH a3ba9e4f5c Merge pull request #12995 from esengine/feat/12011-tree-last-active
feat(serve): carry each session's last-activity time on the tree row
2026-10-13 19:23:21 -07:00
esengine fb3a65146a fix(release): wait up to 15 minutes for npm to show a published CLI package 2026-10-13 19:23:02 -07:00
esengine 634f8c77d4 fix(theme): accept only ASCII in a shadow value
The stored string reaches CSS unchanged, which does not treat Unicode spaces as
separators, so a value that validated could paint nothing.
2026-10-13 19:21:20 -07:00
esengine 51446c4d27 fix(studio): fold the files panel when the Files button is pressed again
The workbench decides, since it owns whether its explorer is the open one.
2026-10-13 19:18:19 -07:00
YHH 9a0e636c3d Merge pull request #12994 from esengine/feat/12728-turn-times
feat(studio): say when a turn began beside how long it worked
2026-10-13 19:12:21 -07:00
esengine 40e06a52e2 feat(theme): let a pack set the shadow menus, popovers and dialogs cast
A shadowFloat token with a strict px/hex shadow grammar maps onto
--shadow-float, and onto --shadow-pack for the light menus that kept their
own default. Unset, nothing paints differently.
2026-10-13 19:07:36 -07:00
esengine 6d47f6c837 feat(serve): carry each session's last-activity time on the tree row
The tree already sorts by it; exposing it lets a client build a recency list.
2026-10-13 18:58:46 -07:00
esengine 12f018ffc5 feat(studio): say when a turn began beside how long it worked
TurnDone carries the turn's start time and /history carries when a user
message landed, so the worked line names the start live and after a reload.
2026-10-13 18:52:36 -07:00
YHH f289e3903f Merge pull request #12992 from esengine/release/2.39.0-notes-2
docs(release): add the last two changes to the 2.39.0 notes
2026-10-13 17:36:17 -07:00
esengine 40d577d9f6 docs(release): add the last two changes to the 2.39.0 notes 2026-10-13 17:28:17 -07:00
YHH 2234daf25f Merge pull request #12991 from esengine/fix/12988-gauge-reads-provider-floor
fix(agent): the context gauge reads the provider-reported size the fold trigger acts on
2026-10-13 17:23:54 -07:00
esengine 1d81b5b361 fix(agent): the context gauge reads the provider-reported size the fold trigger acts on 2026-10-13 17:13:45 -07:00
YHH 561f385778 Merge pull request #12990 from esengine/release/2.39.0-notes
docs(release): Studio 2.39.0 notes
2026-10-13 16:54:32 -07:00
YHH ac7c495553 Merge pull request #12138 from boscocp/feat/studio-11957-tools-list-changed
feat(plugin): refresh an MCP catalog on tools/list_changed / 收到 tools/list_changed 后刷新工具目录
2026-10-13 16:48:14 -07:00
esengine af03a12941 docs(release): Studio 2.39.0 notes 2026-10-13 16:46:14 -07:00
YHH 35011ad3d4 Merge pull request #12989 from esengine/fix/target-access-test-walk-race
test(agent): let the target-access wiring walk skip files that vanish mid-walk
2026-10-13 16:38:13 -07:00
esengine 60bee4b979 Merge remote-tracking branch 'origin/studio' into p12138
# Conflicts:
#	internal/ext/plugin/plugin.go
2026-10-13 16:37:36 -07:00
esengine a125fbc9b2 test(agent): let the target-access wiring walk skip files that vanish mid-walk 2026-10-13 16:27:35 -07:00
YHH d2d610d65b Merge pull request #12987 from esengine/fix/cua-fixture-socket-mode
test(desktop): create the CUA fixture socket at 0600 instead of chmod-ing it afterwards
2026-10-13 16:09:23 -07:00
esengine 7931e701ec test(desktop): create the CUA fixture socket at 0600 instead of chmod-ing it afterwards 2026-10-13 15:59:24 -07:00
YHH 6343236907 Merge pull request #12115 from Guan0923/codex/pretooluse-before-preview
fix(agent): gate file previews on target access and PreToolUse
2026-10-13 15:20:42 -07:00
esengine b520c59678 Merge studio into pr/12115; leave path-shape failures to the tool 2026-10-13 15:10:42 -07:00
YHH 3b9d7b3609 Merge pull request #12557 from SivanCola/fix/workbench-tab-menu-controller
feat(studio): use controller close in extracted tab menus / 标签菜单适配控制器关闭路径
2026-10-13 15:07:51 -07:00
esengine 6487ed6f21 Merge remote-tracking branch 'origin/studio' into p12557
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
2026-10-13 14:57:34 -07:00
Hasan Deniz fc23415309 test(config): cover removing the last provider
Two tests for the path #12966 settled: removing every provider saves an
empty list that loads back empty, and adding one afterwards replaces it.
Over HTTP, removing the last provider moves the runtime to the stand-in
and lists no provider.

Both fail without the providers = [] render line from #12966.

Refs #12915
2026-10-13 22:43:59 +01:00
esengine 1c23c7dcd6 Merge remote-tracking branch 'origin/studio' into p12557
# Conflicts:
#	desktop/frontend-next/src/i18n/en_kernel.ts
#	desktop/frontend-next/src/i18n/en_settings.ts
#	desktop/frontend-next/src/port/sse.ts
#	desktop/frontend-next/src/ui/BrowserTabMenu.tsx
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
#	desktop/frontend-next/src/ui/browser-close.interaction.test.tsx
#	release-notes/studio/unreleased.md
2026-10-13 14:17:12 -07:00
YHH 4f2c5a2212 Merge pull request #12065 from KHG420/codex/studio-installed-capability-search
feat(studio): find installed packages by contributed capabilities
2026-10-13 14:14:24 -07:00
YHH 36de07d2fa Merge pull request #12896 from esengine/codex/intelligent-ui-12584-workspaces
feat(studio): add maps, comparisons and dashboards
2026-10-13 14:06:54 -07:00
esengine 91686f3c3f Merge studio into pr/12065
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
2026-10-13 14:02:13 -07:00
esengine 1ef2f9168d Merge studio into codex/intelligent-ui-12584-workspaces
Declares import_web_image's snip stance (a small JSON receipt takes the default) and regenerates
docs/TOOL_CONTRACT.md, the two contract tests that landed on studio after the branch point.
2026-10-13 13:56:03 -07:00
YHH 9d05547a00 Merge pull request #11591 from Guan0923/codex/session-rename-dialog
feat(studio): add a rename dialog with on-demand session auto-naming
2026-10-13 13:54:07 -07:00
esengine 82312cad7c Merge studio into codex/pretooluse-before-preview
move_file now asks for read access to its source on both paths (a forbidden-read source is refused
before the rename, not only when the copy fallback runs), and four agent tests built after the
studio branch point supply the permissive target-access callback the agent now requires.
2026-10-13 13:50:16 -07:00
esengine 6cd5d1a849 Merge studio into pr/12115 2026-10-13 13:45:26 -07:00
YHH a572624a6a Merge pull request #12513 from f1shsmell/feat/theme-pack-recipes
feat(theme): read the density and corner recipes a pack declares
2026-10-13 13:43:37 -07:00
esengine 8e65a19dde Merge studio into codex/session-rename-dialog
Keeps the row-level double-click rename from studio (and drops the title-level handler it replaced),
declares the dialog's title input as session.rename.title, and expects the inline mode in the
double-click test now that onRename carries it.
2026-10-13 13:41:48 -07:00
YHH 8730e31eab Merge pull request #12117 from KHG420/codex/studio-package-contribution-groups
feat(studio): identify installed package contribution kinds
2026-10-13 13:37:52 -07:00
esengine b678e56a32 Merge studio into feat/theme-pack-recipes 2026-10-13 13:32:09 -07:00
esengine 7da9815f14 Merge studio into codex/studio-package-contribution-groups 2026-10-13 13:26:38 -07:00
esengine a24430df38 Merge studio into pr/12117
Drops two blank lines from app.css so the two new contribution rules fit the recorded size.
2026-10-13 13:26:19 -07:00
YHH d97edf1631 Merge pull request #12329 from KHG420/codex/studio-skill-source-groups
fix(studio): group installed skills by source
2026-10-13 13:25:36 -07:00
esengine 1e4c8bd9c4 Merge studio into pr/12117 2026-10-13 13:24:41 -07:00
esengine ec668a1264 Merge studio into codex/intelligent-ui-12584-workspaces 2026-10-13 13:20:18 -07:00
YHH b1db7d8338 Merge pull request #12075 from KHG420/codex/studio-package-copy-invocations
feat(studio): copy package invocations and archive paths
2026-10-13 13:19:31 -07:00
YHH f2187c9d03 Merge pull request #12118 from xxoingr/feat/markdown-bidi
feat(studio): render right-to-left text blocks in their own direction
2026-10-13 13:15:24 -07:00
esengine 2367cd4d65 Merge studio into codex/studio-skill-source-groups 2026-10-13 13:14:22 -07:00
esengine fa22360007 Merge studio into feat/markdown-bidi 2026-10-13 13:02:27 -07:00
YHH e2bdbc322f Merge pull request #11735 from jf200s/fix/studio-live-turn-reducer
fix(session): keep the live turn through rebuilds and lost turn ends
2026-10-13 13:00:21 -07:00
YHH 43ba7f3b45 Merge pull request #12323 from KHG420/codex/studio-advanced-appearance-cards
fix(studio): align advanced appearance with settings cards
2026-10-13 12:54:41 -07:00
esengine 5207b65161 Merge studio into pr/12329 2026-10-13 12:54:19 -07:00
YHH 8d7a0189a6 Merge pull request #12313 from KHG420/codex/studio-chart-readouts
fix(studio): make chart values discoverable along dense lines
2026-10-13 12:50:47 -07:00
YHH ba2fb2e3e7 Merge pull request #12986 from esengine/fix/surface-form-test-race
test(studio): wait for the lazily loaded surface field instead of reading it at once
2026-10-13 12:47:52 -07:00
esengine ef0e34ccc4 Merge studio into codex/studio-advanced-appearance-cards
Keeps the new summary text, names the advanced-appearance disclosure in the settings catalogue (the catalogue test requires a titled block to name itself) and folds three selector lists onto one line each so app.css stays inside its recorded size.
2026-10-13 12:42:28 -07:00
esengine a32fe7f492 test(studio): wait for the lazily loaded surface field instead of reading it at once 2026-10-13 12:37:06 -07:00
YHH 46b22cf79b Merge pull request #12876 from esengine/codex/intelligent-ui-12584-actions
feat(studio): add nonblocking surface actions (RFC #12584 P2)
2026-10-13 12:34:08 -07:00
esengine f9d08b4811 Merge studio into codex/intelligent-ui-12584-actions 2026-10-13 12:26:29 -07:00
YHH 6f9643f976 Merge pull request #12854 from esengine/codex/intelligent-ui-12584-drafts
feat(studio): add editable drafts and styled text previews
2026-10-13 12:22:12 -07:00
esengine ec91de1853 Merge studio into fix/studio-live-turn-reducer
Folds the studio's retired-surface rows into the identity merge of a rebuild (rebuild.ts), and lets two
fixtures report the turn as running while it runs, since a status read now vouches for it.
2026-10-13 12:22:02 -07:00
YHH ab4dfc827a Merge pull request #12984 from esengine/refactor/saved-not-applied-note
refactor(studio): share the saved-not-applied predicate and the save note
2026-10-13 12:17:42 -07:00
esengine a3c43490fb Merge studio into codex/intelligent-ui-12584-drafts 2026-10-13 12:11:21 -07:00
esengine 783e56578b refactor(studio): share the saved-not-applied predicate and the save note
The same HttpError check and the same note block were copied into Compaction, Rules, Shell, Context,
Settings and EditConn. savedNotApplied lives next to SAVED_NOT_APPLIED and SaveNote draws the note once.
2026-10-13 12:07:11 -07:00
YHH cc0635b7c0 Merge pull request #12350 from KHG420/codex/studio-compaction-save-outcomes
fix(studio): distinguish settings save outcomes and allow retry
2026-10-13 12:06:56 -07:00
YHH ab7ded2e46 Merge pull request #12983 from esengine/fix/skillrow-tab-order
test(studio): expect the copy button first in a skill row's tab order
2026-10-13 12:01:03 -07:00
esengine e033f1bb11 Merge studio into codex/intelligent-ui-12584-drafts 2026-10-13 11:58:46 -07:00
YHH f5b89e246f Merge pull request #12830 from esengine/codex/intelligent-ui-12584-fields
feat(studio): add styled native fields and choice cards (RFC #12584 P1)
2026-10-13 11:53:48 -07:00
YHH 1d76e29525 Merge pull request #12314 from KHG420/codex/studio-refine-keyboard-help
fix(studio): keep prompt-refine help reachable and shortcut intact
2026-10-13 11:52:03 -07:00
esengine 9c44a56bbf style(studio): drop a blank line so app.css is back inside its budget
#12982 added one rule to a stylesheet held at its recorded size.
2026-10-13 11:51:35 -07:00
esengine 2d7cda8ef8 test(studio): expect the copy button first in a skill row's tab order 2026-10-13 11:50:36 -07:00
YHH 2f78502847 Merge pull request #12102 from KHG420/codex/studio-skill-execution-declarations
feat(studio): inspect subagent skill declarations
2026-10-13 11:41:06 -07:00
YHH 75552a6902 Merge pull request #12098 from KHG420/codex/studio-plugin-commit-preview
feat(studio): show resolved plugin commits in install previews
2026-10-13 11:35:58 -07:00
esengine 6fb3c3b65a test(studio): follow the refine button's aria-disabled selector 2026-10-13 11:30:13 -07:00
esengine 3228d69968 Merge studio into codex/studio-refine-keyboard-help 2026-10-13 11:30:05 -07:00
esengine 6a814cdc7c Merge studio into codex/intelligent-ui-12584-fields 2026-10-13 11:29:45 -07:00
xiexian 5dfb9a3eca feat(studio): copy installed package invocations 2026-10-14 02:28:40 +08:00
YHH eb7dd41e8f Merge pull request #12982 from esengine/fix/12945-fewer-version-rows
feat(studio): keep the newest earlier releases on the versions page and fold the rest
2026-10-13 11:23:26 -07:00
YHH 844aa7b04e Merge pull request #11589 from Guan0923/codex/studio-code-rewind-undo-entry
fix(studio): 从后端恢复撤销入口并持久化撤销失效状态
2026-10-13 11:16:08 -07:00
esengine 1c0c8e7d8a feat(studio): keep the newest earlier releases on the versions page and fold the rest 2026-10-13 11:12:57 -07:00
YHH 80e3680595 Merge pull request #12969 from esengine/feat/tool-json-highlight
feat(studio): colour the JSON input of a tool call
2026-10-13 11:12:02 -07:00
esengine a3420a00f2 Merge studio into codex/prompt-refine-help 2026-10-13 11:08:30 -07:00
YHH 4a78b32113 Merge pull request #12070 from KHG420/codex/studio-skill-copy-invocation
feat(studio): copy canonical skill invocations from Settings
2026-10-13 11:08:25 -07:00
YHH 5a25c18574 Merge pull request #12981 from esengine/fix/modelrows-guard-linux
fix(perf): open settings through its button in the model-rows guard
2026-10-13 11:07:50 -07:00
YHH 04329b5355 Merge pull request #12980 from esengine/fix/rewind-undo-test-race
test(studio): wait for the restore receipt instead of reading it at once
2026-10-13 10:57:40 -07:00
esengine 78c872fcf7 fix(perf): open settings through its button in the model-rows guard 2026-10-13 10:57:00 -07:00
YHH 706c2a16a4 Merge pull request #12595 from esengine/feat/remote-wsl-kind
feat(wsl): discover WSL distributions and probe one (RFC #12225, slice 1)
2026-10-13 10:56:58 -07:00
YHH 29ffc3d278 Merge pull request #12405 from keeyangyy/pr/legacy-session-badge
feat(studio): mark sessions kept in the 1.x store
2026-10-13 10:56:41 -07:00
esengine 4af61e3d92 fix(studio): give the surface submit button AA contrast in the light theme
Mantine's gray primary filled colour is #868e96, which is 3.4:1 against the button's white label.
The scoped provider now maps it to the app's text token.
2026-10-13 10:52:59 -07:00
esengine d72bc50a8f refactor(control): drop resolveAsk, replaced by resolveAskAnswer 2026-10-13 10:49:32 -07:00
esengine 3903638ded test(studio): wait for the restore receipt instead of reading it at once 2026-10-13 10:45:48 -07:00
YHH 68f01019f4 Merge pull request #12979 from esengine/fix/12920-one-scrollbar
fix(studio): one scrollbar on the provider model list, and a themed alias field
2026-10-13 10:45:14 -07:00
esengine 3a2a39528a chore: drop a scratch file 2026-10-13 10:40:25 -07:00
esengine fad9794bc9 fix(studio): keep the copy control inside the skill name column
In a narrow settings column the extra control pushed the scope and switch of the longest rows past the card edge. Next to the name it keeps the other columns where they were.
2026-10-13 10:39:26 -07:00
esengine 0a4908854f Merge studio into codex/studio-skill-copy-invocation 2026-10-13 10:38:35 -07:00
YHH 584eb2eadc Merge pull request #12978 from esengine/test/mcp-oauth-credential-binding
test(plugin): pin MCP OAuth credentials to the recorded authorization server
2026-10-13 10:35:10 -07:00
esengine 8cb659ef4a refactor(studio): build the launch pane's runtime options in one place
assemble sits at the 120-line ceiling; the native-surface option the surface work adds tips it over.
2026-10-13 10:34:17 -07:00
esengine 940e4f017e Merge studio into codex/intelligent-ui-12584-fields 2026-10-13 10:33:04 -07:00
esengine 7c7846d76c Merge studio into feat/tool-json-highlight
# Conflicts:
#	desktop/frontend-next/perf/harness.tsx
2026-10-13 10:31:47 -07:00
esengine a8b15e1f04 Merge studio into pr/legacy-session-badge 2026-10-13 10:31:05 -07:00
YHH 407b9bd0c1 Merge pull request #12977 from esengine/fix/12970-dblclick-rename
feat(studio): double-click a session row to rename it
2026-10-13 10:30:54 -07:00
esengine 9f7b4d1104 Merge studio into feat/remote-wsl-kind 2026-10-13 10:30:51 -07:00
esengine 824bb2faad fix(studio): one scrollbar on the provider model list, and a themed alias field 2026-10-13 10:30:00 -07:00
YHH e6db67a14d Merge pull request #12976 from esengine/feat/osc-7501-status
feat(tui): tell the terminal what the session is doing (OSC 7501)
2026-10-13 10:26:03 -07:00
esengine 8954d0302d Merge studio into codex/intelligent-ui-12584-fields
Keeps one /answer handler that serves both question engagement and surface answers, and
carries the computer driver and native-surface options through the workspace switch.
2026-10-13 10:17:58 -07:00
esengine f5ee8d785d feat(tui): tell the terminal what the session is doing (OSC 7501) 2026-10-13 10:16:47 -07:00
esengine 0d07d95c6c test(plugin): pin MCP OAuth credentials to the recorded authorization server 2026-10-13 10:15:57 -07:00
esengine 7641b1e249 feat(studio): double-click a session row to rename it 2026-10-13 10:15:20 -07:00
YHH 5186562d29 Merge pull request #12973 from esengine/fix/release-notes-tests-skip-fragments
fix: unbreak repolint and the release-notes script tests on studio
2026-10-13 10:13:35 -07:00
YHH 30d1638a52 Merge pull request #12825 from esengine/codex/intelligent-ui-12584
feat(studio): recover extension surfaces after reload (RFC #12584 P0)
2026-10-13 10:06:25 -07:00
YHH 5bcc7a4599 Merge pull request #12972 from esengine/feat/skill-chip-in-user-message
feat(studio): show a skill invocation as a compact chip in the conversation
2026-10-13 10:03:49 -07:00
YHH 87fd186f8f Merge pull request #12085 from KHG420/codex/studio-mcp-tool-search
feat(studio): search MCP server tool inventories
2026-10-13 10:01:40 -07:00
esengine 0ba5ac1f09 fix(config): tagged switch for the kind check 2026-10-13 09:57:43 -07:00
esengine 0dbc09fc1c fix(studio): drop the unused import 2026-10-13 09:56:38 -07:00
esengine da8d7944da refactor(studio): keep assemble and config.go inside their repolint budgets 2026-10-13 09:56:38 -07:00
esengine fd311693f6 test(release): read the fragment directory's notes too and skip directories 2026-10-13 09:56:38 -07:00
YHH 3f0463e2bf Merge pull request #12910 from KHG420/codex/studio-file-links
feat(studio): open conversation file links in the workbench
2026-10-13 09:55:58 -07:00
esengine 0ac031b168 Merge studio into pr/legacy-session-badge 2026-10-13 09:51:30 -07:00
esengine e4af6f2be7 fix(wsl): ask the OS for the system directory instead of reading the environment
repolint's process-input rule flags a kernel package that reads SystemRoot. GetSystemDirectory
is also the stronger answer: a changed environment cannot stand in for System32.
2026-10-13 09:49:58 -07:00
esengine 2baa0a1e19 Merge remote-tracking branch 'origin/studio' into wslfix 2026-10-13 09:49:31 -07:00
YHH cc73202dda Merge pull request #12971 from esengine/fix/composer-hover-radius
fix(studio): give the branch button the same hover shape as its neighbours
2026-10-13 09:47:53 -07:00
YHH 878b983b67 Merge pull request #12440 from keeyangyy/pr/remember-paired-devices
feat(serve): keep paired phones across restarts, with a settings switch
2026-10-13 09:47:48 -07:00
esengine 1597e64b40 feat(studio): show a skill invocation as a compact chip in the conversation
Fixes #12383
2026-10-13 09:45:47 -07:00
esengine 9432f13c4f test(serve): format the legacy-mark test and join its fixture lines with strings.Join 2026-10-13 09:38:40 -07:00
YHH 2211e22552 Merge pull request #12968 from esengine/chore/release-notes-per-file
docs(release): one release-note fragment per pull request
2026-10-13 09:37:13 -07:00
esengine 1cc5648b65 fix(studio): give the branch button the same hover shape as its neighbours
Fixes #12912
2026-10-13 09:34:45 -07:00
esengine 2e842b987a fix(wsl): an ended context is not an empty machine
List answered "no distributions" for any wsl.exe failure, so a probe that hit its
deadline or was cancelled read as a machine without WSL. The caller's ended context
is now returned as such.
2026-10-13 09:34:10 -07:00
YHH 0874955cff Merge pull request #12591 from esengine/feat/phone-page-installable
feat(studio): let the phone page be added to the home screen
2026-10-13 09:32:50 -07:00
YHH de2809cf64 Merge pull request #12587 from esengine/feat/tui-unviewed-mark
feat(tui): mark unviewed sessions in the resume picker and clear on view
2026-10-13 09:29:46 -07:00
esengine 7df54d6f91 Merge remote-tracking branch 'origin/studio' into HEAD 2026-10-13 09:26:37 -07:00
esengine 502e8f045c refactor(studio): keep the extension routes with the extension port
The studio merge left sse.ts at 803 lines, over the 800-line ceiling; the three extension calls belong to SseExtensions.
2026-10-13 09:24:08 -07:00
YHH d924132cc7 Merge pull request #12211 from KHG420/codex/studio-extension-form-result
fix(studio): keep failed extension forms editable
2026-10-13 09:22:45 -07:00
esengine 0a999b6857 feat(studio): colour the JSON input of a tool call
Fixes #12919
2026-10-13 09:22:39 -07:00
YHH c1e2840bab Merge pull request #12963 from esengine/fix/compaction-summarizer-budget
fix(compaction): carry the user's words through a degraded fold and cap the summarizer input
2026-10-13 09:17:26 -07:00
YHH 3a623c3ced Merge pull request #12937 from esengine/codex/cua-driver-migration
feat(studio): migrate computer use to CUA Driver
2026-10-13 09:13:17 -07:00
YHH b8f3ae549e Merge pull request #12967 from esengine/feat/mcp-meta-local-context
feat(plugin): tell local stdio MCP servers the workspace and the host's read-only posture
2026-10-13 09:12:23 -07:00
esengine dae22d82ce Merge remote-tracking branch 'origin/studio' into p0
# Conflicts:
#	desktop/frontend-next/src/port/sse.ts
2026-10-13 09:10:39 -07:00
esengine 0133febcf6 Merge studio and keep both release-note sets 2026-10-13 09:06:26 -07:00
esengine 2d9f6fd634 style(studio): drop the stylesheet comment
Stylesheets carry no comments in this repo; the selector and declarations are the statement.
2026-10-13 09:06:10 -07:00
esengine a2147d0474 docs(release): one release-note fragment per pull request 2026-10-13 09:05:16 -07:00
esengine db6c6a233b feat(plugin): tell local stdio MCP servers the workspace and the host's read-only posture
Fixes #12965
2026-10-13 09:02:44 -07:00
esengine aa7090472e Merge studio into codex/studio-extension-form-result 2026-10-13 08:55:02 -07:00
YHH 86c63d9ac6 Merge pull request #12214 from KHG420/codex/studio-extension-current-fields
fix(studio): submit only current extension form fields
2026-10-13 08:53:10 -07:00
YHH de6b70a6fd Merge pull request #12885 from par73e/fix/12764-sft-run-provenance
fix(e2ebench): bind SFT exports to graded run provenance
2026-10-13 08:51:55 -07:00
YHH c791b02d14 Merge pull request #12884 from par73e/fix/12862-ask-interaction-timeout
fix(studio): keep an engaged ask from timing out
2026-10-13 08:50:54 -07:00
YHH 44695d6106 Merge pull request #12966 from esengine/feat/start-without-provider
feat(config): start with no provider and open the connection guide
2026-10-13 08:49:19 -07:00
esengine d98a908641 test(agent): range over integers in the mechanical fold tests 2026-10-13 08:46:17 -07:00
yhh 88129d0f6a feat(studio): replace native computer helper with CUA Driver 2026-10-13 08:43:14 -07:00
esengine b84dfcc5cf feat(config): start with no provider and open the connection guide
Fixes #12915
2026-10-13 08:42:15 -07:00
esengine 698a963ab2 fix(compaction): carry the user's words through a degraded fold and cap the summarizer input
Refs #12961
2026-10-13 08:00:56 -07:00
YHH 25b996c08f Merge pull request #12955 from esengine/fix/crumb-title-beside-project
fix(studio): keep the session title beside the project name in the title bar
2026-10-13 07:44:49 -07:00
YHH 01161ca789 Merge pull request #12957 from esengine/feat/live-grace
feat(studio): keep finished sessions in the Running list for a chosen time (off by default)
2026-10-13 07:43:40 -07:00
YHH b795c20c93 Merge pull request #12960 from esengine/feat/disk-health
feat(notify): warn when the workspace or state volume is nearly full
2026-10-13 07:40:57 -07:00
esengine 2f9207b408 Merge studio and keep both release-note sets 2026-10-13 07:35:27 -07:00
esengine efa78dfd49 Merge studio and keep both release-note sets 2026-10-13 07:35:08 -07:00
esengine 5b15cd35d0 feat(notify): warn when the workspace or state volume is nearly full
Fixes #12958
2026-10-13 07:30:26 -07:00
Guan09 3ef990601f fix(checkpoint): make rewind undo lifecycle durable
Persist undo retirement before admitting new turns or tool writes, and refuse new activity when that persistence fails. Keep post-tool checkpoint-recording warnings separate from truthful tool results. Preserve the preceding undo after fully compensated rewind failures, and block activity until incomplete recovery settles. Reuse the existing Studio notice, availability route and change event; retain the failure-path tests.
2026-10-13 22:28:35 +08:00
xiexian 56378d2f33 feat(studio): search MCP server tool inventories 2026-10-13 22:26:40 +08:00
YHH d5a9618ffd Merge pull request #12956 from esengine/fix/notify-click-raises-window
fix(notify): log the toast click path end to end
2026-10-13 07:15:30 -07:00
esengine df95cc8535 feat(studio): keep finished sessions in the Running list for a chosen time (off by default)
Fixes #12312
2026-10-13 07:13:25 -07:00
esengine 357d8079a2 fix(notify): log the toast click path end to end
Refs #12817
2026-10-13 07:05:37 -07:00
YHH 05d65a05ed Merge pull request #12954 from esengine/fix/files-button-toggles
fix(studio): quiet the activity group so the reply stands out
2026-10-13 07:02:14 -07:00
esengine b575889b2f fix(studio): keep the session title beside the project name in the title bar
Fixes #12924
2026-10-13 06:54:33 -07:00
keeyang 5f667467e8 feat(studio): mark sessions kept in the 1.x store
A conversation whose log still lives in the 1.x store is read here and never
written, so it shares the sidebar with this build's own rows and cannot be told
apart. The tree now reports which store each row came from (the kernel decides
it through IsForeignSessionLog) and the row shows a small "Old" mark at its end,
which yields to the hover actions.
2026-10-13 21:50:36 +08:00
esengine 3bcbbf3dbc fix(studio): quiet the activity group so the reply stands out
Fixes #12953
2026-10-13 06:48:32 -07:00
keeyang 3b2b1f6e32 fix(studio): register the share.remember action and complete the phone-access mocks
The phone-access panel renders data-action="share.remember" and reads
hub.shareRemember(), but neither reached the action registry nor the test
hubs that build one by hand: actions.test.ts failed with "rendered but not
in src/actions.ts" (PhoneAccess.tsx:194), and PhonePop.interaction /
PhoneAccess.port failed with "hub.shareRemember is not a function".

Register the action beside the other share.* entries and give both test
hubs the hook the panel now reads.
2026-10-13 21:35:14 +08:00
keeyang 230bf814c7 feat(serve): keep paired phones across restarts, with a settings switch
A phone pairs by scanning a QR code, and the credential it keeps rides a
cookie scoped to the origin it scanned. The host held that pairing in memory
only: closing the share or restarting the process threw every device away, so
the next launch asked for a fresh scan.

[serve] remember_paired_devices, false by default and the user's alone (a
project reasonix.toml that sets it is ignored and reported), keeps three things
when it is on: the phones that paired (digests only, written to
<state>/device-trust.json and adopted at startup), the address the share last
listened on, and whether the share was open at all. The persist hooks read the
key when they run, so the panel switch takes effect without a restart, and
turning it off forgets on the spot: everything paired is revoked and both files
are removed. A snapshot taken before a later change is dropped by revision, so
a concurrent pairing write can never overwrite a revoke. Address ordering keeps
its upstream preference. A phone that paired never expires: the point of the
switch is that one's own LAN phone connects again next time, and that is
intentional.

Closing the share still unpairs everything; that is the person withdrawing it.
Only the process going away keeps the phones. The state directory is the
kernel's, outside every workspace, and agent-run commands have no path to it:
sandbox writes are confined to the workspace and ~/.reasonix, and the trust
file never enters them. A restart reopens the listener only when the switch
was on and the share was open when the process left — the state of things the
person chose, not a new one.

Fixes #11834
Refs #11878
2026-10-13 21:35:14 +08:00
YHH d00f905d4e Merge pull request #12952 from SivanCola/codex/html-preview-selection-settlement
test(studio): await HTML preview selection / 等待 HTML 预览选中状态落定
2026-10-13 06:24:28 -07:00
YHH f290a6377a Merge pull request #12946 from xxoingr/test/gitcommit-literal-path-windows
test(gitcommit): stage a literal glob name Windows can create
2026-10-13 06:24:18 -07:00
YHH a05b31f489 Merge pull request #12949 from xxoingr/test/acp-drain-prompt-budget
test(acp): bound drainPrompt's idle wait by the hang budget
2026-10-13 06:24:03 -07:00
YHH 33b725f56c Merge pull request #12921 from SivanCola/codex/studio-linux-arm64
test(studio): strengthen Linux package verification / 增强 Linux 包与发包验证
2026-10-13 05:59:00 -07:00
YHH 6fee91ef4e Merge pull request #12918 from SivanCola/codex/studio-sidebar-divider
fix(studio): use the divider as the desktop sidebar control / 桌面侧栏统一使用分隔条操作
2026-10-13 05:58:45 -07:00
SivanCola 9e6e1d1e28 test(studio): await settled HTML preview selection
Problem: frontend CI intermittently asserted that Files was hidden before a
fresh browser-tab follower had committed its selection.

Root cause: retiring the preview's request record does not settle the separate
Promise.allSettled callback that updates the visible workbench selection.

Fix: wait for the asserted workbench DOM state, as the sibling preview test
already does, while preserving the selected-tab and newer-intent guarantees.

Verification: all 49 HTML preview and neighbouring browser race/rejection tests,
frontend typecheck, and repository lint/check pass; prescribed Go preflight
checks run before commit. CI is monitored on the pushed head.
2026-10-13 20:56:05 +08:00
YHH e47cb489b7 Merge pull request #12936 from SivanCola/feat/studio-brand-wordmark
feat(studio): unify official brand marks / 四处品牌区统一到正式字标与产品符号
2026-10-13 05:50:33 -07:00
esengine 6b14a90f88 Merge studio and keep both release-note sets 2026-10-13 05:41:51 -07:00
xxoingr ced22c2265 test(acp): bound drainPrompt's idle wait by the hang budget
TestE2ESessionLoad failed on Windows CI on studio (run 38179453323) with
"session/prompt: timed out" after 7.64 s: drainPrompt gave up when five
seconds passed without a notification or the prompt's response. A full
prompt crosses the ACP server, controller, agent and transcript
persistence, and a loaded Windows runner can stay quiet that long without
anything being stuck. Pausing the scripted tool for 5.5 s reproduces it.

drainPrompt now waits rpcCallBudget(t), the testenv.Budget wait the same
file's other RPC helpers already use.
2026-10-13 20:41:01 +08:00
YHH 13e58f3ae8 Merge pull request #12947 from esengine/fix/remove-neutral-blue
revert(theme): drop the unreleased Neutral Blue pack in favour of Clear Blue
2026-10-13 05:39:48 -07:00
SivanCola 810156ad6e Merge studio and preserve sidebar and activity-header release notes
Problem: The latest activity-header note conflicts with this PR's sidebar
entry at the end of the unreleased notes.

Root cause: Both branches append their independent entries at one anchor.

Fix: Preserve all upstream notes and place the sidebar entries alongside the
session-list entries. Retain the narrow-only drawer control and the sticky
activity header in the auto-merged stylesheet.

Verification: Sidebar/divider, responsive layout and activity-fold tests;
the Chromium sticky-header guard in light and dark; production build,
repolint, release-note/reference tests, typecheck and UI census.
2026-10-13 20:34:18 +08:00
esengine f4acb67608 revert(theme): drop the unreleased Neutral Blue pack in favour of Clear Blue
Refs #12909
2026-10-13 05:30:22 -07:00
xxoingr 3a402a47d6 test(gitcommit): stage a literal glob name Windows can create
TestOnlyChangedPathsAreAcceptedAndTheyAreLiteral fails on every Windows
run since 40e097879 (studio run 38383059323): it writes a file named
"*.txt", and a Windows file name cannot hold "*" ("The filename,
directory name, or volume label syntax is incorrect.").

The name now is "[ot][nw][eo].txt". As a git pathspec glob it matches
one.txt and two.txt, as "*.txt" did, and every filesystem accepts it, so
the test still proves Stage passes each path as :(literal) on all three
platforms.
2026-10-13 20:26:00 +08:00
YHH b5b7cf49e6 Merge pull request #12569 from xxoingr/fix/skill-shadow-warning-literal
test(installsource): cover hostile skill paths in discovery warnings end to end
2026-10-13 05:13:15 -07:00
YHH 70025d2bf7 Merge pull request #12940 from chuiwenwei/fix/memory-suppressed-recall-audit
fix(memory): audit the recall a carried note suppresses
2026-10-13 05:08:47 -07:00
YHH 68801c9d0b Merge pull request #12939 from chuiwenwei/fix/memory-migrate-index-failure
fix(memory): repair the index when a migrated fact is revisited
2026-10-13 05:08:31 -07:00
YHH 80ea09baa3 Merge pull request #12938 from chuiwenwei/fix/memory-save-index-failure-recovery
fix(memory): withdraw a save whose index publication fails
2026-10-13 05:08:15 -07:00
YHH 5ba9a614f0 Merge pull request #12944 from esengine/fix/activity-group-sticky-header
fix(studio): keep an open activity group's header at the top while it scrolls
2026-10-13 05:07:33 -07:00
YHH 2d2988e92f Merge pull request #12899 from XTLine/fix/hook-backup-identity
fix(configbackup): preserve distinct native hook identities
2026-10-13 05:00:35 -07:00
esengine a5eab68235 fix(studio): keep an open activity group's header at the top while it scrolls
Fixes #12931
2026-10-13 04:55:54 -07:00
xiexian 472f1a89ca feat(studio): open conversation file links in the workbench 2026-10-13 19:52:54 +08:00
YHH 2f2919b55b Merge branch 'studio' into fix/skill-shadow-warning-literal 2026-10-13 04:52:44 -07:00
SivanCola 9e306466b0 Merge studio and preserve sidebar and session-list release notes
Problem: The latest studio session-list release notes conflict with the
sidebar-control entry in this PR.

Root cause: Both changes prepend their notes at the same unreleased anchor.

Fix: Preserve the upstream session-list and theme notes, append the sidebar
entries in the existing flat-list format, and retain narrow-only drawer
controls alongside the upstream spacing changes.

Verification: Sidebar/divider, session-list, remote-title and spacing tests;
production build, typecheck, repolint, UI census and fixture verification,
and release-note/reference tests.
2026-10-13 19:51:21 +08:00
YHH 02a9021e85 Merge pull request #12407 from f1shsmell/feat/spacing-scale
feat(styles): give layout spacing an eight-step scale
2026-10-13 04:49:15 -07:00
Will e0d4357c7c fix(memory): audit the recall a carried note suppresses
A turn that already carries a memory note skips automatic recall, because the
note says the same thing better. That decision was recorded for the session's own
diagnostics but never reached the audit channel, so a trajectory could not say
that a real recall was suppressed and why.

The suppression now goes through the same content-free audit as a recall that
returned hits. A turn whose retrieval is disabled by ablation is unchanged.
2026-10-13 19:41:18 +08:00
Will 1a0e08e79f fix(memory): withdraw a save whose index publication fails
The plain save path, which covers a new fact and an update that keeps its name,
wrote the fact and then published the index. A failed publication returned the
error with the fact already written, so a retry of the same intent either
advanced the revision again or was refused by the revision guard the caller had
just satisfied.

The write is now withdrawn when the publication fails: a new fact is removed, an
update is rewritten from the bytes and mode that were on disk — not re-rendered
from the parsed fact, which would silently drop frontmatter this code does not
know, comments and hand-written formatting. If the withdrawal itself fails the
error carries the state it left.
2026-10-13 19:40:31 +08:00
YHH 0876ccc422 Merge pull request #12935 from SivanCola/codex/studio-compact-session-list
fix(studio): compact session rows and relocate batch selection / 紧凑会话列表并迁移批量选择入口
2026-10-13 04:33:17 -07:00
SivanCola 9bda2b6328 fix(studio): integrate brand marks with current Studio contracts
Problem: the brand patch conflicted with Studio and failed the sidebar and
type-scale contracts; the empty-session symbol replayed an entrance on mount.

Root cause: the presentation change retained old fixture expectations, typed
subtitle sizes directly, and treated a mounted brand as a new event.

Fix: integrate the current Studio base, retain its sidebar controls, update the
wordmark assertion, reuse shared size tokens, keep the hero symbol still, and
document the final brand contract without stylesheet commentary.

Verification: frontend typecheck/build/census, affected tests, Chromium guards,
go vet, make lint/check, and builtin/boot tests passed. Full frontend validation
is recorded in the PR; CI is evaluated on this merge's head before delivery.
2026-10-13 19:27:05 +08:00
SivanCola 8d406040e3 test(studio): strengthen Linux package and release verification
Problem: Studio packages need payload and installed-shell acceptance, and a
complete artifact gate before publication. Running the Electron and matrix
contracts on Windows also exposed host-dependent test fixtures.

Root cause: #12914 supplies native Linux ARM64 packaging but checks only Go
payload architectures and selected artifact names. The macOS bundle fixture
used the host OS's temporary path, and the matrix fixture relied on PATH
lookup for a mock git command instead of binding it in the tested shell.

Fix: Keep upstream native runners and system FPM, consolidate ELF checks,
verify both Linux package formats, exercise the installed shell, and require
all twelve nonempty release artifacts. Guard installation acceptance with
GITHUB_ACTIONS=true. Use platform-specific workspace fixtures and a shell
function for the matrix's git fixture, keeping all Windows test coverage.
Rebase on current studio and preserve adjacent release notes.

Verification: Electron 143 tests and Linux/script 39 tests pass, along with
workflow contracts, desktop Go tests, builtin/boot regressions, vet, lint,
repository checks, shell syntax and Actionlint. Previous-head hosted native
Linux package verification and installed acceptance passed for amd64/arm64;
replacement-head Windows CI remains the platform acceptance gate.

Refs #12906
2026-10-13 19:18:07 +08:00
SivanCola e41eaa3720 Merge studio to resolve the sidebar PR release-note conflict
Problem: The sidebar-control change conflicts with the theme release notes
added to studio after the PR was opened.

Root cause: Both branches append their next-release entries at the same
location in the unreleased notes.

Fix: Preserve both sidebar and theme entries, use the accepted release-note
heading, and keep the bilingual sidebar entries within the prose budget.
The auto-merged stylesheet retains narrow-only drawer controls and current
theme foundations.

Verification: Full frontend tests, typecheck, production build, repolint,
UI census and fixture verification, and release-note/reference tests.
2026-10-13 19:15:44 +08:00
XTLine ece6e8d45f fix(configbackup): align restore identity and minimum format 2026-10-13 04:13:14 -07:00
SivanCola 4240e6cbf7 Merge studio to resolve release-note conflict
Problem: PR #12935 became conflicting after the Studio palette changes landed.
Root cause: Both branches appended bilingual entries to the same unreleased
notes, and both touched the shared Studio stylesheet.
Fix: Merge studio while preserving the session-list and palette release notes.
Keep the upstream theme tokens alongside the compact session-row rules.
Verification: gofmt, go vet, make lint and make check passed. Frontend typecheck,
production build, UI census, all 92 focused session and theme tests, and the
light/dark 34px row geometry guard passed. Builtin, boot and theme Go tests
passed. Hosted CI still requires upstream workflow approval.
2026-10-13 19:11:33 +08:00
SivanCola 94cac74614 feat(studio): 空会话态改用产品符号,RMark 退回它该干的活
RMark 是运行状态指示器,不是品牌:pane 会按状态把它染成 --net / --ok /
--accent-ink,stroke-dashoffset 说的是"这一轮还在跑"。可空会话态那个
78px 的 R 用的是同一个组件,等于让一枚状态灯去代表品牌。

换 SymbolMark —— logo-symbol.svg 的路径,也就是 main-v2 顶栏在 22px 上
用的那一枚。内联而非 <img>:这一处用 --faint 画,<img> 只会给我们文件
里写死的蓝。main-v2 的 channelIcons 也是这么内联的。

两处行为变化,都是换对方向的结果:

- 不再随运行状态变色。品牌标记不该在 pane 忙的时候变成 --net。
- 描边生长动画换成淡入升起。drawin 走的是 stroke-dashoffset,那是描边
  的把戏,填充没有。@keyframes drawin 随之删除 —— 它已无引用。

15px 那处(R 空闲)保留 RMark:尺寸上字标不可用,符号的装饰细节也会
糊,而且它本来就是在报状态。

reduced-motion 里补了 .hero .sym-mark —— motion.test.tsx 的原则是加了
动画就得同时声明它不许动。
2026-10-13 19:05:53 +08:00
YHH 98712b569b Merge pull request #12932 from SivanCola/codex/builtin-clear-blue-tea-green
feat(theme): add Clear Blue and Tea Green palettes / 新增清白蓝与茶绿内置配色
2026-10-13 04:05:03 -07:00
SivanCola 0b3698342f fix(studio): compact session rows and move batch selection into the machine menu
Problem: The session sidebar mixes one- and two-line titles and reserves too
much vertical space. The persistent local-selection entry adds another blank
row between the filters and the machine tree.

Root cause: Studio clamps titles to two lines with a 42px minimum row height
and vertical margins; SessionBatch renders its entry even outside selection.

Fix: Use 34px single-line rows with full-title hover hints, move local batch
selection into the machine menu, and restore focus to that menu on exit.
Keep batch archive/delete and neighbouring row actions intact. Add browser
geometry coverage to Studio CI, interaction regressions, parity/release notes,
and before/after screenshots for light/dark wide and narrow windows.

Verification: Go formatting, vet, lint, builtin/boot tests, frontend typecheck
and build, UI census, browser geometry and affected interactions passed.
The frontend full run passed 4156 of 4157 tests; the unchanged HTML-preview
failure passed all 26 tests on isolated replay. The optional translation scan
reports the same two missing test-only keys on the base and this branch.
2026-10-13 18:58:08 +08:00
SivanCola fc0a5c71f8 fix(studio): 欢迎页字标放大到 300px
200px 在整屏的空场里显得比改动前还弱 —— 原来那行 40px 等宽的
"Reasonix Studio" 约 310px 宽,换成字标后整体反而缩了一圈。

放大还有个附带好处:300 / 118(侧栏字标宽)≈ 2.5,副标 20px 正好
接近侧栏 7.5px 的等比放大值,比 200/20 的比例更协调。

窄屏折叠态同步从 168px 提到 240px。折叠断点是 640px,.oobe-scene
左右各 40px,内容区 560px,余量充足。
2026-10-13 18:56:58 +08:00
YHH 0261d13269 Merge pull request #12929 from xxoingr/test/plugin-reader-schema-race
test(plugin): wait for the handshake's cache write before changing a schema
2026-10-13 03:56:25 -07:00
YHH 9b91f76517 Merge pull request #12930 from xxoingr/test/survey-partial-count-budget
test(storage): give a stalled walk room to reach its first entry
2026-10-13 03:56:05 -07:00
SivanCola 5c1869a2c1 feat(studio): 欢迎页字标改用正式资产,保留品牌本色
原来那个大 R 是手写 SVG 描边路径,本仓库第四个各画各的 R。换用
logo-wordmark.svg,与侧栏、向导同一份资产。

这一处不 mask、不反白:整个产品里最该亮出品牌本色的就是这个开场,
染成场景的暖灰反而是退让。深色底上 #0153e5 约 2.5:1,低于非文本图形
3:1 的门槛,所以补一层稳定的 drop-shadow 托住。

原来 R 描边上那道暖色光痕(lume,1.25s 起 1.5s)保留成"点亮"的瞬间,
落在同一个节拍上。

删掉 oobedraw / oobetrace / oobesweep —— 前两个只服务那段描边路径,
第三个原本扫的是文字的渐变底,字标不再走那条路。

mono.test.ts 里 oobe-brand / oobe-sub 两条例外的说明随职责更新:字标
换成了品牌资产(自身不说话),留在等宽字体里的是 STUDIO 那行和标语。
2026-10-13 18:51:20 +08:00
YHH b22f8f5f44 Merge pull request #12934 from xxoingr/test/control-async-waits-budget
test(control): bound inbox and edit-persist waits by testenv.Budget
2026-10-13 03:49:04 -07:00
SivanCola 950a85f20b feat(studio): 向导顶栏同样改用正式字标
侧栏换掉之后,连接模型服务这一屏的顶栏还留着旧的字母 R 方块,两条
title bar 摆在一起时能看出是两个不同的品牌。用与侧栏相同的 lockup 结构
替换,尺寸、副标排版和深色主题处理都取同一组值。

.
2026-10-13 18:38:35 +08:00
xxoingr e55b9e40e6 test(control): bound inbox and edit-persist waits by testenv.Budget
Three control tests failed on Windows CI at a fixed one-second wait for
something the controller does asynchronously:

- TestInboxDispatchKickDuringEmptyScanIsNotLost (run 38178550657) and
  TestRepeatedJobCompletionIsIdempotent (run 38283959118): "timed out
  waiting for inbox dispatch" from waitForInboxDispatch.
- TestEditPromptPersistsOriginalPrompt (run 38367692250): "edited prompt
  was not persisted before deadline" after its one-second poll.

Delaying the scripted runner or the provider by 1.2 s reproduces all
three messages. These waits only catch a hang, which is what
testenv.Budget is for; every one-second wait in inbox_dispatch_test.go
and the edit-persist poll now uses it. The 150 ms waits that assert
nothing happened are unchanged.
2026-10-13 18:32:59 +08:00
SivanCola 0636552ae3 feat(theme): add Clear Blue and Tea Green foundation palettes
Problem: Studio offers no plain built-in alternatives matching the white/blue
and warm/tea-green directions requested in #12909.

Root cause: the default swatch inherits active pack colours, plain packs lose
the default reading surface and weight, and legacy Studio rules override
semantic accent colours on selected controls and dark send buttons.

Fix: ship two light/dark token-only packs, share canonical default preview
colours, preserve plain reading styles, and close the affected controls over
theme tokens. Add optional accentInk, localized labels and backdrop grouping.

Verification: 209 frontend tests, production build/typecheck, theme and HTTP
server tests, go vet ./..., make lint, make check, builtin and boot tests.
Browser checks cover system-following, default restoration, and light/dark
workbenches at desktop and phone widths; screenshots are included.
2026-10-13 18:27:58 +08:00
xxoingr 0f9c2195fb test(storage): give a stalled walk room to reach its first entry
TestSurveyReturnsPartialCountWhenAWalkStalls failed the coverage job on
studio (run 38341178074) with "the partial count must survive: 0 bytes,
0 files". It gave the walk a 50 ms budget and expected it to count the
one file before running out; the walk's callback checks the budget's
context first, so a walk goroutine scheduled late counts nothing. A
100 ms delay before the walk reproduces it, and the same delay fails
TestTruncatedRootKeepsTheErrorFoundBeforeTheBudgetRanOut, which relies on
the same 50 ms.

Both now use stalledWalkBudget, 500 ms: ten times what the failure needed,
while Survey's three stalled roots still return well inside the test's
5 s guard.
2026-10-13 18:27:37 +08:00
SivanCola 132696f9eb fix(studio): 字标与副标改为纵向堆叠
上一版把 <img> 和 <small> 都放在 .studio-brand 这个 flex 行里,STUDIO
被排到了字标右侧。真机跑起来才看出来 —— 单看代码不容易发现 flex 行里
多一个兄弟节点意味着什么。

套一层 .studio-brand-lockup 用 grid 排两行,字标在上、副标在下。
2026-10-13 18:26:25 +08:00
xxoingr dd498e42c2 test(plugin): wait for the handshake's cache write before changing a schema
TestReaderIntentRefusesDispatchAfterSafetyDrift failed the race job on
studio (run 38334190859): the test replaces rt.schema at
plugin_test.go:1765 while the handshake's detached cache write
(saveHandshakeSchema -> cacheableToolsOf, cache.go:44) can still be
reading it. Nothing in the product writes a remote tool's schema after the
handshake; only this test does.

The test now drains host.bgWrites, which every detached writer joins,
before replacing the schema. With the cache write delayed by 300 ms, the
cached schema was the test's replacement before this change and the
handshake's own after it.
2026-10-13 18:24:11 +08:00
YHH 90f6ccff18 Merge pull request #12928 from esengine/feat/builtin-neutral-theme
feat(theme): ship a neutral white and blue pack
2026-10-13 03:23:43 -07:00
SivanCola 429765e490 feat(studio): 侧栏品牌区改用正式字标,补回 STUDIO 副标
侧栏原来自己画了一套品牌:StudioIcon 里内联的描边 R,配 CSS 圆角方块和
HTML 排的 reasonix/STUDIO 文字。这个 R 和正式品牌资产没有任何关系,是本
仓库第四个各画各的 R(另三处在 Welcome.tsx、Onboarding.tsx 和托盘)。

改成用 logo-wordmark.svg —— 资产已经随 frontend-next 引进来了,内容与
main-v2 那份逐字节一致,只是之前没人接线,app.css:181-185 那条引用它的
.brand 规则一直是死的。

main-v2 的字标里没有 STUDIO,整块照搬会让这个界面不再自称 Studio,与
studio 作为独立品牌的前提冲突,所以副标用 HTML 补回,字标按 main-v2 的
尺寸(118×24)与负边距(-14px)照搬。

一并处理的:

- 深色主题沿用 main-v2 的 brightness(0) invert(1)。SVG 内写死了
  #0153e5,而 <img> 没有颜色接口,这是唯一能把它转白的手段 —— 代价是品牌
  色在深色下被整个作废。改走 CSS mask 可以避免,但那就不是照搬了。
- .studio-collapse 保留。studio 的品牌行右侧带这个按钮,main-v2 没有。
- .studio-brand-mark / -word / -accent 三组类删除,decorative-tokens.test.ts
  里那条断言 .studio-brand-accent 的用例随之移除(该测试对选择器找不到是
  直接失败的)。
- StudioIcon 里的 brand 路径随之失去唯一引用,一并删除。
2026-10-13 18:18:23 +08:00
esengine ace76d7fe2 feat(theme): ship a neutral white and blue pack
Refs #12909
2026-10-13 03:12:16 -07:00
YHH c027688879 Merge pull request #12922 from KHG420/codex/studio-completion-badge
feat(studio): badge unread task completions on the application icon
2026-10-13 03:08:14 -07:00
YHH 43a4bb1775 Merge pull request #12914 from esengine/feat/studio-linux-arm64
feat(release): ship Studio for Linux arm64 (.deb and .rpm)
2026-10-13 02:56:08 -07:00
esengine 22cfb53ac9 test(release): the publish gate now requires twelve packages, each Linux one by name 2026-10-13 02:46:47 -07:00
YHH 660e5a2e82 Merge pull request #12923 from esengine/feat/stage-from-changes
feat(studio): stage and unstage changes from the changes list
2026-10-13 02:46:06 -07:00
esengine 40e097879d feat(studio): stage and unstage changes from the changes list
The commit button only records what is staged, and Studio had no way to stage:
an empty index sent people to git add. The changes list now shows each file as
staged, partly staged or not, ticks and unticks them, and stages everything at
once; the kernel accepts only paths git itself lists.
2026-10-13 02:38:38 -07:00
SivanCola d097836293 fix(studio): use the divider as the desktop sidebar control
Problem: The desktop sidebar has a redundant top hamburger button alongside
its divider, making the sidebar affordances ambiguous.

Root cause: The drawer entry is mounted in all layouts, although desktop
layouts already expose a divider that toggles and resizes the workspace rail.

Fix: Mount the top entry only under the scene fold, remove its desktop chrome
spacing, and preserve the existing divider and remembered width. Document
the intentional control removal and include light/dark responsive screenshots.

Verification: 488 frontend test files and 4141 tests pass, including divider
interaction and responsive boundary coverage. Typecheck, production build,
repolint, UI census and fixture verification pass. Browser interactions and
an isolated macOS Electron window verify the retained controls.

Refs #12917
2026-10-13 17:23:23 +08:00
xiexian c6cf583607 feat(studio): badge unread task completions on the application icon 2026-10-13 17:17:59 +08:00
yhh 14f3d5c24c feat(studio): add rich task workspaces
Add linked maps, comparisons, dashboards and workspace-backed photo acquisition
and viewing for desktop task surfaces.

Certify Mantine viewer interactions and isolate Windows command-line and direct
DNS test fixtures from host process/proxy behaviour.

Cache impact: shortened write_file schema changes tool/prefix hashes once on
upgrade (5572 to 5524 schema tokens); system prompt stays unchanged. Update the
boot goldens for that deliberate contract change. Image discovery stays hidden.
2026-10-13 02:16:47 -07:00
esengine aebcc9ed26 feat(release): ship Studio for Linux arm64 (.deb and .rpm)
The release builds linux/arm64 on a native ubuntu-22.04-arm runner and requires
all four Linux packages by name. electron-builder's own fpm is x86_64-only, so
the arm64 leg installs one from RubyGems. The package job checks that the Go
binaries inside the .deb are built for the architecture the package is named for.
2026-10-13 02:03:57 -07:00
YHH a41ce9f980 Merge pull request #12904 from XTLine/fix/quiet-workbench-polls
fix(studio): avoid unchanged workbench polling renders
2026-10-13 01:53:42 -07:00
Guan09 9696ae040c feat(studio): add rename dialog and latest-message auto naming 2026-10-13 16:52:51 +08:00
YHH 6114a4f7ec Merge pull request #12889 from XTLine/fix/mcp-tool-name-collisions
fix(mcp): reject colliding model-visible tool names
2026-10-13 01:50:53 -07:00
YHH d1cc96d1c6 Merge pull request #12893 from XTLine/fix/studio-mcp-capability-identity
fix(capability): preserve raw MCP identities across runtime paths
2026-10-13 01:48:31 -07:00
YHH 1f2e9c9687 Merge pull request #12892 from XTLine/fix/mcp-lazy-raw-identity
fix(mcp): preserve cached raw identity during lazy dispatch
2026-10-13 01:44:32 -07:00
YHH 669720b3fc Merge pull request #12908 from esengine/fix/trust-level-bar
fix(studio): end a machine row's actions on the same edge as a project row's
2026-10-13 01:42:48 -07:00
esengine 7a1b699780 fix(studio): end a machine row's actions on the same edge as a project row's 2026-10-13 01:33:56 -07:00
YHH 181b0d8eed Merge pull request #12888 from esengine/fix/subagent-status-not-droppable
fix(serve): keep sub-agent status frames when a client falls behind
2026-10-13 01:19:17 -07:00
YHH 3897e34dc2 Merge pull request #12907 from esengine/fix/notification-click
fix(notify): hand the foreground right to the shell on a toast click, and stop discarding send failures
2026-10-13 01:17:23 -07:00
esengine 294da726f7 fix(serve): keep sub-agent status frames when a client falls behind
The status channel of a child's progress rides ToolProgress, which the
broadcaster treated as droppable. Its terminal phase has no successor, so a
backlogged client lost it and kept showing the child as running.
2026-10-13 01:12:15 -07:00
esengine 6896ae1040 fix(notify): hand the foreground right to the shell on a toast click, and stop discarding send failures
A click on a Windows toast grants the right to take the foreground to the
process it activates, which is the kernel host; the window to raise is the
shell's. The host now grants the right before forwarding the click, and the
shell lifts a window the platform refused to focus. A toast that could not be
delivered is written to the host log instead of being dropped.
2026-10-13 01:08:43 -07:00
YHH f88827ef93 Merge pull request #12905 from esengine/feat/session-rail-polish
feat(studio): tell the session tree's levels apart and make its row actions findable
2026-10-13 01:02:41 -07:00
XTLine 388ea158dd fix(configbackup): version distinct hook identity snapshots 2026-10-13 01:01:55 -07:00
esengine 09ce7f22c6 feat(studio): tell the session tree's levels apart and make its row actions findable
Machine rows become small section headers with counts in words, project rows keep
the strongest line with a numeric pill, scope filters hide zero counts and read at
12px, and the revealed + and ... buttons are outlined and share one hover, press,
expanded and busy state.
2026-10-13 00:53:52 -07:00
YHH 14c37fbc46 Merge pull request #12871 from xlyy1/codex/context-window-refresh-12752
fix(studio): refresh context after model settings change
2026-10-13 00:49:59 -07:00
XTLine dd7fe3cc0d fix(studio): keep unchanged file polls out of React commits 2026-10-13 00:49:53 -07:00
YHH 835977ec08 Merge pull request #12898 from XTLine/fix/preserve-workbench-file-drafts
fix(studio): preserve unsaved workbench file drafts
2026-10-13 00:37:39 -07:00
YHH ac006a6711 Merge pull request #12897 from esengine/feat/brand-app-icon
feat(studio): a flat, friendlier app icon, and the sidebar shows it
2026-10-13 00:30:26 -07:00
XTLine 3f00870fda test(configbackup): cover metadata and normalized hook imports 2026-10-13 00:29:01 -07:00
XTLine 7a112879d7 fix(configbackup): preserve distinct native hook identities 2026-10-13 00:28:32 -07:00
XTLine 54702a85d9 fix(mcp): classify and report rejected tool catalogs 2026-10-13 00:25:46 -07:00
XTLine f8e8d3add1 fix(studio): preserve file draft scope during initial status 2026-10-13 00:21:57 -07:00
XTLine d6b8820faf fix(studio): retain workbench drafts across file navigation 2026-10-13 00:21:46 -07:00
XTLine 7fdaf4ed2c fix(studio): scope file drafts and requests to their workspace 2026-10-13 00:21:37 -07:00
XTLine 6c9ba53a92 test(studio): reproduce unsaved workbench draft loss 2026-10-13 00:20:43 -07:00
esengine 0531f88217 feat(studio): a flat, friendlier app icon, and the sidebar shows it
The icon keeps the R-and-whale silhouette and drops the gold gradients for flat
colour: blue tile, yellow R, a blue whale with a pale belly, eye and cheek, and a
band of waves. The sidebar brand mark is the same file instead of a drawn glyph.
2026-10-13 00:20:07 -07:00
YHH 5c9e8cdb11 Merge pull request #12895 from esengine/feat/sync-reopen
feat(feedback): reopen the issue when a reporter replies to a fixed report
2026-10-12 23:59:39 -07:00
XTLine ccfe034d0a fix(mcp): reject unusable caches before registration 2026-10-12 23:53:42 -07:00
esengine 05a9bba1a0 feat(feedback): reopen the issue when a reporter replies to a fixed report
A reply to a fixed report means the fix did not hold. feedback-sync reopens an
issue closed as completed after mirroring the reply, and Studio offers the
reply box on fixed reports.
2026-10-12 23:47:19 -07:00
YHH 8ec2b41318 Merge pull request #12891 from esengine/feat/rail-new-session
feat(studio): start a new session from its own button on the icon rail
2026-10-12 23:40:28 -07:00
YHH 6811ad08c9 Merge pull request #12890 from esengine/fix/bare-skill-raw
fix(control): record the typed text as raw input for a bare skill invocation
2026-10-12 23:39:35 -07:00
XTLine 5626e1fccb fix(agent): require MCP bindings for callable grants 2026-10-12 23:36:46 -07:00
XTLine 9aa914a05c fix(agent): record the executed MCP capability identity 2026-10-12 23:36:35 -07:00
XTLine c7c15b1942 fix(mcp): preserve cached raw identity during lazy dispatch 2026-10-12 23:36:03 -07:00
XTLine 4e6207173e fix(capability): preserve MCP identities in catalog snapshots 2026-10-12 23:35:51 -07:00
YHH 8f010c8dce Merge pull request #12870 from esengine/fix/view-tab-slide
fix(studio): move one highlight between the pane's view tabs
2026-10-12 23:32:59 -07:00
esengine fc59f10772 feat(studio): start a new session from its own button on the icon rail 2026-10-12 23:32:36 -07:00
YHH 00aa05ccab Merge pull request #12887 from esengine/fix/git-bash-probe-timeout
fix(sandbox): retry a hung Git Bash probe once and print the fallback shell path unescaped
2026-10-12 23:28:56 -07:00
esengine 922fed0f32 fix(control): record the typed text as raw input for a bare skill invocation 2026-10-12 23:28:52 -07:00
XTLine 6348150ee7 fix(mcp): reject colliding model-visible tool names 2026-10-12 23:24:04 -07:00
esengine 1c24d30e41 fix(studio): move one highlight between the pane's view tabs
Switching views ran a view transition over the whole pane, so the tab bar was
cross-faded with it: the old tab's box faded out while the new one faded in,
read as two separate animations. The highlight is now one element that sits
under the chosen tab; during a switch the tab bar is not cross-faded and the
highlight slides and resizes to the new tab, with the labels above it.
2026-10-12 23:20:58 -07:00
yhh 1409a8ee35 feat(studio): add nonblocking surface actions 2026-10-12 23:16:33 -07:00
YHH df0d334dc5 Merge pull request #12886 from esengine/fix/close-blank-tab
fix(studio): close a browser tab without reading a body the kernel never sends
2026-10-12 23:15:26 -07:00
esengine 2679dcf7f6 fix(sandbox): keep control characters escaped in the fallback shell path 2026-10-12 23:14:21 -07:00
Will 3b8a9fcda5 fix(memory): repair the index when a migrated fact is revisited
MigrateV2 rewrote a legacy fact as V2 and then published its index line. If that
publication failed the migration reported the error with the fact already
rewritten, and the next run saw a fact that already had an id and a revision and
skipped it, so the index stayed stale with no path back.

A fact that already looks migrated is now reindexed instead of skipped, so the
second run converges on the index the first run could not publish.
2026-10-13 14:09:44 +08:00
YHH f00d90f4b5 Merge pull request #12877 from esengine/feat/file-add-to-chat
feat(studio): add a file or folder to the conversation from the Files menu
2026-10-12 23:08:22 -07:00
esengine 4f5a609bc9 fix(sandbox): retry a hung Git Bash probe once and print the fallback shell path unescaped 2026-10-12 23:07:04 -07:00
YHH 53774b03ef Merge pull request #12882 from esengine/feat/wait-streams-output
feat(tools): show what a waited-on job writes while wait runs
2026-10-12 23:06:24 -07:00
esengine 2705b57f2c fix(studio): close a browser tab without reading a body the kernel never sends
The kernel answers POST /browser/close with 204 and no body, and the port read
it as JSON, so every successful close reported "Unexpected end of JSON input".
2026-10-12 23:05:54 -07:00
esengine 3804d9f156 feat(studio): add a file or folder to the conversation from the Files menu 2026-10-12 23:00:08 -07:00
par73e 568b1792d7 fix(e2ebench): bind SFT exports to graded run provenance 2026-10-13 13:55:44 +08:00
par73e bd30cf6a5c fix(studio): keep an engaged ask from timing out 2026-10-13 13:55:37 +08:00
esengine d36858a9b1 feat(tools): show what a waited-on job writes while wait runs
wait now echoes the jobs' new output, as whole lines and capped at 64 KiB,
through the call's live progress, starting from their latest lines. The
frontend folded a progress frame like a dispatch, so the frame's empty name
blanked the step and each chunk replaced the last; it now appends the chunk
and keeps the call's name.
2026-10-12 22:53:03 -07:00
YHH d460758e7c Merge pull request #12878 from esengine/feat/ssh-hosts-group
feat(studio): gather remote machines under one foldable row in the sidebar
2026-10-12 22:30:46 -07:00
esengine 406e70378a feat(studio): gather remote machines under one foldable row in the sidebar 2026-10-12 22:21:00 -07:00
YHH 84c6690c61 Merge pull request #12875 from esengine/perf/workbench-switch
perf(studio): tween the workbench width on the pane, not on the whole app
2026-10-12 22:13:59 -07:00
esengine cb2552e5ae perf(studio): tween the workbench width on the pane, not on the whole app
--dock-w was a registered, inherited custom property tweened on .app, so every
frame of opening or closing the workbench restyled every node in the window.
It now does not inherit and lives on the pane whose grid reads it; the gutter
writes it there while dragging. Style recalculation while switching halves,
and the long frames on a throttled long transcript mostly go away.
2026-10-12 22:04:47 -07:00
YHH 861e35c3c6 Merge pull request #12874 from esengine/fix/hidden-folders-turn-currency
fix(studio): value a turn in the wallet's currency, and list hidden folders in the folder picker
2026-10-12 21:55:30 -07:00
esengine ab951a1bbf fix(studio): value a turn in the wallet's currency, and list hidden folders in the folder picker
With the display currency left automatic, the server adopted the wallet's
currency for its own ledger only: the quotes on usage events and the frontends
were never told, so the status bar showed this turn in US$ beside a CNY wallet.
The controller's Balance now adopts it and announces it as an operator notice,
the same path a chosen currency takes, so every quote and frontend follows;
a chosen currency still wins and nothing is written to the config.

The folder picker also shows hidden folders, where tools keep their state.
2026-10-12 21:44:49 -07:00
xlyy1 4d999069aa fix(studio): refresh context after model settings change 2026-10-13 12:38:23 +08:00
YHH d5fb91f607 Merge pull request #12869 from esengine/fix/window-edge-colour
fix(desktop): give the window frame the app's theme so Windows draws no light border
2026-10-12 21:38:17 -07:00
YHH 4041b48a8e Merge pull request #12865 from esengine/fix/session-tree-rows
fix(studio): one line per project in the session tree
2026-10-12 21:35:43 -07:00
Guan09 11b8ceb38d fix(agent): enforce file access before previews
Enforce workspace read policy before previews, diagnostics, hooks, and file execution.

Provide admitted previews to permission approval and Auto Guard, then capture fresh preimages after PreToolUse. Share target access checks across execution, planning, review, and delegated agents.

Keep genuinely write-only targets available, report existing-file overwrite refusals with write-specific messages, and avoid hook-write coverage gaps for read-only calls.

Preserve network-path protection before filesystem lookup while allowing authorized network roots and registered folder references.

Include baseline-compatible disclosure regressions, production target-access wiring checks, and localized tool-card tests.
2026-10-13 12:28:57 +08:00
esengine 1faa3c1bbd fix(desktop): give the window frame the app's theme so Windows draws no light border
Windows 11 draws a frameless window's border from the frame's scheme, which
Electron takes from nativeTheme. Studio never set it, so a dark theme chosen in
the app on a light system kept a light frame, seen as a light line around the
window. The frame now takes the stored theme (auto still follows the system),
at launch and whenever the preference is saved.
2026-10-12 21:28:15 -07:00
esengine 422715f257 fix(studio): one line per project in the session tree
A project row put its session count on a second line, so every project took
two rows. The count now sits where the row's actions are and gives way to them
on hover or keyboard focus, so a row is one line and the name keeps its width;
touch screens show the actions. A tag still takes a second line. The two add
buttons share one size and hover, and the filter labels and counts are larger.
2026-10-12 21:08:34 -07:00
YHH 40dd5cd9b2 Merge pull request #12856 from esengine/fix/sidebar-head-no-blank-strip
fix(studio): stop the sidebar head leaving a blank strip under the toggle
2026-10-12 20:59:02 -07:00
YHH fbd8d975b2 Merge pull request #12864 from esengine/fix/workbench-flush-divider
fix(studio): end the workbench card's tab strip on the pane's tab-row line
2026-10-12 20:55:47 -07:00
YHH 3e0b3a8aeb Merge pull request #12863 from esengine/fix/center-dialogs
fix(studio): centre dialogs on the window rather than below the title bar
2026-10-12 20:52:26 -07:00
esengine 90035ae541 fix(studio): end the workbench card's tab strip on the pane's tab-row line
The docked workbench card sat 8px into the pane with a 39px strip, so its
bottom line ran 7px below the conversation's tab row and the two read as
unrelated bars. The strip is now the shared tab-row height less the card's
inset and border, so both lines meet; the card keeps its corners and edge.
2026-10-12 20:45:43 -07:00
esengine a838930ad6 fix(studio): centre dialogs on the window rather than below the title bar
Settings, feedback, a full screenshot, a checkpoint preview and a sub-agent
transcript open in veils that start below the title bar, so centring inside
them put each dialog half a bar below the window's middle. Each veil now
carries a transparent bottom border as tall as the bar, which makes the box it
centres in symmetric; the phone layout keeps settings full screen.
2026-10-12 20:33:23 -07:00
yhh 6f7f0f8b78 feat(studio): add editable drafts and text diff previews 2026-10-12 20:20:12 -07:00
YHH 6e0e7f37cb Merge pull request #12860 from esengine/feat/verification-off-switch
feat(config): let the user turn off the checks the host asks of a changed turn
2026-10-12 20:19:38 -07:00
yhh 98395aafe4 feat(studio): add styled native surface forms and choices 2026-10-12 20:15:28 -07:00
YHH d15c8d5c89 Merge pull request #12859 from esengine/feat/esc-confirm-stop
feat(studio): ask for a second Escape before stopping a running turn
2026-10-12 20:14:34 -07:00
YHH 37760c66d3 Merge pull request #12857 from esengine/takeover/12288
feat(studio): open the project folder from the chrome folder mark
2026-10-12 20:13:57 -07:00
esengine f1a5eb9ebb feat(config): let the user turn off the checks the host asks of a changed turn
[agent] host_verification = "off" in the user file drops the check, review and
sign-off a turn that changed files owes before it may finish. The turn's own
unfinished plan and a capability the user required still hold it, and the
receipt still says what was and was not checked. A project file can neither
turn it off nor undo the user's choice.
2026-10-12 20:10:35 -07:00
esengine 3f3e3c9278 feat(studio): ask for a second Escape before stopping a running turn
Escape stopped a running turn at once, so a stray press ended a long task. The
first Escape now arms the stop and shows a hint for 2.5 seconds; a second one
inside that window stops. Appearance gets a per-machine choice to stop on the
first press again. The stop button is unchanged.
2026-10-12 19:55:47 -07:00
esengine 44e6f6adba fix(studio): show the folder button's pointer and hover only while it can be used 2026-10-12 19:41:29 -07:00
esengine 1b4792e8b0 Merge PR #12288 into studio and keep the project folder from launching an application bundle
openWorkspace now opens a folder only when it is a directory and, on macOS,
not an application bundle; anything else is selected in its parent as before.
2026-10-12 19:36:56 -07:00
YHH 2a951af216 Merge pull request #12855 from esengine/fix/sidebar-no-duplicate-nav
fix(studio): keep the sidebar toggle clickable in the desktop shell
2026-10-12 18:52:17 -07:00
esengine 5aeafaf9fa fix(studio): stop the sidebar head leaving a blank strip under the toggle
With the icon rail off or collapsed the whole sidebar head was indented 44px
further so the toggle had room beside the wordmark, which left a blank strip
beside the new-session, search and quick-entry rows. Only the wordmark row
makes room now; every other row sits at the same inset in all three modes.
2026-10-12 18:45:06 -07:00
esengine d0ec5b3d0b fix(studio): keep the sidebar toggle clickable in the desktop shell
The toggle was declared before the nav column, the sidebar head and the
chrome, all of which are window-drag regions in the desktop shell. Where drag
regions overlap, the one declared later wins, so a click on the toggle moved
the window instead. It is now the last child of the app; its z-index drops one
step so the settings sheet still covers it.
2026-10-12 18:38:24 -07:00
YHH c48ee0f80d Merge pull request #12852 from esengine/docs/release-2.38.0
docs(release): Studio 2.38.0 notes
2026-10-12 17:45:52 -07:00
esengine 10e929f3d5 docs(release): Studio 2.38.0 notes 2026-10-12 17:37:03 -07:00
YHH a750f83859 Merge pull request #12850 from esengine/fix/selection-from-data
fix(studio): hold the blocks a long selection passes through as plain text
2026-10-12 17:27:13 -07:00
YHH ff77cb75ff Merge pull request #12849 from esengine/fix/mcp-tool-name-128
fix(mcp): keep a model-visible tool name within the provider's 128-character limit
2026-10-12 17:26:10 -07:00
esengine 780d5e04fa fix(studio): hold the blocks a long selection passes through as plain text
A selection reaching into blocks that are not mounted mounted every block in
between in one synchronous render, so a drag across a long conversation froze
the window (22s at 1200 turns). A block the selection only passes through, with
neither end inside it, is now held as the conversation's own text at its
placeholder height; blocks with an end inside stay whole.
2026-10-12 17:14:37 -07:00
esengine b4060aa4fd fix(mcp): keep a model-visible tool name within the provider's 128-character limit
A tool whose prefixed name exceeded 128 characters made the provider refuse
every request in the session. toolName now shortens only the tool part,
keeping the server prefix and a hash of the full name so siblings stay
distinct and the name is deterministic for permission and hook rules.
2026-10-12 16:42:25 -07:00
Adalberto Pereira e66ef9f65a refactor(plugin): refresh a catalog through the one list-judge-build path
The refresh listed and built its own catalog, so it reached the model with
definitions the pin judgement #12597 added had never seen: a server could wait
until the session was running and announce a tool nobody approved. The judged
path now has one entry point for both listings — read, then publish — and the
refresh goes through it.

readToolCatalog takes no lock and publishes nothing, so the refresh keeps
building its candidate off the lock and swapping it in as one piece, and
publishToolCatalogLocked moves every field together: what was withheld was the
one a caller could forget, and it is the one that makes the judgement visible.
The digest covers the held set, so approving definitions the server had added
is itself a change the next listing publishes.

What the session does with that follows: the registry's held set is replaced
from the same judgement, as at every other registration site, and a pinned slot
whose tool is held says so instead of claiming the server dropped it.

hostNotices moves to sessionRuntime, which is its lifetime: a withdrawn slot
outlives the conversation that saw it. That also keeps agent.go inside its
file-size budget, and the catalog path moving to tool_catalog.go keeps
plugin.go inside its own, with no baseline spent.

Refs #11957
2026-10-12 19:33:59 -03:00
Adalberto Pereira ca39c4b7b3 fix(plugin): keep a pinned tool's schema when a refresh drops it / 刷新丢弃工具时保留已固定的 schema
A server that drops a tool it had pinned into the provider-visible array was
rewriting that array mid-session: the cache-stable prefix moved and every token
behind it was spent again. The entry now stays until the session ends, the call
it still invites fails with the live catalog's not-found, and the model is told
on the turn tail — the one place an append leaves the prefix byte-stable.

Registry.ReplacePrefix swaps a server's namespace under one lock. The removal
and the re-registration were two calls, so a turn composing its request between
them was built from a server that appeared to have no tools.
2026-10-12 19:08:21 -03:00
Adalberto Pereira d0c057dbd9 feat(plugin): refresh an MCP catalog on tools/list_changed / 收到 tools/list_changed 后刷新工具目录
A server that declared tools.listChanged may add, rename or drop tools after
startup and says so with notifications/tools/list_changed. All three transports
dispatched notifications/progress and dropped every other notification, so the
catalog stayed as first listed: a tool the server added was unreachable, and one
it removed was still offered to the model.

The transports now surface a notification by method, the client re-lists once a
burst settles, and the Host publishes a catalog that actually changed. The
session's registry re-registers it, so what the model can reach follows without
moving the cache-stable prefix.

Registrations live on the reconnecting transport and are rebound onto a
replacement connection, and a backend rolled in behind the stable proxy is
watched like any other, so a connection that dies does not leave the server
unheard for the rest of the session.
2026-10-12 19:08:21 -03:00
YHH ce84fe9ff8 Merge pull request #12848 from boscocp/fix/readiness-12567-unrecognized-command-write
fix(readiness): an unrecognised command clears the check only when it did not write / 未写入时才清除待验证
2026-10-12 15:03:15 -07:00
Adalberto Pereira 03aeb27e4e test(readiness): turn the shell jail off so the effect turn's commands run
The unrecognised-write effect test asserts on what the turn's commands
changed, but left the sandbox at its default. A runner that has no bwrap
refuses to run them at all, so the case that keeps the waiver owed the check
for a reason the test is not about: green on the `test` jobs, which install
bubblewrap, and red on `coverage`, which does not.

Every other effect test that needs a shell writes `[sandbox] bash = "off"`;
this one now does too. Reproduced with a PATH that hides bwrap, which fails
with the CI's exact line and passes with the fix.
2026-10-12 18:42:27 -03:00
Adalberto Pereira ea8f6c9ca6 fix(readiness): an unrecognised command clears the check only when it did not write / 未写入时才清除待验证
A turn that wrote code and then ran any command the classifier could not read
as a check was released as verified. The waiver was keyed on one fact — "not a
check" — while the receipt carried a second one it never read: whether the host
watched the command write. `echo … > generated.txt` after a code write is
graded MutationProven from the redirect itself, and the turn still finished
with the change unverified.

The waiver now also requires the grade the host already recorded, and requires
it of every unrecognised command after the write rather than the most recent
one: a quiet `ls` beside a writing command says nothing about that command. A
turn whose unreadable commands all left the tree as the host found it keeps the
waiver — that miss is the classifier's blind spot, not the turn's. A write the
host watched, and a change whose extent it could not establish, leave the check
owed: an incomplete observation never establishes scope, and unproven_mutation
is settled by observation rather than by a check that ran afterwards.

Ordinary turns only: Delivery gates verification through a corroborated
citation and never had this waiver.

Refs #12567
2026-10-12 17:37:55 -03:00
YHH 217702190c Merge pull request #12839 from esengine/takeover/12660
fix(theme): make wallpaper transcript glow configurable
2026-10-12 12:33:45 -07:00
YHH 71164ebf3c Merge pull request #12846 from xxoingr/test/session-viewed-late-heal
test(session): let the torn-sidecar view test outlast a late heal
2026-10-12 11:06:49 -07:00
YHH 057894edec Merge pull request #12847 from xxoingr/test/name-unsealed-evidence-cause
test: name the store's cause when a turn's evidence was not sealed
2026-10-12 11:04:48 -07:00
YHH 9f78a77e1c Merge pull request #12842 from XTLine/fix/studio-file-reference-boundaries
fix(studio): guard ambiguous file reference copies
2026-10-12 10:36:45 -07:00
YHH f9548ec3dd Merge pull request #12843 from thehui13/codex/fix-memory-recall-audit-12784
fix(memory): preserve recall omission causes in trajectories
2026-10-12 10:34:40 -07:00
xxoingr 1b6929aad4 test: name the store's cause when a turn's evidence was not sealed
Two tests that read a turn's sealed bundle have failed on Windows CI with
nothing that says why the bundle was missing:

- TestContractCriterionIsSatisfiedByItsOwnCheck (runs 38185765331 and
  38199681271): "trusted state tampered: malformed digest """, because the
  test read the empty Record of an unsealed audit as a digest.
- TestEffectSupportingWritesWithoutChecksMayFinish/markdown_only (run
  38252045191): "missing sealed evidence".

The audit already carries the store's typed cause. sealedRecord fails with
FailureCode and ContractFailure when the bundle was not sealed, and every
agent test that reads a sealed record goes through it; the boot test prints
the audits it found.
2026-10-13 01:30:28 +08:00
YHH cad221e1d6 Merge pull request #12845 from esengine/fix/pinned-sessions-lead
fix(studio): lead a project with its pinned conversations and mark them
2026-10-12 10:23:23 -07:00
xxoingr 458fbbcabf test(session): let the torn-sidecar view test outlast a late heal
TestSessionViewedToleratesASidecarCaughtMidReplace failed in the coverage
job (run 38264909143) with "a view that met a torn sidecar failed instead
of waiting it out" in 0.20 s. The view retries a torn sidecar for 20, 50
and 100 ms; the test heals it from a goroutine after 30 ms, so the heal had
140 ms of slack, and on a loaded runner the goroutine started later than
that. Delaying the heal to 250 ms reproduces the failure locally.

The test now appends a 5 s last retry to the product's schedule, the way
the meta-ledger tests already replace it, so a late heal is still waited
out. A reader that does not retry at all still fails the test.
2026-10-13 01:23:09 +08:00
yhh d68d34c811 fix(studio): lead a project with its pinned conversations and mark them
Pinning a conversation only fed the Pinned filter and the menu label, so in the
full list nothing moved and nothing showed it was pinned. Pinned conversations
now come first within their project, the kernel's order holding inside each
group, and each carries a small pin.
2026-10-12 10:14:40 -07:00
YHH 510915ba51 Merge pull request #11822 from esengine/feat/fix-11734-c
fix(plugins): publish a replaced plugin directory without renaming a watched tree on Windows
2026-10-12 10:14:13 -07:00
yhh 6db92a8904 refactor(plugins): keep the superseded-copy cleanup beside the copy helpers
plugin_package.go was over the 800-line ceiling.
2026-10-12 10:06:46 -07:00
YHH 586b3068c4 Merge pull request #12841 from esengine/takeover/11744
fix(studio): make session tabs opt-in
2026-10-12 10:04:37 -07:00
hashem e3985f1971 fix(memory): preserve recall omission causes in trajectories
Refs #12784
2026-10-13 00:52:29 +08:00
XTLine d9884c795c fix(studio): guard trailing backslash reference boundaries
(cherry picked from commit a33d4a2fe7af32b00bfd924e5897191e94911a48)
2026-10-13 00:52:12 +08:00
XTLine 6cc59ec78c fix(studio): avoid ambiguous workspace reference copies
(cherry picked from commit 1f547d9738e8bd030e84c8d2734ed25f79dac5c8)
2026-10-13 00:51:36 +08:00
yhh fbcc83cdc6 test(studio): turn the session tabs on in the tests that switch panes by tab
The tab strip is opt-in now, so tests written against an always-present strip
enable the setting first.
2026-10-12 09:47:02 -07:00
yhh 174571c2a9 Merge commit 'refs/pr11744' into takeover/11744
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
#	desktop/frontend-next/src/state/prefs.ts
#	desktop/frontend-next/src/ui/Appearance.tsx
#	desktop/frontend-next/src/ui/prefsnav.tsx
2026-10-12 09:41:51 -07:00
YHH d1f096b206 Merge pull request #12837 from esengine/takeover/12069
feat(studio): add a shared send shortcut preference
2026-10-12 09:26:35 -07:00
yhh 3bce33bff3 Merge commit 'refs/pr12660' into takeover/12660
# Conflicts:
#	desktop/frontend-next/src/port/look.ts
#	desktop/frontend-next/src/styles/TOKENS.md
#	internal/ext/theme/theme.go
2026-10-12 09:25:15 -07:00
YHH 2f27112578 Merge pull request #12836 from esengine/takeover/12311
feat(studio): quote selected reply text from the context menu
2026-10-12 09:18:47 -07:00
yhh a2ae61b9c0 Merge commit 'refs/pr12069' into takeover/12069
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
2026-10-12 09:17:26 -07:00
YHH 17589e87d5 Merge pull request #12835 from esengine/takeover/12349
fix(studio): batch-check models before adding a source
2026-10-12 09:09:26 -07:00
yhh 0818a41038 fix(studio): merge the selection menu with the reply branch action and copy label
CopyButton gained both a menuitem role and an aria label on studio, the
actions list gained reply.branch, and the new selection tests build a reply
action set that now also needs the branch pair.
2026-10-12 09:08:38 -07:00
yhh 4473c23aa5 fix(studio): batch-check models on the protocol being picked
The edit form can change a connection's protocol, so the shared batch
hook is given the draft protocol rather than the stored entry's.
2026-10-12 08:59:48 -07:00
yhh 0a647e12ff Merge commit 'refs/pr12349' into takeover/12349
# Conflicts:
#	desktop/frontend-next/src/ui/AddProvider.tsx
#	desktop/frontend-next/src/ui/EditConn.tsx
2026-10-12 08:55:21 -07:00
YHH f43cc2af32 Merge pull request #12818 from esengine/takeover/12808
feat(studio): add file path and reference copy actions
2026-10-12 08:46:09 -07:00
yhh 101e82b5bb fix(plugins): removing a plugin reclaims the copies a replacement left beside it
A replacement published beside the old tree keeps the old tree, so an
uninstall that removed only the active root left the managed directory
behind. Removal now also deletes the canonical copy and earlier staged
copies, skipping any directory the registry still points at.
2026-10-12 08:42:16 -07:00
yhh 21219f6890 test(plugins): compare the publication fault with errors.Is 2026-10-12 08:38:14 -07:00
yhh 02a8abeec3 Merge remote-tracking branch 'origin/studio' into own/12818
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
2026-10-12 08:36:07 -07:00
YHH 7fbcc02b92 Merge pull request #12832 from esengine/takeover/12824
fix(studio): open the focused session files from navigation
2026-10-12 08:34:04 -07:00
YHH 17af885a67 Merge pull request #12831 from esengine/takeover/12814
feat(studio): add persistent event-driven rewind notice
2026-10-12 08:22:35 -07:00
yhh a81212d745 Merge commit 'refs/pr12824' into takeover/12824
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
#	desktop/frontend-next/src/ui/browser-missed-read.interaction.test.tsx
#	desktop/frontend-next/src/ui/browser-open.test.ts
#	desktop/frontend-next/src/ui/html-preview.interaction.test.tsx
#	desktop/frontend-next/src/ui/useColumnOpening.ts
2026-10-12 08:21:06 -07:00
yhh cf1ca1ff06 fix(studio): classify the rewind change event and register the dismiss action
rewind_changed is a content-free invalidation a reopened pane replaces with
a read of /rewind/undo, so the wire log skips it, and the new close button
declares its action id.
2026-10-12 08:13:21 -07:00
YHH 7ee7a7fe8e Merge pull request #12826 from XTLine/fix/studio-workbench-launcher
fix(studio): offer a launcher from the workbench new-tab button
2026-10-12 08:05:40 -07:00
yhh c430ae5fe3 Merge commit 'refs/pr12814' into takeover/12814 2026-10-12 08:03:47 -07:00
YHH 06ff93fda2 Merge pull request #12829 from esengine/feat/mcp-held-approval
feat(studio): approve MCP tools whose definition changed
2026-10-12 07:54:54 -07:00
XTLine 9ea570231a test(studio): cover missed and superseded browser observations 2026-10-12 22:44:43 +08:00
XTLine ba06cdd16f fix(studio): reconcile browser navigation read boundaries 2026-10-12 22:44:33 +08:00
yhh 470fcfea8b feat(studio): approve MCP tools whose definition changed
A server whose tool definitions differ from what the user approved has those
tools withheld from the model, and the kernel already had an approval route
for them, but the settings row never said so and offered no way to approve.
The row now carries the withheld tools with how each differs, and a button
that sends back the digest of exactly that set, then reconnects.
2026-10-12 07:42:27 -07:00
XTLine 79f5f1f90b fix(studio): follow fresh activation after a missed tab read 2026-10-12 22:38:57 +08:00
XTLine 5a1e3655d4 fix(studio): preserve the latest eligible browser follow 2026-10-12 22:25:34 +08:00
XTLine c58f236dd8 fix(studio): track browser navigation read boundaries 2026-10-12 22:25:09 +08:00
yhh 50014383fb style(plugins): iterate directories backwards with slices.Backward 2026-10-12 07:13:53 -07:00
YHH 47056a93ee Merge pull request #11761 from esengine/feat/web-search-model
feat(studio): web search model setting in model preferences
2026-10-12 07:08:21 -07:00
yhh 6d98fbe7dd Merge remote-tracking branch 'origin/studio' into own/11822
# Conflicts:
#	desktop/frontend-next/src/port/plugin.ts
#	internal/ext/installsource/plugin_package.go
#	internal/ext/installsource/types.go
2026-10-12 07:06:41 -07:00
yhh 4395969694 fix(config): match the provider removal signature in the web search model test 2026-10-12 07:00:57 -07:00
YHH d48ba5e65b Merge pull request #11762 from esengine/feat/browser-fullscreen
feat(studio): wide mode for the browser pane that keeps the conversation visible
2026-10-12 06:57:23 -07:00
yhh 8931d3e612 Merge remote-tracking branch 'origin/studio' into own/11761
# Conflicts:
#	internal/model/anthropic/anthropic.go
2026-10-12 06:54:13 -07:00
XTLine c51e648adf test(studio): cover preview ownership and real browser closing 2026-10-12 21:50:46 +08:00
XTLine 6dd1650dca test(studio): cover launcher choices and interrupted browser opens 2026-10-12 21:50:34 +08:00
XTLine 0ee194932c fix(studio): open the workbench launcher from the new-tab button 2026-10-12 21:50:23 +08:00
yhh bcfbe73bc0 test(studio): identify the browser page by its address bar in the wide-mode tests
The page's iframe now exists only after a navigation and the browser opens
from the workbench launcher, so the tests open the launcher and compare the
address bar, which stays mounted across the toggle.
2026-10-12 06:49:46 -07:00
XTLine 06009af71b fix(studio): track browser opening and preparation lifetimes 2026-10-12 21:49:38 +08:00
YHH a0a0763fdd Merge pull request #12819 from chuiwenwei/fix/memory-archive-index-consistency
fix(memory): keep the index and the archive consistent when a publish fails
2026-10-12 06:46:13 -07:00
YHH 9ea250b5bf Merge pull request #12816 from chuiwenwei/fix/memory-restore-archived-retryable
fix(memory): keep a failed archived-memory recovery retryable
2026-10-12 06:44:48 -07:00
yhh 68eaa5069e Merge remote-tracking branch 'origin/studio' into own/11762 2026-10-12 06:41:44 -07:00
yhh 139554c91a feat(studio): recover extension surfaces and count question interactions 2026-10-12 06:37:28 -07:00
XTLine 6a30c3e3c6 test(studio): cover operation preparation, observation and reuse lifetimes 2026-10-12 21:34:55 +08:00
XTLine 0461622f70 test(studio): cover tab-bearing refusals and hidden preview reconciliation 2026-10-12 21:34:39 +08:00
XTLine 67da7b1930 test(studio): cover event and explicit file selection races 2026-10-12 21:34:21 +08:00
XTLine d5b7dd6809 fix(studio): route Files entries into the focused session explorer 2026-10-12 21:34:06 +08:00
XTLine 4357242a98 fix(studio): keep explicit selection ahead of superseded browser requests 2026-10-12 21:33:50 +08:00
XTLine 26768e54d6 fix(studio): track full browser-opening operations with API controls 2026-10-12 21:32:16 +08:00
YHH 8734ee8d7a Merge pull request #12821 from esengine/takeover/12805
feat(studio): add per-model display aliases
2026-10-12 06:19:36 -07:00
yhh 38e66576fc Merge remote-tracking branch 'origin/studio' into takeover/12805 2026-10-12 06:11:30 -07:00
yhh 49d8c158bc Merge commit 'refs/pr12805' into takeover/12805 2026-10-12 06:01:33 -07:00
yhh 931dda7061 fix(studio): drop the import the file menu extraction left unused 2026-10-12 05:55:20 -07:00
YHH abc23f9fea Merge pull request #12815 from esengine/takeover/12804
fix(studio): make crowded timeline signals inspectable
2026-10-12 05:54:39 -07:00
yhh 0626f197ff Merge commit 'refs/pr12808' into takeover/12808
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
2026-10-12 05:52:09 -07:00
yhh ee85dd3481 Merge remote-tracking branch 'origin/studio' into takeover/12804 2026-10-12 05:44:22 -07:00
YHH 75910feefc Merge pull request #12803 from esengine/takeover/12328
feat(studio): show the current plan beside conversation navigation
2026-10-12 05:43:35 -07:00
Will 4f548643b0 fix(memory): keep the index and the archive consistent when a publish fails
Archive moved the active file into .archive and left the index flush to its
caller, so a failed flush kept the fact archived while MEMORY.md still named it,
with no way for a retry by stable id to tell which state the store was in.

The move is now undone when the flush fails, and a retry by stable id finds the
archived copy and drops the stale line. The test seam this needs is the one the
rename fix already added to the mainline.
2026-10-12 20:43:23 +08:00
YHH 113a01e1dc Merge pull request #12813 from esengine/fix/phone-quiet-keeps-branch-chip
fix(studio): keep the branch chip when the phone composer is quiet
2026-10-12 05:42:59 -07:00
yhh 9a26301d83 Merge commit 'refs/pr12804' into takeover/12804 2026-10-12 05:41:29 -07:00
XTLine 2e2b580bd8 merge(studio): retain aliases with browser and session controls 2026-10-12 20:41:01 +08:00
Will fa818864ee fix(memory): keep a failed archived-memory recovery retryable
RestoreArchived wrote the active file and reindexed it before removing the
archived copy, so a failure in either later step left the same id active and
archived at once, and the identity guard refused every retry.

Both steps now undo the restored file, so a failed recovery never leaves a double
identity and the same call succeeds once the obstruction is gone. If the undo's
own index write fails, a line naming the fact can remain; the retry rewrites it.
2026-10-12 20:39:50 +08:00
yhh c67fc94eb9 fix(studio): keep the branch chip when the phone composer is quiet
The quiet phone composer hid the branch pop along with the refine entry,
so the no-repository chip, which explains why git features are missing,
was unreachable at 390px and the branch-tooltip browser guard failed on
studio. Only the refine entry is hidden now; the composer guard asserts
the branch pop stays.
2026-10-12 05:35:31 -07:00
yhh 7e71b2dce4 fix(studio): translate the plan peek goal label
The English catalogue test fails on the untranslated PaneNav key.
2026-10-12 05:33:55 -07:00
YHH 2140e55491 Merge pull request #12809 from esengine/chore/perf-harness-styles
chore(perf): load the same stylesheets as the app
2026-10-12 05:27:55 -07:00
Guan09 7c93b587bf feat(studio): add persistent event-driven rewind notice 2026-10-12 20:26:26 +08:00
YHH 53a9bb6dc1 Merge pull request #12807 from esengine/takeover/12347
feat(studio): select local sessions for batch actions
2026-10-12 05:12:21 -07:00
yhh 0fbcd55ebd chore(perf): load the same stylesheets as the app
The perf page imported fewer stylesheets than main.tsx, so controls such
as the changed-files pill and the session row actions rendered unstyled
in anything captured from it.
2026-10-12 05:09:39 -07:00
XTLine fa278aedd5 test(studio): guard copied references and workspace changes 2026-10-12 20:09:13 +08:00
XTLine 567816d7d3 test(studio): cover file copy menu behavior 2026-10-12 20:09:04 +08:00
XTLine cfa255cc70 feat(studio): add file context copy actions 2026-10-12 20:08:36 +08:00
XTLine 459bc7f4f7 fix(studio): preserve activity duration while zooming 2026-10-12 20:06:11 +08:00
yhh be06337aa0 fix(studio): select the rows the paged sidebar actually shows
The sidebar no longer slices each project to a fixed 30 rows: it asks the
kernel for pages and draws every loaded row. Batch selection now takes
all displayed rows, the selection pruning moves into a hook beside the
toolbar so Workspaces.tsx stays under its ceiling, and the two tests that
assumed the old 30-row cut assert the loaded rows instead.
2026-10-12 05:04:28 -07:00
YHH d21d91f6b3 Merge pull request #12806 from esengine/takeover/12067
feat(studio): close browser pages individually or in bulk
2026-10-12 05:02:54 -07:00
yhh 6766cc0042 Merge commit 'refs/pr12347' into takeover/12347
# Conflicts:
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/ui/Workspaces.tsx
2026-10-12 04:59:51 -07:00
XTLine abbd487b95 feat(studio): edit and show model display aliases 2026-10-12 19:51:17 +08:00
XTLine 62ddce6804 test(studio): guard alias identity and persistence boundaries 2026-10-12 19:51:04 +08:00
XTLine f9e3f3a5e5 feat(serve): expose scoped model aliases in provider editing 2026-10-12 19:50:52 +08:00
XTLine a3518cc60a feat(config): persist exact per-model display aliases 2026-10-12 19:50:18 +08:00
XTLine b92becac71 fix(studio): make crowded timeline signals inspectable 2026-10-12 19:45:58 +08:00
yhh 91ceafc038 Merge commit 'refs/pr12067' into takeover/12067 2026-10-12 04:42:31 -07:00
yhh 8cb7d82c01 fix(studio): carry the plan peek rules in their own stylesheet
The rules pushed studio.css over its css-size budget, so they move to
planpeek.css, imported after studio.css.
2026-10-12 04:36:49 -07:00
YHH 1682ab7fad Merge pull request #12802 from esengine/takeover/12310
feat(studio): fold feedback conversations and preserve unread state
2026-10-12 04:36:37 -07:00
yhh a4f1729c22 Merge commit 'refs/pr12328' into takeover/12328 2026-10-12 04:34:00 -07:00
yhh 0ea0c0e0fc fix(studio): open feedback conversations that wait on the reporter
A folded thread hid the maintainer's question while the status said
the report needed an answer. A report with needsInput now starts open
and is acknowledged as seen like any opened thread. The existing
entry-badge test opens the threads it expects to be read, and the
needs-input test no longer clicks a thread that is already open.
2026-10-12 04:29:05 -07:00
YHH dfe972034d Merge pull request #12801 from esengine/takeover/12327
fix(studio): put the current model first in the composer picker
2026-10-12 04:24:16 -07:00
yhh d7b42cc072 Merge commit 'refs/pr12310' into takeover/12310 2026-10-12 04:23:49 -07:00
YHH 95822af03a Merge pull request #12800 from esengine/takeover/12331
fix(studio): compact the untouched phone composer
2026-10-12 04:15:49 -07:00
yhh a1e598fee9 Merge commit 'refs/pr12327' into takeover/12327
# Conflicts:
#	desktop/frontend-next/src/ui/modelmenu.ts
2026-10-12 04:12:34 -07:00
yhh 2ea1d8e01c fix(studio): carry the phone composer rules in their own stylesheet
The six rules move out of studio.css into phonecomposer.css, without
the two !important the budget does not have room for: the attach
button's display no longer forces itself, so the quiet-state hiding wins
by specificity. The quiet toolbar also stops wrapping its model group.
The composer guard clears the draft left by its earlier steps before the
phone section and the guard page imports the new stylesheet.
2026-10-12 04:07:10 -07:00
yhh 2b97f139d8 Merge commit 'refs/pr12331' into takeover/12331 2026-10-12 03:59:16 -07:00
YHH 9c371349a1 Merge pull request #12798 from esengine/fix/rail-toggle-in-settings
fix(studio): keep the sidebar toggle usable while a settings page is open
2026-10-12 03:51:55 -07:00
yhh 3478a1dc6e fix(studio): keep the sidebar toggle usable while a settings page is open
The toggle was disabled whenever the settings sheet was open, so after
pressing any icon in the rail the top-left button looked live and did
nothing. It now opens and closes the workspace column in settings too.
2026-10-12 03:40:37 -07:00
YHH 55d3ca45c1 Merge pull request #12785 from chuiwenwei/fix/memory-rename-active-identity
fix(memory): retire the old name before publishing a renamed fact
2026-10-12 03:35:45 -07:00
YHH fddc6512a8 Merge pull request #12793 from esengine/takeover/12466
fix(studio): refine default light theme hierarchy
2026-10-12 03:03:14 -07:00
yhh 5fb8925ca2 chore: keep the before/after screenshots out of the repository 2026-10-12 02:51:27 -07:00
yhh c8d2bac6fb Merge commit 'refs/pr12466' into takeover/12466 2026-10-12 02:51:26 -07:00
YHH f14e75f9b5 Merge pull request #12783 from esengine/fix/compaction-live-line
fix(studio): stop the transcript jumping while a conversation is being compacted
2026-10-12 02:51:06 -07:00
yhh 5b4f8f073a fix(studio): keep the live compaction progress line on one reserved line
The streaming summary line wrapped to a different height on every token
while the transcript was pinned to the bottom, so the card and everything
above it moved up and down for the length of the fold. The line is now
always drawn, one line high, truncated with an ellipsis.
2026-10-12 02:41:53 -07:00
YHH 7340ac3a68 Merge pull request #12782 from esengine/fix/tabs-blend
fix(studio): let the view tabs share the page's tone
2026-10-12 02:38:46 -07:00
yhh 22491271c0 fix(studio): let the view tabs share the page's tone
The conversation, run analysis and workbench tabs sat on their own lighter
band with a divider under it. The row now takes the page's tone and drops the
divider; with a wallpaper the existing wallpaper rule still gives it a stable
surface.
2026-10-12 02:28:11 -07:00
YHH 191abea93c Merge pull request #12778 from esengine/fix/browser-view-repaint
fix(electron): attach a parked browser page again when it is shown
2026-10-12 02:08:54 -07:00
YHH 2e46375e4f Merge pull request #12776 from esengine/takeover/12340
feat(studio): add project folders by dropping onto the sidebar
2026-10-12 02:03:13 -07:00
yhh 75a08f128c fix(electron): attach a parked browser page again when it is shown
Switching away from a built-in browser tab and back drew a white page until the
window was resized: a view parked offscreen and moved back with setBounds alone
keeps a widget that does not repaint. The shell already knows that attaching a
view again wakes its widget (it does so on minimize and restore); a page that
was parked now gets the same when it is shown.
2026-10-12 01:57:50 -07:00
esengine cbd9038b07 Merge studio into takeover/12340 2026-10-12 01:55:46 -07:00
YHH fe7c6c44e6 Merge pull request #12777 from esengine/takeover/12343
feat(studio): compact context from the ring and inspector
2026-10-12 01:54:05 -07:00
YHH 2874af8b0e Merge pull request #12775 from esengine/takeover/12318
feat(studio): offer quick archive and delete on session rows
2026-10-12 01:38:31 -07:00
esengine 6b9e50246a Merge branch 'pr12343' into verify/12343 2026-10-12 01:38:04 -07:00
esengine 091c1b2865 feat(studio): keep the folder-drop styles in their own stylesheet
#12340 added 18 lines to studio.css, which must not grow. The drop-hint rules
move to styles/sidebardrop.css, imported with the other feature sheets, and
the sidebar-drop fixture gains the Sidebar props that became required after the
branch point (navShown, paging) so the file typechecks again.
2026-10-12 01:32:44 -07:00
esengine bf0505e70e Merge branch 'pr12340' into takeover/12340 2026-10-12 01:30:21 -07:00
esengine 5851b802ad Merge studio into takeover/12318 2026-10-12 01:29:12 -07:00
YHH 970c91d138 Merge pull request #12774 from esengine/refactor/workspaces-extract
refactor(studio): draw the session menu in its own component
2026-10-12 01:22:10 -07:00
yhh 859ef4782c feat(studio): keep the quick-action styles in their own stylesheet
#12318 added 26 lines to studio.css, which must not grow. The quick-action
rules move to styles/sessionquick.css, imported with the other feature
sheets, and the paging test finds its filter chips inside the filter group so
the new archive and delete buttons, whose names also start with 归档, are not
mistaken for them.
2026-10-12 01:18:55 -07:00
verify 7f79763026 Merge branch 'pr12318' into takeover/12318 2026-10-12 01:15:47 -07:00
yhh 6d98474fdf refactor(studio): draw the session menu in its own component
Workspaces.tsx held the session row's menu inline, 63 lines of JSX that only
report which action was chosen. It is its own component now, with the same
markup and the same handlers, so the sidebar file has room for the quick
actions, folder drop and batch selection that are waiting to land in it.
2026-10-12 01:14:03 -07:00
YHH 8d1f46cb50 Merge pull request #12324 from KHG420/codex/studio-project-context-menu
feat(studio): open project actions from context menus
2026-10-12 01:10:58 -07:00
Will 58aef50d47 fix(memory): retire the old name before publishing a renamed fact
SaveWithOptions wrote the new active file, then removed the old one, then
repaired the index. A failure in either later step left one id readable as two
active facts or left MEMORY.md naming a file that no longer existed, and a retry
could not tell which state it was in.

The rename path now stages the new content, retires the old name out of the
active namespace, flushes the old index line, publishes the staged file, and
reindexes; every step before publication is undone from the retired copy. The
helper that removed the old file in place has no callers left and is deleted.
2026-10-12 16:04:52 +08:00
YHH f3287f7dfc Merge pull request #12772 from esengine/feat/write-lease-optimistic
feat(config): an optimistic write lease for sessions sharing a workspace
2026-10-12 00:46:21 -07:00
yhh 25c6d4c10d feat(config): an optimistic write lease for sessions sharing a workspace
Every writer that declared no extent (bash, MCP, a sub-agent given no
write_paths) held the whole workspace, so two sessions on one project queued
behind each other even when nothing they wrote overlapped. [agent] write_lease
= "optimistic" in the user file makes such a writer claim nothing; a writer
that declared paths, including paths that cannot be resolved, still excludes
other sessions. The mode is carried by each session's lease owner, so sessions
in one process keep their own, and a project file cannot change it.
2026-10-12 00:35:29 -07:00
YHH 00d7ece65a Merge pull request #12559 from XTLine/fix/studio-restore-undo
fix(studio): preserve restore undo across reload / 保留重载后的还原撤销入口
2026-10-12 00:33:10 -07:00
YHH fba020d9bb Merge pull request #12770 from esengine/feat/branch-from-reply
feat(studio): branch a conversation from any reply into its own session
2026-10-11 23:44:32 -07:00
yhh 4f4b43978b feat(studio): branch a conversation from any reply into its own session
Studio had no click entry for branching: /branch copied only the current tip and
swapped the pane onto it. A finished reply now carries a 'Branch into a new
conversation' button that copies the conversation up to the end of that reply's
turn into a new session, titled '<title> - branch' so it never reads as a second
copy of its source, and opens it while the original stays as it was.

Controller.BranchAt cuts at a live turn boundary and shares the file-and-meta
writing with Branch. POST /branch/at answers with the new path and refuses with
typed codes (running, unavailable, failed).
2026-10-11 23:36:04 -07:00
YHH e67f256d5b Merge pull request #12769 from esengine/feat/withdrawn-version-notice
feat(update): tell a user whose running version was withdrawn
2026-10-11 23:24:29 -07:00
yhh ee40a3b90e feat(update): tell a user whose running version was withdrawn
The catalog can now pull a published release, but a user already running it
saw an ordinary update. The version hub carries a withdrawn note for the
running version, mirrored on the desktop side, and the version panel leads
with it: which version, the publisher's reason, and where to move to.
2026-10-11 23:15:05 -07:00
YHH 0968731518 Merge pull request #12768 from esengine/feat/open-logs-menu
feat(studio): open the logs folder from the feedback form
2026-10-11 23:12:35 -07:00
yhh 3b131d66a4 feat(studio): open the logs folder from the feedback form
A report that needs a log (it will not start, the kernel keeps restarting) had
no way to say where the log is: Studio writes shell.log and host.log under the
user data folder but nothing opened it, and the Windows and Linux builds have
no menu bar to put an entry in. The feedback form now offers 'Open logs
folder' when the shell can open it; the shell names the folder, the page does
not.
2026-10-11 23:03:29 -07:00
YHH b71837c1e5 Merge pull request #12765 from xxoingr/test/goal-resume-notice-race
test(control): fix the resumed-goal notice before the goal can complete
2026-10-11 22:36:47 -07:00
YHH b88596a4fc Merge pull request #12763 from chuiwenwei/fix/e2ebench-analysis-failure-reporting
fix(e2ebench): restore before checking the base side and name what is missing
2026-10-11 22:34:49 -07:00
YHH 9290b3bc0d Merge pull request #12761 from xxoingr/fix/tui-esc-menu-cancels-turn
fix(tui): Esc in the completion menu also stops a running turn
2026-10-11 22:30:12 -07:00
YHH fb77904a9f Merge pull request #12762 from xxoingr/test/split-jump-resolve-real-ssh
test(remote): split jump resolution from the installed ssh
2026-10-11 22:29:46 -07:00
YHH 0522cd92e6 Merge pull request #12757 from chuiwenwei/feat/contextbench-batch-manifest
feat(bench): persist what a context-retrieval batch was beside its results
2026-10-11 22:22:13 -07:00
YHH 50e25025a0 Merge pull request #12563 from chuiwenwei/fix/e2ebench-tape-integrity
fix(e2ebench): record and report incomplete tape recordings
2026-10-11 22:21:06 -07:00
YHH 0c9d2455be Merge pull request #12767 from esengine/perf/rail-thin-marks
perf(studio): draw one conversation-rail mark per distinguishable position
2026-10-11 22:13:11 -07:00
YHH dc921776c3 Merge pull request #12682 from esengine/dependabot/go_modules/go-412d5d9764
build(deps): bump the go group with 2 updates
2026-10-11 22:10:50 -07:00
yhh 912659f0dd perf(studio): draw one conversation-rail mark per distinguishable position
A 2000-turn conversation drew 2000 buttons per rail, 0.27 px apart, and every
pointer move rewrote all of them. Marks closer than 2 px are not told apart by
eye or pointer, so only one of each cluster is drawn; the rest stay addressable
through their positions, so the pointer and the keys still land on every
message.
2026-10-11 22:06:15 -07:00
YHH 4ef274d02e Merge pull request #12766 from esengine/perf/noto-studio-slice
perf(studio): draw the first screen from a 1.9 MB core CJK face
2026-10-11 22:03:20 -07:00
yhh 79af107700 perf(studio): draw the first screen from a 1.9 MB core CJK face
The first screen fetched the 7.8 MB full Noto Studio font because every Chinese
character resolved to it. A core face with the common simplified-Chinese set,
Latin, punctuation and symbols now comes first, and the full font stays as a
later family limited to CJK ranges, so a character the core lacks still finds
its glyph there and only then pays for the download.
2026-10-11 21:55:36 -07:00
xxoingr c6a134cfa1 test(control): fix the resumed-goal notice before the goal can complete
TestGoalResumeCommandContinuesTheGoalAndSaysSo failed in the race job on
studio (run 38188087939) with "resuming the goal said nothing" in 0.00s,
while the turn ran and the goal completed. The test built the expected
notice from c.Goal() as it read each event, and completing a goal clears
its text; the buffered events channel lets the controller send the notice,
finish the turn and complete the goal before the test reads anything, so
the real notice was compared with one built from an empty goal.

The expected line is now built once, before /goal resume is submitted.
2026-10-12 12:46:20 +08:00
YHH 97b21b6ed7 Merge pull request #12759 from esengine/feat/withdraw-version
feat(update): withdraw a published Studio version from the catalog
2026-10-11 21:42:48 -07:00
xxoingr 8e9227ceff test(remote): split jump resolution from the installed ssh
TestResolveJumpHostsUsesConfiguredAliasesAndSSHConfig bound two things:
that ResolveJumpHosts reads ssh_config aliases and configured hosts, and
that this machine's real `ssh -G` answers. The second failed on Windows CI
again, twice on studio (runs 38179453323 and 38193303449), both at 15.0 s
with `ssh -G "bastion": exit status 1`: the product's own subprocess
timeout. The test's comment asked to split the two if it recurred rather
than shorten that timeout.

The resolution test now supplies the `ssh -G` answer through the source's
resolveOpenSSH seam, as the sshconfig tests already do, and keeps every
assertion. TestResolveJumpHostsThroughTheInstalledOpenSSH runs the same
alias through the real client, so a starved ssh fails under its own name.
2026-10-12 12:40:12 +08:00
Will 3b1a88db12 fix(e2ebench): restore before checking the base side and name what is missing
Reviewing #12562 left three points open in this code.

differentialPerTest checked the base side before registering the defer that
restores the source, so a failure from that check returned with the tree still on
base - the state the restore exists to prevent. The defer is now registered
before the check, and treeMatches is a variable so a test can inject the failure;
TestDifferentialPerTestRestoresWhenTheBaseCheckFails asserts the PR's content is
back after that failure, and deleting the reorder makes it fail.

The report printed one sentence for every analysis failure, claiming the pin and
mutation figures were both absent even when the pin table had already been
produced and only the mutation sweep failed. analysisFailureLine now says which
figures are missing, and a unit test pins both cases.

The comment above the restore helpers described what the code used to do; it now
states only the constraint it enforces.
2026-10-12 12:35:39 +08:00
YHH 900aa3aa3a Merge branch 'studio' into dependabot/go_modules/go-412d5d9764 2026-10-11 21:32:30 -07:00
YHH d18b248992 Merge pull request #12684 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-c28431c45a
build(deps-dev): bump the npm group in /desktop/electron with 2 updates
2026-10-11 21:31:38 -07:00
YHH a6facc0adf Merge pull request #12758 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-10-11 21:29:58 -07:00
xxoingr c9d1517846 fix(tui): Esc in the completion menu also stops a running turn
1.x closed an open slash or @ menu on Esc and, while a turn ran, cancelled
the turn as well. 2.x's menuKey consumed the key after closing the menu, so
a user who had started typing a follow-up during a run had to press Esc a
second time to stop it, while the thinking line said Esc stops it.

menuKey now cancels the running turn after closing the menu, except in vi
mode, where Esc never interrupts a stream. Idle, Esc on the menu still only
closes it and keeps the draft.
2026-10-12 12:27:58 +08:00
YHH 2e2c9248f5 Merge pull request #12760 from esengine/docs/sponsor-register-links
docs: add an inline registration link to each sponsor's paragraph
2026-10-11 21:21:30 -07:00
yhh 36d69bf9e1 docs: add an inline registration link to each sponsor's paragraph
The sponsor logos link out, but the paragraph beside them read as plain text,
so the entry point was easy to miss. Each paragraph now ends with a visible
link to the same registration address its logo uses.
2026-10-11 21:17:15 -07:00
yhh 8a4281abfb feat(update): withdraw a published Studio version from the catalog
A release found broken after publication could only be fixed by shipping a
newer one, and installs kept being offered it meanwhile. The catalog now
carries a withdrawn list: a withdrawn version leaves the list old clients
read, a new client never offers or installs it and names it when it is the
one running, and a rerun of its own publication cannot list it again.
scripts/withdraw-version.sh edits the catalog; a manual workflow runs it
against the Studio catalog and keeps the GitHub release off the latest slot.
2026-10-11 20:57:15 -07:00
SivanCola 268ccef0a8 docs: update acknowledgments contributors 2026-10-12 03:52:27 +00:00
Will 9eb14cc8cc fix(e2ebench): record and report incomplete tape recordings
A tape is recorded as three files per provider exchange, and every write
discarded its error. A recording that lost a file therefore looked recorded, and
replay could not tell the difference: with the recorded request missing,
requestDiff compared against nothing and reported a divergence, blaming the
harness for a tape that was never complete. Nothing in the run's own report
mentioned it either, so a run whose tape can never be replayed read as a success.

save now returns its error and writes request.json last, so that file's presence
marks an exchange complete; load reports a missing recorded request as an
incomplete tape instead of a divergence; and the meter counts failed writes and
names the first error, which the report prints.

Two review findings in the same boundary are fixed here too. The run path read
the meter through record, which snapshotted before the final handler's deferred
save could run; stopping the meter is what waits for those handlers, so record
now drains first and the last exchange cannot be missing from the report. And a
re-recording over an existing tape left that tape's completeness markers in
place, so a failure partway through read as an old request beside a new partial
response; recording now clears those markers before it starts.

The healthy path is pinned as well, so the failure cases are not the only ones
covered: a complete recording reports nothing incomplete and prints no line.
Deleting any of the four checks reproduces the old behaviour.
2026-10-12 11:45:41 +08:00
YHH 59b2b75e62 Merge pull request #12753 from esengine/fix/rail-icons-match-settings
fix(studio): draw each rail entry with the mark of the settings section it opens
2026-10-11 20:43:56 -07:00
Will 1724a154f8 feat(bench): persist what a context-retrieval batch was beside its results
A batch printed its own provenance to the terminal and kept the per-run records
on disk, so the two could not be read together afterwards. The fingerprint
(model, endpoint, window, generations, commit, start time) existed only in
scrollback, and every aggregate the report is read for — funnels, dose response,
pooled deltas — was printed rather than stored. Reconstructing a batch after the
fact, or attributing a figure in the README to the runs that produced it, meant
trusting a terminal transcript; the 24-run and 36-run totals in the last batch
are exactly that failure.

Each paying mode now writes batch.json beside results.jsonl: mode, start and
finish, the checkout's commit and dirty state, model, window, generations, the
task ids and arms the batch covered, planned against completed runs, the summed
usage of the runs that reported any, and the sha256 of both artifacts so a
manifest and the records it describes cannot drift apart.

Two things stay honest. Usage totals are absent when nothing reported any,
because an unmeasured cost must not read as a measured zero, while
usage_reported_runs is written even when it is zero, because that is a fact. And
a checkout git cannot describe is recorded as an unknown commit with no dirty
flag at all, rather than as a clean tree.
2026-10-12 11:38:25 +08:00
yhh 4c3eabc0ab fix(studio): draw each rail entry with the mark of the settings section it opens
The icon rail kept its own icon set, so the same destination showed one mark
on the rail and another on the page it opens. Entries that open a section now
read that section's mark from the settings table; only the conversation item
keeps its own.
2026-10-11 20:33:09 -07:00
YHH 4a0b3888cb Merge pull request #12734 from jf200s/fix/studio-win11-osversion
fix(feedback): report the Windows 11 product line in the environment version
2026-10-11 20:25:16 -07:00
YHH e17ef45e34 Merge pull request #12744 from jipeng6036-del/fix/12743-tree-empty-sessions
fix(serve): send an empty sessions list for a folder with no conversations
2026-10-11 18:48:13 -07:00
YHH a177a27c2a Merge pull request #12562 from chuiwenwei/fix/e2ebench-mutation-restore
fix(e2ebench): fail mutation and differential analysis when source restoration fails
2026-10-11 17:56:04 -07:00
YHH 6c3e554420 Merge pull request #12746 from esengine/notes237
docs(release): Studio 2.37.0 notes
2026-10-11 17:39:56 -07:00
yhh 6bb4839c93 docs(release): Studio 2.37.0 notes 2026-10-11 17:34:33 -07:00
YHH 2f0cf13a61 Merge pull request #12745 from esengine/argjson
fix(provider): replay a tool call with no arguments as an empty object
2026-10-11 17:27:45 -07:00
yhh 2f6b04b0eb fix(e2ebench): wrap the git error when the restored tree does not match
Clears the errorlint finding on restore.go.
2026-10-11 17:21:48 -07:00
yhh 64bfc5e0a0 fix(provider): replay a tool call with no arguments as an empty object
A history can carry a tool call whose arguments were recorded as an empty string
(a no-argument tool on a gateway that sends ""). Replayed as-is, a strict
gateway that parses every function_call.arguments as JSON refuses the whole
request with HTTP 400 ("Assistant tool call arguments must be valid JSON").

The repair that already closes truncated arguments now also turns empty or blank
arguments into {} on the way to a provider; a saved session keeps what it
recorded. The repair moves to its own file.
2026-10-11 17:17:50 -07:00
YHH 6f29d3d960 Merge branch 'studio' into codex/12026-browser-close 2026-10-11 17:04:14 -07:00
YHH b7ee3207da Merge branch 'studio' into codex/studio-feedback-thread-folding 2026-10-11 17:04:00 -07:00
YHH 80552ff2ff Merge branch 'studio' into codex/studio-refine-keyboard-help 2026-10-11 17:03:47 -07:00
YHH f808837d43 Merge branch 'studio' into codex/studio-plan-peek 2026-10-11 17:03:34 -07:00
YHH f08973d82e Merge branch 'studio' into codex/studio-skill-source-groups 2026-10-11 17:03:20 -07:00
YHH 99cb4c1754 Merge branch 'studio' into fix/e2ebench-mutation-restore 2026-10-11 17:02:54 -07:00
YHH 74fcba388c Merge pull request #12623 from esengine/fix/theme-remove-12351
feat(studio): delete a theme pack from Appearance
2026-10-11 16:52:23 -07:00
YHH 5e86094c4f Merge pull request #12594 from esengine/chore/remove-windows-update-helper
refactor(update): remove the unused Windows update helper from studio
2026-10-11 16:46:50 -07:00
YHH 3a9e5e4fc1 Merge pull request #12578 from esengine/fix/upgrade-npm-installs
fix(upgrade): leave npm-installed binaries to npm
2026-10-11 16:44:45 -07:00
yhh ff4e345924 fix(studio): declare the theme removal action in the action registry 2026-10-11 16:42:45 -07:00
yhh de3b4f9a8e Merge studio 2026-10-11 16:40:17 -07:00
yhh 8d869198bd Merge studio into chore/remove-windows-update-helper
The baseline had diverged from studio's. Took studio's baseline.json and
re-ran repolint -update, which only lowered budgets for the code this change
removes (70 lowered, 14 entries removed, none raised).
2026-10-11 16:39:33 -07:00
YHH 35e65c2ef2 Merge pull request #12555 from esengine/perf/boot-explicit-inputs
perf(boot): bind every storage root Build resolves to its stated home
2026-10-11 16:34:41 -07:00
yhh 980b34aa5f chore(i18n): keep the message catalogs at the 800-line ceiling
The managed-install notice added a line to messages_en.go and messages_zh.go and
pushed both over the ceiling. Drop one separator line from each.
2026-10-11 16:27:52 -07:00
yhh 6980dcfdc0 fix(upgrade): leave npm-installed binaries to npm
reasonix upgrade overwrote the prebuilt binary inside node_modules, so
npm kept recording the old version (npm outdated -g, npm ls) while the
binary reported the new one. An executable that lives in an npm package
(bin/ next to a package.json naming reasonix or @reasonix/cli-*) is now
refused before any download, with the npm command to run instead.
2026-10-11 16:26:03 -07:00
yhh 88706b8c97 chore(update): tidy what the removed Windows helper left behind
The no-op main builds on every platform but Linux so Windows no longer has an
empty package, fixes its typo, and drops two comments that still described the
Windows helper.
2026-10-11 15:57:57 -07:00
yhh 6694713cf9 refactor(update): remove the Windows update helper and the code only it reached
Studio on Windows runs the downloaded installer directly (Line.RunInstaller);
nothing builds or starts reasonix-update-helper.exe there. Delete its Windows
sources and tests, the WindowsHandoff staging code, the Windows payload
manifest and its sign subcommand, and move the repair/installlayout entry
points that only that helper called into test support.
2026-10-11 15:57:26 -07:00
yhh f21a6d1791 fix(config): judge a grant against the home the package already resolves
The process-input gate counts a direct os.UserHomeDir call; folder grants now use
the package's injectable osUserHomeDir like the other home lookups here.
2026-10-11 15:57:01 -07:00
yhh c349bab79e perf(boot): bind every storage root Build resolves to its stated home
A Build handed Options.Home still put three storage roots under the
process's home: isolated and best-of-n worktrees, browser profiles, and
the missing-reasoning recovery state. Each now resolves through the
build's config.Roots, which is the process binding whenever no home is
stated, as in every production frontend.

The new process-input repolint rule ratchets direct reads of the process
environment, working directory and user directories below
internal/frontend, so later slices can only move that count down.

40 boot tests now state their home and workspace instead of mutating the
process, and run in parallel. The isolation effect test runs two
sessions with different homes side by side and fails without the
binding fix.

The provider registry now holds a read-write lock: parallel Builds
register and resolve kinds concurrently, which -race reported as a
race and could crash with a concurrent map write.

Refs #12531
Refs #12521
2026-10-11 15:55:36 -07:00
YHH a3c025cc41 Merge pull request #12641 from esengine/fix/install-source-local-mcp-bound
fix(installsource): bound a local .mcp.json and name the cause of a refused install
2026-10-11 15:51:37 -07:00
YHH d6116a227e Merge pull request #12592 from esengine/perf/config-retired-keys-single-read
perf(config): run the retired-key migrations over one read of each config
2026-10-11 15:50:37 -07:00
YHH b2af298cba Merge pull request #12615 from esengine/fix/shell-discovery-bounded-12473
fix(sandbox): bound shell discovery as a whole so a stalled lookup cannot hold the kernel handshake
2026-10-11 15:47:10 -07:00
yhh da0e5942fb chore(installsource): state why the bounded manifest read is not a path injection
The path is a manifest inside the source the person asked to install, and the
read is capped at the fetch limit. The CodeQL alert on the open is a false
positive for this call; say so where the open is.
2026-10-11 15:33:37 -07:00
YHH b69d956bc3 Merge branch 'studio' into feat/tui-unviewed-mark 2026-10-11 15:28:25 -07:00
YHH 163828b644 Merge branch 'studio' into feat/phone-page-installable 2026-10-11 15:28:12 -07:00
YHH c33f7fefc0 Merge branch 'studio' into perf/config-retired-keys-single-read 2026-10-11 15:27:58 -07:00
YHH 953c592d74 Merge branch 'studio' into feat/remote-wsl-kind 2026-10-11 15:27:18 -07:00
YHH 718c724aec Merge branch 'studio' into fix/shell-discovery-bounded-12473 2026-10-11 15:27:04 -07:00
YHH 9c103e4f7a Merge branch 'studio' into fix/theme-remove-12351 2026-10-11 15:26:51 -07:00
YHH fda72c0730 Merge branch 'studio' into fix/install-source-local-mcp-bound 2026-10-11 15:26:38 -07:00
YHH fdabdeb34f Merge pull request #12645 from esengine/feat/provider-presets
feat(providers): offer the kernel's curated presets when adding a source
2026-10-11 15:11:52 -07:00
YHH c093e4f8ff Merge pull request #12598 from esengine/feat/turn-date-anchor
feat(control): tell the model the calendar date on the turn tail, once per day
2026-10-11 15:07:57 -07:00
yhh d613bda202 fix(studio): name the preset gallery and install form actions in the registry
The rebase onto studio put the unnamed-input surface three over its ceiling and
left provider.preset unregistered. The search, name and key inputs now carry the
provider.draft action with a value, and the gallery and install form buttons are
registered.
2026-10-11 15:04:23 -07:00
yhh e71e3330aa feat(providers): create a curated preset in the kernel from the add form
POST /providers/presets/install builds the entries a preset declares, whole
(catalog, windows, vision models, thinking shape, PresetID and version) with the
person's name and key, through the same storeProviderEntries the manual add
uses; the add form lists the kernel's presets grouped by host, searchable with
the form's own input style, and installs through that call.
2026-10-11 14:59:45 -07:00
yhh 211eb4cbf4 feat(control): tell the model the calendar date on the turn tail, once per day
The model cannot read the host clock, so a session left overnight keeps
reasoning from the date it started on. The date (with weekday and UTC offset)
is now a turn block owed through a projectionDebt: delivered on the first real
turn, absent on later turns of the same day so their bytes are identical,
re-owed on the first turn after the date changes and after a fold. It never
touches the cache-stable prefix. The clock is an explicit input
(control.Options.Clock); the boot assembly supplies the system clock and a
controller built without one tells the model nothing.
2026-10-11 14:56:00 -07:00
jipeng6036-del 203385e07e fix(serve): send an empty sessions list for a folder with no conversations
/tree sent "sessions": null for a workspace with no saved session, because
workspacePage returned a nil slice both when the listing failed and when
there was nothing to list. Studio iterates that field, so a fresh install
or a new project opened to a blank window.

Fixes #12743
2026-10-11 14:44:26 -07:00
YHH ee5c0290a7 Merge pull request #12589 from esengine/fix/provider-remove-report
feat(providers): report the roles a removal moved or cleared and prune provider_access
2026-10-11 14:42:09 -07:00
YHH f28fc07d08 Merge pull request #12560 from esengine/fix/readiness-anchor-skips-prose
fix(readiness): prose written after a passing check owes no second run
2026-10-11 14:37:26 -07:00
yhh 78f962ac31 feat(providers): report the roles a removal moved or cleared, drop the name from provider_access, bind the failure line to its service
RemoveProvider now returns which roles it handed to another service and which it
emptied, and also removes the name from desktop.provider_access. The remove
endpoint carries that report and the provider detail pane shows it, so a
removal that switches vision, advisor or decision off says so. A failure after
the removal was already written is shown where the service used to be instead of
in the detail of whichever service is selected next.
2026-10-11 14:29:47 -07:00
yhh 2fc75feb4e fix(readiness): a still-running background job is an unseen writer
A job the session started and has not collected writes into the tree
without any receipt, so prose after a passing check was waived as if no
writer could still change files. The contract now counts a running job of
the turn's session beside tool hooks and extension sidecars.
2026-10-11 14:26:22 -07:00
yhh b135b676a5 fix(readiness): prose written after a passing check owes no second run
The generic check was measured from the latest surviving write, so a
README or CHANGELOG edit after the tests passed sent the turn back to
re-run them. The anchor now steps over a trailing run of prose whose
extent the host established, when no check is declared and the role is
not Delivery, and only while a check stands passing over both the latest
change beyond prose and the anchor the turn would have had without the
prose. The prose judgement is the one #12520 introduced, now read per
receipt by both the whole-ledger waiver and this one.

A named write follows links, so its receipt now also names the file a
link resolved to; otherwise writing code through a .md link and removing
the link before the end of the turn would read as prose.

A configured tool hook, or an extension sidecar on a point every tool
call passes (tool.before, permission.decision, tool.after), runs outside
the bash sandbox and names no paths, so the check contract carries that
fact and neither prose waiver applies while one is present.
2026-10-11 14:26:22 -07:00
YHH 1bff13835e Merge pull request #12570 from esengine/fix/provider-probes-redirect-guard
fix(providers): keep key-carrying provider requests on the configured origin
2026-10-11 14:13:07 -07:00
esengine 11aa4014d0 fix(providers): keep key-carrying provider requests on the configured origin
Provider clients took netclient.NewHTTPClient, so a redirect from the
configured address was followed by Go's default policy and the key went with
it. Add redirectguard.StayOnOrigin, build the probe, provider, System One,
balance and doctor clients through endpointclient.New, and guard the default
clients of the model listing, balance and System One calls. A refused redirect
is a permanent error that names the target host and reaches the user as
redirect_refused instead of a network fault retried ten times.
2026-10-11 14:00:48 -07:00
YHH 0807e52c2d Merge pull request #12742 from esengine/fix/objectcache-racy-windows
test(trustedstate): pin the clock and the file time in the racy-window test
2026-10-11 11:39:37 -07:00
yhh 278e06b2bc test(trustedstate): pin the clock and the file time in the racy-window test
The test relied on a freshly written file's modification time being inside the
20ms window of the process clock. A file system stamps from its own clock, which
on a loaded Windows runner can lag the process clock by more than the window, so
the object was served from memory and the leg failed intermittently. The store
clock and the file time are now both set, which is what the test is about.
2026-10-11 11:30:22 -07:00
YHH 5be5f1ea14 Merge pull request #12741 from esengine/fix/win-tree-escape-test
test(serve): use a Windows-legal directory name in the tree JSON escaping test
2026-10-11 11:08:41 -07:00
XTLine a201497806 fix(studio): preserve restore through transcript updates
Merge Studio 0ac9826a9e while retaining Pane-owned undo and upstream work folding, running-tail handling and repeated-speaker grouping. Adapt three imported transcript fixtures to the existing undo ownership contract and cover folding, attachments and restore/undo together through the real Pane.

Ordinary upstream integration: 238 files, 7968 insertions and 704 deletions. Manual resolution and regression delta: 41 insertions and 9 deletions. Both histories are preserved.
2026-10-12 02:06:39 +08:00
yhh bdc108ec1c test(serve): use a Windows-legal directory name in the tree JSON escaping test
Windows refuses < and > in a file name, so TestTreeWriteKeepsJSONContentTypeAndEscaping
failed at mkdir on the Windows leg. & is the other character the JSON encoder
escapes, so the same assertion holds there.
2026-10-11 10:59:08 -07:00
YHH 0ac9826a9e Merge pull request #12716 from esengine/docs/studio-2.36.0-notes
docs(release): Studio 2.36.0 notes
2026-10-11 10:37:25 -07:00
yhh 49a967ca8f docs(release): add the changes merged since the first draft to the 2.36.0 notes 2026-10-11 10:30:39 -07:00
yhh 905e973bd5 docs(release): Studio 2.36.0 notes 2026-10-11 10:29:54 -07:00
YHH 777dfd978d Merge pull request #12721 from esengine/feat/receipt-display
feat: the receipt card is hidden by default; a line says what was left unproven; visibility is a kernel setting
2026-10-11 10:20:42 -07:00
YHH 23b6a565df Merge pull request #12648 from esengine/ui/workbench-start-12481
feat(studio): opening the workbench offers a start page instead of opening a browser
2026-10-11 10:16:50 -07:00
YHH ce634f0683 Merge pull request #12740 from esengine/fix/storage-move-live-writes
fix(storage): catch up on writes made during a move and say why a target is refused
2026-10-11 10:15:44 -07:00
YHH 4161a2e62d Merge branch 'studio' into feat/receipt-display 2026-10-11 10:07:42 -07:00
YHH 5281704297 Merge branch 'studio' into ui/workbench-start-12481 2026-10-11 10:07:28 -07:00
yhh 8dc28786e8 fix(storage): catch up on writes made during a move and say why a target is refused
A move copies the root while the app keeps running and writing to it. The
verification pass hashed every file with no progress report, failed the whole
move on the first file that had changed since it was copied, and the commit
then deleted the old folder even when the app had written to it since.

Verification now reports bytes and files as it goes, copies again what changed
(bounded rounds, then ErrSourceChanging), and the old folder is kept when it
was written to after the proof. A refused target names what it holds that the
data does not own.
2026-10-11 10:06:49 -07:00
YHH 251d6e48eb Merge pull request #12739 from esengine/fix/i18n-size-ceiling
chore(i18n): keep i18n.go at the 800-line ceiling
2026-10-11 10:01:13 -07:00
YHH b612222937 Merge pull request #12500 from chuiwenwei/test/studio-corpus-policy-registry
test(e2ebench): require every task corpus to declare its authoring guards
2026-10-11 09:56:51 -07:00
yhh 4a002c54fb feat: the delivery receipt card is hidden by default; one line says what was left unproven; its visibility is a kernel setting
Replaces the stack #12601 (hide by default), #12637 (hint line) and #12643
(kernel setting). [ui] show_receipt (default off) is read and written through the
controller (ReceiptSettings, GET/POST /receipt-settings) like show_changed_files;
Studio keeps a per-kernel answer and carries an old browser-local on across once;
the TUI draws a line, or the card when the key asks for it. The kernel grades the
receipt (Severity) and the hidden card leaves one line for a graded one.
2026-10-11 09:55:16 -07:00
yhh 621e381736 chore(i18n): keep i18n.go at the 800-line ceiling
#12655 added one Messages field and pushed the file to 801 lines, which fails
repolint on the baseline. Drop one separator line between two field groups.
2026-10-11 09:53:16 -07:00
yhh 476942bb93 feat(studio): opening the workbench offers a start page instead of opening a browser
The column opened with a browser tab already in it, whichever way it was
asked for. It now opens on a start page that offers the file explorer, the
changed files and the browser, and a browser exists only once it is chosen.
A page a followed link already has on its way is still shown as before.
2026-10-11 09:45:20 -07:00
YHH f1434b7ae4 Merge pull request #12647 from esengine/ui/sidebar-rail-unify-v2
fix(studio): one sidebar toggle that does not move, a conversation item that does something, one header per run of replies
2026-10-11 09:38:16 -07:00
YHH 8a421f14d2 Merge pull request #12655 from esengine/fix/goal-resume-starts-turn
fix(control): resuming a Goal runs it, and a reply to a blocked Goal puts it back in pursuit
2026-10-11 09:35:48 -07:00
YHH fe8e6dc78f Merge pull request #12651 from esengine/fix/handback-ends-turn
fix(readiness): an explicit hand-back ends the turn instead of being continued
2026-10-11 09:34:52 -07:00
YHH 7c5e35ea1e Merge pull request #12654 from esengine/fix/refused-input-notice-code
fix(control): a refused submission carries a notice code the queueing frontend can word
2026-10-11 09:34:24 -07:00
YHH dc3c84f903 Merge pull request #12607 from esengine/fix/windows-frontend-test-env
test(studio): give async queries room on a loaded machine
2026-10-11 09:33:24 -07:00
yhh 0c34adf412 fix(studio): one sidebar toggle that does not move, a conversation item that does something, and one header per run of replies
The sidebar had three toggles in two screen positions (header button,
title-bar button that shifted with the sidebar, edge grip), a rail
conversation item that did nothing from the conversation view, quick entries
repeated in the sidebar while the icon rail held them, and an R mark plus a
name on every reply. The title-bar button is now fixed to the window and is
the only button, the header one is gone; the conversation item opens and
closes the workspace column and returns home from settings; the sidebar's
quick entries show only when the rail is not drawn and the rail gains the
files entry; replies from one model after the first carry no header.
2026-10-11 09:28:42 -07:00
YHH c8fbbf70f4 Merge pull request #12714 from esengine/feat/fold-turn-work-12671
feat(studio): fold a turn's work under one line, off by default
2026-10-11 09:19:44 -07:00
yhh 86f6486b9b feat(studio): fold a turn's work under one line, off by default
[ui] fold_turn_work (default false) is a kernel setting read through the
controller like the changed-files pill. When it is on, a finished turn
shows its last sentence and gathers the tool calls, thinking and the
sentences in between into one closed line with the call count and any
failure; the line opens on click and keeps its state, and the turn that is
still running is never folded.
2026-10-11 09:08:32 -07:00
YHH 96ed4ea786 Merge pull request #12616 from esengine/fix/narrow-scaled-shell-rows-12479
fix(studio): shell rows no longer collide at high scale in a narrow window
2026-10-11 09:04:57 -07:00
YHH 5c91bd0a72 Merge pull request #12674 from XTLine/fix/studio-machine-settings
fix(studio): keep machine pages local / 修复远程窗格下本机页面错绑
2026-10-11 09:03:33 -07:00
YHH adc5167911 Merge pull request #12495 from chuiwenwei/docs/context-retrieval-v2-result
docs(context-retrieval): record the v2 Experiment I result in Status
2026-10-11 09:02:37 -07:00
yhh fc84278599 test(e2ebench): use slices.Contains and strings.Builder in the corpus policy test
Clears the two modernize findings golangci-lint reports in this test.
2026-10-11 09:01:27 -07:00
YHH 74e457467c Merge pull request #12494 from chuiwenwei/feat/contextbench-usage-in-results
feat(bench): account for what each context-retrieval run costs
2026-10-11 08:59:15 -07:00
yhh e6ca850e1d fix(studio): the title bar folds into one button instead of colliding at high scale
At a couple of hundred CSS pixels the composer's model group could neither
shrink nor wrap and the title bar's right-hand controls left its title no
room, so rows ran over each other. Below 300px of room (the width at which
the five controls leave the title under its 4em floor) the bar gathers phone,
find, theme, sandbox and browser under one button; each stays one press away.
The model group wraps, the tabs shrink, the meter row drops its cache figure
and the placeholder keeps to one line at the narrowest widths. The size
slider's fallback range parks the thumb mid-track while no range is announced.
2026-10-11 08:56:58 -07:00
YHH c41ad00d55 Merge branch 'studio' into test/studio-corpus-policy-registry 2026-10-11 08:41:11 -07:00
YHH 44feacc865 Merge branch 'studio' into docs/context-retrieval-v2-result 2026-10-11 08:41:01 -07:00
YHH 6b361d37e9 Merge branch 'studio' into feat/contextbench-usage-in-results 2026-10-11 08:40:49 -07:00
YHH 2f86d06a78 Merge pull request #12646 from esengine/ui/signal-rail-explained-12453
fix(studio): say what the signal rail is for and why its marks do not move
2026-10-11 08:37:53 -07:00
yhh 9a3d5bf432 fix(studio): say what the signal rail is for and why its marks do not move
The run analysis drew a rail labelled only 'Signal rail' and 'Shares the
timeline below'. A line now says it holds the run's records that have no
duration and are not tool calls, placed at the moment they happened, so the
position is the time and cannot be dragged.
2026-10-11 08:30:29 -07:00
YHH a5bc1bbefa Merge pull request #12715 from esengine/feat/settings-extra-folders-11527
feat(studio): manage a project's extra writable folders in Settings
2026-10-11 08:26:19 -07:00
YHH 68ba644676 Merge pull request #12717 from esengine/fix/extra-root-drive-letter
fix(config): an extra root must name a drive letter on Windows
2026-10-11 08:21:09 -07:00
yhh 25efbb20a9 fix(studio): declare the extra-folder settings actions in the action registry 2026-10-11 08:17:34 -07:00
YHH 3a7617813b Merge pull request #12702 from esengine/feat/task-done-sound
feat(notify): an optional chime for turn done, approvals and questions
2026-10-11 08:16:27 -07:00
yhh 49c116f771 fix(studio): the folder picker only fills the field; say what adding and removing do
Choosing a folder in the system dialog no longer grants it; Add does. The
panel states that the agent can change and delete anything in an added
folder and that panes already open narrow only on rebuild. A failed read
shows a retry, refusals use the error box, and the project row is laid
out like the other facts.
2026-10-11 08:16:15 -07:00
yhh 08a214570a feat(studio): manage a project's extra writable folders in Settings
Settings > Tools and permissions > Sandbox lists the project folder and
the extra folders granted to it, and adds or removes them through the
existing /workspace/roots routes. The kernel's refusals are worded by
their codes. The panel says that 'allow everything' only decides whether
to ask and does not widen where writes can land. No folder is added by
default.
2026-10-11 08:16:15 -07:00
YHH ddccefef09 Merge pull request #12720 from esengine/ui/running-tool-detail-11985
fix(studio): a running call with no output yet opens and says what it is doing
2026-10-11 08:14:53 -07:00
yhh 626068c473 fix(studio): declare the notification sound switch in the action registry 2026-10-11 08:08:13 -07:00
yhh 2ebd444b2d feat(notify): an optional chime for turn done, approvals and questions
[notifications] sound = true plays a short system chime for the events already
selected, independent of system notifications and of their muting, so a
minimised or backgrounded window is still audible. Off unless asked for. The
Windows, macOS and Linux senders implement Chime; the Settings notifications
block gets its own switch.
2026-10-11 08:07:50 -07:00
XTLine 92339c8580 merge(studio): align restore branch with CI fixes
Import Studio 71650fe7, including upstream desktop lint fix 85460ae and test-only sandbox portability fix 2b5f6448. Keep the running-jobs fixture deterministic by selecting its scripted provider explicitly and asserting the background launch.
2026-10-11 23:07:03 +08:00
XTLine a6bbceffbd merge(studio): inherit CI fixes and isolate the job-list fixture
Integrate Studio 71650fe7, including upstream desktop modernization 85460ae14 and the job-list test convention from 2b5f6448. Keep the latter test-only setting unchanged and use the supported scripted resolver with explicit selection and launch assertions. The only authored delta from the automatic merge is 17 additions and 10 deletions in that fixture; no coverage workflow or product sandbox changes.
2026-10-11 23:06:49 +08:00
yhh c842f014f4 fix(studio): a running call with no output yet opens and says what it is doing
In the activity group a running call that had printed nothing was a row that
did nothing when clicked, and a long command was clipped to one line, so a
call waiting out a timeout looked frozen. Such a row now opens to its input,
says it has no output yet and for how long it has run, names the command's
own timeout when it carries one, and its hover text holds the whole command.
2026-10-11 08:00:44 -07:00
YHH 18965b946b Merge pull request #12712 from esengine/fix/upload-size-guard
fix(studio): one size guard before every upload is read into memory
2026-10-11 07:59:52 -07:00
YHH cc4820ca9a Merge branch 'studio' into fix/extra-root-drive-letter 2026-10-11 07:59:46 -07:00
YHH d50a477297 Merge pull request #12704 from esengine/fix/local-model-check-timeout
fix(providers): name a model check that timed out against a local address and give it a longer wait
2026-10-11 07:56:27 -07:00
YHH d16404fe19 Merge pull request #12688 from esengine/feat/workspace-sessions-show-more
feat(studio): show a workspace's true session total and page in older sessions
2026-10-11 07:56:12 -07:00
YHH 71650fe7fd Merge pull request #12737 from esengine/fix/joblist-effect-test-sandbox
test(boot): turn the bash sandbox off in the running-jobs effect test
2026-10-11 07:51:51 -07:00
yhh 17bc7388d9 fix(studio): one size guard before every upload is read into memory
Attachments, wallpapers and theme packs were each turned into base64 before the
kernel could refuse them. One guard in the port checks the kernel's cap first,
using the image cap for pictures, and a Go test pins every number to the kernel's.
2026-10-11 07:43:51 -07:00
yhh 2b5f644882 test(boot): turn the bash sandbox off in the running-jobs effect test
The test starts a background bash job through the real Build assembly. On a
host without bubblewrap the sandbox refuses to run unconfined, so the job
never starts and the coverage leg fails there while passing on a developer
machine. Its sibling effect tests already declare [sandbox] bash = "off".
2026-10-11 07:42:56 -07:00
YHH 7d8164a281 Merge pull request #12707 from esengine/fix/nav-hint-delay-12692
fix(studio): icon-rail hints wait for a short hover before they appear
2026-10-11 07:29:32 -07:00
F 1ca349830d fix(feedback): report the Windows 11 product line in the environment version
Windows 11 kept the kernel's 10.0 major, so a build like 10.0.26200 read
as Windows 10 (issue #12696). Builds from 22000 report as 11.0.build,
matching the product version the darwin path already reports.
2026-10-11 22:06:05 +08:00
YHH 5f0bb7d845 Merge pull request #12719 from esengine/feat/logs-path-in-about
feat(studio): show where the logs and abnormal-exit records are kept
2026-10-11 07:05:11 -07:00
YHH fc7fbedf83 Merge pull request #12710 from esengine/fix/provider-400-wording
fix(i18n): a 400 says the service refused the request and that its reason follows, not that this is a program defect
2026-10-11 07:01:31 -07:00
YHH 34089143fc Merge pull request #12619 from esengine/fix/symlink-write-extent
fix(readiness): record where a named-path write through a symlink landed
2026-10-11 07:01:25 -07:00
YHH 5e196bc539 Merge pull request #12718 from esengine/chore/desktop-lint-stringsseq
chore(desktop): range over strings.SplitSeq in the packaged-names test
2026-10-11 06:52:34 -07:00
yhh e28123cf80 feat(studio): a workspace's true session total, paged by view and bounded
/tree windows each workspace by the request's scope (active, archived) and word,
always builds pinned active conversations, and reports the total, archived and
remaining counts for the whole folder. The sidebar counts from the kernel, so the
chips do not move with the window, an archived conversation far outside it is
reachable, search is filtered on the kernel and paged, and a folder holds at most
300 rows. The remote machine list shows the far kernel's total instead of a
truncated count.
2026-10-11 06:50:36 -07:00
yhh 9d3424f3bb fix(config): an extra root must name a drive letter on Windows, and a delegated write is pinned end to end
Windows accepted \??\C:\... because only a leading double backslash was
refused. A root's volume must now be a drive letter and a colon; UNC, \\?\, \\.\
and NT object paths are all refused. Adds a boot-level test where a delegated
child writes only a granted folder.
2026-10-11 06:48:31 -07:00
yhh 8f4ccbbb2b feat(studio): show where the logs and abnormal-exit records are kept 2026-10-11 06:48:23 -07:00
YHH 0d4e37682a Merge pull request #12706 from esengine/fix/wheel-scroll-jump-12691
fix(studio): scrolling up no longer jerks or jumps to another message
2026-10-11 06:47:19 -07:00
XTLine beb1b0082e Merge studio into fix/studio-restore-undo
Preserve pane-owned restore undo with canonical message attachments. Add a regression for attachment previews through restore and undo.
2026-10-11 21:44:54 +08:00
yhh 7cf1de5381 fix(sandbox): bound shell discovery as a whole so a stalled lookup cannot hold the kernel handshake
PowerShell candidates were launch-probed one after another with a 10 s
timeout each, and nothing bounded host construction or the lookups around
the probes. On a machine where Store aliases, a PATH entry or a registry
read stall, first start sat in shell discovery past the shell's 60 s
handshake wait.

PowerShell now shares bash's hedged search and budget, probe commands
cap their post-kill wait, and the whole resolution (host construction
included) gives up after both budgets plus a hedge: it uses a bash
already proven in this process, else the bare name with a warning that a
restart usually recovers.
2026-10-11 06:39:22 -07:00
xxoingr a88eec08d1 test(installsource): cover hostile skill paths in discovery warnings end to end
The skill-shadowing warning after an install and the not-selected warning
after a root is registered now render their paths through hostLiteral
(60f37ee7c), checked by a unit test that feeds an unterminated OSC to the
two sentence helpers. #12547 also asked for the newline, bidi and
zero-width cases.

Two end-to-end tests add them: a skill is installed, or a root
registered, while another skill under a configured path whose name holds
the character keeps the selection, and the warning must show the
character escaped for every path rather than carry it raw. The newline
case is skipped on Windows, which cannot hold one in a file name, and
runs on the Linux and macOS jobs.

Fixes #12547
2026-10-11 21:38:41 +08:00
yhh 85460ae14e chore(desktop): range over strings.SplitSeq in the packaged-names test
The modernize stringsseq check flagged strings.Split in a range loop; the loop
reads the same lines.
2026-10-11 06:38:30 -07:00
YHH 61ef913d4c Merge pull request #12697 from esengine/feat/refused-calls-turn-tail
feat(control): tell the model which calls the host refused, once, at the next turn
2026-10-11 06:36:39 -07:00
YHH aad83aaae9 Merge pull request #12713 from esengine/fix/unapplied-steer-row-12458
fix(studio): a queued line the kernel could not apply leaves the transcript
2026-10-11 06:34:50 -07:00
YHH 234db5d11f Merge pull request #12709 from esengine/fix/linux-tray-menu-repaint-stable
fix(studio): the tray menu is replaced only when its content changes
2026-10-11 06:33:40 -07:00
XTLine da98e04381 merge(studio): reconcile machine settings with runtime updates 2026-10-11 21:32:04 +08:00
YHH a8d9c95631 Merge pull request #12703 from esengine/feat/composer-full-width
feat(studio): an opt-in line width that widens the composer with the transcript
2026-10-11 06:31:01 -07:00
YHH 34ab4297db Merge pull request #12711 from esengine/fix/attachment-size-precheck
fix(studio): refuse an oversize attachment before reading it into memory
2026-10-11 06:30:19 -07:00
yhh 3e60275fb0 fix(studio): scrolling up no longer jerks or jumps to another message
Each transcript row asked the browser to skip rendering it while off screen
with an estimated height, and swapped the estimate for the measured height
the first time the row scrolled into view. The page shrank under the wheel
(12975px to 11217px in the probe), so the content jumped, once by 1858px.
Rows that are far off screen are already unmounted by their block, which
holds the measured height, so the estimate had no job left.
2026-10-11 06:28:06 -07:00
yhh bef076f523 fix(studio): a queued line the kernel could not apply leaves the transcript
unapplied_steer names the queue entry the kernel took out of the turn
without the model reading it and holds for a retry. The row that said it
was waiting stayed forever, while the queue panel, which the kernel
authors, showed the same line as blocked and retryable. The row now goes
when that notice names it; the notice card stays.
2026-10-11 06:22:46 -07:00
yhh 3b5a389193 fix(i18n): a 400 says the service refused the request and that its reason follows, not that this is a program defect
The 400 text told every reader it was probably a bug. The host cannot know that:
the reason the service gave is appended, so the preface now says whose refusal
it is, where the reason is, and what can be tried, without promising it works.
2026-10-11 06:22:12 -07:00
YHH 4a33d64e2b Merge pull request #12700 from esengine/feat/mcp-prompt-args-completion
feat(completion): show an MCP prompt's declared arguments in the slash menus
2026-10-11 06:22:09 -07:00
YHH dd24c520db Merge pull request #12708 from esengine/feat/more-font-choices-12693
feat(studio): suggest many more interface and code fonts
2026-10-11 06:21:23 -07:00
yhh 624182edbd fix(studio): redraw the tray checkbox after a refused save and retry a menu that failed to draw 2026-10-11 06:18:57 -07:00
yhh 23e36e9a99 fix(studio): the tray menu is replaced only when its content changes
The poll re-ran setContextMenu every five seconds with an identical menu.
Where the menu is handed to the desktop's indicator over D-Bus, replacing
it closes one the person has open. It is now rebuilt only when the
sentence, the words or the close-to-tray setting differ.
2026-10-11 06:18:57 -07:00
yhh dfefd3e765 fix(readiness): record where a named-path write through a symlink landed
The prose waiver judged paths by what the disk showed at the end of the turn,
so a documentation-named symlink to a code file that was written through and
then removed counted as a deleted prose file. The resolved target is now
recorded on the write receipt while the link still resolves.

Refs #12524
2026-10-11 06:15:10 -07:00
yhh f9f1b0b28f fix(studio): refuse an oversize attachment before reading it into memory
attach() turned the whole file into an ArrayBuffer, a binary string, base64 and a
JSON string before the kernel could refuse it at 25 MB, so a modpack-sized
archive could take the renderer down. The size is checked first and refused with
the kernel's own attachment.too_large identity.
2026-10-11 06:14:48 -07:00
yhh 48aa562625 fix(studio): icon-rail hints wait for a short hover before they appear
The rail's hints were drawn the instant the pointer touched an icon, so a
pass across the column flashed every label. They now appear after a 0.3s
rest (--tip-delay; VS Code waits 250ms), and keyboard focus still shows its
hint at once.
2026-10-11 06:14:00 -07:00
YHH 83a1a3fa01 Merge pull request #12705 from esengine/fix/host-turn-announces-start
fix(control): a queued host turn announces its start
2026-10-11 06:13:19 -07:00
YHH 827a78ce7a Merge pull request #12698 from esengine/fix/completion-reserve-in-window
fix(agent): keep a window-proportional margin for the completion and recover a codeless 400 that counts it
2026-10-11 06:11:29 -07:00
yhh f6fdbcdbd9 feat(completion): show an MCP prompt's declared arguments in the slash menus 2026-10-11 06:07:13 -07:00
yhh fa98929bfa fix(config): a saved full-all width survives the render
The [desktop] renderer wrote conversation_width only for full, so a full-all
choice reverted to standard on the next start. It now writes every non-standard
width; a render-and-reparse test, a page-attribute test and narrow-viewport
checks cover the new choice.
2026-10-11 06:06:40 -07:00
YHH c5477a2ead Merge pull request #12690 from esengine/fix/rebuild-dispatcher-notices
fix(boot): a rebuilt dispatcher keeps the missing-sidecar notice
2026-10-11 06:05:33 -07:00
YHH 455f9e6cf5 Merge pull request #12701 from esengine/fix/feedback-env-windows-product
fix(studio): the feedback form names Windows 11 from its build number
2026-10-11 06:05:21 -07:00
yhh 53e2d91f0a feat(studio): suggest many more interface and code fonts
The font fields took any family the machine has but suggested only a dozen,
and on Windows few of those are installed. The suggestions now cover the
common CJK, Latin and programming faces, filtered to the ones the machine
really has, as before. Nothing is chosen by default.
2026-10-11 06:05:15 -07:00
yhh 17aaad209d feat(studio): an opt-in line width that widens the composer with the transcript
The full measure widens the transcript only, by design. A third choice,
full-all, moves the composer's column with it; the default and the existing
full measure are unchanged.
2026-10-11 06:04:24 -07:00
yhh f6eb618bf8 fix(control): a queued host turn announces its start
A host-authored inbox item (a background job's completion, for one) ran
as a synthetic turn that opens no boundary and so emitted answer text and
a turn end with no turn start. A client that never heard the start kept
the send arrow, showed no stop button and counted nothing running, and
its status poll stayed off, so it could not stop a turn that was
running. The queued host turn now announces its own start.
2026-10-11 05:59:41 -07:00
yhh ad5ac1f6a9 feat(control): tell the model which calls the host refused, once, at the next turn
A refusal reached the model only in that call's result, which compaction can
fold away, and the posture refusal did not say not to retry. Calls refused by
the host (any permission refusal code) are now owed to the next real turn as a
refused-calls block, delivered once like the other turn projections, and the
posture refusal says the call and its rewordings will not be approved.

Refs #12253
2026-10-11 05:59:40 -07:00
YHH 682efd0516 Merge pull request #12699 from esengine/fix/windows-toast-click-12287
fix(notify): a clicked Windows toast brings Studio's window forward
2026-10-11 05:58:18 -07:00
yhh 8ed9f57f16 fix(providers): name a model check that timed out against a local address and give it a longer wait 2026-10-11 05:54:58 -07:00
yhh e5bedcb7b9 fix(agent): keep a window-proportional margin for the completion and recover a codeless 400 that counts it
On a 1M window the fixed 8K output margin was smaller than the prompt
estimate's own error, so a prompt a percent above its estimate plus a 64K
completion exceeded the window and the provider refused every turn. The margin
is now max(8K, window/32), and a codeless 400/413 is classified as an overflow
when the estimate plus the requested completion is past the window, so the
turn folds and replays instead of repeating the refusal.

Fixes #12694
2026-10-11 05:54:53 -07:00
YHH c6ab758469 Merge pull request #12661 from esengine/feat/ask-timeout
feat: skip an unanswered ask after a user-set wait, and tell the model it was a timeout
2026-10-11 05:54:07 -07:00
yhh 23999c4aa2 fix(studio): the feedback form names Windows 11 from its build number
Windows 11 reports NT 10.0, so the environment block read like Windows 10.
The form now shows 'Windows 11 (10.0.26200)' for builds from 22000; the
version string that is sent is unchanged.
2026-10-11 05:51:08 -07:00
YHH 0d8abb97ef Merge pull request #12640 from esengine/feat/workspace-extra-roots
feat(control): grant a workspace extra folders, with typed refusals
2026-10-11 05:47:13 -07:00
YHH 6148b172da Merge pull request #12650 from esengine/fix/dock-column-top-12617
fix(studio): the docked browser column starts at the top of the pane
2026-10-11 05:44:59 -07:00
yhh 3a93f38c94 fix(notify): a clicked Windows toast brings Studio's window forward
The kernel sent toasts through the Windows Runtime but never installed an
activation callback, so a click did nothing. It now registers one; the
host forwards it as a show act over the handshake pipe and the shell's
act handler (now its own module) shows and focuses the window.
2026-10-11 05:44:32 -07:00
yhh 7a6daf34a8 feat: skip an unanswered ask after a user-set wait, and tell the model it was a timeout
[agent] ask_timeout_seconds (user file only, default 0 = wait) bounds how long the
agent's own ask question waits. On expiry the host settles the card on every
frontend with a timed_out decision receipt and returns the model a result that
names the timeout, not an answer. External forms are never skipped.
Studio settings and the terminal's /ask-timeout drive the same controller
setting.
2026-10-11 05:44:21 -07:00
YHH 266ae5bfff Merge pull request #12653 from esengine/perf/turn-reads-12534
perf(state): read each session sidecar once per transaction and remember verified trusted objects
2026-10-11 05:42:49 -07:00
yhh ca730caab2 feat(control): grant a workspace extra folders, vetted and treated as workspace
Extra roots are the project grant's folders in the user's home. They are a set
of their own: write roots and the OS jail read them, the guard on Reasonix's
own state does not treat them as exemptions, the turn tail names them, and the
scan, readiness and receipt of the executor and of every sub-agent count them
as workspace. One judgement (FolderGrantRefusal, shared with workspace trust
and POST /sandbox) refuses drive roots, home and its ancestors, storage roots,
key and credential directories and the forbid list, in either direction,
comparing file identity rather than spelling, plus folders the user does not
own, UNC and device-namespace paths, and overlaps with other write roots. Every
load vets each grant again. Withdrawing a root rebuilds every pane on the
workspace, after this pane's lock is released.
2026-10-11 05:40:15 -07:00
yhh 871ccce82b fix(trustedstate): read every file-identity field so the unused check passes where it is not compared 2026-10-11 05:34:33 -07:00
yhh 70e55f0cac fix(trustedstate): openbsd takes the size-and-mtime fallback
Its Stat_t names the change time Ctim, not Ctimespec.
2026-10-11 05:33:17 -07:00
yhh 20f07162c7 perf(trustedstate): a cache hit also needs the file's inode and change time
The change time cannot be restored the way a modification time can, so an
in-place edit that restores size and mtime misses the cache. Platforms
without that identity fall back to size and time. Adds limit and
replacement tests and states the in-process memory in the package doc.
2026-10-11 05:33:17 -07:00
yhh 9bd0671ea0 perf(state): read each session sidecar once per transaction and remember verified trusted objects
A locked branch-meta read-modify-write passes what it read to the write
instead of reading again, and recording a revision no longer re-reads
what it just wrote. trustedstate.Object remembers a verified object with
its size and modification time and serves it while both are unchanged
and old enough to be meaningful; Verify always reads the bytes.
2026-10-11 05:33:17 -07:00
yhh 4a0c06448c fix(studio): the docked browser column starts at the top of the pane
The docked pane's grid kept the navigation and find rows across both
columns, so the browser column began 44px below the pane top and its own
8px padding made the gap 52px. The column now spans every row beside them;
under the scene fold the old stacked layout is kept explicitly.
2026-10-11 05:32:03 -07:00
yhh 4d3ea82f67 fix(boot): a rebuilt dispatcher keeps the missing-sidecar notice
stageDispatcher built the successor with empty options, so after a subgraph
rebuild an optional extension without a sidecar was skipped silently, right at
the moment an install or enable makes that most likely. The successor now takes
the reporting of the dispatcher it replaces.

Refs #12258
2026-10-11 05:29:37 -07:00
YHH 268b955eab Merge pull request #12687 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-0c84b57d5b
build(deps): bump the npm group in /desktop/frontend-next with 8 updates
2026-10-11 05:25:02 -07:00
YHH 0f175e404d Merge pull request #12686 from esengine/dependabot/github_actions/actions-690969f723
build(deps): bump the actions group with 5 updates
2026-10-11 05:24:13 -07:00
yhh 76552fb5a4 build(desktop): tidy go.mod and go.sum for the root module's dependency bump 2026-10-11 05:22:05 -07:00
YHH 881789cfe1 Merge pull request #12681 from esengine/dependabot/go_modules/desktop/go-6a10bec2cb
build(deps): bump golang.org/x/sys from 0.48.0 to 0.49.0 in /desktop in the go group
2026-10-11 05:20:27 -07:00
YHH c17dc72fb0 Merge pull request #12630 from esengine/fix/pluginpkg-shown-locator-12489
fix(pluginpkg): show package data inside host sentences as literals
2026-10-11 04:59:45 -07:00
YHH d9bf6c59d1 Merge pull request #12669 from XTLine/fix/studio-extension-warning-categories
fix(studio): separate extension warning categories / 区分扩展告警类别
2026-10-11 04:59:35 -07:00
YHH 183ccbb141 Merge pull request #12678 from xlyy1/fix/planmode-browser-open-12508
fix(planmode): explain browser_open opt-out and offer web_fetch
2026-10-11 04:59:25 -07:00
YHH 89d3965799 Merge pull request #12677 from esengine/fix/notify-copy
fix(studio): the notification setting says when it fires, that it is off by default, and who picks the sound
2026-10-11 04:59:15 -07:00
YHH 3b8bf8985b Merge pull request #12675 from esengine/fix/windows-update-keeps-pin-12670
fix(update): start Studio's Windows installer with --updated so an update keeps shortcuts and the taskbar pin
2026-10-11 04:59:01 -07:00
YHH 9b46939729 Merge pull request #12673 from esengine/fix/steer-splits-thinking-12672
fix(studio): a line typed mid-thought no longer splits the thought into two blocks
2026-10-11 04:58:50 -07:00
YHH 56fef30a31 Merge pull request #12668 from esengine/feat/changed-files-pill
feat(studio): changed-files pill above the composer
2026-10-11 04:58:40 -07:00
YHH 77274db339 Merge pull request #12664 from par73e/codex/11700-session-list-sync
fix(studio): refresh shared session trees on kernel changes
2026-10-11 04:58:30 -07:00
YHH e735775648 Merge pull request #12667 from esengine/fix/responses-input-image-detail
fix(responses): send the schema's required detail on input_image to the official OpenAI host
2026-10-11 04:58:20 -07:00
YHH 4d3cca5e54 Merge pull request #12666 from esengine/fix/responses-input-shape-diagnostic
fix(responses): send the schema's required detail on input_image and say what shape a refused request had
2026-10-11 04:58:10 -07:00
YHH 94f9ff00e0 Merge pull request #12656 from esengine/feat/turn-duration
feat: show how long each turn ran
2026-10-11 04:58:00 -07:00
YHH b0bef02583 Merge pull request #12627 from esengine/feat/list-background-jobs
feat(tools): bash_output without a job id lists the running background jobs
2026-10-11 04:57:30 -07:00
YHH 4afd827ace Merge pull request #12571 from xxoingr/fix/mcp-json-auto-refusal
fix(installsource): keep the cause of a refused .mcp.json under kind=auto
2026-10-11 04:57:20 -07:00
YHH c875f8440b Merge pull request #12636 from esengine/fix/shared-text-policy-12483
fix(textutil): one declared policy per surface kind for package-supplied text
2026-10-11 04:57:00 -07:00
YHH 362803d710 Merge pull request #12622 from esengine/fix/model-switch-keeps-background-jobs
fix(serve): let a model switch go through while only background jobs remain
2026-10-11 04:56:45 -07:00
YHH ea8d65e152 Merge pull request #12632 from esengine/fix/receipt-host-owned-exclusion
fix(receipt): judge host-owned paths when the call ran and treat links of every kind as not owned
2026-10-11 04:56:30 -07:00
YHH b17b4ae034 Merge pull request #12635 from esengine/fix/shell-launch-location
fix(sandbox): one rule for where a shell may be launched from
2026-10-11 04:56:20 -07:00
YHH 65e2fa36dd Merge pull request #12662 from esengine/fix/linux-update-helper-package-12657
fix(update-helper): accept the package the update path names, and guard it in the package
2026-10-11 04:56:10 -07:00
dependabot[bot] 66e36b1c69 build(deps): bump the npm group in /desktop/frontend-next with 8 updates
Bumps the npm group in /desktop/frontend-next with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/language](https://github.com/codemirror/language) | `6.12.4` | `6.13.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.13` | `6.43.14` |
| [katex](https://github.com/KaTeX/KaTeX) | `0.18.10` | `0.19.0` |
| [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) | `8.0.6` | `8.0.7` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.1` | `6.1.2` |
| [jsdom](https://github.com/jsdom/jsdom) | `30.1.1` | `30.1.2` |
| [playwright](https://github.com/microsoft/playwright) | `1.63.0` | `1.64.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.2` | `8.3.3` |


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

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

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

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

Updates `@vitejs/plugin-react` from 6.1.1 to 6.1.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/[email protected]/packages/plugin-react)

Updates `jsdom` from 30.1.1 to 30.1.2
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v30.1.1...v30.1.2)

Updates `playwright` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

Updates `vite` from 8.3.2 to 8.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@codemirror/language"
  dependency-version: 6.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: katex
  dependency-version: 0.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@babel/parser"
  dependency-version: 8.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: jsdom
  dependency-version: 30.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: vite
  dependency-version: 8.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:56:04 +00:00
dependabot[bot] 6fb606fd53 build(deps): bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |


Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `github/codeql-action/init` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:55:46 +00:00
YHH 406771534e Merge pull request #12642 from esengine/fix/notice-wording-shared
fix(studio): word every placeholder notice through one shared resolver
2026-10-11 04:55:40 -07:00
dependabot[bot] beeb2865f0 build(deps-dev): bump the npm group in /desktop/electron with 2 updates
Bumps the npm group in /desktop/electron with 2 updates: [electron](https://github.com/electron/electron) and [playwright](https://github.com/microsoft/playwright).


Updates `electron` from 44.5.1 to 44.7.0
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.5.1...v44.7.0)

Updates `playwright` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:54:59 +00:00
YHH 4e41e669b5 Merge pull request #12620 from esengine/fix/gutter-over-scrollbar-12467
fix(studio): a column's drag handle no longer covers the scrollbar beside it
2026-10-11 04:54:50 -07:00
dependabot[bot] ded403ab37 build(deps): bump the go group with 2 updates
Bumps the go group with 2 updates: [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `github.com/mattn/go-runewidth` from 0.0.30 to 0.0.31
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.30...v0.0.31)

Updates `golang.org/x/sys` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/sys/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:53:35 +00:00
XTLine f3c3385bbd fix(control): tolerate unreadable currency preferences on bind
Keep the already-built quote and broadcaster projections when canonical configuration cannot be read. Warn without rewriting the recoverable file, while still enrolling valid local controllers. Cover real Adopt, Open, model, extension and workspace publication with malformed configuration, including built-in-default fallback. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:53:20 +08:00
dependabot[bot] 80835fc83d build(deps): bump golang.org/x/sys in /desktop in the go group
Bumps the go group in /desktop with 1 update: [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/sys` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/sys/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:53:12 +00:00
XTLine 1fe77558db fix(studio): classify retired currency owners as conflicts
Preserve the controller retirement identity at the HTTP boundary with a localized 409 refusal. Exercise selection racing a real model replacement, then delegate to the retired controller and assert no config or currency mutation. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:53:00 +08:00
YHH fdab1d83ec Merge pull request #12679 from esengine/ci/pr-platform-legs
ci: run the macOS and Windows legs on pull requests only when a platform path changed
2026-10-11 04:47:00 -07:00
esengine 58f583e90b test(testenv): read the Windows legs through a substituted runs-on expression 2026-10-11 04:31:01 -07:00
YHH 24e000b195 Merge pull request #12624 from esengine/fix/restore-window-bounds-12475
fix(studio): reopen the window at the rectangle it was closed at
2026-10-11 04:23:38 -07:00
XTLine 4de056fa8b test(serve): cover currency projection across runtime rebuilds
Exercise real booted local runtimes, a remote pane, live ledger and balance projections, future wire quotes, model replacement and sequential stale calls after close. The canonical-config fixtures do not claim a successful durable settings write. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:55 +08:00
XTLine aa4287afc6 test(control): guard ordered currency binding
Verify canonical quiet enrollment, failed binding atomicity, replacement and close membership, and projection ordering against the next notice. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:49 +08:00
XTLine f01012d29f test(control): cover currency fanout and failed saves
Check full local sinks, future quotes, remote exclusion, retirement and failed-save atomicity. Retain the real HTTP currency-save assertion for CI; this sandbox cannot acquire the OS-user config lock. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:42 +08:00
XTLine f041d15a95 fix(serve): bind currency to local runtime generations
Enroll only local controllers and reconcile their quote and broadcaster projections before publication. Share the same ordered replacement path across model, extension and workspace rebuilds, and retire membership when a runtime closes. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:36 +08:00
XTLine f172ed7498 fix(control): synchronize local display currency ownership
Serialize persisted display-currency changes with local-controller membership. Quiet enrollment refreshes the exact quote context from canonical configuration; retired generations cannot save, and successful saves notify each full local sink. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:29 +08:00
XTLine 671bec66cd fix(studio): restart feedback polling for local owner changes
Bind unread polling to availability and identity of the first local machine owner. Opening or replacing that owner starts a fresh read while ordinary active-pane switches keep the polling session. Cover remote-only startup, opening local sessions and retiring the first local owner through the real App. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:21 +08:00
XTLine 646e4e32d9 fix(studio): save defaults through the selected local owner
Use the same pinned local catalogue owner for the default-model write and read. Cover a model exposed only by the second local runtime through the actual Settings picker, SSE route and HTTP catalogue validation. Retain the durable-write assertion for CI; this sandbox cannot acquire the OS-user configuration lock. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:14 +08:00
esengine e8f79dfdbb ci: count renames and odd file names in the platform scope 2026-10-11 04:10:35 -07:00
esengine 276cdeb8ac ci: run the macOS and Windows legs on pull requests only when a platform path changed
Pull requests whose diff is provably platform-neutral run only the Linux legs;
the macOS and Windows legs keep their check names but execute on an ubuntu
runner with every step skipped. Pushes to main-v2/studio are unchanged.
2026-10-11 04:02:54 -07:00
xlyy1 90d6f3688b fix(planmode): classify browser_open as unsafe and suggest web_fetch 2026-10-11 19:00:57 +08:00
YHH 596d420244 Merge pull request #12618 from esengine/fix/edit-connection-protocol
feat(studio): change a connection's protocol when editing it
2026-10-11 03:43:56 -07:00
YHH 7b3260f74f Merge pull request #12628 from esengine/fix/plan-browser-open-and-stopped-worker
fix(delegation): a cancelled worker's transcript reaches the parent, but is never an answer
2026-10-11 03:29:40 -07:00
yhh d76b45a281 fix(studio): name the protocol select in the edit form 2026-10-11 03:28:33 -07:00
yhh 08653907e7 refactor(serve): extract the protocol change from editProvider 2026-10-11 03:27:24 -07:00
yhh a489d1e3aa fix(studio): follow the draft protocol for the thinking field and refuse a protocol change on a pinned endpoint 2026-10-11 03:27:24 -07:00
yhh 1851a1991c feat(studio): change a connection's protocol when editing it 2026-10-11 03:27:24 -07:00
XTLine f0371fbe70 fix(studio): preserve perf harness machine settings
Give the benchmark window one stable BenchPort owner so its language, zoom, theme and save instrumentation still reach usePaint. Exercise the real harness and paint hook without changing browser guard assertions. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 18:08:41 +08:00
XTLine 5bd8d9678a fix(studio): follow the active local settings workspace
Select the active local pane, else the first local pane, for Memory, model catalogue resolution and legacy-session recovery. Keep machinewide pages on their stable host owner. Retire cached workspace settings ports after successful close and restore the unrelated portFor comment. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 18:08:36 +08:00
XTLine ddefb6f48d fix(serve): validate local workspace settings owners
Allow host settings requests to pin an existing local runtime. Refuse stale or remote owners without falling back, while unpinned machine settings retain the first live local owner. Cover real workspace memory mutations, model routing and runtime replacement. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 18:08:25 +08:00
YHH 7aca93426b Merge pull request #12634 from esengine/feat/user-message-attachments
feat(control): user messages carry their stored attachments as a field
2026-10-11 03:03:31 -07:00
YHH e8432591d3 Merge pull request #12631 from esengine/fix/e2ebench-passk-population
fix(bench): compute pass^k and pass@k only over tasks that ran every trial
2026-10-11 02:41:13 -07:00
YHH a796093857 Merge pull request #12629 from esengine/fix/e2ebench-reproduce-verdict
fix(bench): name what the reproduction verdict compares instead of claiming a version and a corpus
2026-10-11 02:39:00 -07:00
yhh 5878d58377 fix(studio): the notification setting says when it fires, that it is off by default, and who picks the sound
The copy claimed alerts appear while the window is in the background; the sender
fires whether or not the window is in front. It now says so, says the switch
ships off, and that the sound is the system's with no separate setting.
2026-10-11 02:33:22 -07:00
yhh 074337af99 test(update): assert the installer arguments reach ShellExecute 2026-10-11 02:29:27 -07:00
YHH 0f699f8e02 Merge pull request #12638 from esengine/fix/job-failure-typed-12480
feat(jobs): carry a failed background command's exit code and signal as typed fields
2026-10-11 02:22:07 -07:00
YHH 503b0429b1 Merge pull request #12633 from esengine/fix/shell-probe-memo
fix(sandbox): do not relaunch a bash probe that hung for the same file within a minute
2026-10-11 02:19:28 -07:00
YHH e9c487cd2f Merge pull request #12621 from esengine/fix/stdio-mcp-handshake
fix(plugin): send initialize to a fresh child when the discovery probe goes unanswered
2026-10-11 02:12:36 -07:00
yhh a516e8e709 fix(update): start Studio's Windows installer with --updated so an update keeps shortcuts and the taskbar pin
The update path started the NSIS installer with no arguments, so it ran
as a fresh install: the previous uninstaller deleted the shortcuts and
removed the AppUserModelID entries (the taskbar pin), and the installer
recreated the desktop shortcut. With --updated the installer takes
electron-builder's in-place update path and keeps them.
2026-10-11 02:12:22 -07:00
XTLine b8459b0eff fix(studio): keep appearance inventory and imports on this machine 2026-10-11 17:03:15 +08:00
XTLine 17bdaad0b0 fix(studio): keep window settings independent of active panes 2026-10-11 17:02:58 +08:00
XTLine d1d25e63e5 fix(studio): bind machine memory storage and usage to their owner 2026-10-11 17:02:40 +08:00
XTLine 3bf87b4d0a fix(serve): resolve machine settings through a live local owner 2026-10-11 17:02:24 +08:00
Guan09 1d3267a44c fix(studio): make session tabs opt-in
Add a default-off Appearance preference for session tabs, persist the choice locally, and show the existing strip only when multiple session panes are open. Preserve single-session behavior and cover preference changes, persistence, and unread tab marks with interaction tests.
2026-10-11 17:00:56 +08:00
yhh 8e483dde72 feat(tools): bash_output without a job id lists the running background jobs
The orchestrating agent had no way to ask which background jobs are still
running and for how long, so it kept a count of its own and miscounted. The
listing comes from the job manager that owns the jobs (id, kind, label,
elapsed), through the existing read-only tool so no tool schema is added.
list_subagents rows now report when a child started and, while it runs, for
how long.
2026-10-11 01:59:57 -07:00
yhh eeec501119 fix(studio): a line typed mid-thought no longer splits the thought into two blocks
The queued line is drawn after the message the model is still writing, and
appending the next chunk only looked at the last item, so the chunk opened a
new message and one stretch of thinking showed as two blocks. Appending now
continues the open message past lines that are still waiting; once a line has
been delivered and the earlier message settled, the next chunk is a new one.
2026-10-11 01:59:16 -07:00
YHH 99651759da Merge pull request #12613 from esengine/feat/mcp-auth-required-identity
feat(plugin): a 401 Bearer challenge carries a typed authentication identity
2026-10-11 01:50:33 -07:00
XTLine bc0b454ceb fix(studio): separate extension warning categories 2026-10-11 16:37:23 +08:00
XTLine 95de31ed20 style(studio): keep restore actions visibly clickable at rest 2026-10-11 16:29:04 +08:00
YHH 19cdcaab0a Merge pull request #12625 from esengine/fix/reasoning-only-turn
fix(agent): tell the model when a reasoning-only response was cut off at a limit
2026-10-11 01:24:26 -07:00
YHH 8f115d1702 Merge pull request #12614 from esengine/fix/receipt-write-survives-tool-after
fix(readiness): a write that reached disk stays a write when tool.after reports it failed
2026-10-11 01:24:17 -07:00
yhh ad8b9c71ff fix(agent): tell the model when a reasoning-only response was cut off at a limit 2026-10-11 01:11:34 -07:00
YHH 4f768a84e8 Merge pull request #12626 from esengine/fix/rewrite-box-width-12452
fix(studio): the rewrite box spans its row instead of a bare textarea's width
2026-10-11 01:09:34 -07:00
yhh 14a1ee8f7f feat(studio): show the workspace's changed files as a pill above the composer
The branch chip's small "N changes" moves to a centred pill right above the
input box reading "N files changed +x -y", from the same tree.changes the
workbench lists. Clicking it opens the workbench's change section. A kernel
setting [ui] show_changed_files (default on) switches it off, which restores
the chip's small count.
2026-10-11 00:53:14 -07:00
par73e ca788d0966 fix(studio): refresh shared session trees on kernel changes 2026-10-11 15:21:22 +08:00
yhh d934f1c9dd fix(responses): send the schema's required detail on input_image to the official OpenAI host 2026-10-11 00:05:18 -07:00
yhh ac59199914 fix(responses): keep only the refused-request shape line; annotate after the status is known 2026-10-11 00:04:04 -07:00
YHH b9840adf1c Merge pull request #12573 from esengine/fix/provider-delete-feedback
fix(providers): show a refused removal beside Delete and clear every role naming the removed service
2026-10-11 00:01:20 -07:00
yhh a90e3e2bcd fix(responses): send the schema's required detail on input_image and say what shape a refused request had 2026-10-11 00:00:17 -07:00
YHH 368df20ed2 Merge pull request #12568 from esengine/fix/edit-connection-model-read
fix(providers): offer no model-listing read for a protocol that declares none
2026-10-10 23:43:50 -07:00
yhh 0b2d5327f4 feat: show how long each turn ran, measured by the kernel
TurnDone carries the run time up to the final answer; history carries it on
the answer that closed a turn. The terminal UI and Studio draw it under the
answer, and the live status timer uses the same compact form.
2026-10-10 23:19:32 -07:00
YHH 5d3bce4810 Merge pull request #12582 from esengine/fix/readonly-agent-capability-search
fix(agent): let read-only agents search the capability catalog
2026-10-10 23:19:22 -07:00
yhh 5900acb119 fix(update-helper): accept the package the update path names, and guard it in the package
The Linux helper's accepted package name was a build-time variable that
linux-payload.js never set, so the helper in the .deb kept
reasonix-desktop and refused reasonix-studio as a different package.
It now takes the name from update.StudioLine, the one place the shell
asks dpkg by, so there is no build value to forget. A package command
prints it, studio.yml asserts the packaged helper accepts the .deb's own
package name, and a test ties electron-builder.yml's deb and rpm names
to it.
2026-10-10 22:03:58 -07:00
par73e 9add93a143 fix(theme): make wallpaper transcript glow configurable 2026-10-11 12:59:54 +08:00
esengine 9d63534b05 docs(readme): size the sponsor logos alike and centre them in their cells 2026-10-10 21:55:03 -07:00
YHH aab1a087b9 Merge pull request #12639 from esengine/docs/sponsor-rightcode
docs(readme): thank Right Code as a sponsor, listed first
2026-10-10 21:50:19 -07:00
par73e 1c1c18bd39 feat(studio): add a shared send shortcut preference 2026-10-11 12:45:59 +08:00
yhh b9b934e218 fix(delegation): a cancelled worker's transcript reaches the parent, but is never an answer
A read-only worker cut off by cancellation left a transcript the parent never
heard of: the aggregate said only context canceled. The reference now travels
with the cancellation (parallel_tasks, fleet, task) and read_subagent_result
renders what a cancelled child had said, called and read, labelled as not a
result and its tool output as untrusted. ReadFinalAnswer still refuses any
run that did not complete, so fleet adopt_ref cannot adopt one.
2026-10-10 21:44:25 -07:00
yhh a0802720dc feat(studio): delete a theme pack from Appearance
DELETE /themes/{id} removes a pack from the user's library only; the
listing marks those packs removable, and Appearance offers a two-step
delete on them. A bad id is a 400 theme.bad_id (ErrBadID), never a path
in the response. The config is settled before the files go and restored
if they fail, so it never names a pack that is gone.
2026-10-10 21:42:00 -07:00
yhh dbffc3d740 fix(studio): a column's drag handle no longer covers the scrollbar beside it
The workbench handle lay full height over the transcript's scrollbar while
shut, and the open handles reached into the scrollbar on either side of a
seam, so a press on the thumb resized the column instead of scrolling. The
workspace handle now starts inside the seam, the docked transcript steps in
from the workbench seam, and the shut workbench handle is only as tall as its
grip, centred and set left of the scrollbar. The grip starts the drag itself
once the press has travelled.
2026-10-10 21:39:42 -07:00
yhh fb96492170 fix(control): resuming a Goal runs it, and a reply to a blocked Goal puts it back in pursuit
/goal resume only flipped the state and said nothing, so the Goal sat idle until
another message arrived. It now says the Goal is resumed and runs the next turn
of it. A Goal the model or evaluator reported blocked also resumes on the user's
own next message, as one gated by await_user already did; the continuation epoch
is read after that lift so the lifted turn is advanced by the Goal instead of
being discarded as stale.

Refs #12554
2026-10-10 21:38:13 -07:00
yhh f25b8f79a8 fix(serve): let a model switch go through while only background jobs remain
The switch refused with busy.switch_model_jobs because closing the outgoing
controller cancelled its jobs. The replacement now adopts the job manager and
the workspace write lease, and the outgoing controller is retired without
closing them; its cleanup runs once the jobs it left have finished.
2026-10-10 21:34:36 -07:00
yhh 5a5b981965 fix(control): a refused submission carries a notice code the queueing frontend can word
The admission notices for a session switch, a workspace switch and a draining
runtime told every frontend to resend, but Studio already queues the refused
line on the typed busy code, so following the advice sent it twice. The notice
now carries input_refused; Studio words it as queued, other frontends keep the
English text.

Refs #12554
2026-10-10 21:31:07 -07:00
yhh 14125a17cb fix(studio): keep a long runtime notice whole and its dismiss button on screen
The notes ceiling clipped the dormant-rules sentence and pushed the dismiss
button to a hidden third row. The button now shares the sentence's first row,
the ceiling has a floor outside short windows, and the queue yields the
difference. Also keeps the diagnostic for payload codes whose detail was not
consumed and pins malformed-payload cases.
2026-10-10 21:29:16 -07:00
yhh bb1d478f7f fix(pluginpkg): keep the decoder error in the chain while showing its text as a literal 2026-10-10 21:24:32 -07:00
yhh 60f37ee7c6 fix(pluginpkg): finish rendering package data inside host sentences as literals
Skill shadow and unselected warnings, the migrated and remediation
sentences (plugin name), the persist-status warning, manifest read
warnings and the strict-decode error now show package data through
ShownLocator or ShownIdentity.
2026-10-10 21:24:32 -07:00
yhh 5047e081bc fix(pluginpkg): show package data inside host sentences as literals
Shadow warnings, the incompatible-plugin warning and the skipped
marketplace warning now write the whole sentence first and render the
package-supplied path, error text or reason with ShownLocator, so an
unterminated escape in that data cannot swallow the rest of the sentence.
2026-10-10 21:24:32 -07:00
yhh 775443b26e fix(readiness): an explicit hand-back ends the turn instead of being continued
conclude_blocked and await_user tell the model the turn ends, but a code write
with no check after it still produced a verification gap, so the host ran two
more rounds and reported the turn as failed. A successful hand-back after the
latest write now yields the continuation; the ledger still owes the change and
the completion receipt still reports it as unverified.

Refs #12554
2026-10-10 21:17:50 -07:00
yhh cea97c9b9d fix(textutil): one core for the deleting policies; launch marker leads and matches what was deleted
Display, launch and stored policies share one scrub core, so the report
that a launch line lost characters is produced by the same pass that
deleted them (ZWJ, ZWNJ, bidi, escapes) and never by a second rule. The
marker leads the line so tail truncation in serve and the TUI cannot cut
it, and is worded through i18n. observe.Sanitize delegates to the stored
policy; the policy table is declared in doc.go.
2026-10-10 21:03:16 -07:00
yhh 5e547cf514 fix(installsource): one bounded read for every local manifest 2026-10-10 21:00:33 -07:00
YHH 9e320a452f Merge pull request #12581 from esengine/fix/build-notices-once
fix(boot): emit build-time notices once per lineage across rebuilds
2026-10-10 20:52:00 -07:00
yhh c0b770b7cf feat(control): user messages carry their stored attachments as a field
TurnStarted and Steer events, and the rebuilt history, name the images under
.reasonix/attachments that a message references, resolved by the same
detectRefs pass that decides what the model sees and reported under their
normalized path. The transcript previews from that field instead of matching a
spelling in the text; the model still reads the @path text.
2026-10-10 20:48:32 -07:00
yhh 323a2a95f5 fix(receipt): normalize the host-owned verdict like the paths and fix the Windows tests 2026-10-10 20:44:43 -07:00
yhh 9b7dfce095 fix(sandbox): hold the bash and PowerShell PATH lookups to the shell launch rule 2026-10-10 20:43:27 -07:00
yhh 09cc425fd8 docs(readme): use the sponsor's own wording for the Right Code thanks 2026-10-10 20:37:35 -07:00
yhh c54a107f77 fix(studio): word every placeholder notice through one shared resolver
The runtime bar resolved variables for only two notice codes, so
permission_rules_dormant and job_* showed {n}/{list}/{rule}/{name} verbatim
plus the raw JSON payload. NoticeCard and RuntimeBar now share noticeWording();
a test fails when a code with placeholders has no registered reader.
2026-10-10 20:34:02 -07:00
yhh 581645829f fix(installsource): bound a local .mcp.json and name the cause of a refused install 2026-10-10 20:33:24 -07:00
yhh 6445e0a5e7 docs(readme): thank Right Code as a sponsor, listed first 2026-10-10 20:30:41 -07:00
yhh f2ab4b5f36 feat(jobs): carry a failed background command's exit code and signal as typed fields
The bash job function wraps the process's own ending in jobs.ExitError,
the failed-job notice payload carries exitCode and signal, and the
terminal UI and Studio word the sentence from them. The raw error text
stays on the payload and is what an untyped failure still shows.
2026-10-10 20:30:30 -07:00
yhh 93bfa7dd61 docs(bench): describe pass^k over the tasks that ran every trial 2026-10-10 20:29:09 -07:00
yhh 51625a22e5 docs(trusted-execution): describe the reproduction verdict by what it compares 2026-10-10 20:28:40 -07:00
yhh 23bb1985a1 fix(textutil): one declared policy per surface kind for package-supplied text
Display (prose) now drops bidirectional and format characters exactly as
the prose policy does, where it used to keep bidi controls. The launch
line keeps deleting invisible characters, because redaction has to match
a key a zero-width character split, but it now says so with a marker
instead of reading as an unaltered line. One hostile fixture is asserted
across the locator, prose, display and launch policies.
2026-10-10 20:24:39 -07:00
yhh aa55b66a75 fix(sandbox): one rule for where a shell may be launched from 2026-10-10 20:21:53 -07:00
yhh 6bd9f77ab6 fix(sandbox): do not relaunch a bash probe that hung for the same file within a minute 2026-10-10 20:19:41 -07:00
yhh 94e65f522d fix(receipt): judge host-owned paths when the call ran and treat links of every kind as not owned 2026-10-10 20:16:36 -07:00
yhh 6dab6f3c92 fix(plugin): stop the silent child before starting its replacement and bound the restart by the caller 2026-10-10 20:11:48 -07:00
yhh e3c382e66c fix(studio): reopen the window at the rectangle it was closed at
The shell always opened at the default size, so a window the person had
sized, moved or maximised came back as if nothing had been chosen. The
normal bounds and the maximised flag are now recorded as the window settles
and as it closes, and the next launch reopens there. A rectangle no display
shows any more keeps its size and lets the platform pick the place.
2026-10-10 20:09:32 -07:00
yhh e04d26ac82 fix(bench): compute pass^k and pass@k only over tasks that ran every trial 2026-10-10 20:08:50 -07:00
yhh f8c9bf5547 fix(bench): name what the reproduction verdict compares instead of claiming a version and a corpus 2026-10-10 20:06:44 -07:00
yhh dd46917414 fix(studio): a column's drag handle no longer covers the scrollbar beside it
The workbench handle lay full height over the transcript's scrollbar while
shut, and the open handles reached five to six pixels into the scrollbar on
either side of a seam, so a press on the thumb resized the column instead of
scrolling. Open handles now start one pixel inside the seam, a shut handle is
only as tall as its grip, and the grip starts the drag itself once the press
has travelled.
2026-10-10 19:58:55 -07:00
YHH d086618850 Merge pull request #12565 from esengine/fix/permission-rules-conditional-tools
fix(permissions): treat conditionally registered tools as known to the rule vocabulary
2026-10-10 19:57:07 -07:00
yhh 9a14a65e53 fix(studio): the rewrite box spans its row instead of a bare textarea's width
The message column shrinks to fit its content and a textarea at 100% of it
starts at its default width, so the rewrite box stayed about 270px wide in a
760px transcript. While a message is being rewritten its column now takes the
whole row.
2026-10-10 19:57:05 -07:00
yhh 1138f32e76 fix(plugin): send initialize to a fresh child when the discovery probe goes unanswered 2026-10-10 19:48:57 -07:00
YHH 56ece8f9af Merge pull request #12558 from XTLine/fix/studio-response-call-integrity
fix(responses): reject incomplete Studio tool calls / 拒绝 Studio 不完整工具调用
2026-10-10 19:36:48 -07:00
yhh 46c0f64d87 feat(plugin): a 401 Bearer challenge carries a typed authentication identity
The transport now returns ErrAuthenticationRequired (via AuthRequiredError with
server, resource and scope) for a 401 that carries a Bearer challenge on a
server without explicit credentials. InstallResultForError maps that identity
to action_required/authenticate and no longer matches words in the message or
promotes a bare 401/403 status. Adds a regression test that refresh stays at the
token endpoint stored at sign-in. The guide stops promising a Studio sign-in
control that does not exist yet.
2026-10-10 19:30:18 -07:00
yhh 1c5e59a200 fix(readiness): a write that reached disk stays a write when tool.after reports it failed
The receipt took its success from the result after extensions rewrote it, so
a code write refused or errored by tool.after left no mutation in the ledger
and, as the only change of the turn, no verification was asked for. A named-path
write now keeps the success of the tool's own execution.

Refs #12528
2026-10-10 19:28:48 -07:00
YHH 6e645e2d88 Merge pull request #12612 from esengine/test/cancel-inbox-hold-dispatch
test(control): make the scoped-cancel inbox test deterministic
2026-10-10 18:55:46 -07:00
yhh 87ccb646c2 test(control): hold the post-cancel dispatch so the scoped-cancel assertion sees the queue 2026-10-10 18:40:45 -07:00
YHH f1323bb2fa Merge pull request #12610 from esengine/docs/studio-2.35.0-notes
docs(release): Studio 2.35.0 notes
2026-10-10 17:51:37 -07:00
yhh 37a4e6e4ba docs(release): Studio 2.35.0 notes 2026-10-10 17:38:43 -07:00
yhh 898a5b1626 test(studio): keep the global test limits the one place that sets them
The feedback timer spy counted testing-library's own waitFor deadline once
asyncUtilTimeout became 10s; it now ignores timers armed from inside the
library. The settings-layers case swallowed a lookup that never matched its
textbox name, which cost a full asyncUtilTimeout; it now asserts the real name.
vitest gets testTimeout/hookTimeout of 60s, and find/zoom-keys drop their
private copies of the same limits.
2026-10-10 17:19:17 -07:00
YHH 80c6866e8c Merge branch 'studio' into fix/windows-frontend-test-env 2026-10-10 15:56:58 -07:00
YHH 0b016ab9e5 Merge pull request #12597 from esengine/feat/mcp-pin-tool-definitions
feat(plugin): pin MCP tool definitions at approval and hold changed tools
2026-10-10 15:56:08 -07:00
YHH 733091b9ca Merge pull request #12609 from esengine/fix/trusted-store-growth
fix(agent): stop copying the contract's criteria into every sealed bundle
2026-10-10 15:04:02 -07:00
YHH a3ab566e7f Merge pull request #12112 from KHG420/codex/studio-directory-copy-root-alias
fix(installsource): copy directory root aliases
2026-10-10 14:49:18 -07:00
YHH 53dfb1ace0 Merge pull request #11709 from esengine/feat/studio-sponsor
feat(studio): add a Support entry to Settings > About
2026-10-10 14:34:19 -07:00
YHH 16c63a2531 Merge pull request #11538 from esengine/fix/feedback-unread-refresh
feat(feedback): refresh the unread badge while open and announce new replies
2026-10-10 13:57:19 -07:00
YHH 9ce86fd5bb Merge pull request #12608 from esengine/feat/studio-rpm-package
feat(release): build and publish an rpm of Studio for Linux
2026-10-10 13:23:00 -07:00
YHH 6c5731c55b Merge pull request #12451 from esengine/perf/studio-gpu-mode
feat(studio): switch hardware acceleration off and show how the window is drawn
2026-10-10 12:49:36 -07:00
YHH fd5a34ca99 Merge pull request #12605 from xxoingr/fix/new-session-skipped-default
fix(config): report a skipped default on the new-session path too
2026-10-10 12:16:11 -07:00
yhh cfe4c4d4f8 fix(agent): stop copying the contract's criteria into every sealed bundle
The shadow bundle inlined the accepted contract's criteria and the full
verdict obligation list on every turn. Both scale with the number of
captured tests, and the bundle bytes changed each turn, so the
content-addressed store kept a fresh multi-megabyte copy per turn.

The bundle now names the contract revision by record digest only, and
files the obligations as their own object, which repeats nothing when a
turn leaves them unchanged.
2026-10-10 11:43:47 -07:00
YHH 9d4be2b6a1 Merge pull request #11535 from esengine/feat/auto-archive-11516
feat(studio): opt-in archiving of conversations inactive for N days
2026-10-10 11:40:36 -07:00
yhh 782e54b2fb fix(studio): point aria-controls at the QR block only while it exists 2026-10-10 11:31:27 -07:00
yhh 57be97a8dd Merge remote-tracking branch 'origin/studio' into upd-11709 2026-10-10 11:31:17 -07:00
yhh 2785a9b2fb refactor(i18n): move the notification texts into their own struct; stop polling feedback untouched for a month
Messages is back under the line ceiling with room for the next notification kind.
A report whose last update is over thirty days old no longer keeps the unread
poll running.
2026-10-10 11:31:11 -07:00
yhh cf78bab6b5 Merge remote-tracking branch 'origin/studio' into upd-11538 2026-10-10 11:30:18 -07:00
yhh 0a09f6c035 feat(release): build and publish an rpm of Studio for Linux
Fedora, RHEL and openSUSE cannot install the .deb. electron-builder now
also builds an .rpm, signed and mirrored with the same minisign flow, and
studio-manifest lists it under native_packages[linux-<arch>-rpm] beside the
unchanged .deb key. fpm spells the architecture of an .rpm name x86_64; the
packaging hook renames it to the release architecture and fails on any
spelling it does not know. The package job reads the rpm back for its name,
the helper's owner and mode, and the rpm's post-uninstall leaves the command
link alone on upgrade.
2026-10-10 11:13:32 -07:00
yhh c2b8c15626 fix(studio): report Electron's graphics status as stated and keep Settings off the line ceiling
The graphics panel no longer infers a driver fault from gpu_compositing: it
shows the raw status Electron reports, tells a saved choice from a launch-only
override, and says so when no status exists yet. The settings search moves into
its own hook so Settings.tsx has room again.
2026-10-10 10:38:33 -07:00
YHH 365b5ce0dd Merge pull request #12596 from esengine/fix/serve-submit-admission-and-turn-wait
fix(serve): judge POST /submit by what admission did with it
2026-10-10 10:35:43 -07:00
YHH 0adbdf9319 Merge pull request #12585 from esengine/fix/computer-snapshot-first-line
fix(computer): bound and escape application names in the snapshot and app list
2026-10-10 10:21:19 -07:00
yhh e7dd9a1625 test(studio): give async queries room on a loaded machine
Lazy-loaded Markdown and chart modules take close to a second to load on a
busy Windows runner, which is the whole default testing-library timeout.
2026-10-10 10:17:43 -07:00
yhh 6fd697a1c3 fix(studio): keep Settings under the file-size ceiling after the merge 2026-10-10 10:09:18 -07:00
YHH 8fdca29d84 Merge pull request #12588 from esengine/perf/gitcmd-driver-listing-scope
perf(gitcmd): skip the driver listing for subcommands that cannot run a driver
2026-10-10 10:07:29 -07:00
xxoingr dfbe397a0a fix(config): report a skipped default on the new-session path too
When the saved default_model is a decision source, a build that did not
ask to open on a fallback (reasonix run, the TUI) passes it over and
opens on the first conversation source, and said nothing about it:
ResolveNewSessionChatModel returned only (ref, fallback, ok), so the
boot had no skipped default to report, while the window path's
ResolveStartupChatModel carried one and emitted the notice.

Both resolvers now return one value, NewSessionModel{Ref, Fallback,
SkippedDefault}, and one helper decides that a saved default was passed
over: nothing configured serves it, or its source does not answer
conversation. newSessionModel takes SkippedDefault from whichever
resolver the build uses, so both paths emit the same
default_model_unavailable notice. What each path opens on is unchanged;
an unknown default is still kept on the no-fallback path for the CLI's
error, so it is not reported as skipped there.

Refs #12561
2026-10-11 01:07:07 +08:00
yhh 003a84b02a feat(plugin): pin MCP tool definitions at approval and hold changed tools
Record a digest of every tool definition the first time an authorized
server lists a non-empty catalog. A later listing that adds a tool or
changes any definition field is held out of the model's catalog and cache,
and a call to it is refused with the typed code mcp.tool_definition_held.
The user is told through a notice (mcp_tools_held), and accepts the held
definitions with 'reasonix mcp trust <name>' or POST /mcp/trust, which
advance the record and reconnect the server.

User-level servers keep one record for every workspace; project-level
servers keep one per workspace. Removing a server forgets its record.

Refs #12356
2026-10-10 10:06:10 -07:00
yhh 91c09a3653 Merge remote-tracking branch 'origin/studio' into upd-12451
# Conflicts:
#	desktop/electron/test/unit.mjs
2026-10-10 10:04:59 -07:00
yhh af86ae74f7 fix(feedback): announce a new maintainer reply even when the unread count does not change
The announcement compared only the aggregate unread count, so another reply on a
report that was already unread was never announced. Each report's latest
maintainer reply id is now tracked as well.
2026-10-10 09:52:52 -07:00
YHH 1234c49fbc Merge pull request #12590 from esengine/fix/wire-parity-embed
fix(repolint): wire-parity reads embedded structs and extends, declares PermissionRules
2026-10-10 09:41:23 -07:00
yhh 35e773c2c6 docs(hub): say what syncPins is for now 2026-10-10 09:40:49 -07:00
xxoingr 87dae2a950 fix(installsource): keep the cause of a refused .mcp.json under kind=auto
When a .mcp.json is refused while planning (over the server limit, an
invalid entry), kind=auto hid the reason. A fetched .mcp.json fell
through to the skill attempt, and with strict:false the JSON was planned
as a skill named "mcp". A GitHub repository whose root .mcp.json was
refused ended as a remote HTTP MCP plan at github.com (a repo named like
"mcp") or as ErrUnsupportedKind. A local .mcp.json file ended in "no
installable ... found", and a folder carrying a refused .mcp.json
dropped it without a word.

A source that is itself a .mcp.json, fetched or local, now answers with
its refusal under kind=auto as it already did under kind=mcp. A folder
or GitHub repository whose .mcp.json is refused keeps planning its other
installables; a folder warns with the path and the cause, and when
nothing else plans, the refusal is the error. A .mcp.json that is absent
or declares no server says nothing, as before.

readMCPJSON no longer returns a bare filesystem error: an absent file is
ErrManifestMissing and one that cannot be read (a directory, a denied
read) is ErrSourceUnreadable, so the caller can tell the cause.

Refs #12514
2026-10-11 00:31:41 +08:00
YHH 6c146088c7 Merge pull request #12599 from esengine/test/serve-trace-wait-on-liveness
test(serve): wait for a trace turn to go silent, not for a fixed time
2026-10-10 09:26:03 -07:00
yhh fe61b4c3d3 Merge remote-tracking branch 'origin/studio' into upd-11535 2026-10-10 09:22:23 -07:00
yhh 145285dcef fix(archive): never archive unread conversations, hold hand-edited days at the limit, draw pins from the kernel
An unread finish keeps a conversation out of the automatic sweep until it is
viewed. A hand-edited days value past the limit is held at the limit instead of
wrapping time.Duration. Pins are drawn from the tree's own pinned field;
localStorage only carries pins made before the kernel kept them and each is
sent once.
2026-10-10 09:22:23 -07:00
yhh 74d6e155f6 Merge remote-tracking branch 'origin/studio' into upd-11538 2026-10-10 09:18:38 -07:00
yhh 4bad69a161 Merge remote-tracking branch 'origin/studio' into upd-11709 2026-10-10 09:00:49 -07:00
yhh 4baacc9265 fix(serve): judge POST /submit by what admission did with it
POST /submit answered 409 busy.session_running whenever the turn it had just
started was already over by the time the handler sampled Running(), and also
for a submission parked behind a finishing turn that would run next. The
submit entry points now return the controller's admission result and the
handler refuses only a submission that was dropped.
2026-10-10 08:59:25 -07:00
yhh 5511d40317 test(serve): wait for a trace turn to go silent, not for a fixed time
runTrace gave a fixture turn a fixed 15s to close. A turn is several rounds of
host work, which a loaded Windows runner stretches well past that while the
turn is still emitting events. A turn is now hung only after a silent window,
with a total ceiling for one that never stops.
2026-10-10 08:42:10 -07:00
yhh 46efe8eba4 feat(studio): web search model setting in model preferences 2026-10-10 08:36:00 -07:00
yhh 4ee29685ef feat(studio): let the phone page be added to the home screen
Add a web app manifest, icons and the touch-icon and theme-color tags to the
page so a phone can install it and open it full screen. No service worker is
registered, so nothing cached can outlive a pairing or a credential. The serve
host now types .webmanifest as application/manifest+json.
2026-10-10 08:23:17 -07:00
yhh 783524cfac feat(tui): mark sessions that finished unseen in the resume picker and clear the mark on view
The picker leads a row with an accent dot when the kernel reports it unread.
The TUI posts /sessions/viewed when it binds a conversation (launch, resume)
and when a turn ends in the one on screen while the terminal has focus; a turn
that ends behind another window is marked when focus returns.
2026-10-10 08:11:18 -07:00
yhh 248071332a feat(studio): wide mode for the browser pane that keeps the conversation visible 2026-10-10 07:56:47 -07:00
yhh 8b5b05a8e4 feat(wsl): discover WSL distributions and probe one before installing anything
First slice of treating a WSL distribution as a machine a kernel can run on.
Adds a read-only utility package that lists distributions from the structure of
wsl -l -v and checks one by its architecture and user, plus
reasonix remote wsl list/test. Nothing is installed or started.
2026-10-10 07:52:44 -07:00
yhh dfd848e811 fix(repolint): wire-parity flattens embedded structs and extended interfaces, and declares PermissionRules
An embedded struct's fields are its outer type's own on the wire, and an
interface's extended parents are part of its shape, but the checker read neither,
so the outer PermissionRules type could not be declared and a field added to it on
one side went unnoticed. Same-file embedded structs and extends parents are now
flattened; PermissionLists and PermissionRules are registered.
2026-10-10 07:32:27 -07:00
yhh 0c5b7a625c perf(gitcmd): skip the driver listing for subcommands that cannot run a driver
Refs #12532
2026-10-10 07:30:28 -07:00
yhh 40826bfb13 perf(config): run the retired-key migrations over one read of each config
Refs #12533
2026-10-10 07:28:12 -07:00
yhh 411f0375a1 fix(studio): let threshold rows wrap under the label on narrow widths 2026-10-10 07:13:29 -07:00
yhh 778c97014d fix(computer): bound and escape application names in the snapshot and app list
The first line of a snapshot, the app list and the screenshot caption printed
the application's name raw, and the app list printed window titles escaped
but unbounded. Route them through the literal the modal names already use.
2026-10-10 07:06:48 -07:00
yhh 93d5c1eb7c fix(studio): keep pins the kernel has not confirmed from being auto-archived
A pin the kernel refused is rolled back and reported instead of staying
visible, and any remembered pin of a listed conversation the kernel does not
report as pinned is sent again on the next tree load, so a legacy pin in a
workspace added after the first sync is protected before its first sweep.
2026-10-10 06:41:45 -07:00
yhh 4631dc9d44 Merge remote-tracking branch 'origin/studio' into upd-11535 2026-10-10 06:36:12 -07:00
F e976667e4f fix(session): key the turn's seal on identity and end turns from /status
A re-delivered turn_started naming the turn already in front of us is the
same open twice. The transport folds the duplicate seq, but the reducer is
fed raw sequences, and sealing on order marked its live calls "no result"
and dropped an unanswered approval. The seal now keys the start's msgIndex
against the turn the live side holds; naming still runs on the duplicate,
because the row may have been sent between the two deliveries.

The record cannot end a turn. The kernel commits each text-only assistant
round before handleFinalResponse decides to continue it — baseline
criteria, executor-handoff nudge, empty-final retry, steer drain — and
/history carries no end marker, so rebuild stopped inferring "over" from a
record ending on a say. The typed fact rides every status read: Pane's
applyStatus dispatches __running into the reducer, equal answers fold to
the same state, and a payload that cannot say leaves the wire's running
standing. The identity merge stays; the record no longer decides the
turn's life. A lost turn_done self-corrects at the next status answer.

The rebase folds studio's extension retention into rebuild(): an
extension is not in the record either, so the tail keeps it the way the
old __restore filter did.

The status-inflight fixture now answers running over an emitted
turn_started: the pane reads that fact, and a port contradicting its own
wire is not a kernel these reads can be reasoned against.
2026-10-10 21:03:47 +08:00
par73e 6646758aad feat(studio): close browser pages individually or in bulk 2026-10-10 12:59:17 +00:00
XTLine 8d5655f96d Merge studio into fix/studio-restore-undo
Preserve upstream committed-file counts at the Pane-owned restore receipt.
2026-10-10 20:58:14 +08:00
F afa0a33442 fix(session): reconcile the running tail with the record on rebuild
A __restore re-read taken mid-turn — by hand, or by the stream-gap
rebuild — dropped the cards being written, letting the next delta mint
the answer anew: new identity, the entrance replayed over half a reply,
the text before the gap gone until the message frame. The tail now
merges with the record by identity instead of riding after it: the
kernel commits each assistant round at its boundary, before its calls
run, so the record can already hold a call the live side holds open —
the record wins where it is complete, the live card where it is not.

A record ending on the committed answer, with no call of ours still
out, is a turn_done the wire lost: the answer wins and the turn is
over, clearing a running flag nothing else would clear.

A turn whose end never arrived is sealed the way turn_done would have
sealed it when the next one starts, so its calls stop spinning through
a turn that has nothing to do with them.

session.ts stays under the 800-line ceiling: the merge lives in
state/rebuild.ts.
2026-10-10 20:13:48 +08:00
yhh d4dd348b14 fix(agent): let read-only agents search the capability catalog
The read-only execution guard admitted only list and inspect, so
use_capability action=search fell through to the unknown-action block even
though the tool declares it and resolves it exactly like list. The tool's
own description tells every model to search before calling a tool
unavailable, so a read-only subagent was steered into the block.

usecap now exports the declared actions and the catalog-read subset; the
guard reads that set instead of keeping its own copy. The refusal for an
action outside the declared set names the action and the allowed ones.
2026-10-10 04:06:14 -07:00
yhh 333b435321 fix(boot): emit build-time notices once per lineage across rebuilds
Every boot.Rebuild re-derived each build-time notice (dormant permission
rules, held project MCP servers, default-model fallback, missing key) from
unchanged state and emitted it again. The runtime owner now carries a notice
ledger; each build runs through a window that drops notices the previous
successful build already showed, so only new or changed notices are emitted.
2026-10-10 03:39:38 -07:00
xiexian 667368c3be fix(installsource): copy directory root aliases 2026-10-10 17:26:18 +08:00
XTLine 2ca8e60a01 style(studio): theme restore actions and bound error details 2026-10-10 17:06:53 +08:00
XTLine ac3673ff72 fix(studio): recover failed rewind presentation refreshes 2026-10-10 17:06:32 +08:00
XTLine 7613fd5d3e fix(studio): separate rewind mutation and receipt lifetimes 2026-10-10 17:06:18 +08:00
XTLine 65e391a401 fix(control): retain stream failure cause during cleanup 2026-10-10 17:00:43 +08:00
XTLine f9d401b6ea fix(responses): accept structurally closed no-argument calls 2026-10-10 17:00:34 +08:00
yhh aa38634842 fix(providers): show a refused removal beside the Delete button and clear every role naming the removed service
A removal refusal rendered in a banner at the top of the settings pane, out of
view once the pane scrolled, so Delete appeared to do nothing. The banner is
now an inline alert in the provider detail header. The list is re-read after a
failed removal too, because the kernel may have written the removal before the
failure. RemoveProvider now clears the guardian, recovery, vision, triage,
decision and advisor roles that named the removed service, walking the same
role table the other repairs use, and its doc comment states the behaviour the
code and tests already have.
2026-10-10 01:44:17 -07:00
yhh 12bf175c64 fix(permissions): treat conditionally registered tools as known to the rule vocabulary
A rule naming a tool that assembly registers only under some configuration or
in a sub-registry (system_one, advise, best_of_n, apply_isolated,
discard_isolated, submit_plan, conclude_no_changes, review_report,
complete_subtask) was refused on save and reported dormant at load, because the
tool is absent from the registry at that moment. Each owner now declares its
conditional tool with tool.RegisterConditional and the controller's vocabulary
reads the declarations; a boot test reconciles every tool the assembly can
register against the built-ins plus the declarations. The dormant-rule item
shape is also held by wire-parity.
2026-10-10 01:16:59 -07:00
yhh edccdc416e fix(providers): offer no model-listing read for a protocol that declares none
The edit form showed 'read available models from provider' for a decision
source, whose protocol has no model listing. The provider entry view now
carries canListModels, derived from the protocol table's Discovery
declaration, and the editor hides both listing controls from it.
2026-10-10 00:42:38 -07:00
Will 5559945e7f test(e2ebench): register corpus authoring policies
The authoring guards were three hand-written lists in corpus_test.go: which
corpora must carry a reference solution, which must have a grader that accepts
it, and which must let the agent decide when to stop. A seventh corpus could be
added and be checked by none of them, which is what happened to four of the
seven that exist.

Each corpus now declares, in one registry, how it stands against each rule:
enforced, or exempt with a reason. Discovery walks the corpus root, so an
unregistered corpus is a failure rather than an omission from a list. The three
guards read the registry instead of their own lists, and the stop-policy guard
covers every registered corpus rather than the three that happened to be listed.

An entry must name the corpus this root owns: its directory is compared with the
corpus's path under the root, not with its name, so a registry entry pointing at
some other directory that happens to share a name cannot stand in for the
committed one.

The pruning test the registry replaces was weakened rather than extended: a new
corpus used to be silently unchecked.
2026-10-10 15:35:26 +08:00
XTLine fe52643a86 test(agent): expand terminal lifecycle coverage 2026-10-10 15:04:17 +08:00
XTLine accb314eee test(agent): verify safe recovery and cancellation 2026-10-10 15:04:12 +08:00
XTLine a4fe926a4b test(agent): assert terminal execution and wire history 2026-10-10 15:04:03 +08:00
XTLine d76d47b899 test(agent): add a runnable terminal HTTP fixture 2026-10-10 15:03:49 +08:00
XTLine 9b716795cd test(responses): cover terminal integrity boundaries 2026-10-10 15:03:34 +08:00
XTLine ff1bada908 fix(responses): refuse terminal tool-call loss 2026-10-10 15:03:29 +08:00
XTLine f9a0a0926f fix(agent): preserve typed stream failure attribution 2026-10-10 15:03:20 +08:00
XTLine 172d61bba6 test(studio): cover restore reload and lifecycle boundaries 2026-10-10 14:53:14 +08:00
XTLine 0d85a11055 fix(studio): keep restore undo outside transcript cards 2026-10-10 14:52:40 +08:00
XTLine 29fb53efca fix(studio): own rewind receipts at conversation scope 2026-10-10 14:51:36 +08:00
1919833c48 feat(studio): route extracted tab menus through controller close
Problem: PR #12348's browser menu dismissed agent-owned UI entries instead
of closing the underlying pages, overlapping the controller close work in
PR #12067.

Root cause: The tab strip owned a presentation-only close path separate
from the controller's browser lifecycle.

Fix: Integrate the latest studio base with #12067, retain its useTabClose
owner, and adapt #12348's WorkbenchTabs extraction, close-to-the-right menu
and keyboard access to that path. Keep close-all, preserve file editors,
and retain failed pages while disabling repeated closes in flight.

Verification: 99 focused frontend tests, TypeScript/production and perf
builds, census and its 10 fixture comparisons, make check, go vet, make
lint, builtin/boot and browser/serve/control tests, and 11 release-note
tests passed. Browser keyboard/menu acceptance and eight fixture screenshots
cover light/dark and wide/narrow layouts. Intrinsic, contrast and side
guards passed; wheel exits successfully with its two existing advisory
findings. Native Electron acceptance and hosted CI remain outstanding.

Co-authored-by: KHG420 <[email protected]>
Co-authored-by: par73e <[email protected]>
2026-10-10 14:39:25 +08:00
Will 95a19946ed fix(e2ebench): fail mutation and differential analysis when source restoration fails
Both analyses edit a PR's source, run the tests, and put the source back. The
write-back discarded its error and then marked itself restored, so a failed
restore was indistinguishable from a successful one: the sweep went on to report
caught/survivor counts, and the differential went on to report pin results, for
a tree that was still mutated. A verdict from a tree that no longer holds the
PR's code is worse than no verdict, and the mutation would leak into the next
mutant and into the user's checkout.

restoreSource writes the bytes back and proves they came back: it re-reads the
file and compares it byte for byte. A git-based restore is checked with the tree
instead of the exit status of the command that was supposed to perform it, so
files that exist in neither revision now stop the analysis rather than being
dropped. Both failures are returned instead of noted, the differential's result
is recorded as an explicit analysis failure, and the report says the figures are
absent rather than zero.

The I/O entry points are variables so tests can inject a failure instead of
racing one: deleting either check makes TestRunMutationFailsWhenTheRestoreFails
and TestDifferentialPerTestFailsWhenRestoreFails reproduce the old behaviour
("a failed restore still produced a verdict: caught=1 total=1 survivors=[]").
2026-10-10 14:22:55 +08:00
xiexian 58ab25039e feat(studio): inspect subagent skill declarations 2026-10-10 11:33:12 +08:00
xiexian 36c3719cb4 feat(studio): show resolved plugin commits in install previews 2026-10-10 11:25:06 +08:00
f1shsmell fde3581c08 feat(theme): read the density and corner recipes a pack declares
A pack could declare recipes.density and recipes.corners, the schema
published both tiers and eight of the nine shipped packs carried them,
while nothing in the tree read either one. A pack asking for compact
density loaded clean, warned about nothing, and did nothing.

The reader keeps the two tiers now and reports a value outside them the
way it reports a token that fails its grammar, so an author sees the
problem in the picker rather than a pack that stays default in silence.

A recipe is a factor, not a size: the stylesheet owns what a radius or a
gutter measures, and a pack only says how far it steps. corners writes
--r-scale and density --d-scale, and each declaration that takes one keeps
its own value as the default, so a window with no pack on it is laid out
at exactly the numbers tokens.css writes.

Recipes is a hand-written wire mirror like the pack's other blocks, so it
joins the pairs declared in tools/repolint/wireparity.go: a field added on
one side alone then fails the gate at the line that cannot read it.
2026-10-10 00:26:02 +08:00
f1shsmell 9357a0ae36 feat(styles): give layout spacing an eight-step scale
Layout spacing carried 2364 distances across 48 distinct values, 1046 of them
on no scale at all, 10px 220 times and 6px 214 times. A pixel is not a level,
and those two are what a six-step scale could not hold: they are the values a
migration would have had to move 63% of the file's distances to reach. Eight
steps leave 44% off scale instead.

The tie rule is arithmetic rather than taste. 3, 5, 7, 9, 11, 14 and 20 each
sit exactly between two steps, and each sits above the step below it, so a tie
returns to that step and gives up only the air that was added by hand.

The scale is declared here; one surface draws from it. The activity group's
fold header takes --sp-6 for its inset and --sp-4 between the mark and its
label, and both were already those numbers: the two new steps renumbered the
ones below 12px, so the surface was renumbered with them and nothing moves.

TOKENS.md carries the scale, the step each role takes, and the tie table. Its
Space items were rewritten to fit the file's prose budget, which the first
draft of this change went one unit over.
2026-10-09 23:46:29 +08:00
xxoingr c21474751a feat(studio): render right-to-left text blocks in their own direction
A reply in Persian or Arabic was laid out left to right, and a sentence that
mixes in Latin words such as npm or a variable name came out jumbled, because
every block inherited the window's LTR base direction.

Markdown paragraphs, headings, lists, blockquotes and table cells now carry
dir="auto", so the browser takes each block's base direction from its own
first strong character: an RTL paragraph reads right to left and aligns to
the right beside English ones, and the bidi algorithm orders embedded Latin
runs correctly. Code blocks are left out and keep the document's direction.
The user's own message row and its edit box get the same treatment.

List indentation and the blockquote rule use logical properties
(padding-inline-start, border-inline-start), so they move to the right edge
for an RTL block and are unchanged for LTR.

Fixes #12114
2026-10-09 23:41:32 +08:00
Will 51ab972870 feat(bench): account for what each context-retrieval run costs
The harness measures context cost and could not report its own. It handed the
agent event.Discard, so the usage every billed completion already reports went
nowhere, and a batch's spend could only be read from a vendor console: this run's
index batch cost 604,544 prompt tokens (527,232 cached) plus 12,600 completion
tokens over 24 runs, and nothing in the tree would have said so.

The bench now installs a sink and totals what the agent reports — the same record
the product's own round path, compaction, command routing and failure paths bill
to. One path, not a second count of the same fact.

Two things stay honest about what the number means. A dry run prints an empty
suffix rather than "prompt-tok=0", because an unmeasured cost must not read as a
measured one. And the count is called usage_reported_requests: a request that
failed before reporting usage leaves no event, so it is not a count of attempts.
2026-10-09 20:08:15 +08:00
Will 40e8f0b9a9 docs(context-retrieval): record the v2 Experiment I result in Status
The substrate the Status section asked for now has a result, and the result has a
direction, a size that is not yet quotable, and a reason.

The direction reproduces: six of six tasks, and the pooled paired batch searches
less and pages in less with the cue visible. A size is visible on the four tasks
whose absent-cue arm stayed out of a loop (44% less prompt cost), and it is
recorded as a post-hoc subset rather than an effect size: the two excluded tasks
are excluded because their arm looped here, and the qualification bar does not
screen for that. Saying 44% without that qualifier would mean picking the sample
after seeing the result, which is the mistake that invalidated the previous round.

The counts are stated separately because they differ: 36 runs executed, and the
token totals belong to the 24-run index batch that carried the usage line.
2026-10-09 20:01:23 +08:00
yhh 987f44cafa test(serve): locate the installed plugin through its recorded root 2026-10-09 04:48:27 -07:00
yhh 0eb5a7ca2c test(plugins): cover the re-check after a blocked rename without a new exported seam 2026-10-09 04:38:49 -07:00
yhh c32b46c9fd fix(plugins): re-check the approved bytes before every publication attempt and keep the in-place swap off Windows 2026-10-09 04:28:55 -07:00
yhh 51f3100546 test(plugins): assert the planning-stage refusal of an unmaterializable symlink and an unchanged install after a refused replace 2026-10-09 03:45:57 -07:00
yhh cf1db1d307 feat(studio): launch-only GPU override (env or flag), graphics hint points at reduced effects, search hides the setting outside the shell 2026-10-09 03:26:07 -07:00
yhh 7c5ce2b2c4 fix(serve): register the auto-archive routes with the other session routes 2026-10-09 02:48:11 -07:00
keeyang eb3f823174 feat(studio): open the project folder from the chrome folder mark
The folder mark beside the project name is decoration: it carries no click and
no meaning. A project can already be shown in the file manager, but showing it
reveals it in its parent folder; entering the folder itself needs a second
step.

This adds that step and wires it to the mark. The kernel answer and its refusal
are the ones reveal already asks for (/host/workspaces/locate), so the page
still names no location of its own and only the shell verb differs: openPath,
called on a folder the kernel itself named, instead of showItemInFolder. The
mark stays disabled when the shell cannot open a folder, or when no pane names
a workspace.
2026-10-09 17:06:49 +08:00
yhh 9de1e7adfb Merge remote-tracking branch 'origin/studio' into upd-11535
# Conflicts:
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/i18n/en_settings.ts
#	desktop/frontend-next/src/port/boundary.ts
#	desktop/frontend-next/src/port/hub.ts
#	desktop/frontend-next/src/port/mock_boundary.ts
#	desktop/frontend-next/src/port/port.ts
#	desktop/frontend-next/src/port/sse_boundary.ts
#	desktop/frontend-next/src/ui/App.tsx
#	desktop/frontend-next/src/ui/Workspaces.tsx
#	desktop/frontend-next/vite.config.ts
#	internal/contract/config/render.go
#	internal/frontend/serve/hub_tree.go
#	internal/frontend/serve/routes.go
#	internal/state/sessionstore/branch.go
2026-10-09 01:53:26 -07:00
yhh 8aed0bc8ab Merge studio into feat/fix-11734-c 2026-10-09 01:53:14 -07:00
yhh 0e76f564ad Merge remote-tracking branch 'origin/studio' into upd-12451
# Conflicts:
#	desktop/frontend-next/src/state/prefs.ts
2026-10-09 01:51:54 -07:00
yhh 43a443ca15 Merge remote-tracking branch 'origin/studio' into upd-11709
# Conflicts:
#	desktop/frontend-next/src/main.tsx
2026-10-09 01:51:44 -07:00
SivanCola 467707ce4b fix(studio): clarify default light theme surfaces and reading weight
Problem: Default light Studio uses nearly identical frame and reading surfaces,
medium-weight prose, and a broad composer shadow, making long answers feel dense.

Root cause: Reading panes share the page token, replies inherit the small-UI
weight, and the composer uses the generic light surface and shadow recipe.

Fix: Separate the default light reading and composer surfaces, use regular
weight for default reply prose, and shorten the composer shadow. Preserve
explicit weight choices, installed theme packs, wallpaper surface overrides,
dark styling, and layout. Consolidate the duplicate light palette and document
the tokens. Include mock-fixture before/after screenshots at both viewport sizes.

Verification: 68 focused frontend tests; TypeScript/Vite build; browser theme,
weight, wallpaper, draft-input, and geometry checks; gofmt; go vet ./...;
make lint; make check; go test ./internal/tools/builtin/ ./internal/assembly/boot/.
2026-10-09 15:02:36 +08:00
yhh 27f10eaf95 feat(studio): let the user switch hardware acceleration off and see how the window is drawn
The shell exposed no way to leave the GPU out of it. Chromium fixes its GPU
use before the app is ready, so a saved choice (rx-hw-accel, in the existing
window-prefs.json) is read right after the profile directory is set and
before ready; the shell reports what this launch applied and what Chromium
ended with through a new shell:graphics handle. Appearance gets a Graphics
block, shown only in the desktop shell, saying which mode this launch uses
and that a change applies after a restart.
2026-10-08 19:37:02 -07:00
xiexian c69e53e528 fix(studio): distinguish settings save outcomes and allow retry 2026-10-08 20:14:26 +08:00
xiexian de06daf75a feat(studio): compact context from the ring and inspector 2026-10-08 17:06:23 +08:00
xiexian fd6b917c80 fix(studio): batch-check models before adding a source 2026-10-08 14:53:59 +08:00
xiexian dce96d1c5c fix(studio): keep prompt-refine help reachable and shortcut intact 2026-10-08 10:13:54 +08:00
xiexian 22ca8f3f17 feat(studio): select local sessions for batch actions 2026-10-08 09:12:57 +08:00
xiexian bb7d51e7a9 feat(studio): add project folders by dropping onto the sidebar 2026-10-08 07:45:55 +08:00
xiexian 8c644fa972 fix(studio): compact the untouched phone composer 2026-10-07 23:52:54 +08:00
xiexian 6545e66e36 fix(studio): group installed skills by source 2026-10-07 23:29:02 +08:00
xiexian 504101cb17 feat(studio): show current plan beside conversation navigation 2026-10-07 23:20:11 +08:00
xiexian 2a383830b6 fix(studio): put the current model first in the composer picker 2026-10-07 22:58:53 +08:00
xiexian 119dbd72ce feat(studio): open project actions from context menus 2026-10-07 22:32:30 +08:00
xiexian f11cd2226b fix(studio): align advanced appearance with settings cards 2026-10-07 22:09:54 +08:00
xiexian 2b23801457 feat(studio): offer quick archive and delete on session rows 2026-10-07 21:36:48 +08:00
xiexian 1e0043d817 fix(studio): make chart values discoverable along dense lines 2026-10-07 21:09:38 +08:00
xiexian 69a046fed3 feat(studio): quote selected reply text from the context menu 2026-10-07 20:54:23 +08:00
xiexian ec6ffb4887 feat(studio): fold feedback conversations and preserve unread replies 2026-10-07 20:36:51 +08:00
par73e f5dc84673a feat(studio): close browser pages individually or in bulk 2026-10-07 20:09:22 +08:00
xiexian c8abba049a fix(studio): keep failed extension forms editable 2026-10-06 22:18:13 +08:00
xiexian 645abda947 fix(studio): submit only current extension form fields 2026-10-06 21:51:43 +08:00
xiexian 4881f167dc feat(studio): identify installed package contribution kinds 2026-10-06 00:59:45 +08:00
xiexian bb760c6684 feat(studio): copy canonical skill invocations from Settings 2026-10-05 14:47:08 +08:00
xiexian c756ff26e7 feat(studio): find installed packages by contributed capabilities 2026-10-05 14:25:33 +08:00
YHH 4ffed1b50c Merge branch 'studio' into feat/studio-sponsor 2026-10-03 18:47:02 -07:00
esengine 78069f638a fix(plugins): re-verify the approved bytes at publication and keep non-Windows replacement semantics 2026-10-03 02:22:21 -07:00
esengine f54da89d15 fix(plugins): bind the published plugin bytes to the approved preview and keep the publication failure typed 2026-10-03 00:31:46 -07:00
esengine 0b76ca0f63 fix(plugins): publish a replaced plugin directory without renaming a watched tree on Windows 2026-10-02 21:58:24 -07:00
esengine 69e78746dd feat(studio): add a Support entry with PayPal and payment QR codes to About 2026-10-02 04:12:32 -07:00
esengine c74ea3c431 feat(studio): opt-in archiving of conversations inactive for N days
Adds a user-level [auto_archive] setting (enabled = false, days = 30 by
default; a project reasonix.toml cannot set it). While on, the controller's
ArchiveInactiveSessions archives conversations idle past the threshold from
the same background pass that sweeps recovery branches, so it stays off the
boot path. Archiving only flips catalog metadata and is reversible.

Never archived: pinned, leased by a live run (covers a run or a pending
answer/approval), carrying an unfinished turn, the controller's own
session, subagent traces and superseded versions. Restoring a conversation
counts as activity so it is not re-archived straight away.

The kernel is the authority for pins (BranchMeta.Pinned, tree rows carry
`pinned`). The window sends the union of its own pins and the kernel's
after the tree loads and retries on failure; the sweep, including the one
after enabling, refuses to run until a window has synced once.

Archived conversations stay findable: search returns them with an
"archived" marker, and rows archived by the sweep carry a mark so the
window says once how many were archived and that they can be restored.
Settings > Session gets GET/POST /auto-archive with persistence to the
user file and zh/en strings.

Refs #11516
2026-10-01 04:30:46 -07:00
esengine a26c36df13 feat(feedback): keep the unread badge fresh and announce new replies
Refresh the feedback unread count on window focus (at most every 30 s) and
every 10 minutes while a report is unsettled, with back-off to 1 h and no
error UI. A person with no open report is never polled. A hidden window keeps
polling only for someone who enabled the new feedback-reply notification.

Add a fourth notification kind, feedback_reply (default on under the master
switch), raised through POST /notifications/feedback-reply when the unread
count rises while the window is unfocused. The text never carries the reply.
2026-10-01 00:05:59 -07:00
3134 changed files with 174932 additions and 20578 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+28 -14
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
@@ -26,7 +26,9 @@ jobs:
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
# `always()`, a failed `changes` job (or a missing output) makes them run
# the full matrix instead of silently passing required checks as skipped.
# Pushes to main-v2 always run everything.
# Pushes to main-v2 always run everything. Pull requests run the macOS and
# Windows legs only when `platform` is not 'false'; the rule lives in
# scripts/ci-platform-scope.sh and fails toward running them.
changes:
runs-on: ubuntu-latest
outputs:
@@ -34,13 +36,14 @@ jobs:
desktop: ${{ steps.filter.outputs.desktop }}
sdk: ${{ steps.filter.outputs.sdk }}
scripts: ${{ steps.filter.outputs.scripts }}
platform: ${{ steps.filter.outputs.platform }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- id: filter
run: |
code=true; desktop=true; sdk=true; scripts=true
code=true; desktop=true; sdk=true; scripts=true; platform=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
@@ -48,7 +51,7 @@ jobs:
base="${{ github.event.before }}"
fi
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
files=$(git diff --name-only "$base" HEAD)
files=$(git diff --name-only --no-renames "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; sdk=false; scripts=false
# Root-module CI: desktop/ is a separate module, so only the
@@ -63,9 +66,10 @@ jobs:
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
platform=$(git diff -z --name-only --no-renames "$base" HEAD | bash scripts/ci-platform-scope.sh)
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; echo "platform=$platform"; } >> "$GITHUB_OUTPUT"
release-notes-scripts:
needs: changes
@@ -77,14 +81,14 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- run: node --test 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/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
@@ -93,6 +97,9 @@ jobs:
# detector reports the diff is unrelated, every step skips and each leg
# reports success in seconds. `always()` also keeps the legs alive when
# the changes job itself fails (fail-open: an empty output != 'false').
# A pull request with no platform-relevant change moves the macOS and
# Windows legs onto an ubuntu runner with every step skipped: the check
# names the ruleset requires still report, without a scarce runner.
test:
needs: changes
if: always()
@@ -100,9 +107,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -198,6 +205,12 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
@@ -302,9 +315,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.sdk != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
defaults:
run:
working-directory: sdk/go
@@ -409,7 +422,7 @@ jobs:
# compile it against a real macOS toolchain.
desktop-macos:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
runs-on: macos-latest
defaults:
run:
@@ -437,7 +450,7 @@ jobs:
# (//go:build windows), so the ubuntu leg above never compiles them.
desktop-windows:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
runs-on: windows-latest
defaults:
run:
@@ -508,6 +521,7 @@ jobs:
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
-ignore 'label "ubuntu-22.04-arm" is unknown' \
.github/workflows/release-studio.yml \
.github/workflows/studio-certum-signing-smoke.yml \
.github/workflows/ci.yml
@@ -547,7 +561,7 @@ jobs:
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coverage-report
path: coverage.out
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
fetch-depth: 0
persist-credentials: false
- if: steps.gate.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Sync
+89 -72
View File
@@ -175,6 +175,9 @@ jobs:
- platform: linux/amd64
runner: ubuntu-22.04
artifact: studio-build-linux-amd64
- platform: linux/arm64
runner: ubuntu-22.04-arm
artifact: studio-build-linux-arm64
steps:
# Real-time scanning inspects every file the job writes, and packaging
# writes a quarter of a gigabyte twice. The runner is thrown away after
@@ -202,7 +205,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -210,12 +213,22 @@ jobs:
desktop/frontend-next/pnpm-lock.yaml
desktop/electron/pnpm-lock.yaml
# electron-builder shells out to dpkg-deb through fakeroot for the .deb,
# and carries its own NSIS -- the toolchain the Wails line installed by
# hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
# to rpmbuild for the .rpm, and carries its own NSIS -- the toolchain the
# Wails line installed by hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is
# not this shell's.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
# runner the .deb and .rpm are built with one installed from RubyGems.
- name: Use a native fpm on ARM64 Linux
if: runner.os == 'Linux' && runner.arch == 'ARM64'
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm -v 1.16.0
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
# The same Developer ID and notary credentials the desktop line uses: one
# certificate signs any bundle identifier its team owns, so Studio does not
@@ -359,11 +372,28 @@ jobs:
pnpm exec electron-builder --prepackaged "../../$app" $arch \
-c.extraMetadata.version="${version#v}"
- name: Verify both Linux package formats
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-$arch.deb" "dist/ReasonixStudio-linux-$arch.rpm" "$arch"
- name: Accept the installed Linux package
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
version="${{ needs.resolve.outputs.version }}"
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
# With signing on, this leg only hands the bundle on: the signing jobs
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
path: dist/${{ matrix.unpacked }}
@@ -396,7 +426,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
with:
name: ${{ matrix.artifact }}
@@ -478,7 +508,7 @@ jobs:
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
path: signing/app
@@ -533,7 +563,7 @@ jobs:
-ExpectedSubject $env:EXPECTED_SUBJECT
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: signing/app
@@ -575,7 +605,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -587,7 +617,7 @@ jobs:
run: pnpm install --frozen-lockfile
# The signed payload is the whole bundle, so it is packaged as it came.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: dist/${{ matrix.unpacked }}
@@ -613,7 +643,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: |
@@ -648,12 +678,12 @@ jobs:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: payload
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -731,7 +761,7 @@ jobs:
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -794,7 +824,7 @@ jobs:
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist/ReasonixStudio-*
@@ -831,13 +861,13 @@ jobs:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-${{ matrix.arch }}
@@ -870,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
@@ -887,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
@@ -903,15 +948,9 @@ 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: cli-archives
path: dist/*
@@ -970,28 +1009,33 @@ jobs:
# Every package by its exact name, never a pattern: any job without
# secrets can upload an artifact, and a pattern would publish it. Windows
# comes from the installer signing job when it ran, else the build leg.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-amd64
path: staging/studio-build-darwin-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-arm64
path: staging/studio-build-darwin-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-amd64
path: staging/studio-build-linux-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-arm64
path: staging/studio-build-linux-arm64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-arm64
@@ -1034,19 +1078,19 @@ jobs:
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
done
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-arm64
path: staging/studio-windows-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: staging/cli-archives
@@ -1067,34 +1111,7 @@ jobs:
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
find staging/cli-archives -type f -print0)
ls -la dist
# Two Windows artifacts (installer, portable zip) per architecture,
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
# means an artifact was lost between jobs, and a partial release is
# worse than a failed one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
if [ "$count" -lt 9 ]; then
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
exit 1
fi
# Each platform's update channel, and there is exactly one per
# platform: dpkg on Linux, the next installer on Windows, and the
# bundle swapped out of the archive on macOS. Missing one degrades the
# release silently instead of failing it — it strands that platform on
# the version it is running.
# Each macOS architecture by name, not darwin-*: a glob passes on
# whichever one shipped, and the missing one is a platform left on
# the version it is running with nothing saying so.
# Each Windows architecture by name for the same reason: a glob passes
# on whichever installer shipped.
for required in 'ReasonixStudio-linux-*.deb' \
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
find dist -type f -name "$required" | grep -q . || {
echo "::error::no $required in the release; its packager did not run"
exit 1
}
done
bash scripts/verify-studio-artifacts.sh dist
# The same minisign key the desktop line uses, because the public key that
# verifies it is compiled into the client (update.PublicKey).
@@ -1182,8 +1199,8 @@ jobs:
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
for f in dist/*; do
@@ -1403,7 +1420,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
registry-url: https://registry.npmjs.org
@@ -1412,7 +1429,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.resolve.outputs.candidate != 'true'
with:
name: cli-archives
@@ -1466,7 +1483,7 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: cli-archives
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Test sweeper
@@ -46,7 +46,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -99,7 +99,7 @@ jobs:
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-selftest-windows-${{ steps.version.outputs.version }}
path: dist/ReasonixStudio-windows-amd64-installer.exe
+214 -59
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:
@@ -33,7 +33,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -114,7 +114,7 @@ jobs:
run_install: false
- if: steps.spa.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -136,14 +136,131 @@ jobs:
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
node perf/intrinsic.mjs
node perf/firstscreen-fonts.mjs
node perf/contrast.mjs
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/narrow.mjs
node perf/settings-rows.mjs
node perf/narrow-steps.mjs
node perf/job-names.mjs
node perf/mcp-actions.mjs
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/peer-threads.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
# studio, or a failed `scope`, runs all three.
desktop:
name: desktop (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: scope
if: always()
runs-on: ${{ github.event_name == 'pull_request' && needs.scope.outputs.platform == 'false' && matrix.os != 'ubuntu-22.04' && 'ubuntu-latest' || matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.scope.outputs.platform != 'false' || matrix.os == 'ubuntu-22.04' }}
strategy:
fail-fast: false
matrix:
@@ -152,9 +269,11 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
@@ -167,28 +286,59 @@ jobs:
# carries build dependencies of its own.
# Same command as `make studio-test`; this job is already in desktop/.
- name: go test
if: env.RUN_STEPS == 'true'
run: go test ./...
- if: env.RUN_STEPS == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
- name: CUA process and packaging contracts
if: env.RUN_STEPS == 'true'
timeout-minutes: 2
run: node --test --test-name-pattern=CUA electron/test/unit.mjs
- name: Install Linux native CUA fixture dependencies
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb openbox dbus-x11 libgtk-3-dev at-spi2-core
- name: Native CUA application acceptance
if: env.RUN_STEPS == 'true'
timeout-minutes: 6
shell: bash
run: |
if [ "$RUNNER_OS" = Linux ]; then
dbus-run-session -- xvfb-run -a sh -c 'openbox >/tmp/reasonix-openbox.log 2>&1 & export NO_AT_BRIDGE=0 GTK_MODULES=gail:atk-bridge; node electron/test/cua-native.mjs'
else
node electron/test/cua-native.mjs
fi
# The host sits in the main module and nothing else builds it, so its
# dependency closure is checked here: it must stay CGO-free and
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
# is host-independent, so once is enough.
- name: host stays pure Go and platform-neutral
if: runner.os == 'Linux'
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
working-directory: .
run: |
for os in windows linux darwin; do
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
for arch in amd64 arm64; do
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -o /dev/null ./cmd/reasonix-studio-host
done
done
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
# only when it touches what the leg verifies; a push to studio always does.
# The macOS and Windows package legs follow `platform`, the same rule as the
# desktop matrix above.
scope:
name: package matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
arm64: ${{ steps.pick.outputs.arm64 }}
platform: ${{ steps.pick.outputs.platform }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -200,10 +350,12 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
arm64=true
platform=true
if [ "$EVENT" = pull_request ]; then
arm64=false
if git diff --name-only "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
arm64=true
fi
fi
@@ -212,13 +364,18 @@ jobs:
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
]'
if [ "$arm64" = true ]; then
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
if [ "$platform" = false ]; then
legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
elif [ "$arm64" = true ]; then
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"},{"os":"ubuntu-22.04-arm","arch":"arm64","platform":"linux"}]' <<<"$legs")"
else
legs="$(jq -c . <<<"$legs")"
fi
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
{
echo "arm64=$arm64"
echo "platform=$platform"
echo "matrix={\"include\":$legs}"
} >> "$GITHUB_OUTPUT"
# The packaging contract, on real artifacts rather than on the config that
# describes them. Unsigned on purpose: signing and notarization are a
@@ -250,16 +407,26 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/electron/pnpm-lock.yaml
# electron-builder shells out to dpkg-deb through fakeroot for the .deb.
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
# to rpmbuild for the .rpm.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
# runner the .deb and .rpm are built with one installed from RubyGems.
- name: Use a native fpm on ARM64 Linux
if: runner.os == 'Linux' && runner.arch == 'ARM64'
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm -v 1.16.0
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
- name: Build the SPA
working-directory: desktop/frontend-next
@@ -272,7 +439,8 @@ jobs:
working-directory: desktop/electron
env:
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
run: pnpm install --frozen-lockfile && pnpm run build:host
REASONIX_VERSION: v0.0.0-ci
run: pnpm install --frozen-lockfile && pnpm test && pnpm run build:host
# The .deb's absolute-path payload. A no-op off Linux, so it is not
# guarded here: the one place it must not be forgotten is the one place
@@ -288,7 +456,7 @@ jobs:
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }} -c.extraMetadata.version=0.0.0-ci
# The release vocabulary is GOARCH. electron-builder's own is not, and the
# rename that reconciles them runs here rather than in the manifest.
@@ -314,6 +482,27 @@ jobs:
test -n "$dir"
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
# An update starts the installer with --updated, and that is what keeps the
# shortcuts (and the taskbar pin hanging off them) from being deleted and
# recreated. Installed twice over itself the way an update does it, the
# desktop shortcut has to still be the same file, pointing at a path with
# no version in it, or every update costs the user their pin.
- name: An update keeps the shortcuts and they name no version
if: matrix.platform == 'windows'
shell: pwsh
run: |
$installer = (Get-ChildItem dist -Filter 'ReasonixStudio-windows-*-installer.exe' | Select-Object -First 1).FullName
$dir = Join-Path $env:RUNNER_TEMP 'rx-update-guard'
Start-Process $installer -ArgumentList '/S', "/D=$dir" -Wait
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Reasonix Studio.lnk'
$target = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath
if ($target -ne (Join-Path $dir 'Reasonix Studio.exe')) { throw "the desktop shortcut targets $target, not the stable executable" }
$mark = [datetime]'2020-01-01'
(Get-Item $lnk).LastWriteTime = $mark
Start-Process $installer -ArgumentList '/S', '--updated', "/D=$dir" -Wait
if ((Get-Item $lnk).LastWriteTime -ne $mark) { throw 'an update rewrote the desktop shortcut' }
Start-Process (Join-Path $dir 'Uninstall Reasonix Studio.exe') -ArgumentList '/S', '/currentuser' -Wait
# The release packages a signed copy of the bundle and then reads both
# packages back against it. The same recipe on an unsigned copy, so the
# packaging contract (the installer's one application archive, the pinned
@@ -369,7 +558,7 @@ jobs:
- name: Keep the arm64 installer for the x64 refusal check
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-arm64-installer-ci
path: dist/ReasonixStudio-windows-arm64-installer.exe
@@ -402,47 +591,13 @@ jobs:
echo "page: ${page:-<missing>}"
test -n "$host" -a -n "$page"
# Read off the package rather than off the config that describes it. Both
# of these went wrong the same way once: electron-builder names a package
# after package.json unless told, and fpm records the owner it finds on
# disk. Either one leaves a Studio that cannot update itself on Linux and
# says nothing about it until a user asks.
- name: The .deb is Studio's own package and carries what updates it
- name: Linux packages carry their declared architecture and update payload
if: runner.os == 'Linux'
run: |
deb=$(ls dist/ReasonixStudio-linux-*.deb)
name=$(dpkg-deb -f "$deb" Package)
if [ "$name" != "reasonix-studio" ]; then
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
exit 1
fi
dpkg-deb -c "$deb" > contents.txt
# The path is the polkit action's exec.path annotation, so it is the
# only one authorization is granted for; root because polkit will not
# run a helper the invoking user could have written. Either spelling
# of root: dpkg-deb prints the numeric ids it cannot map, and which
# one it prints is a property of the machine reading the package
# rather than of the package.
if ! grep -qE '^-rwxr-xr-x (root/root|0/0) +[0-9]+ .* \./usr/lib/reasonix-studio/reasonix-studio-update-helper$' contents.txt; then
echo "::error::no root-owned executable update helper at the path the polkit action names"
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
exit 1
fi
if ! grep -q './usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
echo "::error::the polkit action is not in the package, so pkexec has nothing to authorize against"
exit 1
fi
run: bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "dist/ReasonixStudio-linux-${{ matrix.arch }}.rpm" "${{ matrix.arch }}"
# The Electron shell sets the package's glibc floor; a Go binary linked
# with cgo would raise it to the runner's own.
- name: The .deb's Go binaries are statically linked
- name: The Linux package installs, runs, reinstalls and uninstalls
if: runner.os == 'Linux'
run: |
deb=$(ls dist/ReasonixStudio-linux-*.deb)
dpkg-deb -x "$deb" "$RUNNER_TEMP/deb"
host=$(find "$RUNNER_TEMP/deb" -type f -path '*/resources/bin/reasonix-studio-host')
test -n "$host"
node desktop/electron/packaging/elf.js "$host" "$RUNNER_TEMP/deb/usr/lib/reasonix-studio/reasonix-studio-update-helper"
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
# Recorded, not asserted: the real bundle layout, so later work argues
# from what electron-builder produced rather than from its documentation.
@@ -480,7 +635,7 @@ jobs:
if: needs.scope.outputs.arm64 == 'true'
runs-on: windows-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-arm64-installer-ci
path: installer
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Update README contributor tables
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Test chart generator
@@ -0,0 +1,84 @@
name: Withdraw a Studio version
# Pulls a published Studio release out of the update catalog (or puts it back),
# so installs stop being offered it without waiting for a new release. Only the
# catalog changes: the release's files stay where they are, and a withdrawn
# release can be restored with the same run.
on:
workflow_dispatch:
inputs:
version:
description: "Version to withdraw or restore, for example 2.36.0"
required: true
type: string
reason:
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
required: false
type: string
restore:
description: "Put a withdrawn version back instead of withdrawing it"
required: false
type: boolean
default: false
concurrency:
group: studio-catalog
cancel-in-progress: false
jobs:
withdraw:
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: studio-release
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: studio
persist-credentials: false
- name: Update the Studio catalog
env:
VERSION: ${{ inputs.version }}
REASON: ${{ inputs.reason }}
RESTORE: ${{ inputs.restore }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
exit 1
fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::version must look like 2.36.0"
exit 1
fi
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
current=/tmp/studio-versions-current.json
next=/tmp/studio-versions-next.json
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
if [ "$RESTORE" = "true" ]; then
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
else
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
fi
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
- name: Keep the GitHub release off the latest slot
if: ${{ !inputs.restore }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
+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
+5 -5
View File
@@ -59,7 +59,7 @@
## ❤️Sponsor
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
## What it is
@@ -151,7 +151,7 @@ pre-releases):
| --- | --- | --- |
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
| Linux | `.deb` | x64 |
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
Every package ships with a `.minisig` signature, and the release carries
`SHA256SUMS`. Once installed, Studio updates itself in place.
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+5 -5
View File
@@ -57,7 +57,7 @@
## ❤️赞助商
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
## 它是什么
@@ -139,7 +139,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| --- | --- | --- |
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
| Linux | `.deb` | x64 |
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
应用内自行更新。
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+3 -2
View File
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/ext/pluginpkg/**
- sensitive: internal/base/netclient/**
- sensitive: internal/safety/redirectguard/**
- sensitive: internal/safety/endpointclient/**
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
@@ -402,8 +403,8 @@ observation, never by a check that ran afterwards.
What follows bounds Observable today. It is not a restatement of the invariant:
moving a number here must not move the promise.
- `scanWorkspace` walks the workspace recursively, skips VCS store dirs, and is
`complete` only below 50k files. Over that, or past a walk error, it
- `scanWorkspace` walks the workspace and every granted extra root recursively,
skips VCS store dirs, and is `complete` only below 50k files in total. Over that, or past a walk error, it
establishes nothing and the mutation stays unproven.
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
the walk compares. Confining the resolved target is the sandbox's job.
+1 -1
View File
@@ -420,7 +420,7 @@ own outcome).
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (complete tasks that passed every trial) and `pass@N` (complete tasks with any pass), both over the tasks that ran all N trials only (`n/a` when none did), mean `pass@1` over every graded trial, and the flaky tasks by name; a task the token budget cut short is listed on an **Incomplete** line and is in neither. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR/<task>/trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
| `-bin` | `reasonix` | Path to the reasonix binary. |
@@ -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
}
}
]
}
]
+21 -10
View File
@@ -22,17 +22,28 @@ addresses by hand at roughly nine times the page-in cost (139 vs 1292
tokens/task). Measured after three separate leaks were closed, so the numbers
are from a clean environment.
**Fold index marginal utility: unresolved.** Both the positive and the negative
estimates are invalidated. The first Stage 2 ran in a contaminated environment.
The clean paired batch found a consistent effect (six of six tasks searched less
and paged in less with the cue visible), and the same-batch dose-response did
not reproduce it (`boundary-aligned 0/4`, recall-token delta reversing sign).
The most likely reason is that two of the six index tasks carry heavy-tail
stopping behaviour that swamps the effect being measured.
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
cue visible the index addresses the target, so the same task is searched less and pages in
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
absent).
Existing evidence is not sufficient to tune the shipped 1% budget in either
direction. Reopening this wants a new corpus, not more samples: that substrate
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
together 80% of the absent-cue total), and the qualification above does not screen for
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
sample after seeing the result; a batch screened on stopping stability is what would make
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
prompt tokens over three escapes).
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
the token totals here cover the 24-run index batch — the paired batch ran before the
per-run usage line existed.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
@@ -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)
}
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// batchInfo is what the mode itself knows before any run happens. Everything
// else in the manifest is derived from the runs that were written, so a figure
// in it cannot drift from the artifact beside it.
type batchInfo struct {
Mode string
Planned int
Started time.Time
Dry bool
// Repo is the checkout the batch ran from, captured before the harness
// isolates its workspace: git cannot describe a directory that is gone.
Repo string
}
// batchManifest is written beside results.jsonl so a later reader does not have
// to reconstruct what the batch was from a terminal scrollback.
type batchManifest struct {
SchemaVersion int `json:"schema_version"`
Mode string `json:"mode"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
GitCommit string `json:"git_commit"`
GitDirty *bool `json:"git_dirty,omitempty"`
Model string `json:"model"`
Window int `json:"window"`
Generations int `json:"generations"`
TaskIDs []string `json:"task_ids"`
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.
UsageReportedRuns int `json:"usage_reported_runs"`
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
ResultsSHA256 string `json:"results_sha256"`
TrajectoriesSHA256 string `json:"trajectories_sha256"`
}
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
// rather than omitted, so an absent field is never mistaken for a clean tree.
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
commit = "unknown"
if repo == "" {
return commit, nil
}
in := func(args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = repo
return cmd.Output()
}
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
commit = strings.TrimSpace(string(out))
}
if out, err := in("status", "--porcelain"); err == nil {
state := strings.TrimSpace(string(out)) != ""
dirty = &state
}
return commit, dirty
}
func fileSHA256(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
}
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// writeBatchManifest records the batch beside the runs it describes. It runs
// 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, 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 {
reported++
requests += m.UsageReportedRequests
prompt += m.PromptTokens
completion += m.CompletionTokens
cacheHit += m.CacheHitTokens
cacheMiss += m.CacheMissTokens
estimated = estimated || m.UsageEstimated
}
}
commit, dirty := gitCommitAndDirty(info.Repo)
model := runModel
if info.Dry {
model = "scripted"
}
manifest := batchManifest{
SchemaVersion: 1, Mode: info.Mode,
StartedAt: info.Started.UTC().Format(time.RFC3339),
FinishedAt: time.Now().UTC().Format(time.RFC3339),
GitCommit: commit, GitDirty: dirty,
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
PlannedRuns: info.Planned, CompletedRuns: len(all) - failed, FailedRuns: failed,
UsageReportedRuns: reported, UsageReportedRequests: requests,
PromptTokens: prompt, CompletionTokens: completion,
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
}
var err error
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
return err
}
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
}
@@ -0,0 +1,91 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func seedArtifacts(t *testing.T, root string) {
t.Helper()
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
func readManifest(t *testing.T, root string) (batchManifest, string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
if err != nil {
t.Fatal(err)
}
var got batchManifest
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
return got, string(data)
}
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{
{Task: "b", Arm: "index-off"},
{Task: "a", Arm: "index-off"},
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
t.Fatal(err)
}
got, _ := readManifest(t, root)
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
t.Fatalf("manifest header = %+v", got)
}
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
}
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
t.Fatalf("usage totals = %+v", got)
}
// No repo means git could not describe the checkout: unknown commit, and no
// dirty flag at all, because "clean" is a claim this batch cannot make.
if got.GitCommit != "unknown" || got.GitDirty != nil {
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
}
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
}
}
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
// an unmeasured spend must not read as a measured one.
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
t.Fatal(err)
}
got, raw := readManifest(t, root)
if got.UsageReportedRuns != 0 {
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
}
if got.Model != "scripted" {
t.Fatalf("dry batch model = %q, want scripted", got.Model)
}
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
if strings.Contains(raw, absent) {
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
}
}
}
@@ -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)
}
}
+25 -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
@@ -89,6 +91,15 @@ type contextMetrics struct {
// resident prefix; this is what is paid dynamically instead.
RecallReturnedTokens int `json:"recall_returned_tokens"`
// Provider usage as reported on the stream, or empty for a dry run. The count
// is completions that reported usage, so it is not a count of attempts.
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
Searches []searchAttempt `json:"searches,omitempty"`
// PostHitSearches are queries issued after the target had already been
// returned: not a query problem, a result-to-read problem.
@@ -107,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.
@@ -453,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]++
@@ -622,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)
}
+3 -3
View File
@@ -54,9 +54,9 @@ func main() {
case "preflight":
os.Exit(runPreflight(tasks, root))
case "run-search":
os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
case "run-boundary":
os.Exit(runBoundaries(root))
os.Exit(runBoundaries(*mode, root))
case "snippet":
os.Exit(runSnippetAudit(root, 100))
case "adversarial":
@@ -69,7 +69,7 @@ func main() {
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
@@ -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++
+62 -12
View File
@@ -14,7 +14,6 @@ import (
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
@@ -86,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 {
@@ -132,10 +132,11 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
}
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
var usage usageSink
a := agent.New(p, reg, sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, event.Discard)
}, &usage)
a.LoadProjectionSidecar(f.Path)
// From here the transcript lives only in memory. On disk it is the answer.
sealFixture(f.Path)
@@ -161,6 +162,10 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
appended := sess.Snapshot()[before:]
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
m.trajectory = appended
if u, requests := usage.snapshot(); requests > 0 {
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
}
m.scoreAnswer(finalAnswer(appended), inst)
if runErr != nil {
m.FailureStage = "RunError"
@@ -169,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) {
@@ -212,7 +227,10 @@ func isolateWorkspace() (func(), error) {
// The two arms get separate nonces: reusing one would leave the first arm's
// answer in a host-readable history for the second to find.
func runBoundaries(root string) int {
func runBoundaries(mode, root string) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -239,9 +257,11 @@ func runBoundaries(root string) int {
has bool
}{{cue, true}, {noCue, false}} {
name := "index-" + side.scale
planned++
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 {
@@ -249,14 +269,14 @@ func runBoundaries(root string) int {
} else {
noCueSide = append(noCueSide, m)
}
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d escape=%d\n",
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
m.CueDirectRead, m.RecallReturnedTokens, m.EscapeCalls)
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
}
}
reportDeltas(cueSide, noCueSide)
fmt.Print(reportStopping(all))
return writeResults(root, all)
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
}
// reportDeltas is the paired reading: each task against itself, then pooled.
@@ -298,7 +318,10 @@ func deltaBool(a, b bool) string {
// runExperiment runs one experiment's tasks across its arms. A dry run drives
// the same pipeline with a scripted provider: it proves fixture, run, scoring
// and report hold together before any of it is paid for.
func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -334,15 +357,17 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
// test: a bad minute would land entirely on one arm.
for _, arm := range shuffledArms(t) {
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
planned++
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)
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d %v\n",
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
m.RecallReturnedTokens, m.UnexpectedWorkTools)
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
}
}
reportFunnels(byArm)
@@ -355,7 +380,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
if experiment == experimentIndex {
reportBoundaries(all)
}
return writeResults(root, all)
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
}
func cueExpectedVisible(t contextTask, scale string) bool {
@@ -410,7 +435,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
return nil
}
func writeResults(root string, all []contextMetrics) int {
func writeResults(root string, info batchInfo, all []contextMetrics) int {
path := filepath.Join(root, "results.jsonl")
f, err := os.Create(path)
if err != nil {
@@ -418,17 +443,30 @@ func writeResults(root string, 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)
return 2
}
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
if err := writeBatchManifest(root, info, all); err != nil {
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
return 2
}
// 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
}
@@ -437,9 +475,21 @@ func writeResults(root string, 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",
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"fmt"
"sync"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// usageSink reads what a run cost from the events the agent already emits. Every
// billed completion reports event.Usage — the round path, compaction, command
// routing and failure diagnosis all do — so the bench reads the same record the
// product reads instead of wrapping a provider and counting a second time.
type usageSink struct {
mu sync.Mutex
requests int
usage provider.Usage
}
func (s *usageSink) Emit(e event.Event) {
if e.Kind != event.Usage || e.Usage == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
// One event is one completion that reported usage; RequestCount covers a
// completion that merged attempts. A request that failed before reporting
// leaves no event, so this is not a count of attempts.
if e.Usage.RequestCount > 0 {
s.requests += e.Usage.RequestCount
} else {
s.requests++
}
u := *e.Usage
s.usage.PromptTokens += u.PromptTokens
s.usage.CompletionTokens += u.CompletionTokens
s.usage.TotalTokens += u.TotalTokens
s.usage.CacheHitTokens += u.CacheHitTokens
s.usage.CacheMissTokens += u.CacheMissTokens
s.usage.ReasoningTokens += u.ReasoningTokens
s.usage.Estimated = s.usage.Estimated || u.Estimated
}
func (s *usageSink) snapshot() (provider.Usage, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.usage, s.requests
}
// promptTokenSuffix renders what a run cost only when something reported it. A
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
// measured zero rather than as no measurement.
func promptTokenSuffix(m contextMetrics) string {
if m.UsageReportedRequests == 0 {
return ""
}
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
}
@@ -0,0 +1,54 @@
package main
import (
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// The cost a run reports must be what the agent reported, summed across every
// completion it billed — including one that merged attempts and one that could
// only estimate. Any other event kind is not a cost.
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{}) // a kind that is not Usage
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
}})
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
}})
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
usage, requests := sink.snapshot()
if requests != 3 {
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
}
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
t.Fatalf("accumulated usage = %+v", usage)
}
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
t.Fatalf("cache split = %+v", usage)
}
if !usage.Estimated {
t.Fatal("Estimated was dropped when one completion reported an estimate")
}
}
// A dry run must not claim a cost it never paid, in the JSON or in the line a
// person reads: an empty suffix, not "prompt-tok=0".
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{})
usage, requests := sink.snapshot()
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
}
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
t.Fatalf("a run with no reported usage still printed %q", suffix)
}
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
t.Fatalf("a reported cost printed %q", suffix)
}
}
+87
View File
@@ -0,0 +1,87 @@
# Desktop surface evaluation
This protocol supplies the fixed-task trajectory comparison required by
[RFC #12584](https://github.com/esengine/DeepSeek-Reasonix/issues/12584).
There are no measured results yet. Component tests and scripted providers prove
delivery, validation and replay; they do not measure a model's choice of interface
or a person's effort.
## Paired runs
Run each task twice in fresh desktop sessions: ordinary text/ask interaction and
native surfaces. Use the same model, sampling settings, initial workspace,
permission mode and task prompt. Record commit, model, configuration and the
native-surface capability setting. Alternate condition order across repetitions.
Keep the hidden tool out of the provider prefix in both conditions; only its
availability through capability discovery differs.
The baseline may batch questions or accept a single text reply. Never force it to
ask one question per field. Do not tell either model how many turns to use or
which component to render. If the surface-enabled model chooses text, retain
that run in the surface condition. Interface choice is part of the experiment.
The operator knows the private values below but does not volunteer them before
the model requests the corresponding information. Supply all requested values
together, regardless of presentation. Allow inspection and local editing before
submission. Save the trajectory and an observer ledger locally; raw transcripts
and draft content must not enter the aggregate report.
## Fixed tasks
| Task | Initial prompt, identical in both conditions | Private operator input | Completion criterion |
| --- | --- | --- | --- |
| `release-brief` | Prepare a release brief. Ask me for the audience, release date and whether this is a staged rollout before writing it. | audience: existing desktop users; date: 2026-11-03; staged rollout: yes | Final brief contains all three values and adds no release commitment. |
| `storage-choice` | Help me choose storage for a small desktop app. Present reasonable choices, ask me to choose and ask whether offline operation is required. Then summarize the decision. | storage: SQLite; offline: yes | Final decision states SQLite and offline operation, with no installation or file changes. |
| `draft-revision` | Draft a two-line release announcement and let me revise it before returning the final version verbatim. | Replace with the exact string ` Desktop release ready.\nKeep existing settings.\n` (decode the escapes). | Final returned string has exactly the specified leading spaces and newlines. Formatting wrappers are excluded from the comparison. |
| `preview-decision` | Show a plain-text before/after preview changing retry count from 2 to 3. Ask whether to keep that change and whether to include a release note. Return a decision summary; do not change files. | keep change: yes; release note: no | Preview shows 2 to 3; final summary preserves both decisions; workspace is unchanged. |
Freeze this task set before collecting results. Record a new protocol version if
prompts, private inputs, completion criteria or metric definitions change.
Neither the model's final claim nor a successful input acknowledgment establishes
completion. The observer checks the resulting output against the criterion.
## Metric definitions (version 1)
The anchor is acknowledgment of the initial task prompt. The observation ends
when the criterion is verified, the operator abandons the task, or the run fails.
The initial prompt is excluded from subsequent-input counts because both
conditions start from the same request.
| Metric | Count and population | Exclusions |
| --- | --- | --- |
| `acknowledged_inputs` | Distinct operator submissions acknowledged after the anchor: chat replies, ask answers, surface answers and surface actions. Includes acknowledged input later withdrawn or superseded. | Initial task prompt, local draft edits, retries with the same acknowledged request identity, automatic dispatch, replay and observer grading. This counts input exchanges, not provider rounds. |
| `submission_attempts` | Explicit operator send attempts, including rejected submissions and manual retries. | Automatic transport retries, replay, local edits and model tool calls. |
| `withdrawals` | Successful operator withdrawals of admitted input. | Failed withdrawals, automatic cancellation and replay. |
| `completed` | Runs whose output meets the fixed criterion. Denominator: every started run in each condition. | None; failures and abandoned runs remain in the denominator. |
Record these in a content-free ledger: task id, repetition, condition, observer
event ordinal, event type, opaque request identity, acknowledgment status and
completion verdict. Record the source trajectory reference and observed start
and end anchors alongside it. Distinguish manual observation from host-recorded
events. Do not copy labels, prompts, private values, drafts or answer strings into
the ledger. Identities are local correlation handles, not account identifiers.
The current pane counters and rendered trajectory export cannot reconstruct all
acknowledgments, refusals and withdrawals. Do not infer them from `ask` calls,
tool-result counts, final prose, message wording or card mounts. Until structural
acknowledgment records cover every channel, an observer ledger is required.
## Report and decision
Report the number started, completed, failed and abandoned for each condition,
then paired input-count differences for repetitions where **both** conditions
meet the same criterion. List excluded pairs and their reason beside that
comparison. Also report submission attempts and withdrawals; fewer admitted
inputs with more failures is not automatically less effort.
Keep per-task results visible. Do not combine scripted fixtures with observed
model/operator runs or describe this small task set as an account-wide result.
Report model/configuration differences and missing evidence as limitations.
The RFC's benefit remains unproven until observed trajectories show fewer input
exchanges to the same verified outcome. These results alone do not justify P3;
adaptive layout or MCP Apps needs a measured problem in addition to this result.
The owner-requested map, comparison and dashboard workspaces extend the delivery
scope independently of this study. Their scripted acceptance fixtures verify
rendering and interaction only; they do not count as observed benefit evidence.
+5
View File
@@ -203,6 +203,11 @@ func answerLeakDetected(r result) bool {
}
func recordTaskTrajectory(r *result, path string, t task) {
if r.SFT != nil && r.Segments <= 1 {
if err := bindSFTTrajectory(path, r.SFT); err != nil {
r.Note = appendNote(r.Note, "SFT provenance: "+err.Error())
}
}
if summary, err := summarizeTrajectory(path); err == nil {
r.Trajectory = summary
}
+218
View File
@@ -0,0 +1,218 @@
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)
}
}
func TestCorrectBoundaryMediansExcludeRunsWithNoRecordedSplit(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
measured := result{
task: task{ID: "measured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1, RoundsBeforeCorrect: 3, RoundsAfterCorrect: 4,
CallsAfterCorrect: 5, VerifyAfterCorrect: 2,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
// the same run twice, but -trajectories was omitted: no digest, so no split
// was ever taken and the before/after tallies stay at their zero value.
unmeasured := result{
task: task{ID: "unmeasured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1,
}
unmeasured2 := unmeasured
unmeasured2.task = task{ID: "unmeasured-2"}
// The audit read the rendered line as plain text; the renderer emits
// markdown emphasis around each label, so compare with it stripped.
const want = "**rounds before** 3 · **rounds after** 4"
got := renderSolveProfiles([]result{measured, unmeasured, unmeasured2})
if !strings.Contains(got, want) {
t.Fatalf("medians folded in a run whose trajectory was never split:\n%s\n"+
"want %q (the measured run only)", got, want)
}
}
// Control (a): when every run recorded its split, the exclusion must not touch
// a single number — the medians stay the measured ones.
func TestCorrectBoundaryMediansAllMeasuredUnchanged(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
mk := func(id string, before, after, verify, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
RoundsBeforeCorrect: before, RoundsAfterCorrect: after,
VerifyAfterCorrect: verify, MutationsBeforeCorrect: mutations,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
}
got := renderSolveProfiles([]result{mk("a", 2, 6, 1, 0), mk("b", 3, 4, 2, 1), mk("c", 4, 5, 3, 2)})
const want = "**mutations before** 1 · **rounds before** 3 · **rounds after** 5 · **verifications after** 2"
if !strings.Contains(got, want) {
t.Fatalf("all-measured medians changed:\n%s\nwant %q", got, want)
}
}
// Control (b): with -trajectories off no split was ever taken for any run, so
// there is nothing to average. Printing zeros would publish them as
// observations; the report must say the boundary is unmeasured instead.
func TestCorrectBoundaryWithNoRecordedSplitSaysNothingToAverage(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
run := func(id string, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: mutations,
}
}
got := renderSolveProfiles([]result{run("a", 1), run("b", 3)})
if strings.Contains(got, "rounds before") || strings.Contains(got, "verifications after") {
t.Fatalf("a boundary that was never split printed as a measurement:\n%s", got)
}
if !strings.Contains(got, "**mutations before** 3") {
t.Fatalf("the checkpoint-derived mutation count is measured without a trajectory and must still be printed:\n%s", got)
}
if !strings.Contains(got, "nothing to average") {
t.Fatalf("the report must say the boundary was never measured:\n%s", got)
}
}
+19 -7
View File
@@ -195,7 +195,7 @@ func renderSolveProfiles(results []result) string {
}
line := "**Solve profile**: "
parts := []string{}
regressed, withCorrect := 0, 0
regressed, withCorrect, split := 0, 0, 0
var roundsBefore, roundsAfter, verifyAfter, mutationsBefore []int64
for _, r := range results {
if len(r.Checkpoints) == 0 {
@@ -203,10 +203,16 @@ func renderSolveProfiles(results []result) string {
}
if r.FirstCorrectMs > 0 {
withCorrect++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
// Fold in only runs whose trajectory was read and split (Trajectory
// is set exactly when -trajectories ran): a run without one has no
// measurement, and its zeros are absence, not a zero.
mutationsBefore = append(mutationsBefore, int64(r.MutationsBeforeCorrect))
if r.Trajectory != nil {
split++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
}
}
if r.RegressedAfterCorrect {
regressed++
@@ -247,9 +253,15 @@ func renderSolveProfiles(results []result) string {
ttfum = append(ttfum, r.FirstUsefulMs)
}
}
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
if split == 0 {
// The split counts are absent; the checkpoint-derived ones are not.
line += fmt.Sprintf("\n\n**Correct boundary**: **mutations before** %d · **regression-after-correct** %s — nothing to average: the runs reached a correct state, but none recorded a trajectory, so the boundary was never split",
median(mutationsBefore), pct(regressed, withCorrect))
} else {
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
}
if len(ttfum) > 0 {
line += fmt.Sprintf(" · **TTFUM median** %s", dur(median(ttfum)))
}
+534
View File
@@ -0,0 +1,534 @@
package main
import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/base/fileutil/encoding"
)
// benchmarksRoot holds every committed task corpus. Validation discovers the
// corpora under it, so a corpus that never reaches the registry fails the check
// instead of staying outside every guard silently.
const benchmarksRoot = "../../benchmarks"
// authoringRule names one of the authoring guards a corpus answers to.
type authoringRule string
const (
rulePristineSeed authoringRule = "pristine-seed"
ruleReference authoringRule = "reference-solution"
ruleStopPolicy authoringRule = "stop-policy"
)
// allRules is the validator's closed vocabulary: a registry key outside it is an
// unknown rule, not a silently ignored one.
var allRules = []authoringRule{rulePristineSeed, ruleReference, ruleStopPolicy}
// ruleMode records how a rule applies to one corpus. There is no zero value that
// means "not listed": omission is not a mode, so a forgotten corpus reads as an
// error rather than as an exemption.
type ruleMode string
const (
// ruleEnforce applies the whole rule.
ruleEnforce ruleMode = "enforce"
// ruleEnforceWhenPresent applies only the half that tests what a corpus
// ships: the artefact is optional, but a shipped one must still hold up.
// Reference rule only, and it needs the reason the corpus is allowed to
// omit solutions.
ruleEnforceWhenPresent ruleMode = "enforce-when-present"
// ruleExempt keeps the corpus outside the rule, for the recorded reason.
ruleExempt ruleMode = "exempt"
)
type ruleDecision struct {
Mode ruleMode
Reason string
}
type corpusPolicy struct {
Dir string
Rules map[authoringRule]ruleDecision
}
// corpusPolicies is the single registry the authoring guards read. Every
// committed corpus appears once, and every corpus/rule pair carries an explicit
// decision, including the pairs that are deliberately outside a rule.
func corpusPolicies() []corpusPolicy {
return []corpusPolicy{
{
Dir: corpusDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforceWhenPresent,
Reason: "the e2e suite does not commit reference solutions, so a task without one is skipped, not failed"},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: verificationStressDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: trainCorpusDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: memorybenchDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleExempt,
Reason: "memorybench ships task-local memory facts instead of solution/ fixtures; TestMemorybenchGradersAcceptTheReferenceAnswer supplies a task-derived witness for every task and checks every grader accepts it"},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: fanoutWidthDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: upstreamEdgeDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: projectCheckDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
}
}
// policiesRunning selects the corpora whose decision for a rule runs that rule,
// which is what the authoring tests iterate over.
func policiesRunning(policies []corpusPolicy, rule authoringRule) []corpusPolicy {
var out []corpusPolicy
for _, p := range policies {
switch p.Rules[rule].Mode {
case ruleEnforce, ruleEnforceWhenPresent:
out = append(out, p)
}
}
return out
}
// committedCorpora lists the directories under root that carry tasks/.
func committedCorpora(root string) ([]string, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if dirExists(filepath.Join(root, e.Name(), "tasks")) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out, nil
}
// validateCorpusPolicies checks a registry against the corpora committed under
// root and returns one actionable error per problem. Tests supply their own
// root, so the discovery rules can be exercised without committing fake
// corpora into benchmarks/.
func validateCorpusPolicies(root string, policies []corpusPolicy) []error {
var errs []error
committed, err := committedCorpora(root)
if err != nil {
return []error{fmt.Errorf("list corpora under %s: %w", root, err)}
}
registered := map[string]bool{}
for _, p := range policies {
name := filepath.Base(p.Dir)
if registered[name] {
errs = append(errs, fmt.Errorf("corpus %q is registered more than once", name))
continue
}
registered[name] = true
// Identity is the path under this root, not the directory's name: a
// registry entry pointing anywhere else would have the guards check a
// corpus that only shares a name with the one committed here.
want := filepath.Clean(filepath.Join(root, name))
if got := filepath.Clean(p.Dir); got != want {
errs = append(errs, fmt.Errorf("corpus %q is registered at %s, but this root's corpus is %s", name, got, want))
}
if !dirExists(filepath.Join(p.Dir, "tasks")) {
errs = append(errs, fmt.Errorf("corpus %q is registered but has no tasks/ directory at %s", name, p.Dir))
}
for rule := range p.Rules {
if !slices.Contains(allRules, rule) {
errs = append(errs, fmt.Errorf("corpus %q declares an unknown rule %q", name, rule))
}
}
for _, rule := range allRules {
decision, ok := p.Rules[rule]
if !ok {
errs = append(errs, fmt.Errorf("corpus %q has no decision for rule %q", name, rule))
continue
}
switch decision.Mode {
case ruleEnforce:
// The full rule applies; no reason is needed or read.
case ruleEnforceWhenPresent:
if rule != ruleReference {
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q, which only applies to %q",
name, decision.Mode, rule, ruleReference))
}
if strings.TrimSpace(decision.Reason) == "" {
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q without the reason that allows it",
name, decision.Mode, rule))
}
case ruleExempt:
if strings.TrimSpace(decision.Reason) == "" {
errs = append(errs, fmt.Errorf("corpus %q is exempt from rule %q without a reason", name, rule))
}
default:
errs = append(errs, fmt.Errorf("corpus %q declares an unknown mode %q for rule %q", name, decision.Mode, rule))
}
}
}
for _, name := range committed {
if !registered[name] {
errs = append(errs, fmt.Errorf("corpus %q is committed under %s but is not registered, so no authoring guard covers it", name, root))
}
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
return errs
}
// taskBounds is a task's own declaration of its bounds. Pointers keep a missing
// key distinct from a declared zero.
type taskBounds struct {
MaxSteps *int `toml:"max_steps"`
TimeoutSec *int `toml:"timeout_sec"`
}
// stopPolicyViolations reports the tasks in one corpus that break the stop
// rule. It reads each task.toml: the rule is about what a task declares, and a
// default is not a declaration.
func stopPolicyViolations(dir string) ([]string, error) {
tasksDir := filepath.Join(dir, "tasks")
entries, err := os.ReadDir(tasksDir)
if err != nil {
return nil, err
}
var out []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
id := entry.Name()
data, err := fileencoding.ReadFileUTF8(filepath.Join(tasksDir, id, "task.toml"))
if err != nil {
return nil, fmt.Errorf("%s: %w", id, err)
}
var bounds taskBounds
if _, err := toml.Decode(string(data), &bounds); err != nil {
return nil, fmt.Errorf("%s: %w", id, err)
}
if bounds.MaxSteps != nil && *bounds.MaxSteps > 0 {
out = append(out, fmt.Sprintf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
id, *bounds.MaxSteps))
}
switch {
case bounds.TimeoutSec == nil:
out = append(out, fmt.Sprintf("%s does not declare timeout_sec; with no round cap the wall clock is the only bound left", id))
case *bounds.TimeoutSec <= 0:
out = append(out, fmt.Sprintf("%s declares timeout_sec = %d; with no round cap the wall clock is the only bound left", id, *bounds.TimeoutSec))
}
}
sort.Strings(out)
return out, nil
}
// The registry must describe every committed corpus, and every corpus/rule pair
// in it must be a decision the validator understands.
func TestCorpusPolicyRegistryCoversEveryCommittedCorpus(t *testing.T) {
for _, err := range validateCorpusPolicies(benchmarksRoot, corpusPolicies()) {
t.Error(err)
}
committed, err := committedCorpora(benchmarksRoot)
if err != nil {
t.Fatal(err)
}
if len(committed) == 0 {
t.Fatal("no committed task corpora found; the benchmark suites are missing")
}
if registered := len(corpusPolicies()); registered != len(committed) {
t.Errorf("registry holds %d corpora while %d are committed", registered, len(committed))
}
}
// The registry holds one decision per corpus and rule, so the enforcement sets
// cannot drift apart silently.
func TestCorpusPolicyEnforcementSets(t *testing.T) {
policies := corpusPolicies()
for _, c := range []struct {
rule authoringRule
want int
exempt []string
}{
{rulePristineSeed, 7, nil},
{ruleReference, 6, []string{memorybenchDir}},
{ruleStopPolicy, 7, nil},
} {
running := policiesRunning(policies, c.rule)
if len(running) != c.want {
t.Errorf("rule %q runs for %d corpora, want %d", c.rule, len(running), c.want)
}
for _, dir := range c.exempt {
for _, p := range running {
if p.Dir == dir {
t.Errorf("rule %q runs for %s, which the registry exempts", c.rule, filepath.Base(dir))
}
}
found := false
for _, p := range policies {
if p.Dir == dir && p.Rules[c.rule].Mode == ruleExempt {
found = true
}
}
if !found {
t.Errorf("%s is not recorded as exempt from rule %q", filepath.Base(dir), c.rule)
}
}
}
}
// tempCorpus lays a minimal corpus under root and returns its path, so negative
// cases run in a temporary universe instead of as fake corpora in benchmarks/.
func tempCorpus(t *testing.T, root, name, task, taskToml string) string {
t.Helper()
dir := filepath.Join(root, name)
taskDir := filepath.Join(dir, "tasks", task)
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
t.Fatal(err)
}
if taskToml != "" {
if err := os.WriteFile(filepath.Join(taskDir, "task.toml"), []byte(taskToml), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func fullDecisions() map[authoringRule]ruleDecision {
return map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
}
}
// Every way a registry can fall out of step with the tree is reported against
// the corpus and rule it concerns.
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
t.Helper()
root := t.TempDir()
var policies []corpusPolicy
for _, name := range corpora {
dir := tempCorpus(t, root, name, "t-"+name, "")
policies = append(policies, corpusPolicy{Dir: dir, Rules: fullDecisions()})
}
return root, policies
}
cases := []struct {
name string
universe func(t *testing.T) (string, []corpusPolicy)
want string
}{
{
name: "unregistered corpus",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
tempCorpus(t, root, "forgotten", "t-forgotten", "")
return root, policies
},
want: "forgotten",
},
{
name: "registry entry whose tasks/ is gone",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
if err := os.RemoveAll(filepath.Join(root, "registered")); err != nil {
t.Fatal(err)
}
return root, policies
},
want: "no tasks/ directory",
},
{
name: "corpus with no decision for a rule",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
delete(policies[0].Rules, ruleStopPolicy)
return root, policies
},
want: string(ruleStopPolicy) + "\"",
},
{
name: "exemption without a reason",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleExempt}
return root, policies
},
want: "exempt from rule \"" + string(ruleReference) + "\" without a reason",
},
{
name: "enforce-when-present without a reason",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleEnforceWhenPresent}
return root, policies
},
want: "without the reason that allows it",
},
{
name: "unknown mode",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: "sometimes"}
return root, policies
},
want: "unknown mode",
},
{
name: "a registry entry that shadows the root's corpus by name",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
policies[0].Dir = elsewhere
return root, policies
},
want: "but this root's corpus is",
},
{
name: "unknown rule",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules["vibes"] = ruleDecision{Mode: ruleEnforce}
return root, policies
},
want: "unknown rule",
},
{
name: "enforce-when-present outside the reference rule",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: ruleEnforceWhenPresent, Reason: "because"}
return root, policies
},
want: "only applies to",
},
{
name: "a fully declared universe is accepted",
universe: func(t *testing.T) (string, []corpusPolicy) {
return universe(t, "one", "two")
},
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
root, policies := c.universe(t)
errs := validateCorpusPolicies(root, policies)
if c.want == "" {
if len(errs) != 0 {
t.Fatalf("valid registry reported %v", errs)
}
return
}
if len(errs) == 0 {
t.Fatalf("no error reported, want one mentioning %q", c.want)
}
var jb strings.Builder
for _, err := range errs {
jb.WriteString(err.Error() + "\n")
}
joined := jb.String()
if !strings.Contains(joined, c.want) {
t.Fatalf("errors do not mention %q:\n%s", c.want, joined)
}
})
}
}
// The stop rule covers every registered corpus, so a cap anywhere is a failure
// rather than an unread declaration.
func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
for _, p := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
if p.Rules[ruleStopPolicy].Mode != ruleEnforce {
t.Errorf("corpus %s uses mode %q for the stop rule; every registered corpus enforces both assertions",
filepath.Base(p.Dir), p.Rules[ruleStopPolicy].Mode)
}
}
root := t.TempDir()
for _, c := range []struct {
name string
corpus string
toml string
want []string
}{
{"a cap is rejected", "train", "max_steps = 8\ntimeout_sec = 300\n", []string{"max_steps = 8"}},
{"an omitted wall clock is rejected", "upstream-edge", "max_steps = 0\n", []string{"does not declare timeout_sec"}},
{"a zero wall clock is rejected", "fanout-width", "timeout_sec = 0\n", []string{"declares timeout_sec = 0"}},
{"a negative wall clock is rejected", "project-check", "timeout_sec = -1\n", []string{"declares timeout_sec = -1"}},
{"a declared bound is accepted", "memorybench", "timeout_sec = 300\n", nil},
{"no cap and no declaration of one is accepted", "verification-stress", "max_steps = 0\ntimeout_sec = 300\n", nil},
} {
t.Run(c.name, func(t *testing.T) {
dir := tempCorpus(t, root, c.corpus, "t-case", c.toml)
violations, err := stopPolicyViolations(dir)
if err != nil {
t.Fatal(err)
}
if len(c.want) == 0 {
if len(violations) != 0 {
t.Fatalf("accepted task reported %v", violations)
}
return
}
if len(violations) == 0 {
t.Fatalf("no violation reported, want one mentioning %q", c.want[0])
}
if !strings.Contains(violations[0], c.want[0]) {
t.Fatalf("violation %q does not mention %q", violations[0], c.want[0])
}
})
}
}
+15 -20
View File
@@ -189,7 +189,8 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
for _, policy := range policiesRunning(corpusPolicies(), rulePristineSeed) {
dir := policy.Dir
if !dirExists(dir) {
continue
}
@@ -242,7 +243,9 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
for _, policy := range policiesRunning(corpusPolicies(), ruleReference) {
dir := policy.Dir
decision := policy.Rules[ruleReference]
if !dirExists(dir) {
continue
}
@@ -259,10 +262,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Parallel()
work := stageSeed(t, task.dir)
if !stageSolved(t, task.dir, work) {
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
t.Fatal("no solution/: a corpus task must prove its grader can pass")
if decision.Mode == ruleEnforceWhenPresent {
t.Skip("no solution/: " + decision.Reason)
}
t.Skip("no solution/: the e2e suite does not commit reference solutions")
t.Fatal("no solution/: a corpus task must prove its grader can pass")
}
if err := gradeSeed(t, work); err != nil {
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
@@ -341,25 +344,17 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
// A task that caps its own tool rounds measures the cap, not the agent: the
// product ships unbounded (agent.Run calls bounding the loop the host's call),
// so a capped corpus grades a configuration no default user runs. Only two
// committed tasks ever reached a cap, both no-solution ones, where the host's
// "summarise your progress" preempts the sentence the honesty score exists for.
// so committed benchmark tasks must not set one. With no cap the wall clock is
// the only bound left, so every task must declare a positive timeout_sec.
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
tasks, err := loadTasks(dir)
for _, policy := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
dir := policy.Dir
violations, err := stopPolicyViolations(dir)
if err != nil {
t.Fatalf("load %s: %v", dir, err)
}
for _, task := range tasks {
if task.MaxSteps > 0 {
t.Errorf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
task.ID, task.MaxSteps)
}
// Without a round cap the wall clock is the only backstop left, so
// every task needs one or a stuck run has nothing to end it.
if task.TimeoutSec <= 0 {
t.Errorf("%s has no timeout_sec; with no round cap it is the only bound left", task.ID)
}
for _, violation := range violations {
t.Errorf("%s: %s", filepath.Base(dir), violation)
}
}
}
+45 -10
View File
@@ -108,21 +108,27 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
var pins []pinResult
var mut mutationResult
var analysisErr error
covered, coverTotal := 0, 0
if len(refs) > 0 && testsPass {
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
mut = runMutation(o.repo, o.base, srcFiles, refs)
pins, analysisErr = differentialPerTest(o.repo, o.base, srcFiles, refs)
// A failed restore leaves the tree unreliable, so the mutation sweep that
// would edit those files does not start.
if analysisErr == nil {
mut, analysisErr = runMutation(o.repo, o.base, srcFiles, refs)
}
}
buildOK, buildOut := goBuildAll(o.repo)
passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
passed := len(refs) > 0 && testsPass && buildOK && analysisErr == nil && countPins(pins) > 0
return diffReport{
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
passed: passed, profile: o.profile, m: m, runErr: runErr, analysisErr: analysisErr,
testOut: testOut, testDiff: testDiff,
}
}
@@ -202,6 +208,7 @@ type diffReport struct {
attempt, attempts int
m runMetrics
runErr error
analysisErr error
testOut string
testDiff string
}
@@ -283,6 +290,9 @@ func renderDiff(r diffReport) string {
if r.runErr != nil {
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
}
if line := analysisFailureLine(r); line != "" {
b.WriteString(line)
}
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
return b.String()
}
@@ -326,13 +336,14 @@ func pinCell(p pinResult) string {
// differentialPerTest reverts the PR's changed source to base (deleting files
// new in the PR), runs each generated test on its own against the old code, and
// restores the source. A test that fails on the old code pins the change.
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (out []pinResult, err error) {
for _, f := range srcFiles {
if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
if cerr := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); cerr != nil {
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
// Restore source on the way out; a tree left on `base` would mask the PR for
// later steps, so a failed restore is this analysis failing, not a note.
restored := false
defer func() {
if restored {
@@ -341,9 +352,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
if terr := treeMatches(repo, "HEAD", srcFiles); terr != nil {
err = terr
}
}()
if err := treeMatches(repo, base, srcFiles); err != nil {
return nil, err
}
out := make([]pinResult, 0, len(refs))
out = make([]pinResult, 0, len(refs))
for _, r := range refs {
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
cmd.Dir = repo
@@ -356,10 +373,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
})
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
if cerr := exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run(); cerr != nil {
return nil, fmt.Errorf("restore %s: %w", f, cerr)
}
}
if err := treeMatches(repo, "HEAD", srcFiles); err != nil {
return nil, err
}
restored = true
return out
return out, nil
}
// changedLineCoverage runs the affected packages with a coverage profile and
@@ -659,3 +681,16 @@ func passFail(ok bool) string {
}
return "fail"
}
// analysisFailureLine says which figures a failed analysis leaves absent. A pin
// table that was already produced still stands, so claiming both are missing
// would be wrong whenever only the mutation sweep failed.
func analysisFailureLine(r diffReport) string {
if r.analysisErr == nil {
return ""
}
if len(r.pins) > 0 {
return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
}
return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
}
+7 -12
View File
@@ -17,7 +17,6 @@ import (
fileencoding "reasonix/internal/base/fileutil/encoding"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
type task struct {
@@ -121,7 +120,8 @@ type sourceUsage struct {
type result struct {
task
runMetrics
Profile string `json:"profile"`
SFT *sftProvenance `json:"sft,omitempty"`
Profile string `json:"profile"`
// Arm is the ablation arm the harness requested, not the arm the child
// reported, so a run that died before writing metrics is still attributable.
Arm string `json:"arm"`
@@ -285,7 +285,7 @@ func main() {
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
tapeRecord, tapeReplay := registerTapeFlags()
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N and pass@N over tasks that ran all N trials, beside mean pass@1 over every graded trial")
flag.Parse()
*trajDir = absTrajectoryDir(*trajDir)
if *trials > 1 && *attempts > 1 {
@@ -518,15 +518,10 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
// then drops in verify.sh and runs it as the grader. The grader is added only
// after the run so the agent can't read the answer key.
func runTask(cfg suiteConfig, t task) result {
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
r.Arm = cfg.arm.Arm()
r.Anchor = cfg.anchor
t.Prompt = anchorPrompt(cfg.anchor, t)
if cfg.forcePlanner {
// The host's own routing marker, so the two-model turn engages without
// putting an English sentence in front of every prompt.
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
r.PlanForced = true
t, r, err := prepareRunTask(cfg, t)
if err != nil {
r.Note = "SFT provenance: " + err.Error()
return r
}
// The per-leg file names are decided in runSegments; only the directory has
+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 {
+14 -1
View File
@@ -53,6 +53,10 @@ type meterUsage struct {
DivergedAt int `json:"diverged_at,omitempty"`
Divergence string `json:"divergence,omitempty"`
TapeMissing int `json:"tape_missing,omitempty"`
// TapeIncomplete counts recorded exchanges that could not be written, so a
// recording that cannot be replayed is reported rather than assumed.
TapeIncomplete int `json:"tape_incomplete,omitempty"`
TapeError string `json:"tape_error,omitempty"`
}
// faultScript decides which requests fail. Absolute indices pin a failure to
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
src := io.Reader(resp.Body)
if m.tape != nil {
src = io.TeeReader(resp.Body, &captured)
defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
defer func() {
if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
m.mu.Lock()
m.TapeIncomplete++
if m.TapeError == "" {
m.TapeError = err.Error()
}
m.mu.Unlock()
}
}()
}
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
m.pipeStream(w, src)
+12 -9
View File
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
}
}
// record attaches what the proxy observed. It stays separate from close so the
// snapshot is taken while the run's numbers are still being assembled.
// record attaches what the proxy observed. It stops the meter first, because a
// handler's deferred write can land after the last response is read and stopping
// is what waits for those handlers; a snapshot taken before that misses them.
func (rm runMeter) record(r *result) {
if rm.m == nil {
return
}
rm.close()
observed := rm.m.snapshot()
r.Meter = &observed
}
@@ -114,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 ""
+13 -8
View File
@@ -25,14 +25,13 @@ type mutationResult struct {
// that package, and records whether they catch the mutation. A caught mutant
// means a test actually asserts that function's output; a survivor means the new
// tests don't check it.
func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult {
func runMutation(repo, base string, srcFiles []string, refs []testRef) (res mutationResult, err error) {
changed := changedLineSet(repo, base, srcFiles)
byPkg := map[string][]string{}
for _, r := range refs {
byPkg[r.pkg] = append(byPkg[r.pkg], r.name)
}
var res mutationResult
for _, file := range srcFiles {
if res.total >= maxMutants {
break
@@ -63,22 +62,28 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
lb := fset.Position(fd.Body.Lbrace).Offset
rb := fset.Position(fd.Body.Rbrace).Offset
mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:]
if os.WriteFile(abs, []byte(mutated), 0o644) != nil {
if err := writeFile(abs, []byte(mutated), 0o644); err != nil {
res.total--
continue
}
cmd := exec.Command("go", "test", "-run", runRe, pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
// Restore source even on panic; a file left mutated would corrupt the next mutant.
// Restore source on the way out; a file left mutated would corrupt the
// next mutant, so a failed restore is the analysis failing, not a note.
restored := false
defer func() {
if !restored {
_ = os.WriteFile(abs, srcB, 0o644)
if restored {
return
}
if rerr := restoreSource(abs, srcB); rerr != nil {
res, err = mutationResult{}, rerr
}
}()
caught := cmd.Run() != nil
_ = os.WriteFile(abs, srcB, 0o644)
if rerr := restoreSource(abs, srcB); rerr != nil {
return mutationResult{}, rerr
}
restored = true
if caught {
res.caught++
@@ -87,7 +92,7 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
}
}
}
return res
return res, nil
}
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
+1
View File
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
b.WriteString(renderMeterAccounting(results))
b.WriteString(renderTapeReplay(results))
b.WriteString(renderTapeRecord(results))
b.WriteString(renderFaultRecovery(results))
b.WriteString(renderTimeAttribution(results))
b.WriteString(renderSolveProfiles(results))
+26 -16
View File
@@ -7,10 +7,11 @@ import (
"strings"
)
// reproductionSection says whether two reports are one capability version on
// one corpus, and whether their rates differ by more than chance would. A
// difference inside the interval is not a finding; one outside it is only a
// finding about versions if the version section says the two are one version.
// reproductionSection says whether two reports share a capability set and a
// task prompt set, and whether their rates differ by more than chance would.
// Those are the only identities compared: the capability hash and task id to
// prompt. A difference inside the interval is not a finding; one outside it is
// a finding about a version only if the same capability set says so.
func reproductionSection(pathA, pathB string) string {
a, errA := loadResults(pathA)
b, errB := loadResults(pathB)
@@ -19,8 +20,10 @@ func reproductionSection(pathA, pathB string) string {
}
var s strings.Builder
s.WriteString("\n### Reproduction\n\n")
fmt.Fprintf(&s, "- version: %s\n", versionVerdict(a, b))
fmt.Fprintf(&s, "- corpus: %s\n\n", corpusVerdict(a, b))
version, corpus := versionVerdict(a, b), corpusVerdict(a, b)
fmt.Fprintf(&s, "- capability set: %s\n", version.text)
fmt.Fprintf(&s, "- task prompts: %s\n", corpus.text)
s.WriteString("- not compared: commit, build, configuration, grader, task files other than the prompt\n\n")
s.WriteString("| Rate | A | B | B − A (95% CI) | |\n|---|---:|---:|---:|---|\n")
allWithin := true
for _, m := range []struct {
@@ -46,8 +49,8 @@ func reproductionSection(pathA, pathB string) string {
fmt.Fprintf(&s, "| %s | %d/%d | %d/%d | %+.1fpp (%+.1f, %+.1f) | %s |\n", m.name, ka, na, kb, nb,
100*(float64(kb)/float64(nb)-float64(ka)/float64(na)), 100*lo, 100*hi, verdict)
}
if allWithin && strings.HasPrefix(versionVerdict(a, b), "one version") && strings.HasPrefix(corpusVerdict(a, b), "same") {
s.WriteString("\nOne version on one corpus reproduces within the noise floor.\n")
if allWithin && version.same && corpus.same {
s.WriteString("\nThe same capability set and task prompts reproduce within the noise floor.\n")
}
return s.String()
}
@@ -56,7 +59,14 @@ func counted(results []result) []result {
return slices.DeleteFunc(slices.Clone(results), func(r result) bool { return r.Skipped || r.Attempt > 1 })
}
func versionVerdict(a, b []result) string {
// verdict is one identity comparison: same says the two sides matched on what
// was compared, text says what that was.
type verdict struct {
same bool
text string
}
func versionVerdict(a, b []result) verdict {
setOf := func(rs []result) []string {
var out []string
for _, r := range counted(rs) {
@@ -69,17 +79,17 @@ func versionVerdict(a, b []result) string {
sa, sb := setOf(a), setOf(b)
switch {
case len(sa) == 0 || len(sb) == 0:
return "unknown (a report records no capability set)"
return verdict{text: "unknown (a report records no capability set)"}
case len(sa) > 1 || len(sb) > 1:
return "mixed (a report spans more than one capability set)"
return verdict{text: "mixed (a report spans more than one capability set)"}
case sa[0] == sb[0]:
return "one version (" + short(sa[0]) + ")"
return verdict{same: true, text: "one set (" + short(sa[0]) + ")"}
default:
return fmt.Sprintf("two versions (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))
return verdict{text: fmt.Sprintf("two capability sets (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))}
}
}
func corpusVerdict(a, b []result) string {
func corpusVerdict(a, b []result) verdict {
prompts := func(rs []result) map[string]string {
out := map[string]string{}
for _, r := range counted(rs) {
@@ -100,9 +110,9 @@ func corpusVerdict(a, b []result) string {
}
}
if differ == 0 {
return fmt.Sprintf("same %d tasks", len(pa))
return verdict{same: true, text: fmt.Sprintf("same %d task ids and prompts", len(pa))}
}
return fmt.Sprintf("%d tasks differ between the reports", differ)
return verdict{text: fmt.Sprintf("%d task ids or prompts differ between the reports", differ)}
}
func passRate(rs []result) (int, int) {
+7 -2
View File
@@ -46,17 +46,22 @@ func TestReproductionSection(t *testing.T) {
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
got := reproductionSection(a, same)
for _, want := range []string{"version: one version (aaaaaaaaaaaa)", "corpus: same 50 tasks", "within noise", "reproduces within the noise floor"} {
for _, want := range []string{"capability set: one set (aaaaaaaaaaaa)", "task prompts: same 50 task ids and prompts", "not compared: commit, build, configuration, grader", "within noise", "The same capability set and task prompts reproduce within the noise floor."} {
if !strings.Contains(got, want) {
t.Fatalf("same version report missing %q:\n%s", want, got)
}
}
for _, claim := range []string{"One version", "one corpus"} {
if strings.Contains(got, claim) {
t.Fatalf("the report claims %q, which no check compares:\n%s", claim, got)
}
}
worse := writeReport(t, dir, "c.json", "sha256:aaaaaaaaaaaaaaaa", 5, 50)
if got := reproductionSection(a, worse); !strings.Contains(got, "differs") || strings.Contains(got, "reproduces") {
t.Fatalf("a 70pp drop read as noise:\n%s", got)
}
other := writeReport(t, dir, "d.json", "sha256:bbbbbbbbbbbbbbbb", 40, 50)
if got := reproductionSection(a, other); !strings.Contains(got, "two versions") || strings.Contains(got, "reproduces") {
if got := reproductionSection(a, other); !strings.Contains(got, "two capability sets") || strings.Contains(got, "reproduces") {
t.Fatalf("two versions read as a reproduction:\n%s", got)
}
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// Restoring the source is I/O like any other step, and an analysis must not
// report a verdict about a tree it failed to restore. These helpers make that
// failure loud, and are variables so a test can inject one.
var (
writeFile = os.WriteFile
readFile = os.ReadFile
// treeMatches is a variable so a test can inject the mismatch a restore guards.
treeMatches = treeMatchesTree
)
// restoreSource writes the original bytes back and proves they are back.
func restoreSource(abs string, src []byte) error {
if err := writeFile(abs, src, 0o644); err != nil {
return fmt.Errorf("restore %s: %w", abs, err)
}
got, err := readFile(abs)
if err != nil {
return fmt.Errorf("verify %s: %w", abs, err)
}
if !bytes.Equal(got, src) {
return fmt.Errorf("verify %s: %d bytes on disk, %d expected", abs, len(got), len(src))
}
return nil
}
// treeMatches checks files against a revision. A checkout that exited zero is
// not evidence the tree moved; the tree is.
func treeMatchesTree(repo, rev string, files []string) error {
args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
out, err := exec.Command("git", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s does not match %s after restore: %w: %s", repo, rev, err, strings.TrimSpace(string(out)))
}
return nil
}
+261
View File
@@ -0,0 +1,261 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
t.Helper()
ow, or := writeFile, readFile
writeFile, readFile = w, r
t.Cleanup(func() { writeFile, readFile = ow, or })
}
// A restore that cannot write is not a restore.
func TestRestoreSourceFailsWhenTheWriteFails(t *testing.T) {
withFakeIO(t, func(string, []byte, os.FileMode) error { return errors.New("disk full") }, os.ReadFile)
err := restoreSource("/tmp/unused", []byte("original"))
if err == nil || !strings.Contains(err.Error(), "disk full") {
t.Fatalf("a failed restore write was not reported: %v", err)
}
}
// A restore that wrote something else is not a restore either.
func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
withFakeIO(t,
func(string, []byte, os.FileMode) error { return nil },
func(string) ([]byte, error) { return []byte("truncated"), nil })
err := restoreSource("/tmp/unused", []byte("original"))
if err == nil || !strings.Contains(err.Error(), "bytes on disk") {
t.Fatalf("a silent truncation passed verification: %v", err)
}
}
// The real filesystem is the other witness: writing over a directory fails.
func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
t.Fatal("restoring onto a directory reported success")
}
}
func TestTreeMatchesSeesADirtyTree(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
repo := t.TempDir()
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
git("init", "-q")
file := "a.go"
if err := os.WriteFile(filepath.Join(repo, file), []byte("package a\n"), 0o644); err != nil {
t.Fatal(err)
}
git("add", file)
git("commit", "-qm", "init")
if err := treeMatches(repo, "HEAD", []string{file}); err != nil {
t.Fatalf("a clean tree was reported dirty: %v", err)
}
if err := os.WriteFile(filepath.Join(repo, file), []byte("package a // mutated\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := treeMatches(repo, "HEAD", []string{file}); err == nil {
t.Fatal("a mutated file passed the tree check")
}
}
// The mutation sweep must not hand back a caught/survivor verdict when the
// source could not be restored: the numbers would describe a tree that no
// longer holds the PR's code.
func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go unavailable")
}
repo := t.TempDir()
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return strings.TrimSpace(string(out))
}
mustWrite := func(rel, body string) {
t.Helper()
abs := filepath.Join(repo, rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
mustWrite("go.mod", "module mutationfixture\n\ngo 1.24\n")
mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
mustWrite("pkg/mut_test.go", "package pkg\n\nimport \"testing\"\n\nfunc TestMut(t *testing.T) {\n\tif Answer() != 2 {\n\t\tt.Fatalf(\"got %d\", Answer())\n\t}\n}\n")
git("init", "-q")
git("add", "-A")
git("commit", "-qm", "base")
base := git("rev-parse", "HEAD")
mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
git("add", "-A")
git("commit", "-qm", "change")
// The first write mutates the source, the second restores it. Failing the
// second one is exactly the case the sweep used to swallow.
calls := 0
withFakeIO(t, func(path string, body []byte, mode os.FileMode) error {
calls++
if calls == 2 {
return errors.New("disk full")
}
return os.WriteFile(path, body, mode)
}, os.ReadFile)
res, err := runMutation(repo, base, []string{"pkg/mut.go"}, []testRef{{name: "TestMut", pkg: "./pkg"}})
if err == nil {
t.Fatalf("a failed restore still produced a verdict: caught=%d total=%d survivors=%v", res.caught, res.total, res.survivors)
}
if !strings.Contains(err.Error(), "disk full") {
t.Fatalf("error does not name the failing restore: %v", err)
}
}
// The same guarantee on the differential side: a restore that cannot put a file
// back must stop the analysis instead of reporting pins from a mixed tree.
func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
repo := t.TempDir()
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return strings.TrimSpace(string(out))
}
write := func(rel, body string) {
t.Helper()
abs := filepath.Join(repo, rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("go.mod", "module difffixture\n\ngo 1.24\n")
write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
git("init", "-q")
git("add", "-A")
git("commit", "-qm", "base")
base := git("rev-parse", "HEAD")
write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
git("add", "-A")
git("commit", "-qm", "change")
// pkg/missing.go exists in neither revision, so `git checkout HEAD --` cannot
// put it back: the restore step fails, which must not be swallowed.
srcFiles := []string{"pkg/mut.go", "pkg/missing.go"}
if _, err := differentialPerTest(repo, base, srcFiles, []testRef{{name: "TestMut", pkg: "./pkg"}}); err == nil {
t.Fatal("a failed restore still produced pin results")
}
}
// The base side is checked before any pin runs, and that check can fail too: the
// tree must not be left on base just because the failure happened early. The
// defer that restores is registered before the check for exactly this path.
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
repo := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
run("init", "-q")
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
t.Fatal(err)
}
run("add", "f.go")
run("commit", "-qm", "base")
base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
t.Fatal(err)
}
run("commit", "-aqm", "head")
orig := treeMatches
treeMatches = func(r, rev string, files []string) error {
if rev == base {
return errors.New("injected: base side does not match")
}
return treeMatchesTree(r, rev, files)
}
t.Cleanup(func() { treeMatches = orig })
out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
if err == nil {
t.Fatalf("a failed base check reported success with %d pins", len(out))
}
got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
if rerr != nil {
t.Fatal(rerr)
}
if strings.TrimSpace(string(got)) != "head" {
t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
}
}
// A pin table that was already produced still stands, so the failure line must
// not claim both figures are missing when only the mutation sweep failed.
func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
t.Fatalf("with pins present the line reads %q", only)
}
if strings.Contains(only, "Pin and mutation figures are absent") {
t.Fatalf("with pins present the line still claims both are absent: %q", only)
}
both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
if !strings.Contains(both, "Pin and mutation figures are absent") {
t.Fatalf("with no pins the line reads %q", both)
}
if analysisFailureLine(diffReport{}) != "" {
t.Fatal("a finished analysis produced a failure line")
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ func decideRollout(baseline, candidate []result) rolloutDecision {
case bv == cv:
hold("rollout.same_version")
}
if !strings.HasPrefix(corpusVerdict(baseline, candidate), "same") {
if !corpusVerdict(baseline, candidate).same {
hold("rollout.corpus_mismatch")
}
if !slices.ContainsFunc(counted(candidate), func(r result) bool { return r.NoSolution }) {
+25
View File
@@ -0,0 +1,25 @@
package main
import "reasonix/internal/session/control"
func prepareRunTask(cfg suiteConfig, t task) (task, result, error) {
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
r.Arm = cfg.arm.Arm()
r.Anchor = cfg.anchor
t.Prompt = anchorPrompt(cfg.anchor, t)
if cfg.forcePlanner {
// The host's own routing marker, so the two-model turn engages without
// putting an English sentence in front of every prompt.
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
r.PlanForced = true
}
if cfg.trajDir != "" {
source, err := newSFTProvenance(t)
if err != nil {
return t, r, err
}
r.SFT = source
}
return t, r, nil
}
+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)
}
}
+55 -38
View File
@@ -4,8 +4,11 @@ package main
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
@@ -44,8 +47,9 @@ type sftToolFunction struct {
// from trajectoryRecord because that one deliberately omits tool output: the
// analysis passes would otherwise hold every result of every run in memory.
type sftRecord struct {
RunHeader *sftRunHeader `json:"run_header"`
Event *struct {
BenchmarkRun *sftProvenance `json:"benchmark_run"`
RunHeader *sftRunHeader `json:"run_header"`
Event *struct {
Kind string `json:"kind"`
Text string `json:"text"`
Reasoning string `json:"reasoning"`
@@ -162,22 +166,28 @@ func convertSFT(records []sftRecord) []sftMessage {
}
func readSFTRecords(path string) ([]sftRecord, error) {
records, _, err := readSFTSnapshot(path)
return records, err
}
func readSFTSnapshot(path string) ([]sftRecord, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
return nil, "", err
}
defer f.Close()
var out []sftRecord
sc := bufio.NewScanner(f)
h := sha256.New()
sc := bufio.NewScanner(io.TeeReader(f, h))
sc.Buffer(make([]byte, 0, 1<<20), 64<<20)
for sc.Scan() {
var rec sftRecord
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
return nil, "", err
}
out = append(out, rec)
}
return out, sc.Err()
return out, hex.EncodeToString(h.Sum(nil)), sc.Err()
}
// verifyPrefix reports the header and how many of the run's rounds it provably
@@ -213,6 +223,10 @@ func buildSFTSample(id, prompt, path string) (*sftSample, string) {
if err != nil {
return nil, "unreadable: " + err.Error()
}
return buildSFTSampleRecords(id, prompt, records)
}
func buildSFTSampleRecords(id, prompt string, records []sftRecord) (*sftSample, string) {
hdr, covered, total := verifyPrefix(records)
if hdr == nil {
return nil, "no run header"
@@ -267,36 +281,27 @@ func runSFTMode(dir, suite, reportPath, out string) error {
return fmt.Errorf("no *.trajectory.jsonl files under %s", dir)
}
slices.Sort(paths)
prompts, err := sftPrompts(suite)
if err != nil {
return err
}
passed, err := sftVerdicts(reportPath)
inputs, err := sftInputs(paths, suite, reportPath)
if err != nil {
return err
}
f, err := os.Create(out)
f, err := os.CreateTemp(filepath.Dir(out), ".sft-export-*")
if err != nil {
return err
}
defer f.Close()
defer os.Remove(f.Name())
buf := bufio.NewWriter(f)
enc := json.NewEncoder(buf)
kept := 0
for _, p := range paths {
id := strings.TrimSuffix(filepath.Base(p), ".trajectory.jsonl")
switch {
case !passed[id]:
fmt.Fprintf(os.Stderr, "skip %s: the grader did not pass it\n", id)
continue
case prompts[id] == "":
fmt.Fprintf(os.Stderr, "skip %s: no task prompt under %s\n", id, suite)
continue
for input, err := range inputs {
if err != nil {
return err
}
sample, why := buildSFTSample(id, prompts[id], p)
sample, why := buildSFTSampleRecords(input.id, input.prompt, input.records)
if sample == nil {
fmt.Fprintf(os.Stderr, "skip %s: %s\n", id, why)
fmt.Fprintf(os.Stderr, "skip %s: %s\n", input.id, why)
continue
}
if err := enc.Encode(sample); err != nil {
@@ -307,23 +312,17 @@ func runSFTMode(dir, suite, reportPath, out string) error {
if err := buf.Flush(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(f.Name(), out); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "wrote %d of %d runs to %s\n", kept, len(paths), out)
return nil
}
func sftPrompts(suite string) (map[string]string, error) {
tasks, err := loadTasks(suite)
if err != nil {
return nil, err
}
out := make(map[string]string, len(tasks))
for _, t := range tasks {
out[t.ID] = t.Prompt
}
return out, nil
}
func sftVerdicts(path string) (map[string]bool, error) {
func sftVerdicts(path string) (map[string]map[string]result, error) {
if path == "" {
return nil, fmt.Errorf("-report is required: it names the run report whose grader verdicts decide what is kept")
}
@@ -335,9 +334,27 @@ func sftVerdicts(path string) (map[string]bool, error) {
if err := json.Unmarshal(raw, &results); err != nil {
return nil, err
}
out := make(map[string]bool, len(results))
out := make(map[string]map[string]result, len(results))
for _, r := range results {
out[r.ID] = r.Passed
if r.Skipped {
continue
}
if r.Segments > 1 {
return nil, fmt.Errorf("%s: SFT export does not support segmented runs", r.ID)
}
if r.SFT == nil || r.SFT.RunID == "" || r.SFT.TaskSHA256 == "" || r.SFT.TrajectorySHA256 == "" || r.SFT.Prompt == "" {
if !r.Passed {
continue
}
return nil, fmt.Errorf("%s: report lacks SFT provenance; regenerate it with trajectories", r.ID)
}
if out[r.ID] == nil {
out[r.ID] = make(map[string]result)
}
if _, exists := out[r.ID][r.SFT.TrajectorySHA256]; exists {
return nil, fmt.Errorf("%s: duplicate verdict for one trajectory", r.ID)
}
out[r.ID][r.SFT.TrajectorySHA256] = r
}
return out, nil
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"iter"
"os"
"path/filepath"
)
type sftProvenance struct {
RunID string `json:"run_id"`
TaskSHA256 string `json:"task_sha256"`
Prompt string `json:"prompt"`
TrajectorySHA256 string `json:"trajectory_sha256,omitempty"`
}
func newSFTProvenance(t task) (*sftProvenance, error) {
digest, err := sftTaskDigest(t)
if err != nil {
return nil, err
}
return &sftProvenance{RunID: rand.Text(), TaskSHA256: digest, Prompt: t.Prompt}, nil
}
func sftTaskDigest(t task) (string, error) {
h := sha256.New()
enc := json.NewEncoder(h)
err := filepath.WalkDir(t.dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if path == t.dir {
return nil
}
rel, err := filepath.Rel(t.dir, path)
if err != nil {
return err
}
if rel == "solution" && d.IsDir() {
return filepath.SkipDir
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("task input is not a regular file: %s", path)
}
digest, err := sftFileDigest(path)
if err != nil {
return err
}
return enc.Encode(struct {
Path string
Executable uint32
Digest string
}{filepath.ToSlash(rel), uint32(info.Mode().Perm() & 0o111), digest})
})
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func sftFileDigest(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// The harness binds its inputs after the child closes the trajectory. Event
// bytes stay untouched; the unique run header makes repeated attempts distinct.
func bindSFTTrajectory(path string, source *sftProvenance) error {
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(path), ".sft-trajectory-*")
if err != nil {
return err
}
defer os.Remove(out.Name())
defer out.Close()
header := *source
header.TrajectorySHA256 = ""
if err := json.NewEncoder(out).Encode(struct {
Source sftProvenance `json:"benchmark_run"`
}{header}); err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
if err := out.Close(); err != nil {
return err
}
if err := os.Rename(out.Name(), path); err != nil {
return err
}
source.TrajectorySHA256, err = sftFileDigest(path)
return err
}
type sftInput struct {
id, prompt string
records []sftRecord
}
func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error], error) {
tasks, err := loadTasks(suite)
if err != nil {
return nil, err
}
byTask := make(map[string]task, len(tasks))
for _, t := range tasks {
byTask[t.ID] = t
}
verdicts, err := sftVerdicts(report)
if err != nil {
return nil, err
}
return func(yield func(sftInput, error) bool) {
for _, path := range paths {
input, err := sftInputFor(path, suite, byTask, verdicts)
if err != nil {
yield(sftInput{}, err)
return
}
if input != nil && !yield(*input, nil) {
return
}
}
}, nil
}
func sftInputFor(path, suite string, tasks map[string]task, verdicts map[string]map[string]result) (*sftInput, error) {
id := filepath.Base(path)
id = id[:len(id)-len(".trajectory.jsonl")]
records, digest, err := readSFTSnapshot(path)
if err != nil {
return nil, err
}
r, ok := verdicts[id][digest]
if !ok {
return nil, fmt.Errorf("%s: report does not identify this trajectory; regenerate the report and trajectories together", id)
}
t, ok := tasks[id]
if !ok {
return nil, fmt.Errorf("%s: task is absent from %s", id, suite)
}
taskDigest, err := sftTaskDigest(t)
if err != nil {
return nil, err
}
if taskDigest != r.SFT.TaskSHA256 {
return nil, fmt.Errorf("%s: task files differ from the graded run", id)
}
var source *sftProvenance
for _, rec := range records {
if rec.BenchmarkRun == nil {
continue
}
if source != nil {
return nil, fmt.Errorf("%s: multiple benchmark run headers", id)
}
source = rec.BenchmarkRun
}
if source == nil || source.RunID != r.SFT.RunID || source.TaskSHA256 != r.SFT.TaskSHA256 || source.Prompt != r.SFT.Prompt {
return nil, fmt.Errorf("%s: benchmark run header does not match the report", id)
}
if !r.Passed || r.Skipped {
return nil, nil
}
return &sftInput{id, source.Prompt, records}, nil
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestSFTRejectsAMixedBatchBeforeReplacingTheOutput(t *testing.T) {
dir := testenv.TempDir(t)
writeTraj(t, dir, "fizzbuzz", trajHeader, `{"event":{"kind":"message","text":"failed attempt"}}`, trajUsage)
report := filepath.Join(dir, "report.json")
if err := os.WriteFile(report, []byte(`[{"ID":"fizzbuzz","Passed":true,"sft":{"trajectory_sha256":"another-run","task_sha256":"another-corpus","prompt":"another prompt"}}]`), 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "sft.jsonl")
if err := os.WriteFile(out, []byte("existing export\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := runSFTMode(dir, corpusDir, report, out); err == nil {
t.Fatal("a verdict from another run labelled this failed trajectory as passed")
}
got, err := os.ReadFile(out)
if err != nil || string(got) != "existing export\n" {
t.Fatalf("refused export replaced the existing output: %q, %v", got, err)
}
}
func gradedSFTResult(t *testing.T, dir string, task task, passed bool) result {
t.Helper()
source, err := newSFTProvenance(task)
if err != nil {
t.Fatal(err)
}
if err := bindSFTTrajectory(filepath.Join(dir, task.ID+".trajectory.jsonl"), source); err != nil {
t.Fatal(err)
}
return result{task: task, Passed: passed, SFT: source}
}
func sftFixture(t *testing.T) (dir, suite, report string, rows []result) {
t.Helper()
dir = testenv.TempDir(t)
suite = filepath.Join(dir, "suite")
taskDir := filepath.Join(suite, "tasks", "demo")
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{"task.toml": "prompt = 'original prompt'\n", "verify.sh": "exit 0\n", "workdir/seed.txt": "original seed"} {
if err := os.WriteFile(filepath.Join(taskDir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
tasks, err := loadTasks(suite)
if err != nil {
t.Fatal(err)
}
actual := tasks[0]
actual.Prompt = "planner directive\n\n" + actual.Prompt
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
rows = []result{gradedSFTResult(t, dir, actual, true)}
report = filepath.Join(dir, "report.json")
writeSFTReport(t, report, rows)
return
}
func writeSFTReport(t *testing.T, path string, rows []result) {
t.Helper()
raw, err := json.Marshal(rows)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
}
func TestSFTUsesTheRecordedPromptAndAValidatedSnapshot(t *testing.T) {
dir, suite, report, rows := sftFixture(t)
path := filepath.Join(dir, "demo.trajectory.jsonl")
inputs, err := sftInputs([]string{path}, suite, report)
if err != nil {
t.Fatal(err)
}
var captured sftInput
count := 0
for input, err := range inputs {
if err != nil {
t.Fatal(err)
}
captured = input
count++
}
if count != 1 {
t.Fatalf("got %d inputs", count)
}
if err := os.WriteFile(path, []byte("a later run replaced this file"), 0o600); err != nil {
t.Fatal(err)
}
sample, why := buildSFTSampleRecords(captured.id, captured.prompt, captured.records)
if sample == nil || sample.Messages[1].Content != rows[0].SFT.Prompt {
t.Fatalf("lost captured prompt or trajectory: %+v, %s", sample, why)
}
}
func TestSFTRejectsChangedTaskInputs(t *testing.T) {
for _, file := range []string{"task.toml", "verify.sh", "workdir/seed.txt"} {
t.Run(file, func(t *testing.T) {
dir, suite, report, _ := sftFixture(t)
if err := os.WriteFile(filepath.Join(suite, "tasks", "demo", file), []byte("prompt = 'changed'\n"), 0o644); err != nil {
t.Fatal(err)
}
err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl"))
if err == nil || !strings.Contains(err.Error(), "task files differ") {
t.Fatalf("changed %s accepted: %v", file, err)
}
})
}
}
func TestSFTBindsRepeatedAttemptsToTheirOwnVerdicts(t *testing.T) {
dir, suite, report, rows := sftFixture(t)
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
failed := gradedSFTResult(t, dir, rows[0].task, false)
if failed.SFT.RunID == rows[0].SFT.RunID || failed.SFT.TrajectorySHA256 == rows[0].SFT.TrajectorySHA256 {
t.Fatal("identical event streams from separate attempts share an identity")
}
writeSFTReport(t, report, append(rows, failed))
out := filepath.Join(dir, "out.jsonl")
if err := runSFTMode(dir, suite, report, out); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(out)
if err != nil || len(data) != 0 {
t.Fatalf("the latest failed attempt borrowed a passing verdict: %q, %v", data, err)
}
}
func TestSFTRefusesLegacyAndDuplicateReports(t *testing.T) {
for _, kind := range []string{"legacy", "duplicate", "header"} {
t.Run(kind, func(t *testing.T) {
dir, suite, report, rows := sftFixture(t)
switch kind {
case "legacy":
rows[0].SFT = nil
case "duplicate":
rows = append(rows, rows[0])
case "header":
rows[0].SFT.RunID = "another-run"
}
writeSFTReport(t, report, rows)
if err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl")); err == nil {
t.Fatal("unverifiable report accepted")
}
})
}
}
func TestRunTaskProducesProvenanceThatSFTCanValidate(t *testing.T) {
requireShellStub(t)
dir, suite, report, _ := sftFixture(t)
tasks, err := loadTasks(suite)
if err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "agent")
script := "#!/usr/bin/env bash\nwhile [ $# -gt 0 ]; do\ncase \"$1\" in\n" +
"--trajectory) printf '%s\\n' '" + trajHeader + "' '" + trajUsage + "' '{\"event\":{\"kind\":\"message\",\"text\":\"done\"}}' > \"$2\"; shift 2;;\n" +
"--metrics) printf '%s\\n' '{\"steps\":1,\"complete\":true}' > \"$2\"; shift 2;;\n*) shift;;\nesac\ndone\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
r := runTask(suiteConfig{bin: bin, trajDir: dir, forcePlanner: true}, tasks[0])
if !r.Passed || r.SFT == nil || r.SFT.TrajectorySHA256 == "" || r.Trajectory == nil {
t.Fatalf("runner lost provenance, grader, or digest: %+v", r)
}
writeSFTReport(t, report, []result{r})
out := filepath.Join(dir, "out.jsonl")
if err := runSFTMode(dir, suite, report, out); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
var sample sftSample
if err := json.Unmarshal(raw, &sample); err != nil {
t.Fatal(err)
}
if sample.Messages[1].Content != r.SFT.Prompt || !r.PlanForced || sample.Messages[1].Content == tasks[0].Prompt {
t.Fatal("export did not use the prompt actually passed to the child")
}
}
+11 -4
View File
@@ -122,10 +122,17 @@ func TestRunSFTModeKeepsOnlyGradedPasses(t *testing.T) {
writeTraj(t, traj, "palindrome", body...)
report := filepath.Join(dir, "r.json")
raw, _ := json.Marshal([]result{
{task: task{ID: "fizzbuzz"}, Passed: true},
{task: task{ID: "palindrome"}, Passed: false},
})
tasks, err := loadTasks(corpusDir)
if err != nil {
t.Fatal(err)
}
var results []result
for _, task := range tasks {
if task.ID == "fizzbuzz" || task.ID == "palindrome" {
results = append(results, gradedSFTResult(t, traj, task, task.ID == "fizzbuzz"))
}
}
raw, _ := json.Marshal(results)
if err := os.WriteFile(report, raw, 0o644); err != nil {
t.Fatal(err)
}
+54 -6
View File
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
// This run replaces the tape. Drop the old completeness markers first, or
// a failure here would leave the previous request beside this run's
// partial response and read as a complete exchange.
stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
if err != nil {
return nil, err
}
for _, path := range stale {
if err := os.Remove(path); err != nil {
return nil, err
}
}
} else if _, err := os.Stat(dir); err != nil {
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
}
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
}
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
_ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
_ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
_ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
// tapeWriteFile is a variable so a test can inject a write failure.
var tapeWriteFile = os.WriteFile
// save records one exchange. request.json goes last: its presence is what tells
// a later replay the exchange is complete rather than truncated.
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
if err != nil {
return fmt.Errorf("tape %d response meta: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
return fmt.Errorf("tape %d response.json: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
return fmt.Errorf("tape %d response.body: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
return fmt.Errorf("tape %d request.json: %w", index, err)
}
return nil
}
// load returns the recorded answer to request index, and where the request
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
return resp, nil, "", err
}
recorded, _ := os.ReadFile(t.file(index, "request.json"))
recorded, err := os.ReadFile(t.file(index, "request.json"))
if err != nil {
// Not a divergence: without the recorded request there is nothing to
// compare against, and reporting one would blame the harness.
return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
}
return resp, body, requestDiff(recorded, request), nil
}
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
}
return line + "\n\n"
}
// renderTapeRecord reports recordings that did not finish, so a run whose tape
// cannot be replayed says so instead of looking recorded.
func renderTapeRecord(results []result) string {
var incomplete []string
for _, r := range results {
if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
continue
}
incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
}
if len(incomplete) == 0 {
return ""
}
return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
}
+180
View File
@@ -1,11 +1,15 @@
package main
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
}
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// A recording whose request file cannot be written is reported rather than
// treated as a tape: request.json is what marks the exchange complete.
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
root := t.TempDir()
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "request.json") {
return errors.New("disk full")
}
return orig(path, body, mode)
}
t.Cleanup(func() { tapeWriteFile = orig })
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var got meterUsage
waitFor(t, "the failed write to be reported", func() bool {
got = m.snapshot()
return got.TapeIncomplete > 0
})
if !strings.Contains(got.TapeError, "disk full") {
t.Fatalf("incomplete recording = %+v, want the write error named", got)
}
if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
}
line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
t.Fatalf("report line = %q", line)
}
if renderTapeRecord(nil) != "" {
t.Fatal("an empty run set produced a tape line")
}
}
// A tape with no recorded request is incomplete, not a divergence: there is
// nothing to compare against, and blaming the harness would be wrong.
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
dir := filepath.Join(root, "bugfix-slug", "trial-1")
var markers []string
waitFor(t, "the recorded request", func() bool {
markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(markers) > 0
})
stop()
if err := os.Remove(markers[0]); err != nil {
t.Fatal(err)
}
m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
defer stop()
resp := post(t, base, "/chat/completions", tapedRequest)
readAll(t, resp)
got := m.snapshot()
if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
}
if got.DivergedAt != 0 || got.Divergence != "" {
t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
}
}
// The run path snapshots the meter through record, which must stop the meter
// first: stopping is what waits for the handlers whose deferred writes carry the
// tape counters. Snapshotting before that misses the last exchange.
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var r result
runMeter{m: m, stop: stop}.record(&r)
// No waitFor: the drain inside record is what makes this observable.
if r.Meter == nil {
t.Fatal("record attached no meter")
}
if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
resp.Body.Close()
t.Fatal("the meter was still serving after record, so its handlers were not drained")
}
}
// Re-recording over an existing tape must not let the previous run's
// completeness marker make a partial new exchange look complete.
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
dir := filepath.Join(root, "bugfix-slug", "trial-1")
waitFor(t, "the first recording", func() bool {
m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(m) > 0
})
stop()
// The second recording fails while writing the body, after the request has
// already been replaced in memory.
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "response.body") {
return errors.New("disk full")
}
return orig(path, body, mode)
}
t.Cleanup(func() { tapeWriteFile = orig })
var calls2 atomic.Int32
m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
defer stop2()
readAll(t, post(t, base2, "/chat/completions", tapedRequest))
var r result
runMeter{m: m2, stop: stop2}.record(&r)
if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
t.Fatalf("a failed re-record left completeness markers: %v", markers)
}
// Replay must call the exchange incomplete, not compare a stale request with
// a partial response.
m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
defer stop3()
resp := post(t, base3, "/chat/completions", tapedRequest)
readAll(t, resp)
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
}
if got := m3.snapshot(); got.DivergedAt != 0 {
t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
}
}
// The healthy path must stay quiet: a recording that completed reports no
// incomplete exchange and prints no line about one. Only the failure paths were
// pinned before.
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var r result
runMeter{m: m, stop: stop}.record(&r)
if r.Meter == nil {
t.Fatal("record attached no meter")
}
if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
}
// renderTapeRecord is the printer behind the report's summary block, so an
// empty string here is what "no line is printed" means.
if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
t.Fatalf("a complete recording printed %q, want no line", line)
}
}
+26 -11
View File
@@ -50,42 +50,57 @@ func tallyTrials(results []result) (map[string]trialTally, int) {
return tally, k
}
// trialsLine reports reliability over repeated trials. pass^k counts a task
// only when every trial it ran passed; a task cut short by the budget is
// judged on the trials it has, and the line says how many were complete.
// Empty for a run without trials, so older reports read as before.
// trialsLine reports reliability over repeated trials. pass^k and pass@k are
// over the tasks that ran all k trials (k is the highest trial index that ran);
// a task cut short by the budget has no value for either and is listed apart.
// mean pass@1 is passes over every trial that ran to a grade. Empty for a run
// without trials, so older reports read as before.
func trialsLine(results []result) string {
tally, k := tallyTrials(results)
if k < 2 || len(tally) == 0 {
return ""
}
all, any, complete, runs, passes := 0, 0, 0, 0, 0
var flaky []string
var flaky, incomplete []string
for id, t := range tally {
runs += t.ran
passes += t.passed
if t.ran == k {
complete++
if t.passed > 0 && t.passed < t.ran {
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
}
if t.ran < k {
incomplete = append(incomplete, fmt.Sprintf("`%s` %d/%d", id, t.ran, k))
continue
}
complete++
if t.passed == t.ran {
all++
}
if t.passed > 0 {
any++
}
if t.passed > 0 && t.passed < t.ran {
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
}
}
sort.Strings(flaky)
sort.Strings(incomplete)
passAll, passAny := fmt.Sprintf("n/a (no task ran %d trials)", k), "n/a"
if complete > 0 {
passAll, passAny = shareOf(all, complete), shareOf(any, complete)
}
line := fmt.Sprintf("**Reliability** (%d trials, %d/%d tasks complete): **pass^%d** %s · **pass@%d** %s · **mean pass@1** %s\n\n",
k, complete, len(tally), k, share(all, len(tally)), k, share(any, len(tally)), share(passes, runs))
k, complete, len(tally), k, passAll, k, passAny, share(passes, runs))
if len(incomplete) > 0 {
line += fmt.Sprintf("**Incomplete** (fewer than %d trials, not in pass^%d or pass@%d): %s\n\n", k, k, k, strings.Join(incomplete, " · "))
}
if len(flaky) > 0 {
line += "**Flaky** (passed some trials, not all): " + strings.Join(flaky, " · ") + "\n\n"
}
return line
}
func shareOf(n, d int) string {
return fmt.Sprintf("%s (%d/%d complete)", pct(n, d), n, d)
}
func share(n, d int) string {
return fmt.Sprintf("%s (%d/%d)", pct(n, d), n, d)
}
+23 -9
View File
@@ -21,8 +21,8 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
line := trialsLine(results)
for _, want := range []string{
"3 trials, 3/3 tasks complete",
"**pass^3** 33% (1/3)",
"**pass@3** 67% (2/3)",
"**pass^3** 33% (1/3 complete)",
"**pass@3** 67% (2/3 complete)",
"**mean pass@1** 56% (5/9)",
"`flaky` 2/3",
} {
@@ -35,15 +35,29 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
}
}
// A trial the budget skipped is not a failure; the task is judged on the
// trials it ran, and the line says it was not complete.
func TestTrialsLineJudgesABudgetCutTaskOnWhatRan(t *testing.T) {
results := []result{
trialResult("a", 1, true), trialResult("a", 2, true),
trialResult("b", 1, true), {task: task{ID: "b"}, Trial: 2, Skipped: true},
// pass^k is the chance all k trials pass, which a task that ran fewer has no
// value for. A trial the budget skipped is not a failure, so such a task is
// left out of pass^k and pass@k and named, and its trials still count toward
// mean pass@1.
func TestTrialsLineKeepsATaskThatRanFewerTrialsOutOfPassK(t *testing.T) {
for _, bPassed := range []bool{true, false} {
results := []result{
trialResult("a", 1, true), trialResult("a", 2, true),
trialResult("b", 1, bPassed), {task: task{ID: "b"}, Trial: 2, Skipped: true},
}
line := trialsLine(results)
for _, want := range []string{"1/2 tasks complete", "**pass^2** 100% (1/1 complete)", "**pass@2** 100% (1/1 complete)", "**Incomplete** (fewer than 2 trials, not in pass^2 or pass@2): `b` 1/2"} {
if !strings.Contains(line, want) {
t.Fatalf("bPassed=%v: missing %q in:\n%s", bPassed, want, line)
}
}
}
}
func TestTrialsLineHasNoPassKWhenNoTaskRanEveryTrial(t *testing.T) {
results := []result{trialResult("a", 1, true), trialResult("a", 3, true), trialResult("b", 1, true), trialResult("b", 2, true)}
line := trialsLine(results)
if !strings.Contains(line, "1/2 tasks complete") || !strings.Contains(line, "**pass^2** 100% (2/2)") {
if !strings.Contains(line, "**pass^3** n/a (no task ran 3 trials)") || !strings.Contains(line, "**pass@3** n/a") {
t.Fatalf("line = %s", line)
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"time"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
"reasonix/internal/platform/diskhealth"
"reasonix/internal/platform/notify"
)
// diskWatchEvery is how often the volumes are looked at: a build fills a disk
// over minutes, and a statfs per folder is cheap.
const diskWatchEvery = 5 * time.Minute
// watchDisk looks at the volumes under dirs on a timer and says when one runs
// short of space. The switches are read each time, so turning the notice off or
// on in the window takes effect without a restart, and the language is the one
// the window is set to now.
func watchDisk(ctx context.Context, set *notify.Settings, sender notify.Sender, dirs []string, every time.Duration, read func(string) (diskhealth.Volume, bool)) {
monitor := diskhealth.NewMonitor()
tick := time.NewTicker(every)
defer tick.Stop()
for {
cfg := set.Load()
if cfg.Enabled && cfg.DiskLow {
for _, n := range monitor.Check(time.Now(), dirs, read) {
say := i18n.CatalogFor(config.LoadForEdit(config.UserConfigPath()).DesktopLanguage())
notify.SendDiskLow(sender, say, cfg, n)
}
}
select {
case <-ctx.Done():
return
case <-tick.C:
}
}
}
// startDiskWatch begins the watch and returns the sender it speaks through, so
// the hub's notices and the disk warning leave by the same door.
func startDiskWatch(ctx context.Context, set *notify.Settings, dirs []string) notify.Sender {
sender := notify.NewPlatformSender()
go watchDisk(ctx, set, sender, dirs, diskWatchEvery, diskhealth.Read)
return sender
}
@@ -0,0 +1,68 @@
package main
import (
"context"
"sync"
"testing"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/platform/diskhealth"
"reasonix/internal/platform/notify"
)
type sentMessages struct {
mu sync.Mutex
sent []notify.Message
}
func (s *sentMessages) Send(m notify.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
s.sent = append(s.sent, m)
return nil
}
func (s *sentMessages) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sent)
}
const gigabyte = int64(1) << 30
func lowVolume(string) (diskhealth.Volume, bool) {
return diskhealth.Volume{Free: 2 * gigabyte, Total: 500 * gigabyte}, true
}
func runWatch(t *testing.T, cfg config.NotificationsConfig) int {
t.Helper()
sender := &sentMessages{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
watchDisk(ctx, notify.NewSettings(cfg), sender, []string{"/w", "/w/.state"}, time.Millisecond, lowVolume)
close(done)
}()
time.Sleep(50 * time.Millisecond)
cancel()
<-done
return sender.count()
}
// A volume that stays low is said once however many times it is looked at, and
// two folders on it do not make two notices.
func TestTheDiskWatchSaysALowVolumeOnce(t *testing.T) {
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: true}); got != 1 {
t.Fatalf("sent %d notices, want exactly one", got)
}
}
func TestTheDiskWatchHonoursTheSwitches(t *testing.T) {
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: false}); got != 0 {
t.Fatalf("own switch off sent %d, want none", got)
}
if got := runWatch(t, config.NotificationsConfig{Enabled: false, DiskLow: true}); got != 0 {
t.Fatalf("master switch off sent %d, want none", got)
}
}
+129 -42
View File
@@ -17,7 +17,9 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -35,6 +37,7 @@ import (
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
"reasonix/internal/platform/appupdate"
"reasonix/internal/platform/computer"
"reasonix/internal/platform/crashreport"
"reasonix/internal/platform/feedback"
"reasonix/internal/platform/instanceid"
@@ -67,6 +70,7 @@ type shellIdentity struct {
version string
exe string
pid int
logsDir string
}
func (s shellIdentity) stated() bool {
@@ -89,7 +93,7 @@ func studioInstall(shell shellIdentity) *update.Install {
// nil where the shell did not state enough to own one. Nil is the gate: the
// routes are not registered, so a host that cannot name the application it
// would replace has no way to be asked to replace it.
func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
func studioUpdateHost(shell shellIdentity, owner *shellOwner) appupdate.Capability {
if !shell.stated() {
return nil
}
@@ -97,7 +101,7 @@ func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
// still work; the swap is what refuses, by name.
application, _ := update.ApplicationAt(shell.exe, shell.pid)
return appupdate.New(appupdate.Options{
Owner: &shellOwner{to: to},
Owner: owner,
Running: shell.version,
Line: update.StudioLine(),
Application: application,
@@ -127,7 +131,9 @@ func main() {
// replace it.
studioApp := flag.String("studio-app", "", "the application executable this host runs inside")
studioAppPID := flag.Int("studio-app-pid", 0, "the process id of that application")
computerHelper := flag.String("computer-helper", "", "the native helper that operates this machine's applications")
computerDriver := flag.String("cua-driver", "", "the pinned CUA Driver executable")
computerAbortSocket := flag.String("computer-abort-socket", "", "the private Studio CUA interruption endpoint")
computerSocket := flag.String("computer-socket", "", "the private embedded CUA socket owned by Studio")
crashDir := flag.String("crash-dir", "", "the directory a fatal runtime error is written to")
stripGrants := flag.Bool("strip-package-grants", false, "remove app-package grants from the directory -studio-app runs from, print what changed, and exit")
flag.Parse()
@@ -141,14 +147,64 @@ func main() {
fmt.Fprintln(os.Stdout, instanceid.Current())
return
}
boot.SetComputerHelper(*computerHelper)
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
releaseFatalLog()
var driver *computer.Driver
if *computerDriver != "" {
if *computerSocket != "" {
driver = computer.NewEmbeddedDriver(*computerDriver, *computerSocket, *computerAbortSocket, os.Environ())
} else {
driver = computer.NewDriver(*computerDriver, os.Environ())
}
}
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
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 ""
}
return filepath.Dir(crashDir)
}
func defaultVersion(stated string) string {
if strings.TrimSpace(stated) != "" {
return stated
@@ -160,7 +216,10 @@ func defaultVersion(stated string) string {
// the handshake first and then one line per act a handover asks of the shell,
// because that pipe is the only channel pointing that way; every log goes to
// logs, which is why no line there can be mistaken for one of these.
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity, driver *computer.Driver) int {
if driver != nil {
defer driver.Close()
}
phases := newStartupPhases(logs, time.Now)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -183,7 +242,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
}
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases, driver)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -301,7 +360,7 @@ func resolveKernelLanguage(cfg *config.Config) string {
// assemble builds the hub this host serves: one pane on the workspace it was
// launched in, carrying the capabilities a local window may exercise.
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases, driver *computer.Driver) (*serve.Hub, error) {
// The one-time upgrades belong to whichever entry point starts first; a
// person who only ever opens the window would otherwise never get them.
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
@@ -336,20 +395,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
phases.mark(startupSinks)
root := launchWorkspace(shell.exe)
phases.mark(startupWorkspace)
built, err := boot.BuildRuntime(ctx, boot.Options{
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
Stderr: logs,
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
})
built, err := boot.BuildRuntime(ctx, runtimeOptions(root, paneSink, logs, phases, driver))
phases.mark(startupRuntime)
if err != nil {
return nil, err
@@ -362,31 +408,51 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
browserHost := serve.NewBrowserHost()
boot.SetBrowserHost(browserHost.Dial)
hubCfg := hostServeConfig(cfg.Serve)
// Shut until the person at the window opens it; the context ending closes
// it with the rest of the kernel, unpairing every device.
// Shut until the person at the window opens it; the context ending stops it
// with the rest of the kernel, keeping the phones this machine already adopted.
share := serve.NewDeviceShare(page)
share.RestorePort(cfg.SharePort())
go func() {
<-ctx.Done()
share.Close()
share.Shutdown()
}()
owner := &shellOwner{to: handshakeTo}
// A toast the user clicks is answered here, in the process that sent it, and
// the shell is the one that can bring its window forward.
notify.OnActivate(showOnToastClick(owner, logs))
// A toast that did not go out is invisible to the person it was for, so the
// reason belongs in the log the shell keeps.
notify.OnSendFailure(func(err error) { fmt.Fprintf(logs, "notify: %v\n", err) })
hub := serve.NewHub(serve.HubOptions{
Serve: hubCfg,
Surface: surface.Desktop,
Page: page,
Grant: grantHostCapabilities,
DecorateSink: decorate,
Tray: &studioTray{tracker: tracker},
Notifications: notifySet,
BrowserHost: browserHost,
Asks: asks,
Remote: remotehost.New(ctx, version, asks),
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
Update: studioUpdateHost(shell, handshakeTo),
Share: share,
NativeSurfaces: true,
ComputerDriver: driver,
Serve: hubCfg,
Surface: surface.Desktop,
Page: page,
Grant: grantHostCapabilities,
DecorateSink: decorate,
Tray: &studioTray{tracker: tracker},
Notifications: notifySet,
NotifySender: startDiskWatch(ctx, notifySet, []string{root, serve.SessionDirFor(root)}),
BrowserHost: browserHost,
Asks: asks,
Remote: remotehost.New(ctx, version, asks),
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
Update: studioUpdateHost(shell, owner),
Share: share,
LogsDir: shell.logsDir,
})
share.Attach(hub.Handler())
// A share the person had open reopens where it was: the phone that paired
// against that address keeps its cookie. An address that is gone falls back
// to one this machine now offers, at the cost of one fresh scan.
state := share.RestoreState()
if state.Open {
if _, err := share.Reopen(state.Address); err != nil {
fmt.Fprintln(logs, "reasonix-studio-host: could not reopen phone access:", err)
}
}
srv := serve.New(built.Controller, bc, hubCfg)
srv.SetPaneSink(paneSink)
srv.AdoptRuntime(built)
@@ -403,6 +469,27 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
return hub, nil
}
// runtimeOptions is what the one pane this host launches with is built from.
func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startupPhases, driver *computer.Driver) boot.Options {
return boot.Options{
ComputerDriver: driver,
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: sink,
NativeSurfaces: true,
Stderr: logs,
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,
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
}
}
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
+2 -2
View File
@@ -418,7 +418,7 @@ func TestRunServesUntilTheParentLetsGo(t *testing.T) {
go func() { _, _ = io.Copy(io.Discard, logs) }()
done := make(chan int, 1)
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}) }()
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}, nil) }()
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
@@ -542,7 +542,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
t.Fatal(err)
}
t.Chdir(testenv.TempDir(t))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
if err != nil {
t.Fatalf("assemble: %v", err)
}
+15 -2
View File
@@ -9,13 +9,26 @@ import (
"sync"
)
// The acts a handover asks of the shell. Only two travel: releasing the
// application is this process's own hub, and the shell has nothing to do for it.
// The acts this process asks of the shell. Releasing the application is this
// process's own hub, so the shell has nothing to do for it; the other three are
// what only the shell can do.
const (
actRelaunch = "relaunch"
actQuit = "quit"
actShow = "show"
)
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
// only process that can bring its window forward, to do so.
func showOnToastClick(o *shellOwner, log io.Writer) func() {
return func() {
fmt.Fprintln(log, "notify: toast clicked")
if err := o.ask(actShow); err != nil {
fmt.Fprintf(log, "notify: toast click could not reach the shell: %v\n", err)
}
}
}
// shellOwner is the application this process was spawned by, reached over the
// pipe the handshake went out on -- the only channel pointing this way, since
// stdin is the lease and HTTP runs from the shell to here. Nothing is waited
+34 -2
View File
@@ -3,6 +3,8 @@ package main
import (
"bytes"
"context"
"errors"
"io"
goruntime "runtime"
"strings"
"testing"
@@ -78,7 +80,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
{"an unpackaged launch, which names no version", shellIdentity{exe: "/somewhere/studio", pid: 4321}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := studioUpdateHost(tc.shell, &out); got != nil {
if got := studioUpdateHost(tc.shell, &shellOwner{to: &out}); got != nil {
t.Fatalf("studioUpdateHost = %#v, want nil so no install route registers", got)
}
})
@@ -89,7 +91,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
func TestAHostThatNamesTheApplicationOwnsOne(t *testing.T) {
var out bytes.Buffer
shell := shellIdentity{version: "v2.0.0", exe: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", pid: 4321}
if studioUpdateHost(shell, &out) == nil {
if studioUpdateHost(shell, &shellOwner{to: &out}) == nil {
t.Fatal("a host that stated its application got no capability")
}
}
@@ -116,3 +118,33 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
t.Fatalf("studioInstall = %+v for a launch that named no version, want nil", got)
}
}
// A clicked toast reaches the shell as one line on the pipe the handshake used,
// in the same shape the other acts take.
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
var out bytes.Buffer
showOnToastClick(&shellOwner{to: &out}, io.Discard)()
if got := out.String(); got != "{\"act\":\"show\"}\n" {
t.Fatalf("pipe carried %q, want the show act", got)
}
}
// The click is the first link of a chain that crosses two processes; when the
// window does not come forward, the log says whether the click arrived and
// whether the shell was reached.
func TestAToastClickIsWrittenToTheLog(t *testing.T) {
var log bytes.Buffer
showOnToastClick(&shellOwner{to: &bytes.Buffer{}}, &log)()
if !strings.Contains(log.String(), "toast clicked") {
t.Fatalf("log = %q, want the click recorded", log.String())
}
log.Reset()
showOnToastClick(&shellOwner{to: failingWriter{}}, &log)()
if !strings.Contains(log.String(), "could not reach the shell") {
t.Fatalf("log = %q, want the failed ask recorded", log.String())
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("pipe closed") }
@@ -47,7 +47,7 @@ func TestAssembledTrayLanguage(t *testing.T) {
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
if err != nil {
t.Fatalf("assemble: %v", err)
}
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
build := mappingValue(jobs, "build")
entries := mappingValue(mappingValue(mappingValue(build, "strategy"), "matrix"), "include")
want := map[string]string{"amd64": "ubuntu-22.04", "arm64": "ubuntu-22.04-arm"}
seen := map[string]bool{}
for _, entry := range entries.Content {
platform := mappingScalar(entry, "platform")
if !strings.HasPrefix(platform, "linux/") {
continue
}
arch := strings.TrimPrefix(platform, "linux/")
if seen[arch] || mappingScalar(entry, "runner") != want[arch] {
t.Fatalf("Linux %s does not have one native runner", arch)
}
seen[arch] = true
artifact := mappingScalar(entry, "artifact")
if artifact != "studio-build-linux-"+arch {
t.Fatalf("unexpected Linux artifact %q", artifact)
}
downloaded := false
for _, step := range mappingValue(mappingValue(jobs, "publish"), "steps").Content {
if mappingScalar(mappingValue(step, "with"), "name") == artifact {
downloaded = true
}
}
if !downloaded {
t.Errorf("publish never downloads %s", artifact)
}
}
if len(seen) != len(want) {
t.Fatalf("Linux release architectures = %v, want amd64 and arm64", seen)
}
step := stepNamed(t, build, "Use a native fpm on ARM64 Linux")
if mappingScalar(step, "if") != "runner.os == 'Linux' && runner.arch == 'ARM64'" || !strings.Contains(mappingScalar(step, "run"), "USE_SYSTEM_FPM=true") {
t.Fatal("Linux ARM64 must retain the upstream native FPM setup")
}
for _, name := range []string{"Verify both Linux package formats", "Accept the installed Linux package"} {
step := stepNamed(t, build, name)
if mappingScalar(step, "if") != "runner.os == 'Linux'" || mappingScalar(step, "continue-on-error") == "true" {
t.Errorf("%s must gate every Linux build", name)
}
}
collect := mappingScalar(stepNamed(t, mappingValue(jobs, "publish"), "Collect artifacts"), "run")
if !strings.Contains(collect, "bash scripts/verify-studio-artifacts.sh dist") {
t.Fatal("publication does not require the complete platform artifact set")
}
}
func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
job := mappingValue(jobsOf(t, root), "scope")
run := ""
for _, step := range mappingValue(job, "steps").Content {
if mappingScalar(step, "id") == "pick" {
run = mappingScalar(step, "run")
}
}
if run == "" {
t.Fatal("no executable CI matrix selector")
}
for _, tc := range []struct {
event, file string
arm64 bool
}{
{"push", "docs/test.md", true},
{"pull_request", "docs/test.md", false},
{"pull_request", "scripts/linux-install-smoke.sh", true},
{"pull_request", "scripts/verify-linux-package.sh", true},
{"pull_request", "scripts/verify-studio-artifacts.sh", true},
{"pull_request", "desktop/cmd/update-helper/main_linux.go", true},
{"pull_request", "desktop/electron/test/unit.mjs", true},
} {
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
dir := t.TempDir()
// A shell function owns command lookup on every host, including Git
// Bash on Windows; a PATH shim can resolve the runner's real git.
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
output := filepath.Join(dir, "output")
cmd := exec.Command("bash", "-c", "set -euo pipefail\n"+git+run)
cmd.Dir = "../.."
cmd.Env = append(os.Environ(), "EVENT="+tc.event, "MATRIX_TEST_FILE="+tc.file, "BASE_SHA=fixture", "GITHUB_OUTPUT="+output)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("matrix selector: %v: %s", err, out)
}
body, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var matrix struct {
Include []map[string]string `json:"include"`
}
for line := range strings.SplitSeq(string(body), "\n") {
if value, ok := strings.CutPrefix(line, "matrix="); ok {
if err := json.Unmarshal([]byte(value), &matrix); err != nil {
t.Fatal(err)
}
}
}
seen := map[string]string{}
for _, leg := range matrix.Include {
if leg["platform"] == "linux" {
seen[leg["arch"]] = leg["os"]
}
}
if seen["amd64"] != "ubuntu-22.04" || (seen["arm64"] == "ubuntu-22.04-arm") != tc.arm64 {
t.Fatalf("Linux CI matrix = %v; selector output: %s", seen, out)
}
})
}
}
+5 -9
View File
@@ -191,15 +191,11 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
if !strings.Contains(run, `"$count" -lt 9`) {
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
if !strings.Contains(run, "bash scripts/verify-studio-artifacts.sh dist") {
t.Fatal("publish does not run the complete artifact-set verifier")
}
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
} {
if !strings.Contains(run, "'"+name+"'") {
t.Errorf("publish does not require %s by name", name)
}
ci := parseWorkflowFile(t, "../../.github/workflows/ci.yml")
if !nodeContains(ci, "scripts/studio-artifacts.test.mjs") {
t.Fatal("CI does not exercise missing Windows/Linux/macOS artifacts against the publication verifier")
}
}
+98
View File
@@ -0,0 +1,98 @@
---
status: active
owner: @esengine
backup: @SivanCola
reviewed: 2026-10-13
---
# Foundation palette verification
final result: passed
Clear Blue and Tea Green implement the two approved colour directions for
[#12909](https://github.com/esengine/DeepSeek-Reasonix/issues/12909).
The existing default remains the baseline. Layout, typography, copy and brand
assets follow the product rather than generated mockup variations.
## Screenshots
Captured from the actual Studio components with synthetic performance-harness
data, one conversation turn and an empty composer. Fixture workspace and host
names are not real user data. Screenshots use 1:1 density.
| Palette | Light, 1280 × 720 | Dark, 1280 × 720 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-wide.jpg) | [Dark](docs/design/theme-palettes/default-dark-wide.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-wide.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-wide.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-wide.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-wide.jpg) |
| Palette | Light, 390 × 844 | Dark, 390 × 844 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-phone.jpg) | [Dark](docs/design/theme-palettes/default-dark-phone.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-phone.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-phone.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-phone.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-phone.jpg) |
## Visual comparison
The approved light mockups were 1619 × 971 pixels, including a caption outside
the application. They were viewed together with the implementation screenshots.
Comparison excluded that caption and assessed the palette direction, rather
than treating generated layout drift as a product specification.
- Typography: existing families, size, line height and tracking retained. Plain
light palettes now share the default prose weight; user overrides still win.
- Spacing: existing grid, controls, corners and panel proportions retained.
- Colours: cool white/blue and warm white/tea-green surfaces match the approved
directions. Text, syntax and button contrast checks pass in both schemes.
- Assets: existing logos and icons retained; no added image or sky layers.
- Copy: existing workbench text retained; new palette names and descriptions
have Chinese and English labels.
Full views made the navigation, transcript and composer readable without
additional crops. Dark palettes extend the approved hues; the source mockups
did not specify a dark layout. Phone captures confirm no horizontal overflow.
## Findings and iteration history
1. Default preview inherited the selected pack, and plain packs changed reading
surfaces and weight. Shared canonical default variables and plain-surface
rules now keep the preview independent and typography consistent.
2. Initial comparison found neutral selected navigation and a dark send button
overridden by body colour. Shared pack selectors now use accent ink/wash and
the accent/foreground pair; disabled controls remain neutral.
3. Revised screenshots were compared with both approved directions. No
actionable P0/P1/P2 differences remain. Sky themes now join image themes in
the expandable collection; the active backdrop remains in the first group.
## Behavioural evidence
- Immediate selection, return to default, light/dark and system-following modes
verified in the browser; each preview follows its own scheme tokens.
- Default comparison: original and revised Studio CSS applied to the same DOM.
Fourteen style properties matched for 735 visible elements in each scheme
under a light system and 734 in each scheme under a dark system.
- Compared colour, background, border, shadow, font, size, weight, line height,
tracking, width, height, padding, margin and radius.
- Go integration tests cover selection, config persistence, server recreation
and reset to the default for both new built-in IDs.
- Frontend neighbourhood: 22 test files, 209 tests passed; production build and
typecheck passed. Browser warning/error logs were empty.
- Theme and HTTP server package tests, repository checks and whitespace checks
passed. Lint budgets were not increased.
## Remaining coverage
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
## Workspace preparation / 工作目录准备
The workspace palette and preparation states reuse existing typography and
tokens. Real Git checks cover branches, creation, retry and cancellation; visual
QA passed. Captures remain in
[PR #13598](https://github.com/esengine/DeepSeek-Reasonix/pull/13598).
工作目录面板与准备状态复用现有字号、图标和表面样式;真实 Git 验证了分支搜索、
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
+1 -1
View File
@@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU
| `electron/` | the shell: main process, preload bridge, packaging |
| `frontend-next/` | the SPA it serves |
| `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper |
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) |
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
| `internal/winuninstall/` | taking a previous Windows install over |
+1 -30
View File
@@ -63,11 +63,6 @@ func main() {
notesVersion = os.Args[5]
}
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
case "windows-payload":
if len(os.Args) != 4 {
usage()
}
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
case "genkey":
if len(os.Args) != 3 {
usage()
@@ -88,34 +83,10 @@ func main() {
}
func usage() {
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
os.Exit(2)
}
func genWindowsPayloadManifest(dir, version string) error {
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("Windows payload %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("Windows payload %s is not a regular file", name)
}
_, sum, err := hashFile(path)
if err != nil {
return err
}
hashes[name] = sum
}
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
}
// verifyFile checks <file> against <file>.minisig using the embedded public key —
// the same check the updater runs before applying. A self-test that the signing
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
-26
View File
@@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
}
}
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
dir := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
if err != nil {
t.Fatal(err)
}
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
if err != nil {
t.Fatal(err)
}
for _, name := range update.WindowsPayloadFileNames() {
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
if hashes[name] != want {
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
}
}
}
+33 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/platform/update"
@@ -13,7 +14,8 @@ import (
// archMap is desktop/electron/arch.json: what the packager builds for, and what
// each of those is called in a release.
type archMap struct {
Map map[string]string `json:"map"`
Map map[string]string `json:"map"`
FPM map[string][]string `json:"fpm"`
}
func loadArchMap(t *testing.T) archMap {
@@ -93,3 +95,33 @@ func TestThePackagerSpellingResolvesToAKeyNobodyAsksFor(t *testing.T) {
}
}
}
// fpm names an .rpm for the architecture its own way, and the parser accepts
// that name too: it keys a package nobody asks for. The packager hook renames
// these spellings, so every one must be a non-canonical key here.
func TestFPMSpellingOfAnRPMIsNotAReleaseKey(t *testing.T) {
m := loadArchMap(t)
if len(m.FPM) == 0 {
t.Fatal("arch.json lists no fpm spellings")
}
canonical := map[string]bool{}
for _, to := range m.Map {
canonical[to] = true
}
for from, spellings := range m.FPM {
to, ok := m.Map[from]
if !ok {
t.Errorf("fpm spells %s, which map does not release", from)
continue
}
if got, ok := rpmPackageKey(artifactPrefix + "linux-" + to + ".rpm"); !ok || got != update.RPMPackageKey("linux", to) {
t.Errorf("linux %s rpm = %q (%v)", to, got, ok)
}
for _, spelled := range spellings {
got, ok := rpmPackageKey(artifactPrefix + "linux-" + spelled + ".rpm")
if !ok || canonical[strings.TrimSuffix(strings.TrimPrefix(got, "linux-"), "-rpm")] {
t.Errorf("%s resolved to %q (%v); the rename is no longer load-bearing", spelled, got, ok)
}
}
}
}
+22 -1
View File
@@ -84,6 +84,12 @@ func run(dir, version, tag, deltaDir string, mirrored bool) error {
m.NativePackages[key] = asset
fmt.Printf("native package: %s -> %s\n", name, key)
}
// The .rpm rides beside the .deb under its own key: the deb's key is the
// one every installed client already reads.
if key, ok := rpmPackageKey(name); ok {
m.NativePackages[key] = asset
fmt.Printf("native package: %s -> %s\n", name, key)
}
// Windows updates by running the next installer, so that is what the
// platform lookup has to resolve. The portable archive is never listed:
// resolving it would hand the updater an artifact it cannot install.
@@ -135,7 +141,8 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
func installable(name string) bool {
return strings.HasSuffix(name, ".dmg") ||
strings.HasSuffix(name, "-installer.exe") ||
strings.HasSuffix(name, ".deb")
strings.HasSuffix(name, ".deb") ||
strings.HasSuffix(name, ".rpm")
}
// macArchiveKeys maps a macOS archive to the platforms it can install. The
@@ -185,6 +192,20 @@ func nativePackageKey(name string) (string, bool) {
return update.PlatformKey("linux", arch), true
}
// rpmPackageKey maps a ReasonixStudio-linux-<arch>.rpm artifact to its
// native_packages key.
func rpmPackageKey(name string) (string, bool) {
base, ok := strings.CutSuffix(name, ".rpm")
if !ok {
return "", false
}
arch, ok := strings.CutPrefix(base, artifactPrefix+"linux-")
if !ok || arch == "" {
return "", false
}
return update.RPMPackageKey("linux", arch), true
}
func hashFile(path string) (int64, string, error) {
f, err := os.Open(path)
if err != nil {
+108
View File
@@ -232,3 +232,111 @@ func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
}
}
// Every installed Linux client reads native_packages["linux-amd64"] as the
// .deb and hands it to dpkg. The .rpm may add a key and never change that one.
func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
write := func(dir string, names ...string) {
for _, name := range names {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
}
generate := func(dir string) update.Manifest {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
debOnly, both := t.TempDir(), t.TempDir()
write(debOnly, "ReasonixStudio-linux-amd64.deb")
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
before, after := generate(debOnly), generate(both)
if after.NativePackages["linux-amd64"] != before.NativePackages["linux-amd64"] {
t.Errorf("the deb entry moved: %+v, was %+v", after.NativePackages["linux-amd64"], before.NativePackages["linux-amd64"])
}
if !strings.HasSuffix(after.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
t.Errorf("native_packages[linux-amd64] = %+v, want the .deb", after.NativePackages["linux-amd64"])
}
rpm, ok := after.NativePackages["linux-amd64-rpm"]
if !ok || !strings.HasSuffix(rpm.URL, "ReasonixStudio-linux-amd64.rpm") || rpm.Sig != rpm.URL+".minisig" {
t.Errorf("native_packages[linux-amd64-rpm] = %+v, want the signed .rpm", rpm)
}
if len(after.NativePackages) != 2 {
t.Errorf("native packages = %v, want the deb and the rpm", after.NativePackages)
}
if _, ok := after.Platforms["linux-amd64"]; ok {
t.Error("the .rpm must not resolve as a platform asset")
}
if _, ok := after.Downloads["ReasonixStudio-linux-amd64.rpm"]; !ok {
t.Error("downloads must offer the .rpm to a person")
}
if len(after.Downloads) != len(before.Downloads)+1 {
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
}
}
// A release that ships Linux for both architectures keys each package under its
// own platform: an arm64 client must never be handed the amd64 .deb, and adding
// the second architecture must not move the first one's entries.
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
dir := t.TempDir()
generate := func() update.Manifest {
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
var m update.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
write := func(arch string) {
for _, ext := range []string{"deb", "rpm"} {
name := "ReasonixStudio-linux-" + arch + "." + ext
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
}
write("amd64")
before := generate()
write("arm64")
after := generate()
for key, asset := range before.NativePackages {
if after.NativePackages[key] != asset {
t.Errorf("existing channel %s changed", key)
}
}
if len(after.NativePackages) != 4 || len(after.Downloads) != 4 || len(after.Platforms) != 0 {
t.Fatalf("manifest channels = %+v, want four native packages and human downloads", after)
}
for ext, key := range map[string]string{"deb": "linux-arm64", "rpm": "linux-arm64-rpm"} {
name := "ReasonixStudio-linux-arm64." + ext
asset, ok := after.NativePackages[key]
if !ok || after.Downloads[name] != asset {
t.Fatalf("%s does not resolve to its own download", key)
}
mirror := update.StudioMirror + "/studio-v2.39.0/" + name
github := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/studio-v2.39.0/" + name
if asset.URL != mirror || asset.Sig != mirror+".minisig" || asset.Fallback != github || asset.FallbackSig != github+".minisig" {
t.Errorf("%s loses its mirror, fallback or signature: %+v", key, asset)
}
}
}
-15
View File
@@ -1,15 +0,0 @@
package main
import "fmt"
func installerCommandLine(installer, dir string) string {
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
// The current helper also requires staging-only extraction: NSIS never writes
// the live install, and the helper compare-and-publishes the claimed release
// unit after validating every staged member.
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
-23
View File
@@ -1,23 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if strings.Contains(got, " /S") {
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
if !strings.Contains(got, " /REASONIXSTAGE=1") {
t.Fatalf("auto-update must extract away from the live install, got %q", got)
}
}
@@ -1,10 +0,0 @@
//go:build !windows
package main
import "os"
// Windows verifies each executable with the OS Authenticode policy while the
// same read handle is held. Non-Windows builds keep the release-unit loader
// testable without importing platform-specific trust APIs.
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
@@ -1,97 +0,0 @@
//go:build windows
package main
import (
"fmt"
"io"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
// reads the payload through one handle that disallows concurrent writes,
// deletes, and renames. The bytes handed to the publisher therefore cannot
// change between signature verification and publication.
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
path16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, fmt.Errorf("open staged payload handle")
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return nil, err
}
if !fileInfo.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
trustFile := &windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: path16,
File: handle,
}
trustData := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_NONE,
UnionChoice: windows.WTD_CHOICE_FILE,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
StateAction: windows.WTD_STATEACTION_VERIFY,
UIContext: windows.WTD_UICONTEXT_EXECUTE,
}
verifyErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
closeErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
if verifyErr != nil {
if closeErr != nil {
return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
}
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
}
if closeErr != nil {
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return io.ReadAll(file)
}
+8 -5
View File
@@ -37,11 +37,11 @@ const (
exitPostVerify = 16
)
// packageName is the only dpkg package this helper will install, fixed at
// build time so no runtime input can redirect it. Studio builds the same
// source with its own name; each package installs its own binary, and Polkit
// authorizes that path alone.
var packageName = "reasonix-desktop"
// packageName is the only dpkg package this helper will install. It is Studio's
// line as the update path states it, compiled in, so no runtime input can
// redirect it and no build step has a value to forget; Polkit authorizes this
// helper's own path alone.
var packageName = update.StudioLine().Deb.Package
const (
dpkgDebPath = "/usr/bin/dpkg-deb"
@@ -124,6 +124,9 @@ func runWith(d installDeps, args []string) int {
switch args[0] {
case "install":
return runInstall(d, args[1:])
case "package":
fmt.Fprintln(os.Stdout, packageName)
return exitOK
default:
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
return exitUsage
@@ -3,6 +3,7 @@
package main
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
@@ -14,6 +15,7 @@ import (
"aead.dev/minisign"
"reasonix/internal/base/tempdir"
"reasonix/internal/platform/update"
)
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
@@ -596,3 +598,44 @@ func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
}
}
// The helper installs one package and the shell asks dpkg about the same one:
// both must come from the update path's own line, or an in-app update is
// refused as a different package.
func TestHelperInstallsTheLineTheUpdatePathNames(t *testing.T) {
if packageName != update.StudioLine().Deb.Package {
t.Fatalf("helper accepts %q, the update path names %q", packageName, update.StudioLine().Deb.Package)
}
var out bytes.Buffer
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
code := runWith(realDeps(), []string{"package"})
w.Close()
os.Stdout = old
_, _ = out.ReadFrom(r)
if code != exitOK || strings.TrimSpace(out.String()) != packageName {
t.Fatalf("package command = %d %q, want %q", code, out.String(), packageName)
}
}
// electron-builder names the packages; the helper must agree with both.
func TestPackagedNamesMatchTheHelper(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "electron", "electron-builder.yml"))
if err != nil {
t.Fatal(err)
}
names := map[string]string{}
section := ""
for line := range strings.SplitSeq(string(raw), "\n") {
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "#") && strings.HasSuffix(strings.TrimSpace(line), ":") {
section = strings.TrimSuffix(strings.TrimSpace(line), ":")
}
if rest, ok := strings.CutPrefix(line, " packageName:"); ok {
names[section] = strings.TrimSpace(rest)
}
}
if names["deb"] != packageName || names["rpm"] != packageName {
t.Fatalf("electron-builder.yml names deb %q and rpm %q, the helper accepts %q", names["deb"], names["rpm"], packageName)
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
//go:build !windows && !linux
//go:build !linux
package main
@@ -8,6 +8,6 @@ import (
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Linux builds")
os.Exit(2)
}
-581
View File
@@ -1,581 +0,0 @@
//go:build windows
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/windows"
"reasonix/desktop/internal/winuninstall"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
)
const parentExitTimeout = 2 * time.Minute
var (
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
if err := fs.Parse(args); err != nil {
return 2
}
logger := newLogger()
if installer == "" {
logger.Print("missing --installer")
return 2
}
if !validSHA256(installerSHA256) {
logger.Print("missing or invalid --installer-sha256")
return 2
}
if toVersion == "" {
logger.Print("missing --to-version")
return 2
}
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
logger.Printf("unsupported --install-layout %q", installLayout)
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
if installDir == "" {
logger.Print("missing --install-dir")
return 2
}
if parentPID != 0 {
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if installLayout == installlayout.InstallLayoutVersionedV1 {
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
}
recoverExisting := func(reason string) int {
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
}
}
return 1
}
// The detached helper runs from the update cache, so a plain
// repair.ReadPendingUpdate would validate the transaction against the cache
// directory and reject every legitimate flat install. Claim through the
// explicit install-local launcher path instead; the repair package validates
// the complete transaction identity and exact release-unit path set while it
// acquires the pending + target locks.
claimTargets := windowsReleaseUnitPaths(installDir)
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
claimed, releaseClaim, err := claimPendingFileUpdateFn(
toVersion,
createdAt,
transactionID,
claimLauncher,
claimTargets,
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return recoverExisting("pending update claim failure")
}
defer releaseClaim()
recoverUnstarted := func() int {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
}
}
return 1
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind update installer: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create update staging: %v", err)
return recoverUnstarted()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind update staging: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged installer: %v", err)
return recoverUnstarted()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract installer payload: %v", installerErr)
return recoverUnstarted()
}
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
if err != nil {
logger.Printf("publish staged release unit: %v", err)
if !publishStarted {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
}
return 0
}
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
recoverExisting := func() int {
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch after versioned update failure: %v", err)
}
}
return 1
}
if _, err := installlayout.ReadCurrent(installDir); err != nil {
logger.Printf("read versioned install pointer: %v", err)
return recoverExisting()
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind versioned update installer: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create versioned update staging: %v", err)
return recoverExisting()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind versioned update staging: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged versioned installer: %v", err)
return recoverExisting()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract versioned installer payload: %v", installerErr)
return recoverExisting()
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: toVersion,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
// The release is already active and must not be rolled back for stale
// Add/Remove Programs metadata. A later update or full installer retries
// this idempotent reconciliation.
logger.Printf("reconcile Windows uninstall registration: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
return 1
}
}
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
if !validSHA256(expectedSHA256) {
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
}
sourceInfo, err := os.Lstat(sourcePath)
if err != nil {
return "", nil, err
}
if !sourceInfo.Mode().IsRegular() {
return "", nil, fmt.Errorf("installer is not a regular file")
}
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
if err != nil {
return "", nil, err
}
owner, err := os.Lstat(dir)
if err != nil {
return "", nil, err
}
cleanup := func() error {
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
}
fail := func(err error) (string, func() error, error) {
_ = cleanup()
return "", nil, err
}
source, err := os.Open(sourcePath)
if err != nil {
return fail(err)
}
defer source.Close()
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
if err != nil {
return fail(err)
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
syncErr := staged.Sync()
closeErr := staged.Close()
switch {
case copyErr != nil:
return fail(copyErr)
case syncErr != nil:
return fail(syncErr)
case closeErr != nil:
return fail(closeErr)
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
}
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
return fail(err)
}
return stagedPath, cleanup, nil
}
func verifyInstallerSHA256(path, expectedSHA256 string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("installer is not a regular file")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
hash := sha256.New()
if _, err := io.Copy(hash, f); err != nil {
return err
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fmt.Errorf("installer SHA-256 mismatch")
}
return nil
}
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
if !validSHA256(expectedSHA256) {
return nil, fmt.Errorf("installer SHA-256 is invalid")
}
pathUTF16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathUTF16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
fail := func(err error) (func(), error) {
_ = file.Close()
return nil, err
}
info, err := file.Stat()
if err != nil {
return fail(err)
}
if !info.Mode().IsRegular() {
return fail(fmt.Errorf("installer is not a regular file"))
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fail(err)
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fail(fmt.Errorf("installer SHA-256 mismatch"))
}
var once sync.Once
return func() {
once.Do(func() { _ = file.Close() })
}, nil
}
func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
// fail closed without mutating the live install or leaving flat pending state.
if !preferVersionedWindowsActivation(stagingDir) {
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
return true, nil, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
if installDir == "" {
return nil
}
names := []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
"Reasonix.exe",
}
paths := make([]string, 0, len(names))
for _, name := range names {
paths = append(paths, filepath.Join(installDir, name))
}
return paths
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
dir = filepath.Join(dir, "Reasonix", "updates")
if err := os.MkdirAll(dir, 0o700); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
return log.New(f, "", log.LstdFlags)
}
}
}
return log.New(os.Stderr, "", log.LstdFlags)
}
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
return nil
}
return err
}
defer windows.CloseHandle(h)
waitMS := uint32(timeout / time.Millisecond)
result, err := windows.WaitForSingleObject(h, waitMS)
if err != nil {
return err
}
switch result {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timed out after %s", timeout)
default:
return fmt.Errorf("unexpected wait result %d", result)
}
}
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
// Keep the helper itself hidden, but let the NSIS update-progress window be
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
// performs every live replacement through the claimed transaction.
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
return cmd.Run()
}
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
if path == "" || owner == nil || !owner.IsDir() {
return fmt.Errorf("Windows update cleanup identity is incomplete")
}
for attempt := range 16 {
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
from, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(cleanup)
if err != nil {
return err
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
if os.IsNotExist(err) {
return nil
}
if os.IsExist(err) {
continue
}
return err
}
actual, err := os.Lstat(cleanup)
if err != nil {
return err
}
if !os.SameFile(owner, actual) {
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
restoreTo, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
}
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
}
return fmt.Errorf("Windows update staging changed before cleanup")
}
return os.RemoveAll(cleanup)
}
return fmt.Errorf("cannot allocate Windows update cleanup path")
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
@@ -1,563 +0,0 @@
//go:build windows
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
"reasonix/internal/base/tempdir"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
t.Helper()
paths := windowsReleaseUnitPaths(installDir)
for _, path := range paths {
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
t.Fatal(err)
}
}
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
return tx
}
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(tempdir.New(t), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(source, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
expected := hex.EncodeToString(sum[:])
staged, cleanup, err := stageVerifiedInstaller(source, expected)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cleanup() })
if staged == source {
t.Fatal("installer was not isolated from the mutable cache path")
}
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err != nil {
t.Fatalf("isolated installer changed with source: %v", err)
}
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
t.Fatalf("staged installer = %q, %v", got, err)
}
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err == nil {
t.Fatal("tampered staged installer passed final verification")
}
}
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
source := filepath.Join(tempdir.New(t), "installer.exe")
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
t.Fatal(err)
}
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
if cleanup != nil {
_ = cleanup()
}
t.Fatal("installer bytes different from desktop verification were accepted")
}
}
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
if code := run([]string{
"--installer", `C:\Temp\Reasonix-installer.exe`,
"--installer-sha256", testInstallerSHA256,
}); code != 2 {
t.Fatalf("run without --to-version = %d, want 2", code)
}
}
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
installDir := tempdir.New(t)
seed := tempdir.New(t)
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installDir,
Version: "v1.20.0",
RequestID: "seed-windows-helper",
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
},
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
}); err != nil {
t.Fatal(err)
}
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
legacyClaimed = true
return nil, func() {}, errors.New("legacy claim must not run")
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
reconciledVersion := ""
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
if root != installDir {
t.Fatalf("reconcile root = %q, want %q", root, installDir)
}
reconciledVersion = targetVersion
return true, nil
}
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
return err
}
}
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
"--to-version", "v1.20.1",
"--install-layout", "versioned-v1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
if legacyClaimed {
t.Fatal("versioned update claimed legacy pending transaction")
}
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
if reconciledVersion != "v1.20.1" {
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error {
events = append(events, "wait")
return nil
}
runInstallerFn = func(string, string) error {
events = append(events, "installer")
return nil
}
startRelaunchFn = func(string, string) error {
events = append(events, "relaunch")
return nil
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return pending, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, []repair.FileUpdateInstallReceipt{}, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
return tx, nil
}
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
events = append(events, "stage-installer:"+sha256)
return path + ".claimed", func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
events = append(events, "claim-installer")
return func() { events = append(events, "release-installer") }, nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", pending.ToVersion,
"--created-at", pending.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(pending),
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
pending.ToVersion,
pending.CreatedAt,
repair.UpdateTransactionID(pending),
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
events[2] != "stage-installer:"+testInstallerSHA256 ||
events[3] != "claim-installer" || events[4] != "installer" ||
events[5] != "release-installer" || events[6] != "publish" ||
events[7] != "record-installed" || events[8] != "relaunch" ||
events[9] != "release" {
t.Fatalf("handoff events = %#v", events)
}
}
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
})
waitForProcessExitFn = func(uint32, time.Duration) error {
return errors.New("parent still running")
}
claimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
claimed = true
return &repair.UpdateTransaction{}, func() {}, nil
}
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if claimed || installerRan {
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
}
}
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
relaunched := false
startRelaunchFn = func(path, dir string) error {
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
return nil
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return nil, nil, errors.New("pending lock unavailable")
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-08-02T00:00:00Z",
"--transaction-id", strings.Repeat("a", 64),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch the existing desktop")
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if _, ok := repair.ReadUpdateApplyFailure(); ok {
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
}
}
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return nil }
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, []repair.FileUpdateInstallReceipt{{
UpdateTransactionID: repair.UpdateTransactionID(claimed),
TargetPath: claimed.TargetPath,
InstalledStateID: "installed",
}}, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
}
failure, ok := repair.ReadUpdateApplyFailure()
if !ok || failure.UpdateTransactionID != transactionID {
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
}
}
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalLstatStaging := lstatUpdateStagingFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
lstatUpdateStagingFn = originalLstatStaging
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
return nil, errors.New("staging identity unavailable")
}
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", claimed.ToVersion,
"--created-at", claimed.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(claimed),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if installerRan {
t.Fatal("installer ran without a bound staging directory")
}
if !relaunched {
t.Fatal("helper did not relaunch after staging identity failure")
}
if releases == 0 {
t.Fatal("helper did not release the update claim")
}
}
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
path := filepath.Join(tempdir.New(t), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(path, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
release()
t.Fatal("installer remained writable while execution claim was held")
}
if err := os.Rename(path, path+".replaced"); err == nil {
release()
t.Fatal("installer remained renameable while execution claim was held")
}
release()
release()
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
t.Fatalf("installer remained frozen after claim release: %v", err)
}
}
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
path := filepath.Join(tempdir.New(t), "installer.exe")
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
release()
t.Fatal("installer with the wrong SHA-256 received an execution claim")
}
}
-238
View File
@@ -1,238 +0,0 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/platform/repair"
"reasonix/internal/platform/update"
)
const maxWindowsPayloadMetadataSize = 64 << 10
var verifyWindowsPayloadManifestFn = update.Verify
type stagedFileUpdateMember struct {
targetPath string
content []byte
mode os.FileMode
}
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
// before any live release-unit member is moved. An existing Reasonix.exe is the
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
// installed package that did not have the alias remains unchanged.
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
}
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
}
info, err := os.Lstat(stagingDir)
if err != nil {
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
}
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
if err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
contents := make(map[string][]byte)
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
seenTargets := make(map[string]struct{}, len(claimed.Files))
for _, file := range claimed.Files {
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
targetKey := strings.ToLower(targetPath)
if targetPath == "" || targetPath == "." {
return nil, fmt.Errorf("load staged release unit: target path is invalid")
}
if _, ok := seenTargets[targetKey]; ok {
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
}
seenTargets[targetKey] = struct{}{}
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
continue
}
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
if err != nil {
return nil, err
}
sourcePath := filepath.Join(stagingDir, sourceName)
content, ok := contents[sourceName]
if !ok {
sourceInfo, statErr := os.Lstat(sourcePath)
if statErr != nil {
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
}
if !sourceInfo.Mode().IsRegular() {
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
}
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
if err != nil {
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
}
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
}
contents[sourceName] = content
}
members = append(members, stagedFileUpdateMember{
targetPath: targetPath,
content: content,
mode: 0o700,
})
}
// Publish the running desktop last. If an earlier member fails, the old
// desktop remains the executable entry point that can report/retry recovery.
sort.SliceStable(members, func(i, j int) bool {
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
return !iPrimary && jPrimary
})
return members, nil
}
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
if claimed == nil ||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
return fmt.Errorf("claimed release unit primary executable is invalid")
}
required := map[string]bool{
"reasonix-desktop.exe": false,
"reasonix-guard.exe": false,
"reasonix-launcher.exe": false,
"reasonix-update-helper.exe": false,
"reasonix-cli.exe": false,
"reasonix.exe": false,
}
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
for _, file := range claimed.Files {
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
if target == "" || target == "." ||
!strings.EqualFold(filepath.Dir(target), installDir) {
return fmt.Errorf("claimed release unit target is outside the installation directory")
}
name := strings.ToLower(filepath.Base(target))
seen, ok := required[name]
if !ok {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
if seen {
return fmt.Errorf("claimed release unit contains a duplicate target")
}
required[name] = true
}
for name, seen := range required {
if !seen {
return fmt.Errorf("claimed release unit omits %s", name)
}
}
if len(claimed.Files) != len(required) {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
return nil
}
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest: %w", err)
}
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
}
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
return nil, fmt.Errorf("verify signed release manifest: %w", err)
}
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
if err != nil {
return nil, err
}
return hashes, nil
}
func readWindowsPayloadMetadata(path string) ([]byte, error) {
pathInfo, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !pathInfo.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
}
if !os.SameFile(pathInfo, info) {
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
}
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
if err != nil {
return nil, err
}
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
}
return data, nil
}
func windowsStagedSourceName(targetBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(targetBase)) {
case "reasonix-desktop.exe":
return "reasonix-desktop.exe", nil
case "reasonix-guard.exe":
return "reasonix-guard.exe", nil
case "reasonix-launcher.exe":
return "reasonix-launcher.exe", nil
case "reasonix-update-helper.exe":
return "reasonix-update-helper.exe", nil
case "reasonix-cli.exe":
return "reasonix-cli.exe", nil
case "reasonix.exe":
return "reasonix-launcher.exe", nil
default:
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
}
}
func publishLoadedFileUpdateReleaseUnit(
claimed *repair.UpdateTransaction,
members []stagedFileUpdateMember,
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
) ([]repair.FileUpdateInstallReceipt, error) {
if publish == nil || len(members) == 0 {
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
}
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
for _, member := range members {
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
if err != nil {
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
}
receipts = append(receipts, receipt)
}
return receipts, nil
}
@@ -1,377 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/tempdir"
"reasonix/internal/platform/repair"
"reasonix/internal/platform/update"
)
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
staging := tempdir.New(t)
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
t.Fatalf("last published member = %q, want desktop", got)
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, filepath.Base(target)+"="+string(content))
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
})
if err != nil {
t.Fatal(err)
}
if len(receipts) != len(members) {
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
}
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
}
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
t.Fatalf("publish order = %v", published)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
staging := tempdir.New(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
t.Fatal(err)
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
},
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("incomplete staged release unit was accepted")
}
}
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
for i, file := range complete.Files {
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "omits") {
t.Fatalf("missing target error = %v", err)
}
})
}
t.Run("extra", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "unexpected") {
t.Fatalf("extra target error = %v", err)
}
})
t.Run("duplicate", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "duplicate") {
t.Fatalf("duplicate target error = %v", err)
}
})
t.Run("outside", func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath))
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "outside") {
t.Fatalf("outside target error = %v", err)
}
})
t.Run("primary", func(t *testing.T) {
claimed := *complete
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "primary") {
t.Fatalf("primary target error = %v", err)
}
})
}
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
staging := tempdir.New(t)
for _, name := range update.WindowsPayloadFileNames() {
content := "payload:" + name
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
for _, member := range members {
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
t.Fatalf("missing portable alias was added to publish set: %+v", members)
}
}
}
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
claimed := &repair.UpdateTransaction{TargetKind: "file"}
members := []stagedFileUpdateMember{
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, target)
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
})
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
t.Fatalf("publish error = %v", err)
}
if len(published) != 1 {
t.Fatalf("published members = %v, want one attempted member", published)
}
if len(receipts) != 0 {
t.Fatalf("failed first publish returned receipts: %+v", receipts)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
return nil, errors.New("payload signature rejected")
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("unsigned staged payload must be rejected before publication")
}
}
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
staging := tempdir.New(t)
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
var verified []string
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
verified = append(verified, filepath.Base(path))
return os.ReadFile(path)
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
t.Fatal(err)
}
if len(verified) != len(update.WindowsPayloadFileNames()) {
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
t.Fatalf("missing signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "verify signed release manifest") {
t.Fatalf("bad signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v3")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("release version drift error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
t.Fatalf("release member drift error = %v", err)
}
}
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
t.Helper()
staging := tempdir.New(t)
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
installDir := tempdir.New(t)
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
for _, name := range update.WindowsPayloadFileNames() {
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
}
files = append(files, repair.UpdateTransactionFile{
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
MissingBefore: true,
})
return staging, &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: version,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: files,
}
}
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
t.Helper()
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
content, err := os.ReadFile(filepath.Join(staging, name))
if os.IsNotExist(err) {
content = []byte("missing:" + name)
} else if err != nil {
t.Fatal(err)
}
hashes[name] = update.WindowsPayloadSHA256(content)
}
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
t.Fatal(err)
}
}
func acceptWindowsPayloadManifestForTest(t *testing.T) {
t.Helper()
original := verifyWindowsPayloadManifestFn
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
if string(signature) != "test signature" {
return errors.New("unexpected test signature")
}
return nil
}
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
}
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
t.Helper()
writeWindowsPayloadManifestForTest(t, staging, version)
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
}
func useUnverifiedPayloadReaderForTest(t *testing.T) {
t.Helper()
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = os.ReadFile
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
}
@@ -1,118 +0,0 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
},
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
}); err != nil {
return err
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -1,131 +0,0 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
"reasonix/internal/base/tempdir"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := tempdir.New(t)
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := tempdir.New(t)
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := tempdir.New(t)
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
@@ -1,348 +0,0 @@
import AppKit
import ApplicationServices
// Refs name elements for the model. One is issued per element and never reused,
// so a ref read earlier cannot come to mean something else; an element that has
// gone answers computer.stale_ref.
final class RefTable {
private var next = 0
private var byHash: [CFHashCode: [(ref: String, element: AXUIElement)]] = [:]
private var byRef: [String: (pid: pid_t, element: AXUIElement)] = [:]
func ref(for element: AXUIElement, pid: pid_t) -> String {
let hash = CFHash(element)
if let found = byHash[hash]?.first(where: { CFEqual($0.element, element) }) {
return found.ref
}
next += 1
let ref = "a\(next)"
byHash[hash, default: []].append((ref, element))
byRef[ref] = (pid, element)
return ref
}
func element(_ ref: String, pid: pid_t) throws -> AXUIElement {
guard let entry = byRef[ref] else {
throw Failure(code: "computer.unknown_ref", message: "\(ref) was never issued; refs come from a snapshot")
}
guard entry.pid == pid else {
throw Failure(code: "computer.unknown_ref", message: "\(ref) belongs to another application")
}
var role: AnyObject?
if AXUIElementCopyAttributeValue(entry.element, kAXRoleAttribute as CFString, &role) == .invalidUIElement {
throw Failure(code: "computer.stale_ref", message: "\(ref) is no longer on screen; take a new snapshot")
}
return entry.element
}
}
// Seen is one element's identity for a single walk: the accessibility API hands
// back a new reference for the same element, so equality is the API's, not the
// pointer's.
struct Seen: Hashable {
let element: AXUIElement
init(_ element: AXUIElement) { self.element = element }
static func == (a: Seen, b: Seen) -> Bool { CFEqual(a.element, b.element) }
func hash(into hasher: inout Hasher) { hasher.combine(CFHash(element)) }
}
// The action name for bringing an element into view. The SDK exports no
// constant for it, unlike press and show-menu.
let scrollToVisibleAction = "AXScrollToVisible"
enum Accessibility {
static let maxNodes = 1500
// How far the scroll bar is nudged to measure what the element does with it.
static let scrollProbe = 0.1
static let maxDepth = 40
// Containers that say nothing when unnamed: their children stand in for them.
static let transparent: Set<String> = ["AXGroup", "AXScrollArea", "AXSplitGroup", "AXLayoutArea", "AXUnknown"]
static func app(_ pid: pid_t) -> AXUIElement {
let element = AXUIElementCreateApplication(pid)
AXUIElementSetMessagingTimeout(element, 1.5)
return element
}
static func attr(_ element: AXUIElement, _ name: String) -> AnyObject? {
var value: AnyObject?
return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil
}
static func string(_ element: AXUIElement, _ name: String) -> String {
switch attr(element, name) {
case let s as String: return s
case let n as NSNumber: return n.stringValue
default: return ""
}
}
static func actions(_ element: AXUIElement) -> [String] {
var names: CFArray?
return AXUIElementCopyActionNames(element, &names) == .success ? (names as? [String] ?? []) : []
}
static func snapshot(pid: pid_t, refs: RefTable) throws -> JSON {
try Permissions.requireAccessibility()
guard let running = NSRunningApplication(processIdentifier: pid) else {
throw Failure(code: "computer.no_app", message: "no application with pid \(pid) is running")
}
let root = app(pid)
// Chromium-based applications build their tree only for a client that
// asks; the switch is ignored by everything else.
AXUIElementSetAttributeValue(root, "AXManualAccessibility" as CFString, kCFBooleanTrue)
var windows = (attr(root, kAXWindowsAttribute) as? [AXUIElement]) ?? []
if let focused = attr(root, kAXFocusedWindowAttribute), CFGetTypeID(focused) == AXUIElementGetTypeID() {
let focusedWindow = focused as! AXUIElement
windows.removeAll { CFEqual($0, focusedWindow) }
windows.insert(focusedWindow, at: 0)
}
// An application can hand its own element back in place of every window
// it has; what lies under it is the menu bar and itself again. The menus
// are still worth reading, and the reader has to be told the rest of the
// application is not in here.
windows.removeAll { CFEqual($0, root) }
var note = ""
if windows.isEmpty {
note = Screen.locked()
? "The screen is locked. While it is, macOS exposes no application's windows through accessibility, so nothing inside this window can be read, clicked by ref or hit by point — only the menus below. Typing, keys and screenshots still reach it; unlocking the screen brings the window back."
: "This application exposes no window through accessibility right now, so nothing inside its window can be read, clicked by ref or hit by point — only its menus below. Typing and keys still reach it, a screenshot still shows it, and relaunching it usually brings the window back."
windows = [root]
}
var lines: [String] = []
var count = 0
// An application's tree is not always one: a window of Calculator lists
// the application among its children, and walking that lists the window
// again. Every element is walked once per snapshot.
var seen = Set<Seen>()
func walk(_ element: AXUIElement, _ depth: Int) {
if count >= maxNodes || depth > maxDepth { return }
if !seen.insert(Seen(element)).inserted { return }
count += 1
let role = string(element, kAXRoleAttribute)
var label = string(element, kAXTitleAttribute)
if label.isEmpty { label = string(element, kAXDescriptionAttribute) }
let value = string(element, kAXValueAttribute)
if label.isEmpty && role == "AXStaticText" { label = value }
let children = (attr(element, kAXChildrenAttribute) as? [AXUIElement]) ?? []
if transparent.contains(role) && label.isEmpty {
for child in children { walk(child, depth) }
return
}
var line = String(repeating: " ", count: depth) + "- " + roleName(role)
if !label.isEmpty { line += " \(quote(label))" }
line += " [\(refs.ref(for: element, pid: pid))]"
if !value.isEmpty && value != label && role != "AXStaticText" { line += " value=\(quote(value))" }
if let placeholder = attr(element, kAXPlaceholderValueAttribute) as? String, !placeholder.isEmpty, value.isEmpty {
line += " placeholder=\(quote(placeholder))"
}
if (attr(element, kAXFocusedAttribute) as? Bool) == true { line += " focused" }
if (attr(element, kAXEnabledAttribute) as? Bool) == false { line += " disabled" }
if (attr(element, kAXSelectedAttribute) as? Bool) == true { line += " selected" }
if actions(element).contains(kAXPressAction) { line += " pressable" }
lines.append(line)
for child in children { walk(child, depth + 1) }
}
for window in windows { walk(window, 0) }
return [
"app": running.localizedName ?? "",
"bundle": running.bundleIdentifier ?? "",
"lines": lines,
"truncated": count >= maxNodes,
"note": note,
]
}
static func roleName(_ role: String) -> String {
let bare = role.hasPrefix("AX") ? String(role.dropFirst(2)) : role
return bare.isEmpty ? "element" : bare.prefix(1).lowercased() + bare.dropFirst()
}
static func quote(_ s: String) -> String {
let clipped = s.count > 160 ? String(s.prefix(160)) + "…" : s
let data = try? JSONSerialization.data(withJSONObject: [clipped])
let json = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[\"\"]"
return String(json.dropFirst().dropLast())
}
static func center(_ element: AXUIElement) -> CGPoint? {
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
var pos = CGPoint.zero, size = CGSize.zero
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &pos), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
return CGPoint(x: pos.x + size.width / 2, y: pos.y + size.height / 2)
}
static func perform(_ element: AXUIElement, _ action: String, what: String) throws {
switch AXUIElementPerformAction(element, action as CFString) {
case .success:
return
case .invalidUIElement:
throw Failure(code: "computer.stale_ref", message: "\(what) is no longer on screen; take a new snapshot")
case .actionUnsupported, .attributeUnsupported:
throw Failure(code: "computer.no_action", message: "\(what) takes no \(action)")
case let error:
throw Failure(code: "computer.failed", message: "\(action) on \(what) failed (AXError \(error.rawValue))")
}
}
static func press(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
try perform(element, kAXPressAction, what: ref)
return [:]
}
// menu asks an element for its context menu, which is what a right click is
// for: the menu belongs to the element, so no pointer has to be borrowed to
// open it. Read the application again afterwards to see the menu.
static func menu(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
guard actions(element).contains(kAXShowMenuAction) else {
throw Failure(code: "computer.no_action", message: "\(ref) has no context menu")
}
try perform(element, kAXShowMenuAction, what: ref)
return [:]
}
// reveal scrolls an element into view without a pointer. Where that is not
// offered, the wheel goes to the application itself, which scrolls whatever
// it considers in front — the same thing a wheel over its window would.
static func scroll(pid: pid_t, ref: String, amount: Double, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
if !ref.isEmpty {
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
if actions(element).contains(scrollToVisibleAction) {
try perform(element, scrollToVisibleAction, what: ref)
return ["how": "revealed"]
}
if try revealBy(element, ref: ref) {
return ["how": "revealed"]
}
}
guard amount != 0 else {
throw Failure(code: "computer.bad_step", message: "a scroll needs a ref that can be revealed, or lines to turn the wheel by")
}
let source = CGEventSource(stateID: .privateState)
guard let event = CGEvent(scrollWheelEvent2Source: source, units: .line, wheelCount: 1, wheel1: Int32(amount), wheel2: 0, wheel3: 0) else {
throw Failure(code: "computer.failed", message: "the scroll could not be made")
}
event.postToPid(pid)
return ["how": "wheel"]
}
// revealBy brings an element into view in applications that offer no way for
// it to reveal itself. Their scroll areas advertise page actions that then
// refuse to run (measured: AXScrollDownByPage answers -25205 on AppKit),
// while the scroll bar's own value does move the view — so the geometry is
// solved on that: move the bar a little, see how far the element went with
// it, and put the bar where the element lands inside the area.
static func revealBy(_ element: AXUIElement, ref: String) throws -> Bool {
guard let area = enclosingScroller(element), let bar = verticalBar(area),
let want = frame(element), let have = frame(area) else { return false }
if have.intersects(want) { return true }
guard var at = attr(bar, kAXValueAttribute) as? Double else { return false }
let probe = at > 0.5 ? at - scrollProbe : at + scrollProbe
guard set(bar, probe), let moved = frame(element) else { return false }
let travel = moved.midY - want.midY
if abs(travel) < 1 {
_ = set(bar, at)
return false
}
// How far one unit of the bar carries the element, and the value that
// leaves it in the middle of what the area shows.
let perUnit = travel / (probe - at)
at = min(max(probe + (have.midY - moved.midY) / perUnit, 0), 1)
guard set(bar, at), let landed = frame(element) else { return false }
return have.intersects(landed)
}
static func verticalBar(_ area: AXUIElement) -> AXUIElement? {
for child in (attr(area, kAXChildrenAttribute) as? [AXUIElement]) ?? [] {
guard string(child, kAXRoleAttribute) == "AXScrollBar" else { continue }
if (attr(child, kAXOrientationAttribute) as? String) != "AXHorizontalOrientation" {
return child
}
}
return nil
}
static func set(_ element: AXUIElement, _ value: Double) -> Bool {
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success
}
// enclosingScroller is the nearest ancestor that scrolls, which is what a
// scroll bar belongs to.
static func enclosingScroller(_ element: AXUIElement) -> AXUIElement? {
var at = element
for _ in 0..<maxDepth {
guard let parent = attr(at, kAXParentAttribute), CFGetTypeID(parent) == AXUIElementGetTypeID() else { return nil }
at = parent as! AXUIElement
if string(at, kAXRoleAttribute) == "AXScrollArea" { return at }
}
return nil
}
static func frame(_ element: AXUIElement) -> CGRect? {
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
var origin = CGPoint.zero
var size = CGSize.zero
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &origin), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
return CGRect(origin: origin, size: size)
}
// click is a press at a point: the element there is asked for its action, so
// the person's own pointer never moves. An element that has none cannot be
// clicked this way, and says so rather than being clicked some other way.
static func click(pid: pid_t, x: Double, y: Double, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
var hit: AXUIElement?
guard AXUIElementCopyElementAtPosition(app(pid), Float(x), Float(y), &hit) == .success, let element = hit else {
throw Failure(code: "computer.no_element", message: "nothing of this application is at (\(Int(x)), \(Int(y)))\(Screen.locked() ? "; the screen is locked, and a locked screen exposes no window to click in" : "")")
}
// Only an element of the application that was approved takes the click,
// whatever the hit test resolves the point to.
var owner: pid_t = 0
guard AXUIElementGetPid(element, &owner) == .success, owner == pid else {
throw Failure(code: "computer.no_element", message: "the element at (\(Int(x)), \(Int(y))) belongs to another application")
}
cursor.move(to: CGPoint(x: x, y: y))
let role = string(element, kAXRoleAttribute)
if actions(element).contains(kAXPressAction) {
try perform(element, kAXPressAction, what: "the \(roleName(role)) at that point")
} else if ["AXTextField", "AXTextArea", "AXComboBox", "AXSearchField"].contains(role) {
AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
} else {
throw Failure(code: "computer.no_action", message: "the \(roleName(role)) at that point takes no accessibility action\(Screen.locked() ? " — the screen is locked, which is why this application's window is not there to hit" : "; it needs the real pointer")")
}
return ["role": roleName(role)]
}
static func focus(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
let result = AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
if result != .success {
throw Failure(code: "computer.no_action", message: "\(ref) cannot take focus (AXError \(result.rawValue))")
}
return [:]
}
static func setValue(pid: pid_t, ref: String, text: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFString)
if result != .success {
throw Failure(code: "computer.no_action", message: "\(ref) has no value that can be set (AXError \(result.rawValue))")
}
return ["value": string(element, kAXValueAttribute)]
}
}
@@ -1,34 +0,0 @@
import AppKit
import CoreGraphics
enum Apps {
// The applications a person can see, with their windows on screen. Window
// names need screen recording; without it a window is listed untitled.
static func list() -> [JSON] {
let windows = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [JSON]) ?? []
var byPid: [pid_t: [JSON]] = [:]
for w in windows {
guard (w[kCGWindowLayer as String] as? Int) == 0,
let pid = w[kCGWindowOwnerPID as String] as? pid_t,
let id = w[kCGWindowNumber as String] as? Int,
let boundsDict = w[kCGWindowBounds as String] as? NSDictionary,
let bounds = CGRect(dictionaryRepresentation: boundsDict) else { continue }
byPid[pid, default: []].append([
"id": id,
"title": w[kCGWindowName as String] as? String ?? "",
"bounds": ["x": bounds.minX, "y": bounds.minY, "width": bounds.width, "height": bounds.height],
])
}
return NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular }
.map { app in
[
"pid": app.processIdentifier,
"bundle": app.bundleIdentifier ?? "",
"name": app.localizedName ?? "",
"active": app.isActive,
"windows": byPid[app.processIdentifier] ?? [],
]
}
}
}
@@ -1,82 +0,0 @@
import AppKit
import ScreenCaptureKit
enum Capture {
// window captures an application's frontmost on-screen window by itself,
// whatever covers it, at the display's pixel scale. bounds is where it sits
// in global points, which is what a click at a point in the image maps to.
//
// The answer arrives on whichever thread ScreenCaptureKit finishes on: its
// continuations can need the main thread, so waiting for one there is a
// deadlock that reads as a capture that never finished.
static func window(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
guard #available(macOS 14.0, *) else {
throw Failure(code: "computer.unsupported", message: "capturing one window needs macOS 14 or later")
}
try capture(pid: pid, answer: answer)
}
@available(macOS 14.0, *)
private static func capture(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
try Permissions.requireScreenRecording()
let once = Once(answer)
Task.detached {
do {
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
guard let target = content.windows
.filter({ $0.owningApplication?.processID == pid && $0.windowLayer == 0 && $0.frame.width > 1 })
.first else {
once.fire(.failure(Failure(code: "computer.no_window", message: "the application has no window on screen")))
return
}
let filter = SCContentFilter(desktopIndependentWindow: target)
let config = SCStreamConfiguration()
config.width = Int(target.frame.width * CGFloat(filter.pointPixelScale))
config.height = Int(target.frame.height * CGFloat(filter.pointPixelScale))
config.showsCursor = false
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
let rep = NSBitmapImageRep(cgImage: image)
guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) else {
once.fire(.failure(Failure(code: "computer.capture_failed", message: "the capture could not be encoded")))
return
}
once.fire(.success([
"data": jpeg.base64EncodedString(),
"mime": "image/jpeg",
"width": image.width,
"height": image.height,
"window": target.windowID,
"bounds": ["x": target.frame.minX, "y": target.frame.minY, "width": target.frame.width, "height": target.frame.height],
]))
} catch {
once.fire(.failure(Failure(code: "computer.capture_failed", message: "\(error.localizedDescription)")))
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + 10) {
var said = "the capture did not finish within 10s"
let others = Processes.siblings()
if !others.isEmpty {
said += "; another helper is running (pid \(others.map(String.init).joined(separator: ", "))) and while one of them has captured, the other's capture never returns — quit the other Studio"
}
once.fire(.failure(Failure(code: "computer.capture_failed", message: said)))
}
}
}
// Once carries a request's single answer: the capture and the deadline race for
// it, and the request is owed exactly one reply.
private final class Once {
private let answer: (Result<JSON, Failure>) -> Void
private let lock = NSLock()
private var fired = false
init(_ answer: @escaping (Result<JSON, Failure>) -> Void) { self.answer = answer }
func fire(_ outcome: Result<JSON, Failure>) {
lock.lock()
let first = !fired
fired = true
lock.unlock()
if first { answer(outcome) }
}
}
@@ -1,62 +0,0 @@
import AppKit
enum Clipboard {
// paste puts text where the person is typing without typing it: a long
// value arrives at once, and an application that completes or reformats
// what it is given sees one insertion instead of a hundred keystrokes.
//
// The clipboard is the person's. What was on it goes back, unless something
// else claimed it while this ran — then theirs is the newer one and stays.
static func paste(pid: pid_t, text: String) throws -> JSON {
try Permissions.requireAccessibility()
// Measured: an application that is not in front never handles the
// keystroke, and bringing it forward is the person's screen being taken
// over — which is answered for on its own, not folded into this.
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else {
throw Failure(
code: "computer.needs_front",
message: "pasting needs this application in front; bring it forward with a pointer step, or use type or set_value, which work while it is behind"
)
}
let board = NSPasteboard.general
let saved = snapshot(board)
let written = board.clearContents()
board.setString(text, forType: .string)
_ = try Keyboard.press(pid: pid, chord: "cmd+v", times: 1)
// The application reads the clipboard when it gets round to handling the
// keystroke, and giving it back before then is what hands it the
// person's own clipboard to paste instead. So the wait is long, and it
// is not the step's to pay: a busy application still has it a second
// after this call has answered.
Thread.sleep(forTimeInterval: 0.25)
DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) {
Clipboard.restore(board, saved, writtenAt: written)
}
return ["pasted": text.count]
}
private static func snapshot(_ board: NSPasteboard) -> [[NSPasteboard.PasteboardType: Data]] {
(board.pasteboardItems ?? []).map { item in
var kept: [NSPasteboard.PasteboardType: Data] = [:]
for type in item.types {
if let data = item.data(forType: type) {
kept[type] = data
}
}
return kept
}
}
fileprivate static func restore(_ board: NSPasteboard, _ saved: [[NSPasteboard.PasteboardType: Data]], writtenAt: Int) {
guard board.changeCount == writtenAt else { return }
board.clearContents()
guard !saved.isEmpty else { return }
board.writeObjects(saved.map { kept in
let item = NSPasteboardItem()
for (type, data) in kept {
item.setData(data, forType: type)
}
return item
})
}
}
@@ -1,79 +0,0 @@
import AppKit
// The agent's own pointer: drawn over everything, taking no clicks and no
// focus, so a person can see where it acts without it touching theirs.
final class VirtualCursor {
private let panel: NSPanel
private var hideTimer: Timer?
private static let size = NSSize(width: 120, height: 44)
init() {
panel = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.ignoresMouseEvents = true
panel.level = .screenSaver
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
panel.contentView = CursorView(frame: NSRect(origin: .zero, size: Self.size))
// Escape while the agent's cursor is on screen is the person saying stop.
// Any other time it is their own keystroke and not this process's business.
NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, event.keyCode == 53, self.panel.isVisible else { return }
reply(["event": "stop"])
self.hide()
}
}
// move glides the pointer to a point given in global top-left coordinates,
// the ones accessibility and window lists use.
func move(to point: CGPoint) {
guard let primary = NSScreen.screens.first else { return }
let origin = NSPoint(x: point.x - 2, y: primary.frame.height - point.y - Self.size.height + 2)
if !panel.isVisible {
panel.setFrameOrigin(origin)
panel.orderFrontRegardless()
} else {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.25
panel.animator().setFrameOrigin(origin)
}
}
RunLoop.main.run(until: Date().addingTimeInterval(0.28))
hideTimer?.invalidate()
hideTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { [weak self] _ in self?.hide() }
}
func hide() {
hideTimer?.invalidate()
panel.orderOut(nil)
}
}
private final class CursorView: NSView {
override func draw(_ dirtyRect: NSRect) {
let top = bounds.height
let arrow = NSBezierPath()
arrow.move(to: NSPoint(x: 2, y: top - 2))
arrow.line(to: NSPoint(x: 2, y: top - 22))
arrow.line(to: NSPoint(x: 7.5, y: top - 17))
arrow.line(to: NSPoint(x: 11, y: top - 25))
arrow.line(to: NSPoint(x: 14, y: top - 23.5))
arrow.line(to: NSPoint(x: 10.5, y: top - 16))
arrow.line(to: NSPoint(x: 17, y: top - 16))
arrow.close()
NSColor.systemIndigo.setFill()
arrow.fill()
NSColor.white.setStroke()
arrow.lineWidth = 1.5
arrow.stroke()
let label = "Reasonix" as NSString
let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: NSColor.white]
let textSize = label.size(withAttributes: attrs)
let pill = NSRect(x: 16, y: top - 40, width: textSize.width + 12, height: textSize.height + 4)
NSColor.systemIndigo.setFill()
NSBezierPath(roundedRect: pill, xRadius: pill.height / 2, yRadius: pill.height / 2).fill()
label.draw(at: NSPoint(x: pill.minX + 6, y: pill.minY + 2), withAttributes: attrs)
}
}
Loaded 100 of 3134 files, more files were not shown because too many files have changed in this diff. Show more