Compare commits

...
Author SHA1 Message Date
esengine 58c43fdf6c test(studio): scope the context-card hover guard to the context anchor 2026-10-15 12:07:09 -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
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
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 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 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 d76a5c4b52 Merge pull request #12583 from esengine/release-notes-2.34.0
docs(release): Studio 2.34.0 release notes
2026-10-10 07:03:07 -07:00
YHH fa4eb9c8df Merge pull request #12552 from esengine/fix/glm-contract-by-declared-protocol
fix(effort): apply the GLM depth contract by declared protocol, not host
2026-10-10 06:55:56 -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 afa05c1183 Merge pull request #12553 from esengine/fix/decision-source-not-a-chat-model
fix(models): a decision source is not offered, or accepted, as a conversation model
2026-10-10 05:03:19 -07:00
YHH 90bb7365ee Merge pull request #12546 from esengine/fix/typesafe-provider-probe
fix(providers): verify a decision source through its own protocol, not a model listing
2026-10-10 04:33:35 -07:00
yhh 2908a3575a docs(release): Studio 2.34.0 release notes 2026-10-10 04:14:17 -07: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 d6529d4932 Merge pull request #11692 from Guan0923/codex/studio-rewind-file-count
fix(studio): count files across the full rewind range
2026-10-10 03:40:43 -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
YHH 3eedd82740 Merge pull request #12579 from esengine/fix/installsource-windows-newline-test
test(installsource): make the alias quoting fixture creatable on Windows
2026-10-10 03:08:13 -07:00
yhh ba6cd5b843 test(installsource): drop the newline from the alias path fixture on Windows
A Windows file name cannot hold a newline, so the quoting test could not create its directory. The bidi override still exercises the literal-quoting path there.
2026-10-10 02:50:13 -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 5a74b65bbe Merge pull request #12110 from KHG420/codex/studio-skill-root-alias
fix(installsource): follow skill collection root aliases
2026-10-10 01:54:26 -07: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 6d7b27c718 Merge pull request #12542 from esengine/fix/computer-effect-grading-windows
feat(computer-helper/windows): report modals and grade typing by read-back
2026-10-10 01:27:42 -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 6e6234b833 Merge pull request #12517 from esengine/fix/plan-mode-refusal-cause-exit
fix(planmode): a refused call names its command, cause class and way out
2026-10-10 01:13:50 -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
YHH 9971114aa4 Merge pull request #12541 from esengine/fix/computer-effect-grading
feat(computer): grade each step's effect and carry the modal that holds input
2026-10-10 00:03:22 -07:00
XTLine f9a0a0926f fix(agent): preserve typed stream failure attribution 2026-10-10 15:03:20 +08:00
esengine 557b160d5e fix(models): a decision source is not offered, or accepted, as a conversation model
The model switcher listed every configured source, so a decision wire
(kind typesafe) appeared beside chat models and choosing one failed in the
provider registry as an unknown kind.

GET /models now answers one job: chat by default, answers=decision for the
decision sources, answers=all for the management views. The switch, the
default model and every role refuse a model whose wire does not answer that
job with a typed code (model.decision_only, model.not_decision_source), and
the provider builder refuses it with config.AnswersMismatchError. A saved
default that names a decision source is passed over like a stale one.
2026-10-09 23:58:44 -07:00
XTLine 172d61bba6 test(studio): cover restore reload and lifecycle boundaries 2026-10-10 14:53:14 +08:00
esengine 8b2ebe6e43 A connection that typed its own supported_efforts keeps its stored choice:
the contract translation (disabled -> none/low, enabled -> default) now
applies only when the menu is the contract, the same rule
InheritedEffortOfficial already used. Before, the Token Rhythm preset shape
sent thinking=enabled with reasoning_effort=max for a stored disabled.
2026-10-09 23:52:50 -07: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
YHH 39d06cf514 Merge pull request #12539 from esengine/fix/boot-shell-probe-off-handshake
fix(boot): bound and persist Windows shell discovery so the handshake is not held behind it
2026-10-09 23:47:27 -07: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
YHH 6cda7afaf2 Merge pull request #12540 from esengine/fix/ask-card-answer-affordance
fix(studio): make the ask card's free-text answer discoverable and name the wait
2026-10-09 23:22:46 -07:00
YHH ccb89c69c0 Merge pull request #12538 from esengine/fix/legacy-import-skip-reasons
fix(import): list the 1.x sessions an import could not read, with a typed reason
2026-10-09 22:58:30 -07:00
esengine 998e0fcd3d style(providers): satisfy errorlint and usestdlibvars in the decision check 2026-10-09 22:36:51 -07:00
YHH 0ee3e741a1 Merge pull request #12482 from SivanCola/fix/studio-sidebar-footer-entries
fix(studio): consolidate sidebar footer entries / 合并侧栏重复底部入口
2026-10-09 22:30:34 -07:00
esengine bb7e5947f5 test(providers): cover decision-source check statuses, and give a refused request its own code 2026-10-09 22:13:31 -07:00
xiexian 85e6a14f87 fix(installsource): follow skill collection root aliases 2026-10-10 13:05:09 +08:00
esengine 8e7e41505f fix(providers): verify a decision source through its own protocol, not a model listing 2026-10-09 21:55:55 -07:00
YHH a7109c5bf1 Merge pull request #12536 from esengine/fix/permission-rule-unknown-tool
fix(permissions): report and refuse rules that name no registered tool
2026-10-09 21:55:50 -07:00
Guan09 b792200d24 fix(studio): align rewind counts with affected files 2026-10-10 12:50:16 +08:00
yhh 7219a57b82 fix(computer-helper/windows): grade typing on the exact change, and read no value for keys
key and hold_key need only the modal their keys go to, so they no longer
read the focused element's value. Typing is confirmed only when the
read-back differs from the value before by exactly the typed text put in at
one place, replacing what was selected there; the changed span is taken
from either end, since repeated characters make it ambiguous. A value that
merely changed and contains a short typed text, as autocomplete or another
writer can leave it, is unverifiable.
2026-10-09 21:41:05 -07:00
yhh 792be5d35d Merge branch 'fix/computer-effect-grading' into fix/computer-effect-grading-windows 2026-10-09 21:38:51 -07:00
yhh b304663fa3 fix(computer): bound and escape a modal's names before the model sees them
A modal's title and the name of the window it holds come from the
application. They were quoted but not bounded, so a 100000-character title
reached the model as about 100 KB, while every other label in the tree is
clipped to 160 by the helper. Both names now go through
textutil.BoundLiteral at that length: controls, bidi overrides and
invisible characters show as visible escapes, and so does a quote, so a
name cannot close its own quotation.
2026-10-09 21:38:05 -07:00
YHH cc4f6eaaf2 Merge pull request #12530 from esengine/perf/skill-roots-once
perf(skill): resolve each discovery root once per pass
2026-10-09 21:28:32 -07:00
yhh 0032370259 feat(computer-helper/windows): report modals and grade typing by read-back
The Windows helper sent keys to whatever held the application's focus and
answered success. With Notepad's error dialog up, `type "hello"` returned
Done:1 while the text went to the dialog's OK button (#11366).

The helper now reads two structural facts, never a title or a message:
- a Win32 modal: an enabled window of the process whose owner chain holds a
  window of the same process it has disabled (MessageBox, DialogBox, common
  dialogs);
- a dialog drawn inside its window that declares WindowPattern.IsModal, which
  is what Windows 11 Notepad shows: one top-level window, still enabled, with
  a XAML popup holding its input.

With them:
- type and paste are refused as computer.blocked, before anything is sent,
  when focus sits in a modal on an element that takes no text: a letter there
  can be the dialog's mnemonic. Typing into a field of a modal (a file name
  in a save dialog) still goes in;
- a ref or point step on an element in a window the application has disabled
  is refused as computer.blocked, naming the modal that holds it;
- key and hold_key still go (Enter and Escape answer a dialog) and say which
  modal they went to;
- type, paste and set_value read the focused element's value before and
  after: confirmed when what was typed is in, suspected_noop when nothing
  changed, unverifiable when it cannot be read (no value, a password, or a
  value too long to compare). Values stay in the helper; set_value no longer
  sends its read-back across the protocol;
- a snapshot lists the modals, and marks the modal's line.

The fixture gains a message box mode and an owned form with a text field;
the live tests run in the logged-in desktop session.
2026-10-09 21:25:41 -07:00
YHH edb496c8e1 Merge pull request #12529 from esengine/perf/control-image-routing-config
perf(control): read the config for image routing only when a turn has attachments
2026-10-09 21:12:36 -07:00
esengine 8a64d2b8db feat(computer): grade each step's effect and carry the modal that holds input
A computer_act step that the helper accepted read to the model as done,
whether or not anything reached the target. On Windows, typing into Notepad
while an error dialog held its input returned Done:1 and the text went
nowhere, and the dialog showed in the tree only as an unnamed window.

The helper protocol gains three optional fields, all read from structure:
- a step's result may carry an effect: a closed class (confirmed,
  unverifiable, suspected_noop), the evidence kinds it rests on
  (value_readback, value_unchanged), and the modal its input went to;
- a failure may carry blocked_by, with the new code computer.blocked for
  input a modal would swallow;
- a snapshot may list modals: ref, title, and the window each one holds.

The kernel owns the grading contract: a class without the evidence that
proves it, an unknown class, or no effect at all (an older helper, or the
macOS helper until it reports one) settles as unverifiable. A step that
sends nothing, such as wait, carries no effect. computer_act says each
step's effect after its note and names the modal in a blocked failure; a
snapshot opens with the modals holding the application's input.

Tool schemas and descriptions are unchanged, so the cache-stable prefix is
byte-identical. Approval is untouched: grading only adds information, and the
new refusal only sends less.
2026-10-09 21:12:23 -07:00
YHH 215bfe8dce Merge pull request #12523 from esengine/fix/host-crash-trail
fix(host): quiet routine request logging and keep a crash trail when the kernel dies
2026-10-09 20:58:04 -07:00
esengine 2b2651d9f5 test(planmode): range over strings.SplitSeq
Refs #12508
2026-10-09 20:51:19 -07:00
esengine aa9266c5da test(sandbox): make the swapped-during-probe test depend on the digest alone
The replacement now keeps the size and restores the mtime, so only the digest
comparison can tell it apart; it fails with the digest taken after the probe.

Refs #11434
2026-10-09 20:50:21 -07:00
YHH 7eab681aa9 Merge pull request #12512 from xxoingr/fix/mcp-json-server-limit
fix(installsource): refuse a .mcp.json with more servers than the limit
2026-10-09 20:44:30 -07:00
esengine cc66e5bb2b fix(sandbox): digest the shell before its probe and document proof scope
The digest recorded for a persisted proof is taken before the launch, so an
executable swapped during the probe is not vouched for. The Probe field comment
now says the memo is private while proofs still persist.

Refs #11434
2026-10-09 20:42:33 -07:00
xiexian 58ab25039e feat(studio): inspect subagent skill declarations 2026-10-10 11:33:12 +08:00
YHH 4f02f9c8bf Merge pull request #12520 from esengine/fix/md-only-turn-verification-waiver
fix(readiness): waive the verification ask when every change this turn is prose
2026-10-09 20:25:09 -07:00
xiexian 36c3719cb4 feat(studio): show resolved plugin commits in install previews 2026-10-10 11:25:06 +08:00
yhh f7a6153d2c fix(import): attribute read failures to corrupt, keep copy_failed for writes
A failure with no sentinel on the read side (missing object, bad content
reference, size or digest mismatch) was reported as a failed copy. Reads now
default to corrupt, writes are classified separately, a folder without a
manifest is not listed as a session, and each copy button names its session.
2026-10-09 20:18:53 -07:00
esengine 8d08136127 fix(boot): bound and persist Windows shell discovery so the handshake is not held behind it
Bash discovery launched each Git Bash candidate serially (10 s each), then a
PowerShell check, and remembered the proof only in process, so every cold start
repeated it in front of the Studio handshake. Candidates are now probed with a
hedge, the search shares one budget and falls back with probe_timeout, and a
successful launch is persisted keyed by path, size, mtime and content digest
(treated as a miss on any doubt). Boot gains shell, prompt and environment
phase marks so the start-up trace names every stretch.

Refs #11434
2026-10-09 20:04:14 -07:00
esengine ffe7638955 fix(studio): make the ask card's free-text answer discoverable and say so in the status line
The free-text box of a question card stayed display:none until Other or a
single pick was chosen, so a card with only fixed options showed nothing
typeable while the run line read "Waiting on you" and the open plan kept
turning its in-progress arc.

The box is now always present on a live card and typing into it is an
Other answer, so there is one control for the free-text path. The run line
names the wait ("Waiting for your answer") and the plan stops turning its
arc while a decision is pending.
2026-10-09 20:00:20 -07:00
yhh 8edecb10ce fix(import): report each 1.x session an import could not read, with a typed reason
The legacy import dropped unreadable sessions behind event.Discard and
returned only a warning count. The migration layer now produces one skip
record per session (name, source path, reason code taken from the reader's
sentinel errors), the import response carries them, and the storage panel
lists them with the fact that the originals are untouched.
2026-10-09 19:46:25 -07:00
SivanCola b4b9fb364e fix(studio): preserve navigation action identities
Problem: The browser contrast guard timed out opening Settings after the
duplicate sidebar footer was omitted beside visible icon navigation.

Root cause: The icon rail's Settings and Account controls did not carry the
chrome.settings and chrome.account identities declared for those actions.

Fix: Declare each footer action identity alongside its icon, label and
destination, and render it on the rail button. Extend the interaction test to
verify those identities and destinations through both footer locations.

Verification: The new identity assertion failed on the previous rail and
passed after the fix. All 36 related interaction tests, production and perf
builds, ui-census invariants and 10 fixture reports passed. The CI browser
guards (intrinsic, contrast and wheel) plus perf/side.mjs passed. Formatting,
go vet, make lint, make check, builtin/boot Go tests and diff checks passed.
2026-10-10 10:42:05 +08:00
esengine 68f7df17ff fix(permissions): report and refuse rules that name no registered tool
A rule such as ask=["rm"] parses but its tool name matches no tool, so it never
fires and the user believes a boundary exists that does not. Saving a rule whose
tool no registered tool (or MCP/extension namespace) can match is now refused
with permissions.rule_unknown_tool; rules already in the file are kept, listed
as dormant in the permissions panel, and announced once at load with a typed
permission_rules_dormant notice.
2026-10-09 19:16:42 -07:00
yhh bfe720403f perf(skill): resolve each discovery root once per pass and share one pass across the built-in subagent tools
Store.roots canonicalised every directory up to three times per call, and
BuiltinSubagentTools ran a full discovery pass for each of the five built-in
skills it checks. On Windows each canonicalisation is a chain of directory
lookups, so every boot.Build paid for around 120 of them in this one step.

Refs #12521
2026-10-09 19:00:31 -07:00
yhh f177f4fbe5 perf(control): read the config for image routing only when a turn has attachments
resolveTurnImages and withVisionRouting loaded the full project config on
every turn to decide how to route images, including turns with no image at
all, where neither answer is used.

Refs #12521
2026-10-09 19:00:31 -07:00
YHH 309e6fb68d Merge pull request #12525 from esengine/ci/windows-full-timeout-budget
ci: raise the Windows package timeout to 18m and print the slowest boot tests on failure
2026-10-09 18:36:49 -07:00
yhh 8ab28a89fb ci: raise the Windows package timeout to 18m and print the slowest boot tests on failure
internal/assembly/boot took 478-717s on the Windows runner across 12 push runs, so the 12m per-package timeout panicked. The full step now tees go test -json and, on failure, prints the failed tests' output and the 15 slowest boot tests.
2026-10-09 18:20:31 -07:00
yhh 69266faf66 fix(readiness): a failed named-path write keeps its targets in the changed set
tool.after can report a finished write as failed, and a move can stop
half-done, so a failure is not proof that nothing was written. Non-prose
targets of a failed named-path call now keep the generic check owed. The
declared-checks cause also covers checks captured at task start.

Refs #12519
2026-10-09 18:06:41 -07:00
yhh c77b9fd284 docs(host): say which log files are redacted; show the exit notice in the page's language
The crash files are the runtime's raw output and are not redacted, only
shell.log and host.log are. The notice now follows the same language the
page draws in instead of the machine locale.
2026-10-09 17:38:16 -07:00
yhh 9e600e1750 fix(host): keep routine polling out of the INFO log and leave a crash trail when the kernel dies
A Studio kernel that died of a runtime fatal left no readable trace: its
stderr sat in host.log, which request logging rotated away every few
minutes, and the shell quit without recording how the kernel ended.

Routes that are polled declare themselves with routine(); a successful
request to one logs at Debug, a refused one stays at INFO. The kernel
mirrors runtime crash output into a bounded crash directory the shell
names, the shell quotes the tail into shell.log and the next launch tells
the person once.
2026-10-09 17:04:16 -07:00
yhh fb0ecb026e fix(readiness): a failed command that may have written is not a proven prose-only change
Failed non-named-path mutations leave the extent of the change unestablished,
so the prose-only waiver no longer applies. The ask now names an unestablished
extent and declared checks as causes, spells paths from the workspace root, and
the trace fixtures are regenerated.

Refs #12519
2026-10-09 16:59:02 -07:00
YHH 65b51b7c02 Merge pull request #12518 from esengine/fix/gitcmd-no-path-format-dependency
fix(gitcmd): resolve repository paths without rev-parse --path-format
2026-10-09 16:11:12 -07:00
yhh 433059e4ba fix(readiness): waive the generic verification ask when every change this task made is prose
The waiver read the whole workspace scan, so a README edit in a repository
with any code was still held to a verification command. It now reads the paths
the task changed: all prose (.md/.rst), scope established, no declared check.
The ask also names the changed paths that keep it owed.

Refs #12519
2026-10-09 16:08:37 -07:00
YHH 98a522aa84 Merge pull request #12511 from xxoingr/fix/tui-ctrl-z-suspend
fix(tui): Ctrl+Z suspends the TUI to the shell
2026-10-09 15:57:37 -07:00
YHH 0f337099d6 Merge pull request #12510 from xxoingr/fix/tui-empty-enter-tail
fix(tui): Enter on an empty composer returns to the newest output
2026-10-09 14:59:31 -07:00
YHH 1bb48c6363 Merge pull request #12505 from esengine/fix/model-switch-while-bg-jobs-12335
fix(serve): say whether a turn or leftover background jobs block a model switch
2026-10-09 14:38:50 -07:00
YHH e1ff932a15 Merge pull request #12506 from esengine/fix/foreign-open-no-copy-12474
fix(session): opening a 1.x conversation no longer forks a copy
2026-10-09 13:15:00 -07:00
yhh 15a2ae11d9 fix(gitcmd): resolve repository paths without rev-parse --path-format
Git before 2.31 echoes the unknown option as an extra output line, which
Open reported as not-a-repository. Normalise the git dir, common dir and
work tree in Go instead, and do the same for the index path lookup.
2026-10-09 13:12:28 -07:00
YHH 58f599ca1c Merge pull request #12171 from XTLine/feat/workspace-branch-switch
feat(session): check the workspace out to another local branch
2026-10-09 12:07:31 -07:00
YHH b3e1811916 Merge pull request #12498 from esengine/feat/provider-reasoning-shape-copy
feat(providers): word the thinking parameter from the protocol's declared effort field
2026-10-09 10:27:05 -07:00
esengine cbc8086776 fix(planmode): bound and escape the echoed command, name dynamic subcommands
The refusal echoed the command verbatim, so a long or multi-line command
could flood the message or forge a Cause line. The subject now goes through
textutil.BoundLiteral. A compound statement whose subcommand is dynamic
(git $X; ls) is attributed to shell expansion instead of leaking the
internal placeholder as an unknown program. The declared-writer cause no
longer claims a tool declared that it changes state.

Refs #12508
2026-10-09 10:04:13 -07:00
yhh 1f8f3711c8 Merge remote-tracking branch 'origin/studio' into fix/plan-mode-refusal-cause-exit 2026-10-09 10:03:24 -07:00
YHH b5082f4f5b Merge pull request #12477 from esengine/fix/rewrite-box-fit-height
fix(composer): size the rewrite box to its text, as the composer does
2026-10-09 09:43:49 -07:00
xxoingr 85ed87426d fix(installsource): refuse a .mcp.json with more servers than the limit
The install preview always shows every high-risk action, and an entry
with "tier": "eager" is high risk. A marketplace is capped at 64 plugins
and a skill repository at 200 skills, but a .mcp.json had no cap: a
package could ship tens of thousands of eager entries, bounded only by
the 2 MB remote read, and every one of them would be displayed.

parseMCPJSON, which planning runs for both a local and a fetched
.mcp.json, now refuses a file declaring more than maxMCPJSONServers (64,
the marketplace cap) with ErrInvalidManifest, before any entry is turned
into an action. The refusal is a hostFactError, so the caller reads the
declared count and the limit rather than an omitted detail.

With kind=mcp, a fetched URL whose path names a .mcp.json now returns
its parse error, as a local .mcp.json already did, instead of
ErrUnsupportedKind; the over-limit refusal otherwise never reached a
remote caller. Any other document fetched with kind=mcp, such as a
SKILL.md, still answers ErrUnsupportedKind.

Refs #12488
2026-10-10 00:34:17 +08:00
XTLine 905b74156a fix(gitcmd): resolve canonical repository paths through aliases 2026-10-10 00:29:54 +08:00
esengine 89668ab2a9 fix(planmode): a refused call names its command, cause class and way out
The planning gate recorded every call it could not prove read-only as a
side effect and told the model it "would change state", with no command,
no cause and no alternative. The effect is now Unclassified when the host
only failed to prove a read, SideEffect when a tool declares itself a
writer or an argument makes a reader write, and the refusal carries the
classifier's structural proof (unknown program, shell construct from the
parser, writing arguments, background, undeclared tool). The marker now
defines read-only shell in the same terms.

Refs #12508
2026-10-09 09:27:18 -07: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
YHH e3a4fa4c2b Merge pull request #12092 from XTLine/fix/composer-branch-chip
fix(studio): read the composer branch from git, not the capability scope
2026-10-09 09:13:53 -07:00
xxoingr 34b00e408d fix(tui): Ctrl+Z suspends the TUI to the shell
1.x suspended to the shell on Ctrl+Z, as terminal programs do; 2.x left
the key unbound, so it did nothing and there was no way to drop to the
shell without quitting.

Ctrl+Z now returns tea.Suspend from shortcutKey. Bubble Tea releases the
terminal (alternate screen, mouse tracking, bracketed paste) before
stopping the process and restores and repaints it on fg, so nothing of
1.x's manual mouse reset is needed. On Windows Bubble Tea has no job
control and ignores the request. GUIDE.md and its Chinese version list
the key.

Refs #12144
2026-10-09 23:52:26 +08:00
xxoingr 2c11679055 fix(tui): Enter on an empty composer returns to the newest output
After scrolling back through the transcript, Enter on an empty composer
did nothing; getting back to the live output took Ctrl+End or the wheel.
1.x jumped to the bottom and followed the tail again on an empty Enter,
idle or while a turn ran.

An empty (or blank) Enter now does the same in full-screen mode, through
followTail, which Ctrl+End also uses. It still sends nothing. Inline mode
has no in-app view to move and is unchanged. GUIDE.md and its Chinese
version note the behaviour on the Enter row.

Refs #12144
2026-10-09 23:49:34 +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
esengine da4e3d97d7 fix(serve): tell a running turn from leftover background jobs when a model switch is refused 2026-10-09 07:12:27 -07:00
yhh 9ebbde6678 fix(session): a rebuilt 1.x conversation no longer forks a copy on open
Swapping in this build's system prompt after a model switch made the
transcript differ from the 1.x log, so the save that follows moved the
conversation to a new session. The system prompt is host contract, not
conversation; the unchanged check now leaves it out.

Refs #12474
2026-10-09 06:50:08 -07:00
YHH cdfe244aa3 Merge pull request #12037 from esengine/fix/bound-install-preview-text
fix(installsource): bound and sanitise package-supplied preview text
2026-10-09 06:45:16 -07:00
YHH d45419d5f1 Merge pull request #12476 from esengine/fix/job-completion-notice-code
fix(jobs): give background job completion notices a typed code and payload
2026-10-09 06:44:42 -07:00
XTLine dce0b0c2fb fix(studio): inherit tooltip guards and isolate branch fixture line endings 2026-10-09 20:55:30 +08:00
XTLine 4a123ae574 fix(studio): enforce tooltip regression guard and accessible naming 2026-10-09 20:53:56 +08:00
esengine f26b7cc8e4 test(studio): let the Chat fixture entry offer the thinking switch 2026-10-09 05:27:56 -07:00
esengine c3752645d7 fix(queue): keep the queued-line editor inside the rows the list shows
The list scrolls inside a height the composer's aux area caps, so an editor
grown to its text could be taller than the list and get cut off at an edge.
The fit hook now takes the room left by the list, and the edited row is
scrolled into view. The browser check asserts the whole row sits inside the
list's visible area at desktop and phone width.

Refs #12477
2026-10-09 05:19:34 -07:00
esengine 24f8a0fe2d fix(providers): resolve the effort field per entry and cover existing configs
The effort field is now resolved from the entry's reasoning protocol rather
than its kind, so an auto entry that resolves to a bespoke shape (Zhipu,
MiniMax, LongCat, DeepSeek) no longer claims reasoning_effort. The catalog
carries the protocols the field holds under and the panels read it instead of
keeping a copy. Adds the Anthropic none case and an end-to-end check that
existing effort-only configs keep their request bytes.
2026-10-09 05:18:30 -07:00
YHH 2b9aab4248 Merge pull request #12497 from esengine/fix/studio-wireparity-duplicate-look
fix(repolint): drop the duplicate look.ts mirror declarations that broke the studio build
2026-10-09 05:14:27 -07: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
yhh cbc1a537ff docs(installsource): describe hiddenActions and previewTruncated as they behave
The package doc still said a cut of 50 actions sets previewTruncated, and the
field comment said escaped text sets it. The test-only PreparePlugin option
is marked as such.
2026-10-09 05:08:09 -07: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 961f4e0c3a fix(repolint): drop the duplicate look.ts mirror declarations that broke the studio build
#12389 and #12035 each added the tsLookFile constant and an appearanceView mirror; the two merged cleanly as text and left tools/repolint unable to compile (tsLookFile redeclared). Keep one constant and one appearanceView entry, plus the zoomRangeView entry #12035 added.
2026-10-09 05:01:15 -07:00
YHH f1a9256ade Merge pull request #12486 from esengine/fix/windows-git-bash-discovery
fix(sandbox): find Git Bash through its registry install path and say when the shell falls back to PowerShell
2026-10-09 04:55:48 -07:00
yhh b51fd9cbb3 fix(installsource): report hidden steps and cut text separately
PreviewTruncated also fired when only whole steps were left out, so the
confirmation said text had been shortened when none had. It now means that
text was cut or escaped, and HiddenActions carries the step count. The card
no longer repeats the plan-level sentence; it shows a short marker aligned
with the other rows.
2026-10-09 04:52:57 -07:00
yhh 987f44cafa test(serve): locate the installed plugin through its recorded root 2026-10-09 04:48:27 -07:00
YHH d3c3e861de Merge pull request #12035 from esengine/fix/ui-scale-single-range-and-keys
feat(desktop): one declared interface-scale range, and Ctrl/Cmd +/-/0 to change it
2026-10-09 04:47:32 -07:00
XTLine 58807823ad fix(session): bound branch lease waits and distinguish workspace refusals 2026-10-09 19:44:27 +08:00
XTLine 9a114fd836 fix(studio): bound branch tooltips and merge latest studio 2026-10-09 19:39:22 +08: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
esengine 70621ada23 feat(providers): word the thinking parameter from the protocol's declared effort field
The protocol table now declares where a chosen effort level lands in the
request body (reasoning_effort, reasoning.effort, output_config.effort). The
protocol catalog and the provider list carry it, and the add, detail and edit
panels word their help and example body from it instead of naming the Chat
field for every wire.

The Responses wire now takes the same thinking switch, reasoning_protocol and
declared levels as Chat in all three panels and in the edit endpoints.
reasoning_protocol=none is honoured when resolving the effective effort, and
a declared openai protocol lists its own ladder as the request vocabulary.
2026-10-09 04:36:50 -07:00
YHH 257f39e8c9 Merge pull request #12039 from esengine/fix/acknowledgments-owner-filter
fix(docs): keep the owner, aliases and tool identities out of the acknowledgments table
2026-10-09 04:34:33 -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
esengine 51fef65939 test(perf): name the viewport check for what it measures and tighten the queue editor bound
The shrunken-viewport case is renamed to say it checks the box stays within
60% of a shorter viewport with the caret at the end. The queue editor check
now compares against its computed cap and the room the list leaves. The
editor's own max-height was dead (the composer's textarea rule wins), so it
is removed again.
2026-10-09 04:27:24 -07:00
YHH f05a3e3b79 Merge pull request #12083 from esengine/fix/compaction-failure-visible-and-bounded-notice
feat(compaction): announce a held automatic compaction
2026-10-09 04:27:24 -07:00
yhh 293367b5cb fix(sandbox): name the interpreter in use when advising how to silence the PowerShell fallback
prefer=powershell tries Windows PowerShell 5.1 before pwsh, so advising it to a pwsh 7 user would trade away && chains. Advise prefer=pwsh when the interpreter in use chains.
2026-10-09 04:14:33 -07:00
YHH b7afe286eb Merge pull request #12469 from esengine/fix/compaction-truncated-summary-retry
fix(compaction): retry a length-truncated summary once with a larger output cap
2026-10-09 04:14:21 -07:00
YHH f33b563b13 Merge pull request #12468 from esengine/fix/nav-hint-clipped
fix(studio): icon-rail hover hints are no longer clipped or covered
2026-10-09 04:12:13 -07:00
YHH e58220852a Merge pull request #12465 from esengine/fix/receipt-card-noise
fix(receipt): keep host-owned state and the English sentence off the delivery card
2026-10-09 04:10:23 -07:00
yhh e4846a0c5a fix(sandbox): find Git Bash through its registry install path and say when the shell falls back to PowerShell
Auto-detection never looked at where Git for Windows records its own install, so a Git outside the standard roots whose git is not on PATH was invisible and the session silently ran PowerShell. Add that registry location as the last bash candidate source, make a failed probe say whether it timed out, give the probe 10s instead of 3s for a cold start, and write the fallback warning that the startup notice already carries.
2026-10-09 04:09:59 -07:00
yhh 81f310d55a test(market): hold the theme gate and every count field it sums
The theme category check had no test that reached ErrNotTheme. A marketplace
of fifty theme packages plus one with a hook must be refused and keep the
hook step visible in the envelope; sixty pure themes must pass. A table
sets each count field and the runtime alone, so dropping any term from the
sum is caught. install_source gains a test-only PreparePlugin option so the
market layer can plan a marketplace without a network.
2026-10-09 04:08:19 -07:00
YHH d256440031 Merge pull request #12109 from KHG420/codex/studio-registered-skill-discovery
fix(installsource): report registered skill discovery by source
2026-10-09 04:08:18 -07:00
yhh 2e0c5ef3cc fix(installsource): gate on the unprojected plan and keep host sentences whole
The theme gate read the bounded preview, which lists at most 50 steps while
a marketplace may hold 64 plugins and a skill repository 200 skills, so a
hook or runtime past the cut skipped the check and the confirmation. The gate
now reads the unprojected actions; the preview keeps every high-risk step,
caps the rest, and reports how many it left out; the real skill count is
shown. Package inventory returns the package's own text again, and the
display projection is a separate call.

The FULL TRUST reason embedded the runtime command inside a sentence that was
then stripped as prose, so an unterminated escape sequence in an argument
swallowed the warning. The command is now rendered literally after the host
sentence. BoundProse reports removal as well as truncation, BoundLiteral
cannot be mimicked by typed escapes, and colliding display keys are flagged.
2026-10-09 03:54:24 -07:00
YHH de4e0e6a9d Merge pull request #12107 from KHG420/codex/studio-skill-install-shadow-diagnostics
fix(installsource): report skill installation shadowing accurately
2026-10-09 03:50:43 -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 6a901e8458 Merge pull request #12099 from KHG420/codex/studio-installed-scenario-guide
docs(studio): run scenario exercises from installed packages
2026-10-09 03:35:06 -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
XTLine 3b839b0821 feat(session): check the workspace out to another local branch
Stack on the refreshed read-only branch chip. Guard checkout atomically against all active panes and unfinished jobs in this host, preserve queued input cancellation, honor existing writer leases, and protect ignored local files including late-created collisions.
2026-10-09 18:22:55 +08:00
SivanCola 019bd48bd8 fix(studio): consolidate sidebar footer entries
Problem: Usage, feedback, account/workspace and settings appeared twice when
the global icon navigation and workspace sidebar were both visible.

Root cause: Sidebar rendered its footer unconditionally, independent of the
navigation visibility already owned by App.

Fix: Pass the existing navigation visibility into Sidebar and render the
footer only when navigation is hidden. Preserve the fallback entries in off,
collapsed-only and narrow-window modes, plus existing feedback and settings
routing. Update layout/parity docs, pending release notes and fixture images.

Verification: 36 relevant frontend interaction tests, 11 release-note/link
tests and the frontend production build passed. Formatting, go vet, make lint,
make check, builtin/boot Go tests and diff whitespace checks passed. Browser
fixtures cover light/dark wide windows and narrow drawers.
2026-10-09 18:20:18 +08:00
YHH 4583c328c7 Merge pull request #12389 from f1shsmell/chore/repolint-look-wire-parity
chore(repolint): declare six look.ts wire mirrors
2026-10-09 03:19:35 -07:00
esengine c6ac527dc5 fix(queue): fit the queued-line editor to its text and cap heights in dvh
The queued-line editor took its height from the number of line breaks, the
same defect as the rewrite box, so it now uses the shared fit-to-content hook.
Both height caps use dvh (with a vh fallback on the same line) so a soft
keyboard shrinking the viewport shrinks the cap. The browser check gains the
queue editor and a shrunken-viewport case; the harness can read a seeded
queue line back.
2026-10-09 03:17:17 -07:00
esengine 0b34dac58d fix(jobs): name the job in a failed-job notice and cover the killed path
A failed job's notice now carries the same JobNotice payload as finished and
killed ones, plus the job function's own error text, so a frontend words
it from the label or id and shows the raw error beneath. A failure stored
without a payload still renders its text. Adds an emission test for the
killed notice.
2026-10-09 03:08:55 -07:00
YHH 6a822a2e20 Merge pull request #12104 from chuiwenwei/feat/memory-omitted-matches
feat(memory): report omitted matches on the memory tool paths
2026-10-09 03:03:47 -07:00
XTLine 4916417b29 fix(studio): read the composer branch from git, not the capability scope
Rebase the read-only branch chip onto the current Pane split. Keep the stylesheet within budget, register the complete workspace Git wire contract, and verify refresh after turn completion.
2026-10-09 17:59:33 +08:00
yhh 7c5ce2b2c4 fix(serve): register the auto-archive routes with the other session routes 2026-10-09 02:48:11 -07:00
YHH ccf26ffb48 Merge pull request #11572 from Guan0923/codex/studio-rewind-checkpoint-state
fix(checkpoint): sync file state after code rewind to prevent false conflicts
2026-10-09 02:45:55 -07:00
yhh 00b01ef3b9 test(desktop): drop an unused binding 2026-10-09 02:27:12 -07:00
yhh cf190c021b fix(desktop): show the interface-size keyboard hint only where the chords are registered 2026-10-09 02:26:14 -07:00
esengine 2d112a548a fix(composer): size the rewrite box to its text, as the composer does
The rewrite box took its height from the number of line breaks, so a long
paragraph written as one line showed about 60px of it. The composer's
fit-to-content logic is now a shared hook used by both, with the box capped
at 60vh. A browser check covers a long single line at desktop and phone
width in both themes.
2026-10-09 02:22:52 -07:00
esengine 01e2f80ad3 fix(jobs): give background job completion notices a typed code and payload
The completion notice carried only English text with a numeric id, so a
frontend could neither localize it nor name the job. Finished and killed
notices now carry a code and a JobNotice payload (kind, id, label); a
failure carries a code and keeps its diagnostic. Studio and the terminal
UI word them from the code; uncoded notices from older sessions still
render their stored text.
2026-10-09 02:18:50 -07:00
YHH f303de0326 Merge pull request #11374 from drakeo338/claude/11372-fix
fix(config): keep the config edit lock directory per user and refuse a foreign owner
2026-10-09 02:15:05 -07:00
yhh 87a6a8132c test(compaction): size the held-notice fixture so the retry stays held under the growth-bounded release 2026-10-09 02:12:45 -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 3da403da04 Merge pull request #12135 from KHG420/codex/studio-memory-draft-lifetime
fix(studio): preserve memory drafts and pending writes
2026-10-09 01:59:07 -07:00
yhh 911f9cf471 Merge studio into fix/compaction-failure-visible-and-bounded-notice 2026-10-09 01:53:45 -07: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
yhh 17ea70c8df Merge studio into fix/acknowledgments-owner-filter 2026-10-09 01:51:38 -07:00
YHH a20c5f107b Merge pull request #12126 from KHG420/codex/studio-package-install-connection
fix(studio): scope package installation forms to connection
2026-10-09 01:42:29 -07:00
yhh d81168f2a3 build(desktop): record rivo/uniseg checksums 2026-10-09 01:40:54 -07:00
yhh 3eee28cabe Merge studio into fix/ui-scale-single-range-and-keys 2026-10-09 01:40:53 -07:00
yhh 4e8b8e7963 Merge remote-tracking branch 'origin/studio' into upd-12037 2026-10-09 01:40:29 -07:00
yhh d6b2ef4de7 fix(compaction): restart the card's digest on retry, bound-test the retry, align docs
A text-less compaction_progress frame now tells a frontend the streamed
digest was discarded, so a retried summary does not append to the cut one.
The pressure effect test counts attempts as pairs with a larger retry cap
instead of capping raw request count. Adds a shared-window room test and
updates SPEC.md.
2026-10-09 01:36:07 -07:00
YHH 79cef29613 Merge pull request #12470 from esengine/docs/sponsor-packycode
docs(readme): add a sponsor slot for PackyCode
2026-10-09 01:27:24 -07:00
YHH 535c65d8d6 Merge pull request #12367 from KHG420/codex/studio-adopted-interceptors
fix(studio): keep adopted interceptors working after model switches
2026-10-09 01:26:59 -07:00
YHH 2ed7e86f14 Merge pull request #12456 from esengine/release-notes-2.33.0
docs(release): Studio 2.33.0 release notes
2026-10-09 01:15:44 -07:00
yhh 287370ca03 fix(compaction): retry a length-truncated summary once with a larger output cap
The summary request is capped at 16K output tokens and reasoning shares that
cap, so a model with no low reasoning tier can spend all of it and have the
digest discarded. Truncation is already a typed finish reason, so the host
retries once with the cap doubled (bounded by the model's configured output
ceiling) and fails with the cap and retry recorded when that is cut too.
2026-10-09 01:02:20 -07:00
yhh 0d80335152 fix(studio): icon-rail hover hints are no longer clipped or covered
The rail clipped itself to its animated width, which cut off the hints that
hang outside it, and shared a z-index with the workspace rail that paints
later. The rail now keeps its open width and slides off screen instead, and
sits one layer above the workspace rail.

Refs #12462
2026-10-09 00:45:03 -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 8fe0b30dc0 fix(receipt): keep explicit VCS writes counted and refuse links into the snapshot store
Only the walk skips VCS store entries. An explicit write into a VCS store is a
change like any other. The snapshot-store exclusion is a path convention for
de-noising, so a symlink anywhere on the way disables it.
2026-10-08 23:40:03 -07:00
yhh 23be1e3344 fix(receipt): drop the CACHEDIR.TAG exclusion
A marker file anyone can write, the model included, would shrink what the host
observes of a delivery. Only state the host itself writes is excluded.
2026-10-08 23:12:39 -07:00
yhh 49073a9648 fix(receipt): keep host-owned state and the English sentence off the delivery card
The unverified-change gap carried a sentence the kernel wrote in English, so
every language showed it; it now carries only the declined command. Changes the
host or a tool owns -- the task-snapshot store, VCS stores (including a .git
file), and directories tagged with CACHEDIR.TAG -- no longer count as work.

Refs #12457
2026-10-08 23:05:04 -07:00
YHH 5e17135b6f Merge pull request #12456 from esengine/release-notes-2.33.0
docs(release): Studio 2.33.0 release notes
2026-10-08 20:53:03 -07:00
YHH 24dee91241 Merge pull request #12450 from esengine/fix/feedback-sync-stable-tags
fix(feedback-sync): count only stable studio tags and merged closing PRs
2026-10-08 20:52:37 -07:00
yhh 4a13a49dc7 docs(release): Studio 2.33.0 release notes 2026-10-08 20:39:55 -07:00
YHH 471c40cff3 Merge pull request #12448 from esengine/perf/studio-idle-gpu
perf(studio): stop a sky pack and frosted panels from keeping the GPU busy
2026-10-08 20:37:20 -07:00
YHH ffca8ca15d Merge pull request #12446 from esengine/chore/bump-go-and-x-net
chore(deps): bump Go toolchain to 1.26.9 and x/net to v0.60.0
2026-10-08 20:21:44 -07:00
YHH 60d26453c8 Merge pull request #12447 from esengine/fix/tui-input-complexity
refactor(tui): bring onKey back under the complexity budget
2026-10-08 20:06:45 -07:00
YHH ee742a31fd Merge pull request #12454 from esengine/fix/mcp-binding-linux-tests
test(mcp): make the enable-binding tests hold on Linux
2026-10-08 19:56:06 -07:00
esengine 37c4f3d744 test(mcp): make the enable-binding tests hold on Linux
The tests that fake Windows name probing on every OS spelled PATHEXT in
upper case while their fixture files are lower case. Windows file lookups
ignore case, so the real lookup finds mcp.cmd for .CMD; a Linux filesystem
does not, so under the fake the probed name missed the fixture and the
tests failed there. The seam fakes the probing, not the filesystem: spell
the extensions as the fixtures are.

Adds the off-Windows counterpart: where nothing probes extensions, a .cmd
beside the command does not affect the binding, and the command file does.
2026-10-08 19:39:56 -07: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
yhh daa13a418a fix(feedback-sync): count only stable studio tags and merged closing PRs
A fixed entry is upgraded from next to a concrete version only when a merged
closing pull request is contained in a studio tag whose release page is neither
a draft nor a prerelease. Prerelease status is read from the releases API, not
from a version cutoff.
2026-10-08 19:32:30 -07:00
yhh 39e4f4033d perf(studio): stop a sky pack and frosted panels from keeping the GPU busy
A pack with a sky drew a blurred cloud bank under six backdrop-filter panels,
so every frame re-blurred 41% of the window and the idle window held a GPU
engine near full on integrated graphics. Frosted panels that are already
72-94% opaque blurred nothing visible, and the grain layer forced a
full-window overlay blend on every frame.

- Sky: CSS breathing animations removed; the canvas redraws on a 15 fps
  timer and only while the window is shown and focused (presence.ts).
- backdrop-filter kept on the composer and the transcript backing only;
  the other panels get a higher flat tint instead.
- Grain drops mix-blend-mode.
- New Appearance setting, Visual effects: Full / Reduced (rx-effects,
  immediate, per machine). Reduced removes remaining blur, text glow, the
  grain and the sky.
- perf/effects.mjs guards all of it in a real browser.
2026-10-08 19:30:48 -07:00
yhh 0cfa0484ce refactor(tui): move the steer and history-edge key rules out of onKey 2026-10-08 19:28:51 -07:00
yhh c8622f7a8c chore(deps): bump Go toolchain to 1.26.9 and golang.org/x/net to v0.60.0 2026-10-08 19:27:43 -07:00
YHH 178dbb7934 Merge pull request #12443 from esengine/feat/studio-open-html-preview
feat(studio): open an HTML file from the explorer as a page preview
2026-10-08 18:23:00 -07:00
YHH a59623aedc Merge pull request #12442 from esengine/feat/studio-model-picker-parity
feat(studio): group, search and mark the composer model picker
2026-10-08 18:21:51 -07:00
YHH e28b76734c Merge pull request #12437 from xxoingr/fix/tui-ctrl-enter-steer
fix(tui): Ctrl+Enter steers a running turn
2026-10-08 18:20:42 -07:00
YHH 5180a74008 Merge pull request #12436 from xxoingr/fix/tui-right-click-paste
fix(tui): right-click with no selection pastes clipboard text
2026-10-08 18:19:33 -07:00
YHH 02395fea16 Merge pull request #12435 from xxoingr/fix/tui-recall-multiline
fix(tui): history recall walks past a multi-line entry
2026-10-08 18:18:28 -07:00
YHH be021ac52f Merge pull request #12332 from KHG420/codex/studio-sent-image-previews
fix(studio): preview saved image attachments in user messages
2026-10-08 18:17:28 -07:00
YHH 172678e1cd Merge pull request #12429 from icebear02/fix/device-panel-relay-status
fix(studio): explain why Internet device access is unavailable
2026-10-08 18:16:24 -07:00
YHH 449f8ca219 Merge commit from fork
fix(mcp): bind a project server's enable to its declaration
2026-10-08 18:10:26 -07:00
yhh 0dee6fd100 feat(studio): open an HTML file from the explorer as a page preview
Clicking .html/.htm in the workbench explorer shows the file as a page in the
shared browser through the existing disk-path open, reloading when the file
changes on disk. Windows that draw no browser views and remote workspaces keep
the source editor.
2026-10-08 18:09:36 -07:00
yhh 6062a8d011 feat(studio): group, search and mark the composer model picker
The model menu now always opens with a search field, groups models under a
sticky provider heading with a letter chip, uses compact one-line rows and
grows with the window (up to 640px) instead of a short fixed box. Rows keep
the wire format and show a reads-images badge where the model declares it.
Enter no longer lands on the manage action, and an empty search says so.
2026-10-08 17:57:50 -07:00
esengine e2d8eba20d fix(mcp): bind a project server's enable to its declaration
An enable recorded for a server that .mcp.json or a project reasonix.toml
declares held for the server's name. The row now records a digest of the
declaration as written (type, command, args, env, url, headers), the project
.env values it expands, and the content of the workspace files it names:
its arguments, and every file the launcher could start for its command,
found by the launcher's own lookup (base/proc): the declaration's PATH for a
bare name (every Windows spelling of it, in key order), and PATHEXT
siblings on Windows, dotted names included. A different declaration reads
as ActivationChanged: off and awaiting the user. Rows written before this
carry no digest and ask once.

- Specs for a repository-declared server carry a LaunchCheck run before
  every start and redial; env overrides merge in key order.
- Why a server waits is a typed code (awaiting_user_decision,
  changed_since_enabled) on MCPHealth, the serve and ACP wires and the TUI;
  Studio and the TUI word it in the reader's language; Studio shows the
  launch line beside the switch.
- A connect or reconnect by name refuses a repository server in any pending
  state with ErrMCPApprovalOwed (serve: mcp.approval_owed) and its launch
  line; a server from the user's own files is still answered by a connect.
- Build reports each held project server once, so `reasonix run` prints
  one warning line per server; serve prints the same at startup; /mcp and
  `reasonix mcp list` show them. Repository-chosen names are sanitised
  wherever they are printed, and run diagnostics sanitise every line.
- `reasonix mcp enable` prints the approved launch line; a project install
  records the decision for the declaration as written; doctor --live
  starts only what a session would.
2026-10-08 12:09:24 -07:00
esengine 7ba591ad13 test(mcp): an enable covers the declaration and the files it runs
Every test for the project MCP approval change, ahead of the change:

- boot effect tests through the real assembly and the settings switch: a
  rewritten declaration, a launcher changed mid-session and an executable
  swapped behind the declaration's own PATH must not start on an earlier
  enable; held servers are reported once each at build; a repository's
  server name reaches no notice as control sequences.
- config: the enable covers the declaration, the .env values it expands,
  the files it names, the executable the launcher resolves (declared PATH,
  every spelling of PATH, PATHEXT siblings including dotted names), legacy
  rows ask again; base/proc holds the launcher's lookup.
- plugin: the launch check runs before start and redial; env merges in a
  fixed order.
- control/cli/tui/frontend: a connect by name never approves a repository
  server that waits; why it waits is a typed code on every surface; /mcp,
  `reasonix mcp list`, serve's startup warning, run diagnostics and the TUI
  panel print a hostile name as plain text; doctor --live starts only what
  a session would.
2026-10-08 12:09:24 -07:00
YHH a90594dda2 Merge pull request #12439 from esengine/fix/flaky-frontend-2
fix(studio): move focus into the feedback body after the environment has committed
2026-10-08 11:55:51 -07:00
YHH 87b385f86e Merge pull request #12438 from esengine/fix/flaky-frontend-1
test(studio): wait for the pane's subscription before emitting into it
2026-10-08 11:54:53 -07:00
yhh fa985108d7 fix(studio): move focus into the feedback body after the environment has committed
The form moved focus from the tab into the body in a frame callback queued from the environment promise. The fieldset is disabled until that environment is committed, so a frame that fired before the commit called focus() on a disabled textarea and focus stayed on the tab. The move now runs in an effect on the environment, after the commit.
2026-10-08 11:40:32 -07:00
yhh d29c170e54 test(studio): wait for the pane's subscription before emitting into it
The unread interaction tests emitted wire events through a listener map that was only filled once the pane subscribed, and a missing listener was a silent no-op. The first test of the turn-ending group waited for the port to exist, which is earlier than the subscription, so an event could be dropped under load. emit now waits for the listener.
2026-10-08 11:40:22 -07:00
xxoingr bf55a5221b fix(tui): Ctrl+Enter steers a running turn
docs/GUIDE.md lists Ctrl+Enter (or /steer) as the key that adds guidance
to the active turn, and 1.x bound it so. 2.x bound only Ctrl+S, so on a
terminal that reports Ctrl+Enter the documented key did nothing.

While a turn runs, Ctrl+Enter now sends the composer as steer through the
same path as Ctrl+S. Idle it does nothing, as in 1.x, so a press meant as
a newline never submits the draft. Ctrl+S is unchanged.

Refs #12144
2026-10-09 02:29:23 +08:00
xxoingr de3c6bdcd2 fix(tui): right-click with no selection pastes clipboard text
With in-app mouse capture on, a right-click copied an active transcript
selection and otherwise did nothing. 1.x pasted the clipboard's text on
a right-click with no selection, and docs/GUIDE.md and docs/CLI.md say
2.x does too, so the documented paste never happened.

A right-click with no selection now pastes through the same path as
Shift+Insert: text only, through the normal paste handling, with the
existing hint over SSH. It pastes only while the composer is drawn; the
condition that draws it moves into composerShown so the view and the
mouse read one answer. A right-click on a selection still copies it.

Refs #12144
2026-10-09 02:27:16 +08:00
xxoingr ea148fed50 fix(tui): history recall walks past a multi-line entry
Up and Down recalled history only while the composer held a single line.
Once a recalled entry had several lines, Up moved the caret to its first
line and then stopped: nothing older could be reached, and a multi-line
draft could not step into the history at all.

Up now recalls from the composer's first line and Down from its last, as
1.x did; between them the keys move the caret as before. A single line,
soft-wrapped or not, behaves exactly as it did.

Refs #12144
2026-10-09 02:24:54 +08:00
YHH 41e8f3be6f Merge pull request #12421 from xxoingr/fix/tui-mcp-panel-keys
fix(tui): q, h/l and Left/Right in the MCP manager
2026-10-08 11:11:48 -07:00
YHH 034c9519f4 Merge pull request #12409 from esengine/dependabot/npm_and_yarn/workers/crash-report/multi-1f96396017
chore(deps): bump tinypool and vitest in /workers/crash-report
2026-10-08 10:53:41 -07:00
YHH 0188c0a5c7 Merge pull request #12408 from esengine/dependabot/npm_and_yarn/workers/crash-report/source-map-js-1.2.2
chore(deps-dev): bump source-map-js from 1.2.1 to 1.2.2 in /workers/crash-report
2026-10-08 10:52:29 -07:00
YHH edc780dbd3 Merge pull request #12420 from xxoingr/fix/tui-ask-jkhl
fix(tui): j/k and h/l drive the question card
2026-10-08 10:37:48 -07:00
YHH 75ccd2abcd Merge pull request #12419 from xxoingr/fix/tui-approval-jk
fix(tui): j/k and Ctrl+N/Ctrl+P move the approval cursor
2026-10-08 10:22:02 -07:00
icebear02 4c459ad337 test(studio): cover empty cloud status reason 2026-10-09 01:08:05 +08:00
icebear02 d3d4c03452 fix(studio): preserve neutral empty cloud status 2026-10-09 01:07:33 +08:00
YHH b6cd0df288 Merge pull request #12423 from KHG420/codex/studio-feedback-image-read-lifetime
fix(studio): keep pending screenshots within their feedback draft
2026-10-08 10:07:21 -07:00
icebear02 12129ac460 refactor(remotecloud): classify relay dial errors
Refactor error handling in relay dial logic and extract into a new function.
2026-10-09 01:06:16 +08:00
xiexian aedbba6314 fix(studio): retain saved image previews after a failed load 2026-10-09 01:01:58 +08:00
YHH 72f596d209 Merge pull request #12411 from KHG420/codex/studio-queue-edit-read-order-preflight-r2
fix(studio): keep the latest queued edit selection
2026-10-08 09:52:37 -07:00
YHH c47bdfd847 Merge pull request #12432 from esengine/docs/ui-quality-process
docs: UI-change section in the PR template and a 1.x parity table
2026-10-08 09:47:52 -07:00
YHH 40bac6c3c4 Merge pull request #12344 from KHG420/codex/studio-pane-startup-reads
perf(studio): avoid duplicate reads before the session is known
2026-10-08 09:41:30 -07:00
YHH f2112e6ad8 Merge pull request #12426 from KHG420/codex/studio-resend-rewind-failure
fix(studio): preserve message edits when rewind fails
2026-10-08 09:38:50 -07:00
yhh a0a53ca890 docs: add UI-change section to the PR template and a 1.x parity table
Cause: 30 days of Studio UI changes landed without screenshots or review, and a visible control was hidden without a linked issue or a release note.
The PR template had no UI requirement and no table tracked 1.x features.
2026-10-08 09:25:40 -07:00
YHH d779d89bcc Merge pull request #12424 from esengine/docs/readme-plain-language
docs(readme): say what Reasonix does in plain words
2026-10-08 08:12:37 -07:00
YHH d4e7fe771a Merge pull request #12428 from esengine/feat/studio-nav-rail-modes
feat(studio): icon rail at the far left, with feedback and three display modes
2026-10-08 08:03:46 -07:00
icebear02 129743f52f test(repolint): cover the cloud remote status mirror 2026-10-08 22:56:06 +08:00
icebear02 6efb57191f i18n(studio): translate cloud relay status messages 2026-10-08 22:55:52 +08:00
icebear02 25d938b61a test(studio): cover typed cloud status messages 2026-10-08 22:55:36 +08:00
icebear02 8afc203738 fix(studio): explain unavailable Internet access 2026-10-08 22:55:21 +08:00
icebear02 421c32c8dd test(studio): seed the mock cloud reason 2026-10-08 22:54:59 +08:00
icebear02 8cdfffca62 fix(studio): type the cloud remote status reason 2026-10-08 22:54:40 +08:00
yhh 8429cd2708 feat(studio): icon rail at the far left, with feedback and three display modes
Refs #12427. The rail moves left of the sidebar, gains the feedback entry, and the Appearance setting becomes always / only when the sidebar is collapsed / never. Stored on and off values keep their meaning.
2026-10-08 07:53:34 -07:00
icebear02 d1dac77fb5 fix(serve): expose the cloud remote status reason 2026-10-08 22:50:28 +08:00
icebear02 1e2171c7a2 fix(studio): carry the cloud status reason to the share bridge 2026-10-08 22:50:04 +08:00
icebear02 53a8a82896 fix(remotecloud): classify unavailable relay states 2026-10-08 22:49:38 +08:00
yhh 46658f5508 docs(readme): lead with where it runs, then what it does in plain verbs 2026-10-08 07:22:47 -07:00
yhh 008aca42b6 docs(readme): rewrite the plain-language copy in a calmer, more concrete voice 2026-10-08 07:07:27 -07:00
yhh 8dbd6583d7 docs(readme): say the model is a configured service, not something that runs on your machine 2026-10-08 06:30:24 -07:00
xiexian ff48415d47 fix(studio): preserve message edits when rewind fails 2026-10-08 21:23:27 +08:00
yhh f1a8525c44 docs(readme): say what Reasonix does in plain words before the technical detail 2026-10-08 06:21:45 -07:00
xiexian 174228553a fix(studio): keep screenshot reads with their feedback draft 2026-10-08 20:43:48 +08:00
xiexian 29be7e29ec perf(studio): coalesce reads while initial status is pending 2026-10-08 20:32:16 +08:00
xiexian c69e53e528 fix(studio): distinguish settings save outcomes and allow retry 2026-10-08 20:14:26 +08:00
YHH f73421aa65 Merge pull request #12422 from esengine/docs/release-notes-2.32.0
docs(release): Studio 2.32.0 notes
2026-10-08 05:00:52 -07:00
yhh 1128e4dab9 docs(release): Studio 2.32.0 notes 2026-10-08 04:59:12 -07:00
xxoingr 55476745c4 fix(tui): q, h/l and Left/Right in the MCP manager
The /mcp panel opened a server's tools with Enter and backed out with
Esc only. 1.x's MCP manager also went in with l / Right, backed out one
level with h / Left, and closed from anywhere with q, and docs/GUIDE.md
(both languages) still lists those keys, so pressing them did nothing.

The panel now takes l and Right as Enter, h and Left as Esc (back to the
list, or closed from the list), and q closes it from the list or a
server's tools. The repository-declared confirm line is unchanged: any
key but y still cancels it.

Refs #12144
2026-10-08 19:45:53 +08:00
xxoingr bd439b77ef fix(tui): j/k and h/l drive the question card
The ask card took only the arrows: Up/Down (and Tab) to move between
rows, Left/Right to move between questions. 1.x's question card also
took j/k and h/l, and docs/GUIDE.md (both languages) lists them for it,
so pressing them on 2.x did nothing.

The card now takes k as Up, j as Down, h as Left and l as Right, with the
existing wrap, clamp and auto-submit stop. A typed answer or note is
unaffected: the composer gets those keys as text before the card does.

Refs #12144
2026-10-08 19:43:08 +08:00
xxoingr cb755dac33 fix(tui): j/k and Ctrl+N/Ctrl+P move the approval cursor
The approval card moved its cursor with the arrows and Tab only. 1.x also
took j/k and Ctrl+N/Ctrl+P there, and docs/CLI.md says approval rows use
the same row-selection keys as the pickers, so pressing them did nothing.

The card now takes k and Ctrl+P as Up and j and Ctrl+N as Down. None of
them is a row shortcut (y/a/p/n/x), so no answer changes meaning.

Refs #12144
2026-10-08 19:40:00 +08:00
YHH 44f87ce11e Merge pull request #12413 from esengine/fix/running-delegation-marked-at-start
fix(agent): mark a proxied sub-agent call as a delegation while it runs
2026-10-08 04:35:13 -07:00
YHH d44551ac21 Merge pull request #12414 from esengine/fix/refused-call-not-interrupted
fix(studio): show a call the host refused as not run, not interrupted
2026-10-08 04:31:12 -07:00
YHH 680732c5ed Merge pull request #12416 from esengine/fix/recovered-inbox-notice-typed
fix(studio): word the recovered-inbox warning in the window's language
2026-10-08 04:26:44 -07:00
YHH 2e534ca1aa Merge pull request #12415 from esengine/fix/compaction-threshold-shows-effective
fix(studio): say on the compaction threshold field when the capacity guard caps it
2026-10-08 04:22:41 -07:00
YHH e44afbc368 Merge pull request #12342 from KHG420/codex/studio-rewind-restore-prompt
fix(studio): restore rewound prompts to the composer
2026-10-08 04:13:25 -07:00
YHH faf7999823 Merge pull request #12120 from xxoingr/perf/pane-checkpoint-reads
perf(studio): read a pane's checkpoints once on mount
2026-10-08 03:58:28 -07:00
yhh d44a65f3f1 fix(studio): say on the compaction threshold field when the capacity guard caps it 2026-10-08 03:58:04 -07:00
yhh 5edd77861a fix(agent): mark a proxied sub-agent call as a delegation while it runs
A call reached through use_capability only learned its delegation profile after the target finished, so the deck's sub-agent list and the running count missed every sub-agent until it ended. The profile is now announced when execution starts.
2026-10-08 03:53:32 -07:00
yhh 7866a7b1d0 fix(studio): word the recovered-inbox warning in the window's language
The notice now carries its count as a typed payload next to the unchanged English text.
2026-10-08 03:52:30 -07:00
YHH 94b57ca1c0 Merge pull request #12406 from esengine/feat/studio-version-notes
feat(studio): read a version's release notes from the Versions page
2026-10-08 03:48:26 -07:00
yhh 9092e5ede6 fix(studio): show a call the host refused as not run, not interrupted
The argument contract now names its refusal, and the delegation views read that identity instead of treating every failed delegate as interrupted.
2026-10-08 03:45:47 -07:00
YHH d67faee3b5 Merge pull request #12373 from KHG420/codex/studio-queue-send-now-order
fix(studio): send the selected queue item next
2026-10-08 03:42:30 -07:00
esengine 6dcd0d3cc7 fix(studio): keep only declared documents in the notes cache, bound the cache name, keep a row's label open
A notes response is accepted only when its declared type is markdown or plain
text, so an error page served with 200 is refused and never written. The cache
name is lower-cased with a digest for mixed-case prereleases and over-long
versions are refused. Expanding a row on a narrow window keeps its label and
date. Error wrapping and the wait group follow the linter.
2026-10-08 03:33:29 -07:00
dependabot[bot] aa139c9f15 chore(deps): bump tinypool and vitest in /workers/crash-report
Removes [tinypool](https://github.com/tinylibs/tinypool). It's no longer used after updating ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Removes `tinypool`

Updates `vitest` from 3.2.7 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest)

---
updated-dependencies:
- dependency-name: tinypool
  dependency-version:
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 5.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-08 10:31:17 +00:00
dependabot[bot] b128ecb2d2 chore(deps-dev): bump source-map-js in /workers/crash-report
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-08 10:30:55 +00:00
YHH 1da6e15962 Merge pull request #12404 from esengine/feat/studio-notes-publish
feat(release): publish each version's notes to the mirror before the catalog names them
2026-10-08 03:29:38 -07:00
xiexian 940d193d26 fix(studio): keep the latest queued edit selection 2026-10-08 18:25:39 +08:00
YHH 5f07a893ab Merge pull request #12369 from KHG420/codex/studio-folded-thought-reveal
fix(studio): pause reasoning reveal while thoughts are folded
2026-10-08 03:25:18 -07:00
esengine b9c95807f7 feat(studio): read a version's release notes from the Versions page
The kernel serves GET /studio/versions/{version}/notes from the release mirror,
keeps each fetched document on disk for good, shares one request between
concurrent readers and refuses by typed code. A row whose catalog entry names
notes gets an inline view with images off.
2026-10-08 03:19:27 -07:00
esengine 8de80b3fe7 fix(release): publish a version without notes when the notes upload fails
The notes are optional, so a failed upload or head-object warns and writes the
catalog entry without a pointer instead of failing the Mirror to R2 step.
2026-10-08 03:15:03 -07:00
YHH c450fad954 Merge pull request #12403 from esengine/fix/flaky-failed-job-phase
test(control): answer the ask that is on screen, not any registered one
2026-10-08 03:10:32 -07:00
YHH c9a6aab619 Merge pull request #12375 from KHG420/codex/studio-tree-refresh-failure
fix(studio): retain the sidebar tree after a failed refresh
2026-10-08 03:08:52 -07:00
esengine 97aab82b2d feat(release): publish each version's notes to the mirror before the catalog names them
Studio release workflow uploads studio/notes/<version>.md ahead of the catalog
write and gives the new entry an optional notes field. A one-time script
backfills the entries already in the catalog.
2026-10-08 03:00:31 -07:00
YHH fa9d78ce14 Merge pull request #12395 from esengine/perf/pluginpkg-single-pass
perf(pluginpkg): scan skills once per parse, parse outside the state lock
2026-10-08 02:58:49 -07:00
YHH a6374d9b9a Merge pull request #12400 from esengine/fix/flaky-wallet-source-flicker
fix(studio): do not blank the wallet when the first status names its account
2026-10-08 02:57:01 -07:00
yhh e1d65e0377 test(control): answer the ask that is on screen, not any registered one
pendingAsk returned an arbitrary entry of the ask map, including one still queued behind the prompt lock. When the second ask took the lock first, the test answered the queued one and then waited for it while the other held the lock for the user: a hang until the package timeout.
2026-10-08 02:55:57 -07:00
YHH 51a94e6602 Merge pull request #12385 from KHG420/codex/studio-queue-edit-escape
fix(studio): keep Escape in the queue editor from stopping the turn
2026-10-08 02:53:55 -07:00
YHH 70a990428b Merge pull request #12399 from esengine/fix/flaky-workspace-lock-owner
fix(workspacelist): let a caller's deadline bound the lock wait
2026-10-08 02:51:01 -07:00
yhh 226c567194 fix(studio): do not blank the wallet when the first status names its account
useWallet treated the empty account of a pane whose status had not arrived as a source, so the first status turned it into a source change: the wallet read that had already landed was emptied and read again.
2026-10-08 02:47:27 -07:00
yhh 32a293b12a fix(pluginpkg): tell a same-root reinstall apart from the entry a parse started from 2026-10-08 02:46:49 -07:00
YHH c6e3169e48 Merge pull request #12394 from esengine/fix/flaky-failed-job-phase
test(boot): make the failed-job provider independent of the job-finished notice
2026-10-08 02:42:02 -07:00
YHH 18e0cf8224 Merge pull request #12391 from esengine/perf/coalesce-package-loads
perf(config): concurrent plugin package reads share one parse
2026-10-08 02:41:16 -07:00
yhh 94d695393e fix(workspacelist): let a caller's deadline bound the lock wait
Update capped the lock wait at 5s even for a caller that supplied its own deadline, so the number of writers a queue could hold was fixed by the package rather than by the caller. The serve tests now pass a budgeted context and fail on the returned error instead of reading a swallowed one as a wrong count.
2026-10-08 02:39:16 -07:00
yhh 7fab14208a test(config): use WaitGroup.Go in the package-read coalescing test 2026-10-08 02:22:00 -07:00
yhh b7ebb884b9 perf(pluginpkg): scan a package's skills once per parse and parse outside the state lock 2026-10-08 02:21:13 -07:00
yhh 4b12befedf test(boot): key the failed-job provider on the START/CHECK turn, not the last user message
The host appends a background-job-finished notice as a user-role message once the job ends. When it landed before the CHECK turn composed, the provider read the notice as the last user message, took its default branch and never issued wait/read, so the answers it recorded for w1 and r1 were empty.
2026-10-08 02:07:58 -07:00
yhh 7ba9278f08 perf(config): concurrent package reads share one parse
Every config load re-parses each enabled plugin package, and a settings sheet
fires a dozen such loads at once, each queued behind the package store's lock.
With a 293-skill package a burst costs one full parse per request. Callers now
wait for a parse that starts after they arrived and share it, so a burst costs
at most two.
2026-10-08 02:07:25 -07:00
xiexian de06daf75a feat(studio): compact context from the ring and inspector 2026-10-08 17:06:23 +08:00
YHH eaac0e8399 Merge pull request #12387 from esengine/fix/import-1x-v5-workspace-routing
fix(migration): file imported 1.x Desktop sessions under their own workspaces
2026-10-08 01:54:43 -07:00
YHH 2872663c2d Merge pull request #12382 from esengine/chore/ui-foundations-f0
chore(studio): F0 ratchet for !important and stylesheet length, browser guards, wheel probe
2026-10-08 01:47:40 -07:00
YHH 1d592d4e61 Merge pull request #12386 from esengine/refactor/studio-pane-split
refactor(studio): split Pane.tsx by concern and lift the pane view into state
2026-10-08 01:42:57 -07:00
YHH e5eb7b892b Merge pull request #12384 from esengine/test/model-switch-keeps-projection
test(boot): a model switch keeps the compaction projection
2026-10-08 01:41:08 -07:00
f1shsmell ca9de5c0d9 chore(repolint): declare six look.ts wire mirrors
Every type the desktop re-declares by hand is held to the kernel's, and
src/port/look.ts was the one file under port/ that no pair named. The theme
a person is looking at is all of it: the pack listing, the pack's two shape
blocks, the import receipt, and the reader's size and wallpaper.

Declared and not inferred: no spelling tells a mirrored contract from a
struct that merely carries json tags.
2026-10-08 16:38:58 +08:00
yhh 40a53ecd8d fix(migration): file imported 1.x Desktop sessions under their own workspaces
The desktop-sessions-v5 by-id store carries no workspace, so every session
from it landed in the workspace picked in the dialog. The owner is recorded
in desktop/workspace-state-v1.json; read it from the picked folder and route
each session to its workspace while that folder still exists.
2026-10-08 01:36:39 -07:00
esengine be9631d18a refactor(studio): split Pane.tsx by concern and lift the pane view into state
Pane.tsx sat at the 800-line ceiling with twelve open pull requests editing
it. The view (flow, analysis, browser) lived in component state, out of reach
of the tab strip and of any setting that will place tabs.

The view now lives in state/paneview.ts, keyed by pane id and owned by the
mounted instance, so a remount for another conversation still starts on the
conversation. Submit, surface slots, the meter row, the plan fold and the run
line move to files of their own; props and the report type move to
panetypes.ts. No visual or behavioural change.
2026-10-08 01:24:34 -07:00
yhh dcb3becaf9 test(boot): a model switch keeps the compaction projection and the system prompt it covers 2026-10-08 01:22:56 -07:00
yhh 78aeea4eac chore(studio): ratchet !important and stylesheet length, wire browser guards, add wheel probe
Adds css-important, css-size and stray-file rules to repolint, deletes the
stray Settings.tsx-e, runs the intrinsic, contrast and concurrent browser
guards in the studio workflow, and adds a report-only wheel-scroll probe.
2026-10-08 01:22:44 -07:00
YHH cc4f466a66 Merge pull request #12136 from KHG420/codex/studio-hook-trial-receipt
fix(studio): associate hook receipts with their trial
2026-10-08 01:20:50 -07:00
xiexian fe483f46d5 fix(studio): consume Escape when dismissing a queued draft 2026-10-08 16:11:14 +08:00
xiexian 5b026929f4 fix(studio): associate hook receipts with their trial 2026-10-08 15:30:49 +08:00
YHH c1f9dc9e1c Merge pull request #12377 from esengine/feat/studio-nav-rail-setting
feat(studio): bring the icon rail back behind an Appearance setting
2026-10-08 00:22:17 -07:00
YHH e9f2b8a6ba Merge pull request #12374 from esengine/fix/window-ground-colour
fix(desktop): match the window background to the page ground
2026-10-08 00:16:46 -07:00
YHH 4cc3f056eb Merge pull request #12372 from esengine/fix/delete-folded-recovery-row
fix(serve): deleting a folded sidebar row also removes the covered recovery copies it hides
2026-10-08 00:14:39 -07:00
YHH f086775c3e Merge branch 'studio' into feat/studio-nav-rail-setting 2026-10-08 00:03:44 -07:00
YHH 8e5c0f3443 Merge branch 'studio' into fix/window-ground-colour 2026-10-08 00:03:06 -07:00
YHH 741b1da8cf Merge branch 'studio' into fix/delete-folded-recovery-row 2026-10-08 00:02:27 -07:00
YHH 63798bff1f Merge pull request #12376 from esengine/fix/settings-extension-models-test-default-model
test(settings): expect the two-call default-model write in the extension refresh test
2026-10-08 00:01:51 -07:00
yhh 6039ba386c feat(studio): bring the icon rail back behind an Appearance setting
The rail was hidden by the studio layer's prototype alignment, which left Nav.tsx rendered but display:none. It is drawn again, beside the workspace column, when this machine has it on (default) and the window is wider than the scene fold.
2026-10-07 23:57:35 -07:00
xiexian fd6b917c80 fix(studio): batch-check models before adding a source 2026-10-08 14:53:59 +08:00
yhh d64c204309 test(settings): expect the two-call default-model write in the extension refresh test 2026-10-07 23:50:03 -07:00
Guan09 f0c4d0828f fix(checkpoint): sync file state after code rewind to prevent false conflicts 2026-10-08 14:47:03 +08:00
yhh 9f2f03e703 fix(desktop): give the Studio window a background that matches the page ground
The BrowserWindow carried no backgroundColor, so any pixel the page does not
cover (frameless window at a fractional display scale) showed the default
white. The ground now follows the stored rx-theme, or the system for auto, at
creation and when preferences are saved or the system theme changes.
2026-10-07 23:37:55 -07:00
yhh 6c193b2783 fix(serve): iterate the removal guards with slices.Backward 2026-10-07 23:33:28 -07:00
YHH d383652b6f Merge pull request #12129 from KHG420/codex/studio-hook-editor-draft
fix(studio): preserve hook edits during trial and save
2026-10-07 23:32:01 -07:00
YHH e01c8ed11a Merge pull request #12093 from KHG420/codex/studio-market-publish-navigation
fix(studio): hold navigation during market publication
2026-10-07 23:31:05 -07:00
YHH 88514b8424 Merge pull request #12161 from KHG420/codex/studio-extension-model-refresh
fix(studio): refresh provider models after extension changes
2026-10-07 23:30:09 -07:00
xiexian 310b8f0702 fix(studio): keep session rows after a failed tree refresh 2026-10-08 14:27:00 +08:00
yhh e11b9b86f5 fix(serve): take every guard before deleting a folded row and erase the lead last 2026-10-07 23:16:58 -07:00
yhh 4f17c13990 fix(serve): keep diverged recovery copies when deleting a folded row 2026-10-07 23:10:16 -07:00
xiexian 49094ac587 fix(studio): send the selected queue item next 2026-10-08 14:09:03 +08:00
yhh de9ff2bed0 test(serve): cover open, versioned and held siblings when deleting a folded row 2026-10-07 23:00:13 -07:00
yhh ceacf5ab25 fix(serve): deleting a folded sidebar row removes its whole recovery lineage
The sidebar folds a conversation's recovery copies under one row, but
/tree/sessions/remove deleted only that row's file. The next copy then took
its place, so the delete looked like a no-op with the order shuffled.
Remove the lineage the way archive already addresses it, leaving siblings a
pane has open.
2026-10-07 22:50:01 -07:00
YHH 3a3a720cda Merge pull request #12359 from KHG420/codex/studio-tui-immediate-local-completion
fix(tui): offer local command completion before kernel replies
2026-10-07 22:48:39 -07:00
YHH ad8cb13030 Merge pull request #12363 from KHG420/codex/studio-memorybench-step-budget
fix(bench): let memorybench tasks run without a step cap
2026-10-07 22:40:21 -07:00
YHH 14004b18bb Merge pull request #12366 from KHG420/codex/studio-latest-followed-link
fix(studio): keep the latest followed browser page selected
2026-10-07 22:39:36 -07:00
YHH 8a315bd011 Merge pull request #12358 from KHG420/codex/studio-remote-reuse-missing-token
fix(remote): preserve a live serve when its token is unavailable
2026-10-07 22:38:50 -07:00
xiexian 4b3ad7c29e fix(studio): pause reasoning reveal while thoughts are folded 2026-10-08 13:18:26 +08:00
YHH cb5c0e036c Merge pull request #12125 from KHG420/codex/studio-runtime-confirmation-window
fix(studio): renew each runtime application confirmation
2026-10-07 22:11:30 -07:00
xiexian 5da56c79dd fix(boot): bind switched interceptors to adopted sidecars 2026-10-08 12:59:32 +08:00
xiexian a591b7044e fix(studio): keep the latest followed browser page selected 2026-10-08 12:45:41 +08:00
xiexian 1092e8c8e2 fix(bench): let memorybench tasks run without a step cap 2026-10-08 12:21:40 +08:00
xiexian 041f40672d fix(tui): offer local command completion before kernel replies 2026-10-08 11:46:31 +08:00
xiexian 3bb435aa10 fix(remote): preserve a live serve when its token is unavailable 2026-10-08 11:34:19 +08:00
YHH cb04e619db Merge pull request #12353 from esengine/fix/refusal-no-guessed-reasoning-cause
fix(provider): stop attributing every refused body to dropped assistant reasoning
2026-10-07 20:26:18 -07:00
yhh 7245b82c6f fix(provider): stop attributing every refused body to dropped assistant reasoning
A 400/422 on a request whose history carried reasoning the connection
could not replay was labelled "the endpoint wants the thinking content
sent back", whatever the endpoint actually said. Dropped reasoning is a
fact about the request, not a cause the host observed, and the endpoint's
refusal carries no typed signal separating the two. The hint is removed;
the refusal now shows the status guidance and the endpoint's own words.
2026-10-07 20:14:57 -07:00
Will ad85f2707f feat(memory): report omitted matches on the memory tool paths
Search and list dropped results silently: hits below the relative score
floor (KeepTopRelativeScore compacts in place, taking the count with it)
and everything past limit. A reader could not tell a complete result from
a truncated one, while the empty-result message already warns that no
result is not proof of absence.

Both paths now carry the note automatic recall already uses for its own
omissions, counting both reasons and staying silent when nothing was
dropped. The spelling lives in one helper the recall block calls too, so
the two cannot drift apart; its output is unchanged. The count is one
function as well, so the case where both reasons fire is tested directly
instead of only through fixtures. Empty results keep their guidance: the
relative floor always keeps the top hit, so empty still means nothing
matched.

Refs #11853.
2026-10-08 11:02:07 +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 26bf6547d9 fix(studio): restore rewound prompts to the composer 2026-10-08 08:04:33 +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
drakeo338 d651569af7 fix(config): keep the config edit lock directory per user and refuse a foreign owner
The lock directory sat at a shared, predictable /tmp path, so another
identity could create it first and every later run by this user failed
on os.Chmod with a bare EPERM.

Put it under the OS user's home (from the account database, so HOME and
TMPDIR overrides do not move it), as Windows already did. Keep the owner
uid check before the chmod as a backstop: it returns ErrLockDirWrongOwner,
naming the path and uid, without touching the directory. The /tmp name
is only a fallback when no home is recorded. The check is a no-op on
Windows; lockdir_owner_other.go is renamed lockdir_owner_windows.go.

Fixes #11372
2026-10-06 15:08:47 +00: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
xxoingr 8bb3f9d58c perf(studio): read a pane's checkpoints once on mount
Opening a session pane read GET /checkpoints three times against a real
kernel: the mount effect read it, the turn-boundary effect read it again on
its own first run, and a third time when the first status named the session
path.

useCheckpointRefresh now owns every read outside a session reload: the mount
read, a move from one known session to another, and both turn edges. The
kernel opens a turn's checkpoint when the turn starts and fills it while the
turn runs, so the read at turn start stays and the rewind entry still appears
on a running turn's message. The first status naming the session the mount
read covered no longer reads again, so opening a pane costs one request.

A failed read is tried once more after a second, since no later edge is owed
to it, and a read that a newer one superseded is dropped instead of
overwriting the newer list.

Refs #12080
2026-10-06 21:25:50 +08:00
xiexian 6386cd91c9 fix(studio): preserve memory drafts and pending writes 2026-10-06 18:18:05 +08:00
xiexian f01e71ccfc fix(installsource): verify registered skills by source identity 2026-10-06 13:33:30 +08:00
xiexian 6166ba143f fix(installsource): attribute skill discovery to its installed path 2026-10-06 13:24:35 +08:00
xiexian 33a56d2f8a fix(studio): refresh provider models after extension changes 2026-10-06 10:53:29 +08:00
xiexian 215dedc5b4 fix(studio): hold navigation during market publication 2026-10-06 09:13:19 +08:00
xiexian 20c883c724 fix(studio): renew each runtime application confirmation 2026-10-06 09:03:06 +08:00
xiexian b2628325b7 fix(studio): scope package installation forms to connection 2026-10-06 08:58:18 +08:00
xiexian c7a6704558 fix(studio): preserve hook edits during trial and save 2026-10-06 05:21:31 +08:00
xiexian 4881f167dc feat(studio): identify installed package contribution kinds 2026-10-06 00:59:45 +08:00
xiexian c76e155243 docs(studio): run scenario exercises from installed packages 2026-10-05 21:40:48 +08:00
yhh cbadfe1753 feat(compaction): announce an automatic compaction whose retry is held after a failed attempt
When a summary fails under the pressure trigger the retry is held, and
while it is held and compaction is due nothing reached the user. The
agent now emits a coded notice, compact_held, once per turn and cause,
with the failure's code as detail. The desktop and the TUI word it from
the code in the UI language; a code a build cannot word keeps the
kernel's English.
2026-10-05 02:13:15 -07: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 95c63e6602 fix(installsource): bound and sanitise package-supplied preview text
Project every string and list of an install plan through one bounded
projection shared by all frontends: names, paths, commands and arguments
render hidden characters as visible escapes, prose has them removed, and
each field, list and the action count has a cap. A cut ends the field with
an ellipsis and sets previewTruncated on the action and the plan. The planId
still digests the complete unprojected plan, and the market ledger records
the unprojected paths. Installed-package detail views (inventory, record,
runtime, warnings) use the same limits from the shared text utility.
2026-10-04 21:03:30 -07:00
yhh 16933b79d4 fix(docs): keep the owner, aliases and tool identities out of the acknowledgments table 2026-10-04 20:47:45 -07:00
esengine 7aed16a34f feat(desktop): one declared interface-scale range, and Ctrl/Cmd +/-/0 to change it
The interface scale was declared four ways: the config comment said
0.8..1.6, the save clamped to 0.7..1.8, the slider ran 0.8..2.5 and the
presets covered 0.9..1.3. Dragging the slider to 2.0 showed 2.0 while the
kernel stored 1.8.

config.ZoomMin/ZoomMax/ZoomStep are now the only declaration. The save
clamp, the rendered config comment and the range /appearance announces
(mirrored to the page by wire-parity) all read them, and the slider is
drawn from the announced range instead of carrying its own. A stored
value outside the range reads as the nearest bound but is not rewritten
unless the user changes the zoom.

Ctrl/Cmd + '=' / '+' / '-' / '0' (read by key or by key position, so
shifted and non-QWERTY layouts work) step the interface scale through the
named presets (range ends outside them) and reset to standard, saving
through the same appearance setting as the Appearance control. Chromium
page zoom is not used.

The range is 0.8..1.8: 1.8 was the largest value the old save could
store, so nobody loses headroom. The keys exist only inside the desktop
shell; a browser tab keeps its own page zoom.

Fixes #12032
Part of #11962
2026-10-04 20:42:20 -07: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
2793 changed files with 147754 additions and 20268 deletions

No files matched your search

+12
View File
@@ -24,6 +24,18 @@ only, on `main-v2`.
-
## UI changes
<!--
Delete this section if the PR touches nothing under `desktop/frontend-next/src`
or the website pages.
-->
- Screenshots, before and after: light and dark, a wide window and a phone-width one (about 390 px). Attach them to this description.
- Entries moved, hidden or removed: none / list each one.
- Hiding or removing a visible control needs a linked issue and a release-notes line naming it. Do not retire UI with `display: none !important` or a feature flag.
- If a row of `docs/STUDIO_PARITY.md` changes, update it here.
## Issues
<!--
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
<defs>
<style>
.cls-1 {
fill: #ffffff;
stroke: #ffffff;
stroke-miterlimit: 10;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
<defs>
<style>
.cls-1 {
fill: #111111;
stroke: #111111;
stroke-miterlimit: 10;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+50 -29
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/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.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/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
@@ -179,12 +186,9 @@ jobs:
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
#
# That timeout is 12m, not 3m: across four consecutive runs of the same
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# variance alone, with four heavy packages sharing four vCPUs. The boot package
# has since grown to 422s and then ran past 480s on two consecutive runs of the
# same code, so 8m no longer leaves room. A hang still trips the job budget.
# The per-package timeout is 18m: internal/assembly/boot took 478-717s
# (12 push runs) on the Windows runner, so 18m is 1.5x the worst. A hang
# still trips the step budget (25m smoke, 35m full).
#
# Every root-module `go test` that runs on Windows passes -count=1. With
# the result cache on, go test records each file and environment access
@@ -195,11 +199,11 @@ jobs:
# internal/base/testenv holds every such step to it.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 20
timeout-minutes: 25
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./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/...
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/...
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -218,14 +222,12 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite still uses the same 12m package budget as Windows smoke:
# agent/boot/control already hit 180s package timeouts under runner load,
# so 3m fails on slow machines without a code regression. It runs with
# -count=1 for the reason given at the smoke step. A hang still dies here
# rather than on a package.
# Full push suite uses the same 18m package timeout as Windows smoke and a
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
# the failed tests' output and the 15 slowest boot tests.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 30
timeout-minutes: 35
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -234,7 +236,25 @@ jobs:
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./...
shell: bash
run: |
go test -count=1 -p 4 -timeout=18m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (full) failure detail
if: failure() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
shell: bash
run: |
f="$RUNNER_TEMP/full.jsonl"
[ -s "$f" ] || exit 0
echo "::group::output of failed tests"
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
| map(select(any(.[]; .Action == "fail")))[]
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
echo "::endgroup::"
echo "slowest internal/assembly/boot tests (seconds, name):"
jq -rn '[inputs | select((.Package | endswith("/internal/assembly/boot")) and .Test != null and .Action == "pass")]
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed)\t\(.Test)"' "$f" || true
race:
needs: changes
@@ -289,9 +309,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
@@ -396,7 +416,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:
@@ -424,7 +444,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:
@@ -495,6 +515,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
@@ -534,7 +555,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
+85 -61
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 }}
@@ -911,7 +941,7 @@ jobs:
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: cli-archives
path: dist/*
@@ -970,28 +1000,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 +1069,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 +1102,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 +1190,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
@@ -1242,6 +1250,22 @@ jobs:
exit 0
fi
# The notes object goes up before the catalog entry that names it, so
# no client reads a pointer to an object that is not there yet. Notes
# are optional: a failure here publishes the version without a pointer
# rather than keeping the new version out of the catalog.
notes_key="studio/notes/${VERSION#v}.md"
notes_arg=()
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
--endpoint-url "$endpoint" \
--content-type "text/markdown; charset=utf-8" \
--cache-control "public, max-age=31536000, immutable" &&
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
else
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
fi
# Studio's own catalog. Writing the ROOT versions.json here would hand
# desktop users Studio releases, so the prefix is not optional.
current=/tmp/studio-versions-current.json
@@ -1253,7 +1277,7 @@ jobs:
echo "no existing Studio catalog; starting a new one"
fi
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
@@ -1387,7 +1411,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
@@ -1396,7 +1420,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
@@ -1450,7 +1474,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
+190 -58
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
@@ -81,9 +81,103 @@ jobs:
exit 1
fi
# Real-Chromium guards that run without a kernel. Only the ones that passed
# three consecutive local runs are wired; the rest stay manual (see
# desktop/frontend-next/perf/README.md). Skipped unless the SPA changed.
browser-guards:
name: browser guards
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: desktop/frontend-next
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: SPA changed?
id: spa
working-directory: .
run: |
if [ "${{ github.event_name }}" = "pull_request" ] &&
git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- desktop/frontend-next; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.spa.outputs.run == 'true'
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- if: steps.spa.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.spa.outputs.run == 'true'
name: Install
run: pnpm install --frozen-lockfile
- if: steps.spa.outputs.run == 'true'
name: Build the perf harness and install Chromium
run: |
pnpm exec vite build --config vite.perf.config.ts
pnpm exec playwright install --with-deps chromium
- if: steps.spa.outputs.run == 'true'
name: Guards
run: |
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/plan-chip.mjs
node perf/flow-feather.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/toast-anchor.mjs
node perf/addfolder-rest.mjs
node perf/scroll-jump.mjs
node perf/add-project-selects.mjs
node perf/setup-defer.mjs
node perf/search-toggle.mjs
node perf/narrow.mjs
node perf/settings-rows.mjs
node perf/narrow-steps.mjs
node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip
# 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:
@@ -92,9 +186,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
@@ -107,28 +203,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:
@@ -140,10 +267,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
@@ -152,13 +281,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
@@ -190,16 +324,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
@@ -212,7 +356,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
@@ -228,7 +373,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.
@@ -254,6 +399,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
@@ -309,7 +475,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
@@ -342,47 +508,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.
@@ -420,7 +552,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
+3 -3
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
@@ -33,6 +33,6 @@ jobs:
body: |
Refreshes the README acknowledgments tables from the GitHub contributors API.
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1`
- Anonymous contributors are shown without email addresses.
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=50&anon=1`
- The repository owner, declared aliases, tool identities, bots and entries without a GitHub login are left out (`scripts/credit-identities.json`).
labels: documentation
+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"
+7
View File
@@ -198,6 +198,13 @@ in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
- Every PR carries the four sections of the PR template: Cause, Blast radius,
Neighbouring behaviours tested, Why this layer.
- The new test must fail without the change.
- A UI change (`desktop/frontend-next/src` or the website pages) fills the
template's UI section: screenshots before and after, light and dark, wide
and phone width.
- The same section carries the line "Entries moved, hidden or removed".
- Hiding or removing a visible control needs a linked issue and a release-notes
line naming it, and updates `docs/STUDIO_PARITY.md` for a 1.x feature.
- Review holds these; no check reads them.
**Transparency.** The PR template has an optional checkbox for disclosing AI
assistance. It is for transparency only and does not change how a change is
+47 -9
View File
@@ -43,9 +43,9 @@
<br/>
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
<p align="center"><strong>Open source · MIT · runs on your computer, model of your choice</strong></p>
<h3 align="center">An open-source coding agent for your terminal, desktop, browser and editor.</h3>
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
@@ -57,6 +57,42 @@
<br/>
## ❤️Sponsor
<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
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
You pick the model: a DeepSeek preset is built in, any OpenAI-compatible service is one config entry, and you pay the model provider directly.
## What you get
- **Read, edit, run.** It reads code, edits files and runs tests and commands, all inside your project folder.
- **Permission levels.** Have it ask every time, or let everyday actions run on their own; your forbid rules always hold. To see the approach first, use plan mode: it starts work only once you approve.
- **Rewind.** A file is saved before it changes, so you can rewind by conversation turn without touching your git history. Only files its edit tools changed are covered, not what a shell command did.
## 30-second start
Download, add a model key, open a project. New here? Start with the Studio desktop app.
1. Download and install the desktop app, Reasonix Studio, from the [releases page](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true).
2. Open it and enter your model provider and key when asked.
3. Open your project folder and write what you want, for example "add retry on failure to the http client".
4. At the level you chose, it stops and asks before anything that needs your say-so. Allow it to continue, or refuse.
Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setup` to pick a model, then run `reasonix`. See [Install](#install) and [Quick start](#quick-start).
## Four ways to use it
- **Desktop app (Studio).** A graphical app; the best place to start.
- **Terminal.** A full-screen text interface; `reasonix run "task"` also works in scripts.
- **Browser.** `reasonix web` opens a web interface on your own machine.
- **Editor.** Install the extension in VS Code; other editors that support ACP (Agent Client Protocol, a common way for an editor to connect to an AI assistant) can connect to `reasonix acp`.
All four are the same program underneath. What follows is for advanced users and contributors.
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
@@ -82,6 +118,8 @@ or reimplemented on 2.x where they fit the new architecture.
## Features
Technical highlights for advanced users.
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek ships as a preset; any
@@ -113,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.
@@ -238,11 +276,11 @@ A small list of folks whose work has shaped Reasonix the most — the current to
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**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) | [**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.
+47 -9
View File
@@ -43,9 +43,9 @@
<br/>
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<p align="center"><strong>开源 · MIT · 程序跑在你电脑上,模型自选</strong></p>
<h3 align="center">终端、桌面、浏览器、编辑器里都能用的 AI 编程助手。</h3>
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
@@ -55,6 +55,42 @@
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
## ❤️赞助商
<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>
## 它是什么
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
模型你自己选:内置 DeepSeek 预设,其他兼容 OpenAI 接口的服务填一条配置就行,费用直接付给模型服务商。
## 你能得到什么
- **读、改、跑**:读你的项目、改文件、跑测试和命令,都在你选的项目文件夹里。
- **权限分档**:每次都问你,或让日常操作自动放行;你设的禁止规则始终有效。想先看方案,就开计划模式,批准后它才动手。
- **可回退**:改文件前先存快照,按对话轮次回退,不碰 git 历史。只覆盖编辑工具改的文件,命令行命令造成的改动不在内。
## 30 秒上手
下载、填模型密钥、打开项目。第一次用,从桌面应用 Studio 开始。
1. 从[发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)下载并安装桌面应用 Reasonix Studio。
2. 打开应用,按提示填入模型服务商和密钥。
3. 打开你的项目文件夹,写下想做的事,例如“给 http 客户端加上失败重试”。
4. 按你选的档位,该问你的操作它会先停下来问;同意就继续,不同意就拒绝。
更喜欢终端的话,下载 `reasonix` 命令行后运行 `reasonix setup` 配好模型,再运行 `reasonix` 即可。详见[安装](#安装)和[快速开始](#快速开始)。
## 四种用法
- **桌面应用(Studio)**:图形界面,适合第一次使用。
- **终端**:全屏文字界面,也能用 `reasonix run "任务"` 在脚本里运行。
- **浏览器**:`reasonix web` 在本机打开网页界面。
- **编辑器**:VS Code 安装扩展即可;其他支持 ACP(Agent Client Protocol,编辑器接入某个 AI 助手的通用约定)的编辑器,可连接 `reasonix acp`。
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
@@ -76,6 +112,8 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
## 特性
以下是写给进阶用户的技术要点。
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
@@ -101,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 会在
应用内自行更新。
@@ -220,11 +258,11 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**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) | [**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. |
+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.
@@ -1,5 +1,4 @@
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
class = "cjk"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CJK5T"]
@@ -1,5 +1,4 @@
prompt = "Write the deploy region for this project (just the region string) into answer.txt."
class = "conflict"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CONF2P"]
@@ -1,5 +1,4 @@
prompt = "Write the command to install this project's dependencies into answer.txt."
class = "contradiction"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CONTRA"]
@@ -1,5 +1,4 @@
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
class = "distractor"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-DIST5Q"]
@@ -1,5 +1,4 @@
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
class = "exact"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-EXACT7Q"]
@@ -1,4 +1,3 @@
prompt = "Add a function add(a, b) returning a+b to util.py."
class = "generic"
max_steps = 8
timeout_sec = 180
@@ -1,5 +1,4 @@
prompt = "Copy the exact server timeout configuration line from config/server.conf into answer.txt, unchanged."
class = "history"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-HIST6M"]
@@ -1,5 +1,4 @@
prompt = "Which tool do we install dependencies with here? Write the exact install command into answer.txt."
class = "paraphrase"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-PARA3X"]
@@ -1,6 +1,5 @@
prompt = "Create a file named notes.txt containing one line: hello."
class = "pinned"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-PIN4D"]
memory_markers_prefix = true
@@ -1,5 +1,4 @@
prompt = "Confirm the current release branch of this repo and write it into answer.txt."
class = "stale"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-STALE1"]
@@ -1,5 +1,4 @@
prompt = "Set the retry max backoff to 250 in this project's configuration."
class = "symbol"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-SYM9K"]
@@ -1,5 +1,4 @@
prompt = "Write the current API base URL for this project into answer.txt."
class = "update"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-UPD8R"]
@@ -1,5 +1,4 @@
prompt = "查一下灰度的放量比例,写进 answer.txt。"
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1GR"]
@@ -1,5 +1,4 @@
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1XL"]
@@ -1,5 +1,4 @@
prompt = "Set the max idle connection seconds to 30 in this project's configuration."
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1SYM"]
+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
}
+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} {
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
+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)
+4 -2
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
}
+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
}
+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)
}
}
+106 -41
View File
@@ -17,6 +17,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -35,6 +36,8 @@ 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"
"reasonix/internal/platform/notify"
@@ -66,6 +69,7 @@ type shellIdentity struct {
version string
exe string
pid int
logsDir string
}
func (s shellIdentity) stated() bool {
@@ -88,7 +92,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
}
@@ -96,7 +100,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,
@@ -126,7 +130,10 @@ 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()
if *stripGrants {
@@ -139,16 +146,46 @@ func main() {
fmt.Fprintln(os.Stdout, instanceid.Current())
return
}
boot.SetComputerHelper(*computerHelper)
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
os.Exit(run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell))
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 := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell, driver)
releaseFatalLog()
os.Exit(code)
}
// logsDirOf is the shell's logs directory: the crash directory it passed is
// the "crash" folder inside it.
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
}
return version
}
// run serves until the lease ends or the process is signalled. stdout carries
// 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()
@@ -171,7 +208,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
@@ -289,7 +326,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 {
@@ -324,20 +361,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
@@ -350,31 +374,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)
@@ -391,6 +435,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 {
@@ -416,7 +481,7 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
registrar.SetCloudRemoteStatus(func() serve.CloudRemoteStatus {
status := host.Status()
return serve.CloudRemoteStatus{
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error,
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error, Reason: string(status.Reason),
}
})
}
+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")
}
}
+86
View File
@@ -0,0 +1,86 @@
---
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.
+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()
}
Loaded 100 of 2793 files, more files were not shown because too many files have changed in this diff. Show more