Compare commits

...
Author SHA1 Message Date
esengine 16ba8d38bf docs(release): studio 2.46.0 notes 2026-10-19 16:46:45 -07:00
YHH cda566e404 Merge pull request #13949 from SivanCola/fix/failed-fold-keeps-standing-state
fix(control): a fold that folded nothing does not re-owe standing state
2026-10-19 12:42:06 -07:00
zumac a00519304e fix(control): a fold that folded nothing does not re-owe standing state
Every CompactionDone forgot what the model had been told -- the date,
the skills listing, the project instructions and rules -- so the next
turn sent them again. A truncated or refused digest also emits
CompactionDone, with Messages == 0: nothing was summarised and the turn
that carried those blocks is still verbatim, so re-sending them only
grew the prompt the next attempt had to fold, by one skills listing per
failed fold.

The producer already says which case it is: a successful fold reports
Messages = len(fold) and emitCompactionAborted reports 0. The sink now
reads that instead of treating every completion as a fold.

TestEffectTruncatedSummaryOnPressureKeepsCodeAndRetries goes from 5 to 6
failed summaries over its 10 turns: the prompt no longer grows by a
re-sent listing after each failure, so the pressure fold lands a turn
later. That is inside the test's bound of 6 and is the growth the retry
gate is meant to read.
2026-10-19 12:25:11 -07:00
YHH 0b8de9e636 Merge pull request #13945 from SivanCola/fix/run-script-verbatim-read
fix(script): a script's read_file returns the file's text, not its display
2026-10-19 11:15:03 -07:00
YHH 564716a091 Merge pull request #13944 from SivanCola/claude/e2ebench-nested-rounds-trials
fix(e2ebench): count script calls inside their round; keep one trajectory per trial
2026-10-19 11:14:58 -07:00
YHH 6c631b0870 Merge pull request #13613 from SivanCola/codex/session-agent-compositions
feat(studio): add session Agent compositions / 新增会话级 Agent 预设
2026-10-19 10:58:52 -07:00
zumacandClaude Opus 5.5 c3daadf167 fix(script): a script's read_file returns the file's text, not its display
Inside run_script, call("read_file", ...) returned the model-facing form:
every line prefixed "   N→", CRLF folded to LF, the default 2000-line page,
and — past 32 KiB — a window with a "read on from here" marker. A script
that read a file, edited the text and wrote it back put all of that into
the file. With deepseek-flash on migrate-signature-3packages and
agent.code_mode on, one bulk rewrite corrupted 36 files and cost ~15 rounds
to repair.

Two layers each rendered for the model a result whose reader is a program:

- read_file: a call another tool made (tool.IsNested) gets the decoded text
  verbatim — line endings and final newline kept, no default limit, offset
  and limit selecting whole lines. The model's own read is unchanged.
- nestedInvoker: the calling tool reads what the tool returned, not the
  bound fitted to the model's context. The nested card still shows the
  bounded form; toolOutcome.keepFull records both originals in one place.

write_file already preserves the existing file's encoding, so a BOM file
round-trips once the read stops adding to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:57:36 -07:00
zumacandClaude Opus 5.5 938b20e7a3 fix(e2ebench): count script calls inside their round; keep one trajectory per trial
model_rounds and round_outcomes treated every call run_script made as a
top-level dispatch, so each inner result reopened a model gap and each inner
dispatch closed one. One recorded code_mode run with 24 executor requests
reported a median of 87.5 rounds. Nested calls carry no ParentID — only an id
derived from the caller's, inside its dispatch→result window — so the digest
had nothing structural to tell them apart.

The nested id is now declared in internal/contract/tool (NestedCallID /
IsNestedCallOf) and the agent's nestedInvoker builds ids through it, so the
producer and the reader share one definition. e2ebench's callNesting tracks
open top-level calls and treats subagent calls (ParentID) and invoker calls
(derived id) alike: neither cuts a round, joins a batch or adds tool time.
roundEnds and splitAtCorrect use the same tracker. NestedCalls reports how
many there were, and ModelRounds' doc now says what it counts.

With -trials N every trial wrote <task>.trajectory.jsonl, so only the last
survived and every trial's digest read it. Trials now write
<task>.trial-N.trajectory.jsonl (legs: <task>.trial-N.segM...); a single
trial keeps the old name. sft mode resolves the task id through
trajectoryTaskID, which strips trial and leg marks; traj, serve and barrier
modes glob *.trajectory.jsonl and label each file by its stem, so trials
read as distinct entries.

Cause: the digest had no identity for tool-made calls; the trajectory path
had no trial in it.
Blast radius: cmd/e2ebench digest and file naming; nestedInvoker's id string
is unchanged (same "<parent>.<n>" format, now built by the contract helper).
Neighbouring behaviours tested: subagent ParentID exclusion, segment paths,
answer-isolation audit across legs, existing round decomposition tests.
Why this layer: the id convention is owned where nested calls are created;
the digest reads it rather than restating the format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:56:50 -07:00
SivanCola a3dab1bff1 fix(compositions): check retained hook credentials at argument boundaries
Problem: Windows CI rejects the retained-hook source-deletion fixture as a
credential-bearing launch before reaching its restoration assertions.

Root cause: credential projection scans the complete quoted shell string as a
generic value, splitting a Windows drive colon inside a quoted file argument.
That creates an unmatched quote and falsely marks the command as sensitive.

Fix: reduce shell launches to static arguments before credential inspection,
preserve exec and legacy argv launches, and continue refusing literal token and
header arguments or shell forms that cannot be reduced safely. Keep source
commands and retention/restoration semantics unchanged.

Verification: regression reproduces both quoted Windows path failures before
the fix, while exec/legacy argv and token/header refusal cases stay covered.
Focused retention/security regressions and their race run pass; full builtin
and boot tests, gofmt, vet, lint and changed-file checks pass locally.
2026-10-20 01:37:50 +08:00
SivanCola b5a8cfd299 fix(compositions): enforce filesystem boundaries and portable retention tests
Problem: CodeQL reported new path and allocation alerts, and Windows repeatedly
failed the retained-hook restoration test.

Root cause: Checked selectors were still passed onward as raw request values;
some generated paths and capacity hints did not expose their bounds at the IO
owner. The HTTP DTO also carried an internal inherited binding. The hook fixture
fed Windows backslashes through Bash resource parsing.

Fix: Resolve import/export selections to registered values, forward checked
relative names for resume/migration, and guard the generated session slug before
joining it to state storage. Use rooted declaration/resource reads and copies,
bound resource reads, and avoid unchecked capacity addition. Pass inherited
bindings outside the wire DTO. Normalize and quote the hook fixture path, including
spaces, while retaining source-deletion and exact-binding assertions.

Verification: Hosted alert locations reproduced locally with the path-injection
and allocation queries; before/after query results and the base scan are retained
separately. Boundary regressions fail on the original head and pass with the fix.
Owning HTTP, store, config, hook, sidecar, plugin, specification and encoding suites;
full builtin/boot tests; gofmt, vet, lint and changed-file checks. Hosted Windows
and CodeQL results remain required for exact-head acceptance.
2026-10-20 01:11:52 +08:00
YHH 42636ddcb4 Merge pull request #13937 from esengine/c5/hidden-panes-skip-layout
perf(studio): hidden panes skip layout while the rail animates
2026-10-19 09:39:32 -07:00
YHH da14d60b31 Merge pull request #13939 from SivanCola/fix/studio-changelog-publication
Publish the Studio website changelog after releases / 发布后自动同步并验证官网 Studio 更新日志
2026-10-19 09:37:51 -07:00
SivanCola 8df4882d16 fix(studio): restore opt-in visibility for the mode picker
Problem: The composer exposed the new mode picker to users who had not opted in,
contrary to the maintainer's product-visibility decision.

Root cause: Removing the persistent visibility preference also removed its
settings/search action and the RunLine mounting gate.

Fix: Restore the original on/off storage key and default-off behavior. Add the
switch and concise explanation to Persona management, restore settings search
and the action, and gate both mounting and catalog reads. Preserve card defaults,
catalog/editor design, persona copy and current conversation bindings. Failed
storage writes retain the previous choice and report an error.

Verification: Missing/disabled/unknown and toggle regressions fail before the
fix. Focused Chinese/English navigation, persistence, storage-failure and binding
tests pass. Full frontend: 729 files / 5,857 tests; build/typecheck, UI census and
ten identical fixture reports; gofmt, vet, lint, make check, builtin and boot
suites. Browser visual acceptance remains unavailable under the URL policy.
2026-10-20 00:35:37 +08:00
SivanCola 5211538354 feat(studio): give built-in personas warmer names and descriptions
Problem: Built-in preset names and descriptions read like configuration modes
rather than collaborators, and their relationship to Harness prompts was unclear.

Root cause: Technical copy was reused across the catalog, composer and persona
editor without explaining the existing short-identity prompt adaptation.

Fix: Rename the built-ins to Everyday partner and Lightweight partner, update
their bilingual descriptions and guidance, and derive the unbound picker label
from its canonical catalog definition. Keep IDs, persona bytes and selections
unchanged, retain historical translations, refresh revision metadata and document
the pinned official Harness reference and native prompt boundary.

Verification: Frontend 729 files / 5,852 tests and production build/typecheck;
contract, store and HTTP suites; gofmt, vet, lint, changed-file check, builtin and
boot suites. Fix a discovered worktree test race by waiting for the asynchronous
checkout state instead of the creation title shared by initial and checkout
phases. Browser visual acceptance remains unavailable under the URL policy.
2026-10-20 00:25:41 +08:00
SivanCola 17327b4875 feat(studio): add preset cards and explicit new-task defaults
Problem: Built-in presets use flat rows, choosing a future task mode requires
repetition, and persona fields are hidden behind an advanced disclosure.

Root cause: Management navigation and new-task launch intent have no shared
explicit preference; the editor groups basic persona fields with identifiers.

Fix: Match the selected two-column card hierarchy with badges, IDs, descriptions
and separate details, usage and copy actions. Persist an explicit built-in card
choice in a new machine-local key and inject it through the local/remote open
owners only for new tasks. Explicit request choices override it; restoring
history keeps its binding. Existing server preferences alone remain inert.
Expose both persona textareas, runtime context, scope and ID directly, rename
settings navigation to Persona, and keep literal persona fields authoritative.

Verification: Full frontend suite plus real MockHub launch effects, blank-pane
reuse, local/remote history, explicit overrides, storage compatibility/refusal,
cross-window refresh and independent footer actions. Production build/typecheck,
UI census/fixtures, format, vet, lint, changed-file and builtin/boot checks.
Browser-rendered visual comparison remains blocked by the URL policy, as
recorded in design-qa.md.
2026-10-20 00:15:23 +08:00
SivanCola 9023835220 fix(studio): clarify additional persona instructions
Problem: "After the first-party guidance" exposes prompt-construction jargon
without explaining what users should enter.

Root cause: Both persona editors use a position-based label; the composition
editor has no suffix helper text.

Fix: Label both suffix inputs "Additional instructions" and explain that the
text follows the built-in system instructions and may be left blank. Add the
Chinese/English copy and associate the helper with each textarea. Keep the
existing literal suffix binding and update affected interaction expectations.

Verification: 24 focused persona, preset and localization tests; production
build/typecheck; formatting, vet, lint, changed-file checks and builtin/boot
suites.
2026-10-19 23:59:15 +08:00
SivanCola 5ea0ed7046 fix(studio): align the preset creation button with its heading
Problem: The catalog's create icon and label stack inside a cramped button.

Root cause: Studio icons render as blocks while the button lacks a horizontal
layout; the title and description also share a flex item beside the button.

Fix: Give the button an inline-flex row with no wrapping, a 36px minimum height
and 16px horizontal padding. Align title and action in one grid row and place
the description across the next row, retaining the primary theme colors.

Verification: 27 focused frontend tests, production build/typecheck, formatting,
vet, lint, changed-file checks, and builtin/boot suites. The local preview stays
running; browser observation remains blocked by its URL security policy.
2026-10-19 23:50:35 +08:00
SivanCola 805d9d189a feat(studio): separate preset catalog and editor views
Problem: Preset browsing, creation and the Standard persona were mixed in one
long Settings page, hiding the save flow and exposing configuration too early.

Root cause: Catalog selection and editable drafts shared one inline surface,
without an owner-level guard for Settings navigation.

Fix: Follow the selected full-content editor reference. Group the catalog by
scope; open a separate editor with common fields first, expandable capabilities
and advanced options, and a persistent Cancel/Save footer. Preserve literal
persona bytes, revision checks, import/export/delete and session bindings.
Guard dirty exits across return, Settings close, category and search paths.
Keep pending saves mounted and reject stale import results. Reusable switches
declare button type so toggles cannot accidentally submit the editor form.

Verification: Full frontend suite: 727 files, 5843 tests; focused editor,
navigation, pending/refused save, import ownership and binding regressions;
production build, typecheck, UI census and fixture verification; root format,
vet, lint, changed-file checks and builtin/boot suites. Browser visual acceptance
remains blocked by the local URL security gate and is recorded in design-qa.md.
2026-10-19 23:41:23 +08:00
esengine 23bf613596 docs(release-notes): shorten the pane note to the column limit 2026-10-19 08:27:35 -07:00
esengine 9fe36abc5b perf(studio): hidden panes skip layout while the rail animates
Every open tab keeps its pane mounted, and a hidden pane was only
visibility:hidden, so it still took part in layout. The rail width is animated
per frame, so each frame laid out every pane's transcript again. With three
panes the median frame at 4x CPU slowdown went from 94 ms to 18 ms.
2026-10-19 08:27:35 -07:00
SivanCola b9d4dbadcc fix(studio): show the Agent mode picker by default
Problem: the mode picker required a separate settings toggle before users
could discover or use it.
Root cause: both the picker and its run-status row depended on a persistent
visibility preference unrelated to the conversation's selected mode.
Fix: render the picker directly, remove the retired setting, preference state,
search entry and action, and update the bilingual documentation. Standard
remains the default; displaying the catalog never selects a composition.
Verification: failing-first tests for absent and disabled legacy preferences;
default Standard with a saved Minimal preference; picker, settings, remote-open
and settings-search interaction suites; frontend typecheck, full suite, census,
fixture verification and build; browser preview; go vet, make lint, make check,
builtin tools and boot assembly tests.
2026-10-19 23:07:18 +08:00
SivanCola 9a7460ac87 fix(release): publish and verify the Studio website changelog
Problem: Publishing Studio did not regenerate its website changelog.

Root cause: Product publication and the website deployment had no explicit handoff, and Pages only accepts runs from the website branch.

Fix: Dispatch the website workflow after stable publication with the required version and a unique request ID. Wait for that deployment and propagate failures. Register dispatch on the default branch without enabling deployment from it, and use the workflow commit when recovering old tags.

Verification: All 13 publication tests, shellcheck, signing workflow contracts, go vet, make lint, make check, and the required builtin/boot suites pass.
2026-10-19 23:07:01 +08:00
SivanCola 17ad4fd742 fix(studio): distinguish preset scope from its ID
Problem: the custom-preset form grouped storage scope and ID under one label,
leaving their purpose unclear.
Root cause: two independent controls shared the Preset ID field container
without visible labels or usage guidance for each control.
Fix: give each control its own label and short translated description, and
associate the descriptions with the controls for assistive technology.
Verification: AgentPresets interaction tests, frontend typecheck, production
build, census and fixture verification; browser layout and console checks;
go vet, make lint, make check, builtin tools and boot assembly tests.
2026-10-19 22:58:46 +08:00
SivanCola a655a9fbe3 Merge studio into session Agent compositions
Problem: PR #13613 conflicted with the current studio branch.
Root cause: both branches extended session status, ACP configuration, and
project dispatch while the shared stylesheet and session-new function reached
their existing size limits.
Fix: retain composition bindings alongside tariff status, apply the ACP
approval ceiling, and reuse pending dispatch panes without dropping inherited
composition bindings. Move the picker styles to their own sheet and compose
the ACP config projections without increasing the ratchet budgets.
Verification: frontend typecheck, 5,830 frontend tests, production build,
census invariants and fixtures; go vet, make lint, make check; ACP, HTTP serve,
composition contracts/storage, session storage/control, delegation, builtin
tools, and boot assembly tests.

Merged studio 3d9f9ff74c into PR head
ae6a9de150.
2026-10-19 22:47:32 +08:00
YHH 3d9f9ff74c Merge pull request #13933 from esengine/c5/keep-launch-credential
fix(desktop): restore the launch credential cookie when something removes it
2026-10-19 07:02:13 -07:00
esengine 1fb85da958 docs(release-notes): shorten the credential note to the column limit 2026-10-19 06:46:00 -07:00
esengine c9d33ca8cb fix(desktop): restore the launch credential cookie when something removes it
The kernel refuses every request without it, and the cookie belongs to the
host rather than the port, so a removal left the window refused until a
relaunch. The shell holds the token and puts it back, logging the cause.
2026-10-19 06:46:00 -07:00
YHH 3e8c1dad79 Merge pull request #13930 from esengine/c5/mac-tray-template
fix(desktop): draw a monochrome template glyph in the macOS menu bar
2026-10-19 06:44:48 -07:00
YHH 16b91bc1ad Merge pull request #13925 from chuiwenwei/fix/e2ebench-compare-wall-population
fix(e2ebench): count a killed run's wall in the same population as the report
2026-10-19 04:26:18 -07:00
Will 7905358552 test(e2ebench): keep the compare goldens LF on every checkout
.gitattributes pins *.go to eol=lf but not *.golden, so a Windows checkout turns
the byte-compared goldens into CRLF and TestCompareOutputUnchangedWithoutUnaccountedRuns
fails there. Pin *.golden to eol=lf as well.
2026-10-19 18:51:01 +08:00
esengine cffde3f3e9 fix(desktop): draw a monochrome template glyph in the macOS menu bar
The macOS tray loaded the full-colour tray.png, so the menu bar showed the
whole app badge. A Template-suffixed black-on-transparent pair lets macOS tint
it for light and dark bars; Windows and Linux keep their files.
2026-10-19 03:05:23 -07:00
YHH 036b25d499 Merge pull request #13923 from chuiwenwei/fix/history-rescan-retries-corrupt-sources
fix(history): retry a corrupt source once its transcript is readable
2026-10-19 02:10:17 -07:00
YHH ce06cec619 Merge branch 'studio' into fix/history-rescan-retries-corrupt-sources 2026-10-19 01:53:12 -07:00
esengine 81521fa074 test(sessionstore): pass the fourth ImportV4From argument in the prefix test 2026-10-19 01:52:59 -07:00
Will d8e55c6fd4 fix(e2ebench): count every run's wall in compare's rate figures
aggregateArm dropped an unaccounted run's wall before adding it to
s.WallMs, so compare's Solved/hour and global wall/task divided by a
smaller wall than the suite report uses for the same -json artifact:
report.go adds every run's wall, and a killed run's wall is measured
unconditionally in main.go (only its tokens never landed). The
per-class accumulator ran before the skip, so one section printed two
populations, and the sign of "which arm is cheaper" could flip.

WallMs is now the all-run wall and AccountedWallMs the accounted
subset: Solved/hour and wall/task divide the all-run total, while
"Wall seconds / solved" keeps dividing accounted wall by accounted
solves because that denominator is solved runs. No new persisted
state, and neither how Unaccounted is decided (segmentrun.go) nor the
suite report changes.

This is the residual of #13446 applied to a field that is NOT
unmeasured. That PR decided which runs are unaccounted and kept their
spend out of the token/cost ratios; it never touched wall, and this
change does not revisit that decision. It fixes the wall population
those adjacent rate lines divide, measured for every run including the
ones #13446 excluded elsewhere. Not a re-report of #13446.

Verified: both probes are red on 53a8db913 (compare Solved/hour 120.0
vs the report's 3.9; global wall/task -30.0s vs the class row's
+870.0s) and green after; a no-unaccounted fixture renders compare
byte-identically (goldens); "Wall seconds / solved" divides accounted
wall by accounted solves under either rate line reverted to the
accounted total, while the matching probe turns red; the suite
report's Solved/hour convention is pinned unchanged. gofmt,
GOOS=windows go vet ./cmd/e2ebench/, go test ./cmd/e2ebench (only the
pre-existing environment TestStateHomeIsOutsideWorkdirParent fails,
identically with this change stashed), make check (repolint: clean),
and golangci-lint run ./cmd/e2ebench/... (0 issues) pass.
2026-10-19 16:52:54 +08:00
Will 88092a4c8b fix(history): retry a corrupt source on rescan, not only on save
Cause: a transcript that could not be read was stamped health=corrupt while its
root was left state=ready with the failing size/mtime signature. Both automatic
repair paths trusted that row: reconcileRoot returned before indexPath ran and a
restart reopened the same ready row. Equal size/mtime says nothing about
readability, so a restored mode or a remounted transcript was never revalidated;
status stayed Indexed 0 / Total 1 until a save or a manual `catalogs reindex
history`.

Fix: chose the scan's skip predicate — reconcileRoot only short-circuits when
state=ready, the signature matches, indexed==total, and no source of that root
is non-ok (health<>'ok' or missing_since<>0). That is the decision that
prevented indexPath from ever being consulted. The same rule is applied at
source granularity: sourceProjectionUnchanged now also requires the stored
health to be 'ok', because after the root is rescanned a legacy source with no
ledger digest (digest=="") would otherwise be certified healthy from its
fingerprints without being read.

Fast path kept: a root whose last scan was complete and healthy still returns
before any transcript is opened, so the five-minute tick remains a directory
walk plus stat, not a re-read of every transcript. A source that stays corrupt
is retried once per scan (one failed read per corrupt source per tick) and
scan_generation advances exactly once per scan, so a permanently unreadable
transcript cannot wedge or loop the worker.

Tests: the audit's TestCorruptSourceIsRetriedOnceTheTranscriptIsReadable (red
before: health stays corrupt after the transcript heals) plus controls for a
healthy unchanged root still skipping without a read (scan_generation, revision
and document ids unchanged; a now-unreadable transcript keeps health=ok) and for
a permanently corrupt source retried on every scan while its healthy sibling is
not re-read. Permission fixtures skip on Windows and root, and every catalog in
these tests points at a temp path.
2026-10-19 16:34:39 +08:00
YHH 53a8db913a Merge pull request #13917 from esengine/feat/history-tool-output
feat(studio): a finished call's long output is kept as its two ends in the page and fetched whole from the kernel on request
2026-10-19 01:19:36 -07:00
YHH 145b068da1 Merge pull request #13921 from esengine/c5/v4-prefixed-ids
fix(sessionstore): import the 1.x Desktop sessions whose ids carry a desktop- or desktop-manual- prefix
2026-10-19 01:16:44 -07:00
YHH bf4f3f44f0 Merge pull request #13880 from imcreazy/fix/v4-import-keeps-archived-state
fix(migration): keep the conversations 1.x had archived archived
2026-10-19 01:04:48 -07:00
esengine 6add7ebfc9 Merge studio into feat/history-tool-output 2026-10-19 01:01:41 -07:00
YHH f43ef4fd44 Merge pull request #13912 from chuiwenwei/fix/memory-migration-reports-unreadable-facts
fix(memory): report a fact the migration cannot read instead of stopping
2026-10-19 00:59:58 -07:00
esengine ce3c8b9b14 fix(sessionstore): import the 1.x Desktop sessions whose ids carry a desktop- or desktop-manual- prefix 2026-10-19 00:59:42 -07:00
YHH e3449c11ad Merge pull request #13907 from kevin9327/fix/gitignore-escaped-leading-bang
fix(gitignore): keep an escaped leading ! or # in root ignore patterns
2026-10-19 00:59:14 -07:00
YHH cca093601b Merge pull request #13915 from esengine/perf/progress-tail-window
perf(studio): a running command keeps only the end of its output in the page and says how much came before
2026-10-19 00:48:38 -07:00
YHH 78d1233283 Merge pull request #13916 from esengine/c5/hide-machines-with-rail
fix(studio): the sidebar does not repeat the Remote entry while the icon rail shows it
2026-10-19 00:42:31 -07:00
esengine 9552fbbfdc Merge studio into fix/gitignore-escaped-leading-bang 2026-10-19 00:41:49 -07:00
imcreazy 67e3ff51b4 fix(migration): keep the conversations 1.x had archived archived 2026-10-19 15:38:18 +08:00
esengine 6d171bacda fix(studio): keep the lookup tools' output whole, since the manifest card counts lines and hits from it 2026-10-19 00:32:50 -07:00
YHH eaaf2ce99d Merge pull request #13914 from esengine/fix/feedback-capture-after-teardown
fix(studio): a feedback capture that finishes after the dialog is gone writes no state
2026-10-19 00:24:36 -07:00
YHH f8f670fabb Merge pull request #13895 from jf200s/fix/studio-stream-deltas
fix(event): merge tool progress with the deltas around it; fix(studio): the reveal cursor follows a shorter replacement back
2026-10-19 00:23:57 -07:00
esengine 0c5e8e2264 feat(studio): a finished call's long output is kept as its two ends in the page and fetched whole from the kernel on request 2026-10-19 00:22:56 -07:00
esengine a47ffb4134 docs(release): note the sidebar Remote entry change 2026-10-19 00:20:22 -07:00
esengine 8376709ee3 fix(studio): the sidebar does not repeat the Remote entry while the icon rail shows it 2026-10-19 00:20:01 -07:00
YHH 60aba758a8 Merge pull request #13909 from esengine/fb/remote-hist
fix(remote): wait for a desktop answer that is still arriving instead of reporting the computer offline
2026-10-19 00:09:48 -07:00
YHH 13e007e845 Merge pull request #13903 from esengine/perf/subagent-children-mount-on-expand
perf(studio): a finished sub-agent card mounts its child calls only while it is open
2026-10-19 00:05:53 -07:00
esengine 47bf3ce5db perf(studio): a running command keeps only the end of its output in the page and says how much came before 2026-10-19 00:03:23 -07:00
F b5f18a7be9 test(event): use integer ranges for the two counting loops
golangci-lint's intrange rule rejects a C-style loop whose index is never
read. Both loops in the new cases just repeat an emit, so they take the
Go 1.22 form. Behaviour is unchanged.
2026-10-19 15:01:57 +08:00
esengine 581b367f04 fix(studio): a feedback capture that finishes after the dialog is gone writes no state 2026-10-19 00:00:32 -07:00
YHH 5238af4629 Merge pull request #13911 from esengine/feat/feedback-process-memory
feat(feedback): a report carries each process's memory as a fixed type and two numbers, shown in the form first
2026-10-18 23:54:04 -07:00
YHH 0afe5434a1 Merge pull request #13910 from chuiwenwei/fix/compaction-sidecar-respects-write-authority
fix(sessionstore): gate the compaction sidecar on the session write permit
2026-10-18 23:52:17 -07:00
YHH 214a8871da Merge pull request #13906 from kevin9327/fix/gitignore-global-excludes-like-git
fix(gitignore): read core.excludesFile the way git does
2026-10-18 23:41:14 -07:00
YHH ab5dfe515f Merge pull request #13908 from esengine/a9/session-row-title
fix(studio): a session title takes the room the row leaves it
2026-10-18 23:37:19 -07:00
YHH 28bd6a44d8 Merge pull request #13905 from chuiwenwei/fix/memory-restore-archive-staged
fix(memory): stage archive entries before publishing a restore
2026-10-18 23:34:46 -07:00
esengine e3f1e64dcd fix(studio): find and landing frames stop once their transcript has left the document 2026-10-18 23:30:37 -07:00
Will 730c2ab9fe fix(memory): report unreadable facts instead of stopping the migration
Store.MigrateV2WithVersion returned on the first fact whose os.ReadFile
failed, so facts later in the same directory and every later directory
were never migrated. MigrationReport carried only { Migrated int } and the
boot caller discarded it, so nothing named the skipped files: the audit
observed {Migrated:1} with zeta and omega never reached.

The contract says otherwise. docs/SESSION_MEMORY_RETRIEVAL.md:259-261: a
read condition is "reported instead of returning partial lists or
calculating a revision from incomplete history". :268-269: "These
conditions ... do not establish data deletion." The pass therefore records
each failed basename in MigrationReport.Unreadable, sorted, and continues
with the rest; boot's warning names them instead of printing only the
first path.

A per-fact read failure returns a nil error on purpose: the field is the
channel for "skipped", and folding it into err would make a skipped fact
indistinguishable from a pass that could not run. Directory and index
failures still return an error, because those leave the pass's own work
incomplete.

Tests cover: the audit fixture (alpha readable, beta chmod 000, zeta after
it, omega in the second directory) now migrating alpha/zeta/omega with
Unreadable=[beta.md], cross-directory sort order, a fully readable control
with no error and an empty list, the repair path for an already-migrated
fact behind an unreadable one, and the boot warning naming beta.md.
2026-10-19 14:28:15 +08:00
esengine 26830b037f docs(release-notes): remote slow answer 2026-10-18 23:22:19 -07:00
F 63e7e13f69 docs(event): state the constraint, not the history, above the delta key
Review asked for comments to describe what holds now and leave how the code
got here to the commit history. The behaviour is unchanged.

The history this replaces, kept where it belongs:

  * Leaving Source in the zero-probe kept ToolProgress on the passthrough
    path, so the first attempt at merging progress merged nothing in
    practice. Source is identity — it names the emitting party — so it
    belongs in the key, and keeping it there is also what stops a planner's
    text merging into an executor's.
  * The e2e run over a real kernel is what caught that; the unit tests did
    not, because they emitted deltas with no Source at all. Both are now
    covered: TestCoalesceMergesDeltasCarryingSource and
    TestCoalesceSeparatesBySource pin the key's shape.
2026-10-19 14:21:59 +08:00
Will 432a49fe73 fix(sessionstore): gate the compaction sidecar on the session write permit
Cause: persistCompactionStateLocked reached SaveCompactionState, a blind
atomic overwrite carrying no writer identity, while transcript saves fail
closed with ErrSessionWriteAuthorityMissing. A controller in the
stale-authority window (rebind, controller replace, lease generation change)
therefore installed and published a fold whose transcript save was refused:
it replaced the owner's installed digest and paid for the same span twice.

Enforced in the store rather than in the agent: (*Session).SaveCompactionState
presents exactly the permit save.go presents (requireWriteAuthorityForSave,
including BeginSave's in-flight accounting that lease Release waits for), so
there is one adjudication of the permit and no second check re-reading
WriteAuthority() in the agent. Every write to the owner's sidecar already
funnels through the agent's single persistCompactionStateLocked, which now
calls that method. The raw SaveCompactionState remains for paths that write a
fresh recovery lane and for low-level CAS tests that never bind authority.
The CAS on (generation, projection_version, covered_prefix_hash) is untouched:
this is an additional gate, not a replacement.

On refusal the typed authority error propagates as
errProjectionNotPersisted: commitSummaryProjection restores the previous
projection, so nothing is installed or published, and the next attempt under a
valid permit folds once.

Tests: internal/runtime/agent/compaction_sidecar_authority_test.go proves the
no-authority refusal (sidecar and digest unchanged), the authorized control
(one summary, one digest), and that a refused publish costs one normal retry,
not a second summary.
2026-10-19 14:20:56 +08:00
esengine ba2460c01b fix(studio): a session title takes the room the row leaves it
Refs #13904
2026-10-18 23:20:29 -07:00
kevin9327 67854e87b6 fix(gitignore): keep an escaped leading ! or # in root ignore patterns
reanchorPattern stripped the backslash from \!name and \#name before
handing a root pattern to the matcher, which then read the first as a
negation and the second as a comment. Files named !name or #name that git
ignores through the root .gitignore, .git/info/exclude or the global
excludes file were therefore searched and listed. Nested .gitignore files
were not affected, since their patterns are re-anchored under /dir/.

Keep the escape on root patterns when it guards a leading ! or #.
2026-10-19 15:20:17 +09:00
esengine 092a375576 feat(feedback): a report carries each process's memory as a fixed type and two numbers, shown in the form first 2026-10-18 23:19:27 -07:00
kevin9327 4892a8875e fix(gitignore): read core.excludesFile the way git does
The global ignore file was taken from the first core.excludesFile found:
$XDG_CONFIG_HOME/git/config before ~/.gitconfig, and the first value in a
file. Git reads the files in that order and keeps the last value, so a
user's ~/.gitconfig setting lost to the XDG one. The value was also used
nearly raw, so an inline comment or escaped backslashes produced a path
that does not exist, and the user's global ignore rules were silently
dropped.

Keep the last value across files and within a file, and decode the value
like git: strip unquoted # and ; comments, remove quotes, and unescape
\\ and \". Other backslashes stay as written so raw Windows paths still
work.
2026-10-19 15:18:44 +09:00
esengine b2b7b78aa2 fix(remote): wait for a desktop answer that is still arriving instead of reporting the computer offline 2026-10-18 23:16:42 -07:00
F f7ea4be898 fix(studio): the reveal cursor follows a shorter replacement back
useRevealed paces a streamed answer on the frame clock and keeps its cursor
in a ref. The effect returned early when the cursor had caught up and only
handled a shrink inside step(), which the early return made unreachable. A
replacement shorter than what was already revealed — regenerate, rewind,
history restore — therefore left the cursor above the new length, and every
later growth below it was short-circuited: the typing animation silently
stopped for that message while the text itself still arrived in one piece.

Handling the shrink before the early return fixes it. The cursor comes back
with the text, so the next growth is paced exactly like the first one.

The existing three tests did not cover this: they only grew the text. The
new case fails against the old code with "expected 0 to be greater than 0".
2026-10-19 14:16:25 +08:00
F 5e1aef3260 fix(event): merge tool progress with the deltas around it
A shell emits one ToolProgress per output line, so a build log crossed the
coalescer as one frame per line. Downstream that filled a subscriber's
64-frame queue and the hub then dropped answer text beside it, because text
and progress are both droppable. Measured on a real 500-line command: 285
frames unrepaired, 2 with this.

streamDelta replaces isStreamDelta and returns the key a burst merges under
plus its payload, so progress joins text, reasoning and compaction progress:

  * the key carries Source. Every progress chunk an executor emits sets
    Source="executor", and leaving it in the zero-probe kept the event on the
    passthrough path — the first attempt at this merged nothing in practice.
    Keeping it in the key also stops a planner's text merging into an
    executor's.
  * the key carries the call id, so two tools reporting at once never land
    in one another's card.
  * progress carrying more than ID+Output — a sub-agent's status channel sets
    Name, ParentID and a rate — fails the probe and still crosses untouched.
  * progress without an id is not buffered under an empty key.

Verified against a real kernel over SSE: a 500-line command emits 2 frames
that concatenate to the same 500 lines in order, and a 3000-line command
emits 4. Alternating text and progress is unchanged (the key switch flushes
either way, so that burst still cannot merge).
2026-10-19 14:16:22 +08:00
esengine 675c093dbc perf(studio): a finished sub-agent card mounts its child calls only while it is open 2026-10-18 23:01:20 -07:00
YHH 156cd28a5d Merge pull request #13855 from SivanCola/codex/market-source-client
fix: explain invalid market source commits / 准确提示市场来源提交号错误
2026-10-18 22:55:20 -07:00
YHH f2110ddc9c Merge pull request #13847 from esengine/feat/peers-settings-section
feat(studio): session messages have a settings entry of their own, with projects allowed through to add and take back
2026-10-18 22:40:09 -07:00
YHH 4a005015ac Merge pull request #13897 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-10-18 22:39:59 -07:00
YHH 947907bb77 Merge pull request #13900 from esengine/feat/trust-wait-badge
feat(studio): a conversation out of sight that is asking whether to trust its folder is marked as waiting in the sidebar
2026-10-18 22:30:30 -07:00
esengine bd9df22bc6 feat(studio): a conversation out of sight that is asking whether to trust its folder is marked as waiting in the sidebar 2026-10-18 22:11:28 -07:00
YHH b6c837b0af Merge pull request #13893 from esengine/feat/13870-ask-refine
feat(studio): the free-text answer of a question card can optimise the prompt like the composer
2026-10-18 22:09:24 -07:00
YHH 0e0eecf2b6 Merge pull request #13890 from esengine/fb/dispatch-trust
fix(dispatch): keep an undecided project's window open and say the task was not sent
2026-10-18 21:55:58 -07:00
esengine 164c03c141 fix(dispatch): count panes waiting for a trust answer against the session ceiling 2026-10-18 21:39:58 -07:00
YHH 62afcaddf5 Merge pull request #13800 from chuiwenwei/fix/memory-rebuild-index-when-unreadable
fix(memory): rebuild the derived index when it cannot be read
2026-10-18 21:09:18 -07:00
YHH 5b1c1aeadd Merge pull request #13896 from esengine/feat/13142-board-default-join
feat(studio): linking a computer offers the contribution board with a name to confirm, and the terminal says how to join
2026-10-18 21:08:53 -07:00
YHH a8fc3c05b7 Merge pull request #13887 from lancy69/fix/6007-project-config-fallback
feat(config): support .reasonix/reasonix.toml project fallback
2026-10-18 21:08:45 -07:00
Will 0a78408ea9 test(memory): the active sweep branch must not recreate an archive entry
The kill test proves the leftover is removed and the live fact is untouched, but
nothing pinned the branch itself: forcing an active id down the rename-back path
now fails because ListArchived lists the live fact as archived again.
2026-10-19 12:01:20 +08:00
Will 1e1fb59a60 fix(memory): stage archive entries before publishing a restore
RestoreArchived moved the archive entry away only after the active file
and its index line were published. A process killed in between left one
id both active and archived, so ListArchived reported a live fact and a
retry was refused as "already active".

The entry now moves to a hidden .restoring-<id>-<stamp> name before the
active file is written. The stamp is the archive time, so the name
carries both the id and the original archive name with no journal and no
content comparison. Every restore sweeps leftovers first: an active id
means the dead restore had published, so only the redundant copy goes;
an absent id means it never published, so the entry is renamed back. A
failed sweep reports its error and publishes nothing.
2026-10-19 11:58:44 +08:00
YHH d18912cd30 Merge pull request #13854 from esengine/feat/12144-tui-keys
feat(tui): queued messages are drawn as rows and managed with the keys 1.x had
2026-10-18 20:41:20 -07:00
SivanCola 22c3c2b167 docs: update acknowledgments contributors 2026-10-19 03:37:09 +00:00
Lancy cfb483ae11 feat(config): support nested project config fallback
Resolve .reasonix/reasonix.toml only when the root project config is absent. Share selection across loading, local edits, installs and repair; retain project-scope restrictions and reject symlink escapes.

Fixes #6007
2026-10-19 11:36:17 +08:00
Will 36370578c9 test(memory): skip the permission-fault fixtures on Windows
chmod 000 does not remove read permission on Windows, so the fixtures that make
MEMORY.md unreadable would fail their own self-check there. The classification and
aside behaviour are unchanged on Unix; the directory-named case stays cross-platform.
2026-10-19 11:31:44 +08:00
esengine e0e1f7fa55 feat(studio): linking a computer offers the contribution board with a name to confirm, and the terminal says how to join 2026-10-18 20:28:33 -07:00
esengine 118f8a624a refactor(tui): switch on the selected id directly 2026-10-18 20:20:29 -07:00
esengine f987d4326b feat(studio): the free-text answer of a question card can optimise the prompt like the composer 2026-10-18 20:15:00 -07:00
esengine d67d18df49 fix(dispatch): keep an undecided project's window open and say the task was not sent 2026-10-18 20:02:09 -07:00
esengine 039756a281 docs(release-notes): shorten the queue-rows fragment under 200 columns 2026-10-18 20:01:11 -07:00
esengine 8bd8220792 Merge studio into feat/peers-settings-section 2026-10-18 20:00:55 -07:00
esengine db653b55f2 Merge studio into feat/12144-tui-keys 2026-10-18 19:57:36 -07:00
YHH 0e56db4b59 Merge pull request #13881 from imcreazy/fix/stream-error-code-may-be-numeric
fix(openai): read a numeric error code from a stream error event
2026-10-18 19:54:22 -07:00
YHH bf3fcd9934 Merge pull request #13879 from esengine/fix/skill-subagent-label
fix(delegation): a skill sub-agent's row carries the first line of its task as its label
2026-10-18 19:54:14 -07:00
Will a7cf278566 test(memory): the aside copy is kept for the person, not served as a fact
After an unreadable index is moved aside and the derived index republished, the
store must list exactly the fact files and the index must not name the aside.
2026-10-19 10:50:42 +08:00
Will 399a6b62e1 fix(memory): rebuild MEMORY.md only for deterministic read faults
MigrateV2WithVersion is the only entry that recomputes the derived
MEMORY.md, and it returned readIndexIn's error. Every mutation path reads
that same file first, so a restored store whose index has a foreign owner
or a changed ACL froze every memory write for good: boot only warned, and
the docs' promised re-index has no other entry.

Classify the read error by identity: only fs.ErrPermission (permission
denied, a foreign owner, an ACL) and a typed not-regular-file error (a
directory named MEMORY.md) reach the rebuild. Every other error -- an I/O
failure, a resource limit, a momentary lock -- still returns, because the
file may be perfectly good and republishing would overwrite hand-written
text a retry can still read.

Before publishing, atomically rename the unreadable entry to a stamped
name in the same directory (MEMORY.md.unreadable-<unixnano>), so the bytes
survive for a later repair; a rename needs write access to the directory,
not read access to the file. If the rename fails, return the original
error and publish nothing. flushIndexIn keeps the readable path, so
hand-written text is still preserved line for line when it can be read.

No new persisted state beyond the aside copy; the MigrateV2 signature and
boot call site are unchanged, and an unreadable store directory or fact
file still aborts the pass.

Tests: the audit probe is red before and green after; controls cover the
stale readable index, hand-written survival, byte-identical healthy boot,
missing-index rebuild, and a later save succeeding after the repair. New:
a transient read error leaves the index byte-identical (red against the
previous revision), the unreadable bytes survive under the stamped name, a
directory index repairs, and a failed aside rename publishes nothing. Each
new guard was reverted alone to confirm its control goes red.
2026-10-19 10:50:42 +08:00
esengine f7b9f2db90 docs(studio): split the sub-agent row layout note into short rules 2026-10-18 19:39:09 -07:00
esengine a304b5d426 Merge remote-tracking branch 'origin/studio' into fix/skill-subagent-label 2026-10-18 19:38:29 -07:00
YHH 4a3cca633b Merge pull request #13886 from esengine/fix/13884-archive-list
fix(studio): the sidebar says it is reading, not that nothing matches, while the rows of another view are still in hand
2026-10-18 19:37:54 -07:00
YHH 963d45aeec Merge pull request #13878 from esengine/a9/sse-fixtures
test(openai): pin how usage is read from captured DeepSeek streams (stop and length)
2026-10-18 19:37:20 -07:00
YHH d249fd9ae3 Merge pull request #13885 from esengine/fix/13883-first-tool-snapshot-bound
fix(agent): the first tool waits at most snapshotWait for the turn-start snapshot
2026-10-18 19:37:07 -07:00
esengine 3a3b3411b9 fix(studio): the sidebar says it is reading, not that nothing matches, while the rows of another view are still in hand 2026-10-18 18:50:14 -07:00
esengine 4cfe4b1d2f fix(agent): the first tool waits at most snapshotWait for the turn-start snapshot
The turn-start workspace snapshot was awaited with no bound, so in a very large
workspace (a drive root: 7 s on a warm SSD for the first turn, 0.5 s after) the
first tool could not start until the walk finished, and a command's own
timeout_seconds, which only starts when the command does, never covered it.
The wait now ends at snapshotWait, the bound the end-of-turn snapshot already
has: the walk is cancelled, the snapshot records itself incomplete, and the
bundle says the turn's changes were not compared.
2026-10-18 18:35:33 -07:00
esengine 252ed5f28b fix(studio): a narrow sub-agent list gives the task a line of its own
The row sized the name, the model chip and the status to their content and gave
the task only what was left, so with the chip present the task was squeezed to
8 px at a 360 px panel and 0 px at 280 px. Below 460 px of the list's own width
the row now takes up to three lines: name and status first, the task second,
the chip third.
2026-10-18 18:17:53 -07:00
imcreazy 94c1eb913d fix(openai): read a numeric error code from a stream error event 2026-10-19 09:10:01 +08:00
esengine da2f54207c fix(delegation): a skill sub-agent's row carries the first line of its task as its label
profileForSkill set only the name and count, so a skill sub-agent (explore,
locate, run_skill, read_only_skill) showed no task while a task sub-agent
showed its first line. The label helper moves to the event package, next to
the Profile it fills, and both producers use it.
2026-10-18 18:02:12 -07:00
esengine 56795d8913 docs(release): studio 2.45.0 notes 2026-10-18 17:56:42 -07:00
YHH 5163088956 Merge pull request #13853 from esengine/fix/browser-fit-v2
fix(studio): a page with a viewport of its own is centred at its size or scaled to fit, and the agent's clicks are mapped by the scale
2026-10-18 17:55:29 -07:00
esengine a0589c8ad0 test(studio): pixel checks that local html, a 3200x1800 image document and a PDF fill the built-in browser, with and without an agent viewport 2026-10-18 17:37:07 -07:00
esengine 45444a47cb test(openai): pin how usage is read from captured DeepSeek streams (stop and length) 2026-10-18 17:24:01 -07:00
YHH 6318ab0ae4 Merge pull request #13840 from esengine/fix/13833-root-missing
fix(agent): refuse a call whose project or external folder is gone, with one typed cause
2026-10-18 16:56:47 -07:00
esengine d536418db3 refactor(agent): lift the workspace lease hold out of prepareToolExecution; drop the test-only IsRootMissing 2026-10-18 16:39:50 -07:00
esengine 77134b5ef0 Merge studio into fix/13833-root-missing 2026-10-18 16:27:14 -07:00
YHH 8d714ba296 Merge pull request #13825 from esengine/feat/13320-bound-candidate
fix(compaction): retry a refused summary request at a smaller size
2026-10-18 16:23:24 -07:00
esengine f73a5b1507 docs(release-notes): wrap the root-missing fragment under 200 columns 2026-10-18 16:08:32 -07:00
esengine 814dbd5402 Merge remote-tracking branch 'origin/studio' into feat/13320-bound-candidate 2026-10-18 16:08:06 -07:00
YHH fdaf05584f Merge pull request #13820 from imcreazy/fix/explorer-after-closing-a-file
fix(studio): closing a file opened from the explorer comes back to the explorer
2026-10-18 14:41:38 -07:00
YHH dfa8f70ea4 Merge pull request #13877 from esengine/a9/cost-effect
test(boot): a DeepSeek-shaped OpenAI-compatible usage reaches the quote split by cache
2026-10-18 14:31:52 -07:00
YHH 54ca8a7847 Merge pull request #13876 from esengine/fix/13642-subagent-images
fix(delegation): a delegate gets the pasted images its task names, and a skill sub-agent is routed to the vision model
2026-10-18 14:29:31 -07:00
YHH f2ac8905ad Merge pull request #13875 from esengine/a9/versions-refresh-watch
fix(studio): opening the Versions page checks for a release when automatic download is chosen
2026-10-18 14:25:46 -07:00
esengine 1b25fca477 test(boot): a DeepSeek-shaped OpenAI-compatible usage reaches the quote split by cache (local, not pushed) 2026-10-18 14:12:49 -07:00
esengine fe6c7c3a91 fix(studio): opening the Versions page checks for a release when automatic download is chosen
Refs #13683
2026-10-18 14:10:17 -07:00
YHH e741c70074 Merge pull request #13874 from esengine/a9/fix-feedbackmine-selector
test(feedback): read the status chips inside the list, not the first one on the page
2026-10-18 14:10:02 -07:00
esengine c0d08b437c fix(delegation): a delegate gets the pasted images its task names, and a skill sub-agent is routed to the vision model
A task text that names a file in the host's own .reasonix/attachments directory
now gives the delegate that image (judged by the resolved path, read through the
same checks an @-reference gets), on the vision role with the note that it rides
the message, including on a follow-up turn that carried no image. The
read_only_skill runner took the turn's candidates without routing the model or
saying so, so a text-only skill sub-agent dropped them silently.
2026-10-18 14:09:16 -07:00
esengine f800585f31 test(feedback): read the status chips inside the list, not the first one on the page 2026-10-18 13:55:04 -07:00
YHH 45c763ce15 Merge pull request #13814 from esengine/fix/shell-mode-stuck
fix(studio): a refused ! command stays selected in the box so the next message replaces it
2026-10-18 13:36:21 -07:00
YHH 8e1ea87adc Merge pull request #13873 from esengine/fix/13872-tui-stream-append
fix(tui): appending to a streamed answer, its thinking or a command's output no longer copies it whole
2026-10-18 13:09:42 -07:00
esengine 30c63e6023 fix(tui): appending to a streamed answer, its thinking or a command's output no longer copies it whole
Each delta did s += piece on the row's text, so a long stream cost time and
memory quadratic in its length: 20000 progress events of one command (4.5 MB)
took about 13 s of the UI's own time. The text now grows in a buffer kept beside
the row, trusted only while the row still holds exactly what it last handed out.
2026-10-18 12:53:36 -07:00
YHH 80fcd85b54 Merge pull request #13843 from esengine/feat/11411-effort-in-place
feat(effort): changing reasoning depth no longer rebuilds the session and works mid-turn
2026-10-18 11:54:24 -07:00
esengine 0ce5ce6436 Merge studio into feat/11411-effort-in-place 2026-10-18 11:40:16 -07:00
YHH 27ad7ded21 Merge pull request #13871 from esengine/fix/13684-open-lookup
fix(serve): match an open pane's row whatever the case of its transcript name
2026-10-18 11:04:42 -07:00
YHH fe0020056e Merge pull request #13869 from esengine/a9/usage-billing-mode
fix(usage): keep a subscription's estimate out of the paid total
2026-10-18 10:56:03 -07:00
YHH f5d2874d61 Merge pull request #13868 from esengine/fix/jump-resolve-flaky
test(remote): bound the real-ssh jump test by the run's budget, not the product's 15 s
2026-10-18 10:49:57 -07:00
YHH de2763b21e Merge pull request #13866 from esengine/feat/13142-board-studio
feat(feedback): the contribution board in Studio and the terminal, through the kernel
2026-10-18 10:49:14 -07:00
esengine 3b54ee9438 fix(serve): match an open pane's row whatever the case of its transcript name 2026-10-18 10:44:09 -07:00
YHH 0d50803ab1 Merge pull request #13842 from esengine/feat/11531-lease-release
feat(workspace): a session waiting on a person yields its write claim, and says so when another session took it
2026-10-18 10:41:02 -07:00
esengine d8abd5b5ce fix(usage): keep a subscription's estimate out of the paid total
Refs #13276
2026-10-18 10:32:04 -07:00
esengine e6ec598a58 feat(feedback): the contribution board in Studio and the terminal, through the kernel 2026-10-18 10:31:58 -07:00
YHH 78cb353fd8 Merge pull request #13865 from esengine/fix/13858-port-identity
fix(studio): two model services on one host and different ports are two services, and a duplicate is never dropped from the list
2026-10-18 10:25:50 -07:00
YHH bf1ff861ad Merge pull request #13864 from esengine/a9/price-provenance2
feat(billing): a model's price can say where it was read and when
2026-10-18 10:22:42 -07:00
YHH a925403b6f Merge pull request #13860 from esengine/a5/opencode-go-gateway
fix(providers): recognise OpenCode Go under its documented /inference/go prefix
2026-10-18 10:21:17 -07:00
YHH 38060091bd Merge pull request #13862 from esengine/test/control-git-push-trust
test(control): cover the push trust gate and hold control/git_push.go to it
2026-10-18 10:20:58 -07:00
esengine d5997b6496 feat(tui): queued messages are drawn as rows and managed with the keys 1.x had 2026-10-18 10:20:12 -07:00
esengine 0b848ac7f2 fix(studio): the session messages switches and the allowed projects share one read of the settings 2026-10-18 10:19:33 -07:00
esengine d4089914ea test(remote): the real-ssh jump resolution test bounds ssh by the run's budget, not the product's 15 s
The product keeps its 15 second bound on one ssh -G; the source now carries it
as a field with that default, and the test that runs the installed ssh sets it
to testenv.Budget, a bound that only reports a hang.
2026-10-18 10:17:52 -07:00
YHH 7b499ffaeb Merge pull request #13863 from esengine/a5/effort-undeclared
fix(effort): say a model's levels are undeclared, and where to declare them
2026-10-18 10:13:03 -07:00
YHH 5d6743aa2c Merge pull request #13844 from esengine/a9/acp-permission-mode
feat(acp): --permission-mode fixes the session posture and caps what a client can raise
2026-10-18 10:12:13 -07:00
esengine add01e5f04 fix(studio): a viewport override with a zero or non-finite size means no override 2026-10-18 10:11:53 -07:00
esengine 223a158a02 Merge origin/studio into feat/11531-lease-release 2026-10-18 10:06:32 -07:00
esengine ad7e7f835f fix(studio): two model services on one host and different ports are two services, and a duplicate is never dropped from the list 2026-10-18 09:58:38 -07:00
imcreazy 481aa9f9a6 fix(studio): closing a file opened from the explorer comes back to the explorer 2026-10-19 00:58:10 +08:00
esengine 1766c04904 feat(billing): a model's price can say where it was read and when
Refs #13276
2026-10-18 09:56:46 -07:00
YHH ea0538a72e Merge pull request #13851 from esengine/fix/13335-sibling-folders-domain
fix(workspace): a writer with no declared extent holds its own folder, not the whole repository
2026-10-18 09:56:07 -07:00
esengine 5888922ffc refactor(i18n): keep the /effort undeclared wording in the setting catalogue 2026-10-18 09:53:32 -07:00
esengine 94e236f203 docs(release): note the /effort wording fix 2026-10-18 09:51:17 -07:00
esengine 8ce086e1bc fix(effort): say a model's levels are undeclared, and where to declare them 2026-10-18 09:51:16 -07:00
esengine e1e5301681 test(control): cover the push trust gate and its refusals, and hold the file to it
internal/session/control/git_push.go measured 0 from the control package's own
tests, so declaring it sensitive recorded a floor of 0 that could never fail.
The tests cover the trust refusal for both calls, a repository that is not
there, a branch with no upstream, and the commit lock released after a refused
push; the floor is now 100.
2026-10-18 09:47:51 -07:00
esengine 7b8f7de8b0 docs(release): note the OpenCode Go endpoint fix 2026-10-18 09:45:27 -07:00
esengine fe45ff0a8c fix(providers): recognise OpenCode Go under its documented /inference/go prefix 2026-10-18 09:43:43 -07:00
esengine ae9fba3fd0 fix(workspace): a writer with no declared extent holds its own folder, not the whole repository
Two project folders under one git repository share a writer domain on purpose
(the lock and the visibility of each other's claims), but an undeclared writer
claimed the domain root, so sibling projects waited on each other for bash and
MCP writes, and the refusal read held X, requested X. It now claims the
selected folder; a session on the repository root still overlaps both, and a
declared path that cannot be resolved still claims the whole repository.
2026-10-18 09:36:18 -07:00
SivanCola 9710592281 fix: explain invalid commit-pinned market sources accurately
Problem: trusting a publisher whose plugin source has a mistyped commit
reports a misleading local-or-unsafe-source installation failure.

Root cause: plugin source validation collapsed its refusal into the generic
bad-source class before the HTTP and presentation layers could explain it.

Fix: preserve a distinct invalid-plugin-source error through publication and
installation, translate its complete-commit requirement in both languages,
and align the shared client validator with existing registry path checks.

Verification: Go vet, repository lint, owning market/serve tests, the required
builtin/boot tests, 391 frontend market/i18n tests, and TypeScript checking.
2026-10-19 00:32:35 +08:00
YHH ebe4f2dca2 Merge pull request #13850 from esengine/c5/sensitive-git-push
chore(security): declare the Git push path and the host-only route list sensitive
2026-10-18 09:31:49 -07:00
esengine 817e3fa03d fix(studio): a page with a viewport of its own is centred at its size or scaled to fit, and the agent's clicks are mapped by the scale 2026-10-18 09:31:00 -07:00
YHH 442f05cd11 Merge pull request #13846 from xxoingr/test/boot-catalog-close-budget
test(boot): bound the catalog close in cleanup by the hang budget
2026-10-18 09:24:13 -07:00
esengine 8126f5cf1e chore(security): declare the Git push path and the host-only route list sensitive
The push is the one git action Reasonix performs that leaves the machine, and
the host-only route list decides which requests a paired device may make. Both
now demand review plus security_review and are held to the coverage they have:
gitpush 84.0, serve/git_push.go 77.0, serve/host_routes.go 93.0, and
control/git_push.go 0 (its behaviour is covered by the serve and boot effect
tests, which the per-package gate does not count).
2026-10-18 09:17:17 -07:00
YHH 904ca4359b Merge pull request #13834 from esengine/feat/git-panel
feat(studio): one Git panel: a changes tree, discard, push with a preview, commit details and a section per repository
2026-10-18 09:10:38 -07:00
esengine c5e8080866 feat(workspace): a session waiting on a person yields its write claim, and says so when another session took it
An Auto Guard card and a call that asks before it writes now give the claim
back for the wait; the lease records granted claims so the resume can name
who took the ground, the model gets workspace.claim_contended and the receipt
workspace_claim_yielded. A blocked acquisition ends at a user-set maximum wait
with workspace.claim_timeout.
2026-10-18 09:08:45 -07:00
xxoingr e4a0841fec test(boot): bound the catalog close in cleanup by the hang budget
closeBootTestHistoryCatalog gave the history and usage catalog closes a
fixed 5 s. Closing a SQLite catalog, and waiting out an open still in
flight, can take longer on a loaded Windows runner, and the posture
effect test failed there in cleanup with "close usage catalogs: context
deadline exceeded". The wait only exists to report a hang, so use
testenv.Budget; a close that never returns still fails the same way.
2026-10-19 00:07:46 +08:00
esengine 053247aa1e feat(studio): session messages have a settings entry of their own, with projects allowed through to add and take back 2026-10-18 09:06:42 -07:00
esengine ab66297bab feat(acp): --permission-mode fixes the session posture and caps what a client can raise
Refs #13839
2026-10-18 09:00:03 -07:00
YHH 02341bf950 Merge pull request #13763 from esengine/a9/tools-disabled
feat(tools): turn tools off by name, one registry removal for every surface
2026-10-18 08:55:30 -07:00
esengine a52b35551f feat(session): a sub-agent and the status line follow an in-place depth change
A child that shares the parent's provider starts on the depth in force now, and one
that inherits the parent's model without an effort of its own is built for it, as a
rebuild would have given. The status line reads the depth the session is on.
2026-10-18 08:51:08 -07:00
esengine edc7e01bb4 feat(session): change reasoning depth in place when the provider takes it per request
A provider that exposes the depths it accepts in Request.EffortOverride
(provider.RequestEffortProvider; the OpenAI-shaped adapter does) lets a session change
depth without a rebuild: the controller sets a session depth every main request
carries, updates its running selection, and accepts the change mid-turn. Serve (and so
the terminal and the desktop) and ACP both apply it in place and keep their rebuild for
a depth the provider does not take per request. The level is still saved to the user's
config so a restart opens on it.
2026-10-18 08:51:08 -07:00
esengine a07ca77b69 fix(git): repositories are listed once and kept until the structure changes, reads are settled and not repeated, a kernel without the route keeps the old behaviour 2026-10-18 08:50:37 -07:00
esengine 6b653a7f32 fix(agent): refuse a call whose project or external folder is gone, with one typed cause 2026-10-18 08:46:27 -07:00
YHH d8f9eef43d Merge branch 'studio' into a9/tools-disabled 2026-10-18 08:42:35 -07:00
YHH 89d86f4d3f Merge pull request #13838 from esengine/a9/peak-indicator
feat(status): show which of the vendor's billing windows the model is in
2026-10-18 08:42:12 -07:00
SivanCola ae6a9de150 Merge studio into session-agent-compositions after UI review fixes
Problem: the advancing Studio branch conflicted with the mod-turn settlement
test and changed queue pause and child-dispatch reporting contracts beside the
reviewed composition work.

Root cause: both branches added turn-completion waiting, the combined inbox
owner exceeded the file ceiling, and the effort audit still expected concrete
runtime identity in dispatch profiles after Studio restored selection profiles.

Fix: integrate exact Studio da9fbe7307 normally.
Keep both queued TurnDone and durable idle settlement. Group pause operations
in inbox_pause.go without behavior changes. Assert requested selections on
dispatch events and concrete identities on status events, retaining real
provider effort, rejection and persisted-child checks. Preserve landed queue,
billing, phone and remote protection work and the default-off composition UI.

Verification: format/vet/lint/make check; all 229 root Go packages and required
builtin/boot; affected HTTP/storage/controller and focused boot races; pause
controller regression; Desktop/SDK; 699 frontend files and 5670 tests,
typecheck/build/census; 12 browser/eight Electron renderer scenes; Electron
units (177 pass, one existing skip); 13 official ACP SDK conformance tests.
No threshold or authority expansion; no hosted check result is substituted.
2026-10-18 23:19:34 +08:00
esengine f8e16bfc29 feat(status): show which of the vendor's billing windows the model is in
The vendor's published schedule already prices every request; the status bar
now says, live, whether the model is billed at the peak or the off-peak rate
and when that changes, where the model is billed at the vendor's own rate.
2026-10-18 08:16:23 -07:00
esengine 30c6589451 wip peak indicator 2026-10-18 08:16:16 -07:00
esengine 0608ffeb63 fix(git): a repository name and a path are taken from a directory listing and from git's own list, never built from the request 2026-10-18 08:10:18 -07:00
SivanCola 2e2b316d86 fix(studio): address composition UI review and host archive uploads
Problem: repeated New session reset a blank pane's mode, copied built-in
descriptions ignored the window language, and identifier fields missed theme
styles. The picker was always visible and native archives were parsed in the
renderer. Unrelated transitions also repositioned every open popup.

Root cause: pane reuse resolved an unrequested default; editor styles depended
on a container the identifier row did not share; interface opt-in was missing;
archive transport carried parsed objects; placement had no anchor scope.

Fix: change a reused pane only for an explicit selection and preserve focus on
refusal, translate built-in copies, share field tokens, add a default-off local
picker switch that preserves bindings and the original hidden-state layout,
upload opaque files to the existing host decoder with typed localized errors,
keep the prior JSON envelope, and scope transitions to each popup's anchor or
container. Show catalog unavailability and wrap the editor components.

Verification: failing-first regressions; format/vet/lint/make check; all 228
root Go packages, required builtin/boot, HTTP/storage races and exact-binding
archive transport tests; Desktop/SDK; 691 frontend files and 5608 tests,
typecheck/build/census; 12 browser and eight Electron renderer scenes across
languages/themes/widths; Electron units (176 pass, one existing skip).
No authority, schema, safety policy or verification threshold is widened.
2026-10-18 23:06:07 +08:00
YHH da9fbe7307 Merge pull request #13836 from esengine/a5/phone-push-service-error
fix(phone): say a browser that cannot reach its push service is that, not an account outage
2026-10-18 08:02:05 -07:00
YHH c38beaf09a Merge pull request #13835 from esengine/fix/13811-browser-fit-wide-viewport
fix(studio): show a page the agent laid out wider than the pane whole, scaled to fit
2026-10-18 08:01:11 -07:00
esengine a3a8c1ff79 fix(serve): host-only routes are one list the handlers and the remote proxy both read; windows fixtures and a HEAD read that failed is not a detached HEAD 2026-10-18 07:56:16 -07:00
esengine aa60b7f48b fix(serve): a paired device cannot preview or send a push or discard a change 2026-10-18 07:52:20 -07:00
esengine 040aa7dbde fix(phone): say a browser that cannot reach its push service is that, not an account outage 2026-10-18 07:48:27 -07:00
esengine ad9219543b fix(studio): show a page the agent laid out wider than the pane whole, scaled to fit 2026-10-18 07:44:17 -07:00
YHH 5f4c9ffad7 Merge pull request #13822 from esengine/a5/phone-push
feat(phone): remind this phone through Web Push, with its own key and a push-only service worker
2026-10-18 07:32:53 -07:00
YHH e5be3420b3 Merge pull request #13832 from esengine/a5/step-up-effect
test(mcp): hold step-up authorization at the boot boundary
2026-10-18 07:31:52 -07:00
YHH d130baa87c Merge pull request #13828 from esengine/a9/balance-format
feat(billing): a balance endpoint declares the format it answers in
2026-10-18 07:28:27 -07:00
esengine d294509df6 docs(release): the Git panel 2026-10-18 07:27:42 -07:00
esengine 18d679077d feat(studio): one Git panel: a changes tree to stage, discard and commit, push with a preview, commit details, and a section per repository 2026-10-18 07:27:41 -07:00
esengine a81f06208c feat(git): push the current branch to its upstream from a throwaway repository, with a preview and typed refusals 2026-10-18 07:27:41 -07:00
esengine 617a7c40d6 feat(git): a commit opens into its files and each diff, and one changed file can be discarded 2026-10-18 07:27:41 -07:00
esengine 344db1d16b feat(git): a workspace folder of projects lists its repositories, and every git read and write names which one 2026-10-18 07:27:41 -07:00
YHH aa34e20192 Merge pull request #13830 from esengine/fix/thought-time-test
test: three timing-dependent tests no longer depend on wall-clock thresholds or cleanup races
2026-10-18 07:25:46 -07:00
YHH 9f19b31fdf Merge pull request #13791 from esengine/a9/queue-paused
fix(peers): a message the person let in starts over a paused queue, by what paused it
2026-10-18 07:23:09 -07:00
esengine 8dcbc55218 test(mcp): gofmt 2026-10-18 07:12:58 -07:00
esengine 8993a8eec1 test(mcp): drop an unneeded Sprintf 2026-10-18 07:12:58 -07:00
esengine a40d996915 test(mcp): hold step-up authorization at the boot boundary 2026-10-18 07:12:58 -07:00
esengine 1119118780 Merge origin/studio into a5/phone-push 2026-10-18 07:12:44 -07:00
esengine 92b39e3ed0 feat(billing): a balance endpoint declares the format it answers in
A balance_url on a host that is not a vendor's own now names its format
(balance_format) or is not read, and the key is not sent to it. The connection
editor edits the address and format. Existing relay entries are declared as
deepseek once, and a format the 1.x line drops from the shared file is put back
at the next start.
2026-10-18 07:10:10 -07:00
YHH 6b8928a8c3 Merge pull request #13782 from esengine/a5/appimage
feat(release): build and verify an AppImage of Studio on Linux (download only)
2026-10-18 07:08:12 -07:00
YHH 6b0c9ef061 Merge pull request #13784 from esengine/a5/remote-agent-guard
feat(cli): refuse remote subcommands that change the book or open a link for an agent's shell
2026-10-18 07:07:31 -07:00
YHH 6e9294dd99 Merge branch 'studio' into a9/queue-paused 2026-10-18 07:07:27 -07:00
SivanCola 38609899ae fix(composition): preserve edit authority and mandatory safety policy
Problem: ungranted HTTP clients could mutate presets or switch compositions,
and empty hook selections could remove configured safety guards. A paired
remote pane could also borrow the host credential, and a selected native
extension could override the host's denied read.

Root cause: preset routes bypassed request-scoped edit grants, proxying erased
device authority, and frozen automation was treated as the whole safety policy.

Fix: gate preset mutations before store/admission access, refuse paired preset
writes before remote credential forwarding, overlay current authorized guards
outside retained automation, update their shared runner on live edits, resolve
hook inspection/save/approval/reload against one home, and mark bound runtime
packages tighten-only. Preserve ordinary unbound extension behavior, immutable
bindings, optional retained automation and the accepted Standard/Minimal UI.

Verification: failing-first HTTP, proxy and real boot/provider regressions;
format, vet, lint, make check, full root and required builtin/boot suites;
affected package and focused boot races; Desktop/SDK; frontend 689 files/5598
tests, typecheck/build and census; browser eight/Electron renderer four scenes;
Electron units. Correct an inherited mod fixture's premature teardown using
the existing durable-idle notification, with 20 independent process runs.
No test threshold, approval gate or product Close behavior is weakened.
2026-10-18 22:03:15 +08:00
esengine 424343a8b4 test(boot): the mod-queued-prompt test waits for the turn it started before the temp dirs go
The queued prompt starts a turn of its own, still writing under the test's temp
directory when the test returned, so the directory's cleanup failed with 'directory not
empty'. Seen as 30 failures in 40 runs with eight test processes at once, 0 in 40 after.
2026-10-18 07:02:34 -07:00
esengine 118310d400 test(control): the heredoc cancel test waits for the shell to start, not for 2 seconds
The test gave a cold Git Bash two seconds to write the heredoc's file; on a shared
Windows runner the start can take longer, and the failure (4.25 s) was that wait plus
the cancel. The bound is now 15 seconds, only to keep a hang from lasting forever.
2026-10-18 07:02:34 -07:00
esengine 334598db44 test(boot): the thinking-time effect test asserts a measurement was taken, not a wall-clock size
The test compared the host's measured thought to the provider's 60 ms sleep minus 10,
which a loaded -race runner breaks (42 ms against 50). The size of the measurement is
the thought clock's own unit test with an injected clock; this test keeps its claim that
a measurement is taken, kept with the turn and framed identically.
2026-10-18 07:02:34 -07:00
YHH c58a1d7cb3 Merge pull request #13827 from esengine/a5/acp-sdk-conformance
test(acp): drive the agent with the official SDK and hold its output to the shipped schema
2026-10-18 07:01:43 -07:00
YHH 5b3968c6f5 Merge pull request #13795 from esengine/a5/push-host
feat(host): tell enrolled phones that something is waiting, and manage them from the desktop
2026-10-18 06:56:27 -07:00
YHH 26d7a21bf6 Merge pull request #13809 from esengine/chore/deps-go-group-with-desktop
chore(deps): bump the go group with 11 updates, including the desktop module
2026-10-18 06:56:15 -07:00
esengine f676e1d61a ci: run checks against studio 2026-10-18 06:48:32 -07:00
esengine 22bfd09c40 Merge remote-tracking branch 'origin/studio' into a5/remote-agent-guard 2026-10-18 06:48:32 -07:00
esengine 14ea889c81 Merge remote-tracking branch 'origin/studio' into a5/appimage 2026-10-18 06:46:34 -07:00
esengine d73352f4a6 test(acp): drive the agent with the official SDK and hold its output to the shipped schema 2026-10-18 06:44:42 -07:00
esengine 751def6d32 chore(deps): bump the go group with 11 updates, including the desktop module 2026-10-18 06:37:48 -07:00
YHH d8f84ed690 Merge pull request #13819 from esengine/fix/dispatch-profile-selection
fix(delegation): the dispatch profile stays the call's selection
2026-10-18 06:33:48 -07:00
esengine a26328f24b style(compaction): wrap the last refusal 2026-10-18 06:29:11 -07:00
esengine f7bc03da36 fix(compaction): retry a refused summary request at a smaller size
A summary request the provider rejects as larger than its context (the documented
context_length_exceeded code, nothing else) is retried at 70%, 50% and 35% of the
refused input, shortened by the same deterministic steps as before; a refused request
costs nothing, so the digest is not given up until three smaller inputs were refused.
A provider that refuses every size ends as summary_input_too_large.
2026-10-18 06:28:05 -07:00
esengine 79d971d607 feat(phone): remind this phone through Web Push, with its own key and a push-only service worker 2026-10-18 06:19:59 -07:00
YHH 97d7009cbb Merge pull request #13804 from esengine/dependabot/github_actions/actions-e40781d787
chore(deps): bump the actions group with 2 updates
2026-10-18 06:09:58 -07:00
esengine ba25d7c854 fix(peers): a message the person let in starts over a paused queue, by what paused it
The inbox manifest now records why a queue is paused, as a typed code beside
the old boolean. A message approved on its card is claimed over any pause; one
let in by a standing release or dispatch switch only over a pause the host set
to guard stale input, and stays queued over one the person set. The pause and
older queued items are left as they were.
2026-10-18 06:07:09 -07:00
esengine 002b09a921 fix(delegation): the dispatch profile stays the call's selection; the actual model rides the status events
#13812 made the dispatch-time profile the concrete runtime. The effort-switch audit
test (merged beside it) holds that profile to what the call selected (empty model and
effort when it inherits), so studio went red. The selection is audited as the model
asked it; what the child ran on is on its status events, which the window already
reads, so the dispatch profile goes back to the selection.
2026-10-18 06:05:44 -07:00
SivanCola db1401cb5b merge: preserve remote composition selection with current Studio
Problem: Studio's shared remote-open lifecycle conflicts with the composition
callback, and the HTTP adapter omits the selected composition from its body.

Root cause: composition selection was carried by an App-local path rather than
the shared open-ordering owner and the transport serialization boundary.

Fix: merge exact Studio 5b1c282fed, extend
useOpenRemotePane and SseHub with the selection, preserve newest-open ordering,
duplicate-click coalescing and refusal cleanup, and keep editor styles with
their existing catalog stylesheet without widening the shared CSS budget.
Align the child-effort effect assertion with Studio's concrete dispatch model
and effort reporting while retaining provider, refusal and transcript checks.
Keep Standard/Minimal cards, the mode-only left trigger and right run state,
default-off semantics and all existing source/workspace authorization gates.

Verification: failing-first remote Hook/HTTP body regressions; format, vet,
lint, make check, full root and required builtin/boot tests; affected package
and focused boot races; Desktop/SDK Go; frontend typecheck, 689 files/5598 tests
and build; census fixtures; browser eight and Electron renderer four scenes;
Electron unit suite (176 pass, one existing skip). Local review/security review
does not replace maintainer approval or hosted CI.
2026-10-18 21:05:02 +08:00
esengine 7f21f635ab feat(tools): turn tools off by name, one registry removal for every surface
[tools].disabled removes a tool from every request and refuses a call to it with
the reason; tools another feature requires declare so and stay on with a note;
a project file can only add names. Settings lists every tool with a switch.
2026-10-18 06:03:20 -07:00
YHH bda7ee6772 Merge pull request #13816 from esengine/a9/readme-dsh
docs: point to the DeepSeek Harness integration from the README and the guide
2026-10-18 05:53:30 -07:00
YHH 7df0c74c55 Merge pull request #13807 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-76902424de
chore(deps-dev): bump the npm group in /desktop/electron with 2 updates
2026-10-18 05:53:07 -07:00
YHH 63cfcee555 Merge pull request #13799 from esengine/dependabot/go_modules/desktop/go-1d423e97b0
chore(deps): bump golang.org/x/mod from 0.41.0 to 0.42.0 in /desktop in the go group
2026-10-18 05:52:35 -07:00
YHH a151994365 Merge pull request #13798 from chuiwenwei/fix/compaction-stale-context-not-latched
fix(agent): release a stale-context maintenance block on the growth that caused it
2026-10-18 05:52:16 -07:00
YHH 5b1c282fed Merge pull request #13812 from esengine/feat/13640-child-model
feat(delegation): sub-agents show the model and effort they ran on
2026-10-18 05:47:23 -07:00
YHH 1b0b593f66 Merge pull request #13813 from esengine/a5/search-model-not-added
fix(settings): do not offer search models from connections that were never added
2026-10-18 05:44:33 -07:00
esengine edbe588256 docs: point to the DeepSeek Harness integration from the README and the guide
The integration lives in docs/DSH.md; the README and the guide now say it
exists and give the three steps and the index address in the README.
2026-10-18 05:43:50 -07:00
YHH ed9a4ad1c9 Merge pull request #13778 from esengine/feat/13694-mod-rulings
feat(extension): a mod answers a tool call, adds turn context, reads the session and queues a prompt
2026-10-18 05:42:40 -07:00
YHH 31d39290ec Merge pull request #13808 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-8a0a74f25b
chore(deps): bump the npm group in /desktop/frontend-next with 7 updates
2026-10-18 05:30:14 -07:00
YHH 245b85ec13 Merge pull request #13638 from SivanCola/codex/unified-project-sidebar
Unify local and SSH project navigation / 统一本机与 SSH 项目侧栏
2026-10-18 05:29:51 -07:00
YHH f43d985bda Merge pull request #13797 from chuiwenwei/fix/memory-finish-interrupted-rename
fix(memory): repair a rename interrupted after retiring the old file
2026-10-18 05:22:13 -07:00
esengine 0556027a94 fix(studio): a refused ! command stays selected in the box so the next message replaces it 2026-10-18 05:18:25 -07:00
esengine 44d3acfaf5 fix(desktop): the row keeps the model name and nothing in front of or behind it 2026-10-18 05:18:06 -07:00
esengine 8f691039d3 fix(desktop): the sub-agent row gives way on the provider, not the model name 2026-10-18 05:16:44 -07:00
esengine bcd54401f5 docs(release): shorten the search-model note 2026-10-18 05:09:12 -07:00
esengine 2f3ef27558 fix(settings): do not offer search models from connections that were never added 2026-10-18 05:04:45 -07:00
esengine e2c0aeb1a2 refactor(delegation): keep the child identity record in its own file 2026-10-18 05:00:56 -07:00
esengine b5c891a593 feat(desktop): sub-agents show the model and effort they ran on
The panel row, the full-record header and the nested card of the spawning call show
provider/model · effort, with the full reference as the title. The card takes it
from the child's status events, so a failed or interrupted run keeps it; a batch's
items each carry their own.
2026-10-18 05:00:56 -07:00
esengine 88982d21ad feat(delegation): every status of a child names the model and effort it ran on
The dispatch profile now names the concrete runtime a call resolves to (an alias
expanded, the parent's model when the call inherits), and each child's status events,
from queued to the terminal one, carry what its run actually started with, per item
for a batch. A failed or interrupted child therefore still says what it ran on.
2026-10-18 05:00:56 -07:00
dependabot[bot] 6e91c80dac chore(deps): bump the npm group in /desktop/frontend-next with 7 updates
Bumps the npm group in /desktop/frontend-next with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.3.0` | `19.3.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.3.0` | `19.3.1` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.7` | `14.6.8` |
| [jsdom](https://github.com/jsdom/jsdom) | `30.1.2` | `30.1.3` |
| [playwright](https://github.com/microsoft/playwright) | `1.64.0` | `1.64.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.3` | `8.3.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |


Updates `react` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react)

Updates `react-dom` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom)

Updates `@testing-library/user-event` from 14.6.7 to 14.6.8
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.7...v14.6.8)

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

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

Updates `vite` from 8.3.3 to 8.3.4
- [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.4/packages/vite)

Updates `vitest` from 5.0.3 to 5.0.4
- [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.4/packages/vitest)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: jsdom
  dependency-version: 30.1.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 12:00:23 +00:00
dependabot[bot] 6509535600 chore(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.7.0 to 44.7.2
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.7.0...v44.7.2)

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

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 12:00:17 +00:00
YHH c70464535b Merge pull request #13793 from esengine/feat/13780-level-notice
feat(feedback): tell the person once when their level goes up, if they asked
2026-10-18 04:57:50 -07:00
dependabot[bot] 0cf0d10c2a chore(deps): bump the actions group with 2 updates
Bumps the actions group with 2 updates: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.3 to 4.39.0
- [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/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.0
- [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/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 11:56:46 +00:00
YHH 54f4f5a8b6 Merge pull request #13787 from esengine/a9/peer-release-idle
fix(peers): the text of a message from another session is not read for file references
2026-10-18 04:53:09 -07:00
dependabot[bot] 68d3238e98 chore(deps): bump golang.org/x/mod in /desktop in the go group
Bumps the go group in /desktop with 1 update: [golang.org/x/mod](https://github.com/golang/mod).


Updates `golang.org/x/mod` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/mod/compare/v0.41.0...v0.42.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 11:52:28 +00:00
YHH c88d1715d4 Merge pull request #13790 from esengine/fix/terminal-feedback-surface
fix(cli): name the terminal's feedback surface in its first build so /feedback works from the start
2026-10-18 04:49:39 -07:00
YHH 8078d62b03 Merge pull request #13789 from esengine/a9/dsh-enable-step
docs(dsh): an installed plugin starts off and must be switched on
2026-10-18 04:43:20 -07:00
Will 944c61f9e6 test(memory): cover the interrupted-rename pairing and name-conflict edges
Two controls the audit's contract implies but nothing pinned: a staged copy whose
retired twin came from a different publish is left untouched (no pair, no writes),
and a fact created at the old name after the kill is never overwritten — the retry
refuses and keeps both artifacts for a later attempt. The refusal only named the
conflict once the exist check unwrapped the error, so it now uses
errors.Is(err, fs.ErrExist).
2026-10-18 19:42:39 +08:00
YHH 3f98212608 Merge pull request #13246 from chuiwenwei/fix/sessioninbox-unreadable-blobs-not-empty
fix(sessioninbox): stop publishing an empty queue when the bodies cannot be read
2026-10-18 04:42:33 -07:00
esengine 37892a1738 test(release): the artifact check requires the AppImage for both architectures 2026-10-18 04:40:24 -07:00
YHH 2ce0c3f97a Merge pull request #13777 from esengine/a5/wsl-cli
feat(cli): add, test and connect a WSL machine from the command line
2026-10-18 04:29:46 -07:00
esengine 29220a5c18 Merge origin/studio into a5/push-host 2026-10-18 04:26:23 -07:00
esengine ffa3d3df29 feat(desktop): mount the phone notification block under Remote settings 2026-10-18 04:25:25 -07:00
Will 13938149d5 fix(agent): release the stale-context block on the growth that caused it
A fold that loses the transcript race records FailContextChanged, but no later
turn could release it: retriesOnGrowth did not name the code, the receipt holds
until the generation advances, and the generation only advances on a commit the
held receipt prevents. One racing append then latched automatic compaction off
for the rest of the session, across restarts, and the next overflow failed the
turn instead of folding.

Name the code in retriesOnGrowth so the append that made the fold stale is the
growth that releases the receipt. The receipt already carries InputHash and
InputTokens, and every other code keeps its hold. A plan/provider refusal still
holds, and the retry buys exactly one fold: without further growth the next
prepare neither folds again nor re-records the block.
2026-10-18 19:24:01 +08:00
Will 406bf96f1e test(memory): pin that an interrupted rename restores the old revision first
The recovery may either finish the rename forward or roll it back. Only rollback
keeps an ExpectedRevision from before the rename valid, so the choice is a
contract, not an implementation detail; forcing the forward path makes this test
fail with name=beta revision=2.
2026-10-18 19:21:09 +08:00
Will 3eadd288f9 fix(memory): repair a rename interrupted after retiring the old file
publishRename retires the old fact file, flushes the index, then publishes
the staged new file. A kill in that window leaves the fact's only copies as
hidden .retired-* and .staged-* files: Read/List/Revisions report nothing,
the save retry by id reports "memory id not found", and a retry by name
mints a second identity for the same name.

A save by id that finds no active file now looks for the hidden pair a
single publishRename call writes, matched by the nanosecond stamp in the
two file names, with the staged copy's frontmatter carrying that id. The
rule is disk-driven and rollback-first: readable retired bytes are restored
under the old name (same bytes and mode) and reindexed, so the caller's
expected revision still names the fact and the retried save redoes the
rename itself. Only when the retired copy cannot be parsed does recovery
publish the staged copy forward under its new name. With no matching pair
nothing is written and the id is still reported not found, so a retired
copy left by a committed rename is not revived.

Covered by the audit's kill probe, an uninterrupted-rename control, a name
retry after recovery, a failed (not killed) publish, an unpaired-artifact
guard, and a cross-scope move whose pair spans two directories.
2026-10-18 19:19:35 +08:00
esengine 9de84cee74 style: modernize the new tests 2026-10-18 04:17:47 -07:00
esengine 8ddda45b60 style(desktop): a shorter line for the queued-prompt row of the mod preview 2026-10-18 04:17:45 -07:00
esengine 9bb7a469d2 refactor(config): render the notifications section from its own file 2026-10-18 04:12:50 -07:00
esengine 319f079a63 feat(host): tell enrolled phones that something is waiting, and manage them from the desktop 2026-10-18 04:11:40 -07:00
esengine 1f285edf60 feat(desktop): the mod preview lists reading the session and queueing prompts separately 2026-10-18 04:08:35 -07:00
YHH 81e0438de3 Merge pull request #13783 from esengine/feat/13111-origin-record
feat(agent): count a call whose arguments copy text an external result gave the task
2026-10-18 04:08:06 -07:00
YHH 4d094ce1f9 Merge pull request #13781 from esengine/a5/wsl-ui
feat(desktop): add a WSL distribution as a remote machine from Settings
2026-10-18 04:04:26 -07:00
YHH 66d144dd90 Merge pull request #13779 from esengine/a5/sched-pkg
refactor(schedule): run the scheduled child from a small package the Studio host can import without the command line
2026-10-18 04:03:00 -07:00
Will a88c474cbd i18n: carry the unrecovered wording in the embedded catalogues
Review follow-up after rebasing onto studio: messages_en/zh/zh_tw.go sit at the
800-line ceiling, so the two new strings move into the embedded QueueMessages and
NotifyMessages catalogues, which have their own files. No call site changes.
2026-10-18 19:02:12 +08:00
esengine 0c000c7ad4 feat(feedback): tell the person once when their level goes up, if they asked 2026-10-18 04:01:54 -07:00
esengine 2c8eb49a14 test(modhost): the byte-ceiling refusal reaches the mod as its own code 2026-10-18 04:01:29 -07:00
esengine 2a9743d790 feat(extension): split the session capability, and bound one queued prompt
session.read and session.submit are separate capabilities, so a mod that only
reads usage cannot put words in front of the model. One prompt a mod queues is
bounded by [mods] submit_bytes (8 KiB, 0 for none) and refused with a typed
identity that does not spend the per-turn count. The docs say the turn a queued
prompt starts is held to this session's own approvals and sandbox.
2026-10-18 04:01:29 -07:00
esengine 96f261b398 docs(mods): the receipt gap for a running mod, in the docs and in matching English 2026-10-18 04:01:28 -07:00
esengine 4af9d05744 docs(extension): keep the new prose and comments inside the repo's budgets 2026-10-18 04:01:28 -07:00
esengine 336cbc3c22 chore(extension): regenerate the protocol artifacts for the prompt refusal identity 2026-10-18 04:01:28 -07:00
esengine f28360eadc feat(extension): a mod reads the session and queues a prompt, and settings bound what it may add 2026-10-18 04:01:28 -07:00
esengine 49abf8c521 feat(extension): an extension may add context blocks to the turn tail, bounded by the user's mod ceilings 2026-10-18 04:01:28 -07:00
esengine f26e1c1359 feat(extension): a block at tool.before may carry an answer that stands in for the tool's output 2026-10-18 04:01:28 -07:00
SivanCola 965f331798 test(studio): align browser guards with unified project rows
Problem: Studio browser guards failed on 17f816d11 when the ordering guard
counted SSH rows as conversations in its local project. The remaining quick
action fixture also expected two trailing Archive/Delete buttons.

Root cause: Browser acceptance scripts still encoded the former separate
machine lists, trailing Delete action and individual folding chevrons. The
English wording scan also lacked a newly visible demo conversation title.

Fix: Scope ordering to the named local project and independently assert SSH
rows and keyboard focus are preserved. Exercise default-off and explicit
quick-action choices for local and remote rows, including hit targets,
leading Pin/trailing Archive geometry and menu access. Load the real row
stylesheet in the compact fixture and match its existing 1px spacing while
retaining 34px rows, ellipsis and contained controls. Update manual tree
checks to use project-row folding and five-item previews.
Recognize the existing demo title as user content in the wording fixture list.

Verification: Reproduced the ordering, quick-action and fixture failures
before the repair. Ran all 55 CI browser-guard commands against the built
perf harness and reran repaired guards, the affected manual tree scripts,
gofmt, root vet, make lint,
make check and the required builtin/boot suites. This changes acceptance
scripts only; production UI, workflows and budgets are unchanged.
2026-10-18 18:59:22 +08:00
Will 51db70d463 i18n(desktop): translate inbox.unrecovered 2026-10-18 18:57:25 +08:00
Will d91111647b fix(sessioninbox): give the unrecovered refusal an identity and stop racing the sink
The reviewer's blocking item: rebindInbox (called from New) started a goroutine
that read c.sink while New was writing it. Capture the sink before starting the
goroutine, for the new unrecovered notice and for the pre-existing recovered one
(it had the same shape and the same race).

Also give the refusal an identity: ErrUnrecovered, wrapped by commitManifestLocked,
with a case in the serve switch so the frontend gets inbox.unrecovered instead of an
internal fault. repolint's refusal-path rule requires that case.
2026-10-18 18:57:20 +08:00
Will 50a235f0f7 fix(sessioninbox): show an unreadable queue as unknown instead of empty
The store already told a caller that the queue's bodies could not be read
(InboxSnapshot.Unrecovered), but nothing user-facing consumed it: the TUI's
/queue line and the notice stream both dropped the field, so an unreadable
queue still read as "inbox empty" and the user could not tell it from a
genuinely empty one.

The TUI mirror and renderQueueList now carry and say the unknown state, and
the controller emits a distinct inbox_unrecovered warn notice (with an
InboxUnrecovered payload naming the queue directory) beside the existing
inbox_recovered one, so its sentence is not confused with "recovered N".

Cause: the frontend contract field had no consumer on the TUI or notice
surfaces.
Blast radius: TUI /queue line, TUI and desktop notice wording, and the two
controller inbox open paths; additive field, code and payload, no on-disk
schema change, recovered behaviour unchanged.
Neighbouring behaviours tested: empty-but-readable queue, recovered salvage
count, TUI mirror JSON decoding of the real GET /inbox body, and both the
serve and control boundaries.
Why this layer: the store owns "unknown vs empty"; the surfaces that render
a queue are where a user can act on it.

Provider-visible prefix: none
2026-10-18 18:57:20 +08:00
Will dc409fc506 fix(sessioninbox): stop publishing an empty queue when salvage cannot read bodies
salvageOrphanBlobsLocked returned nil for both "no blobs" and "blobs
unreadable", so a corrupt manifest plus an unreadable blobs directory
committed an empty paused manifest (recovered=true, recoveredN=0,
items=null) over the quarantined one. The queued bodies stayed on disk but
were unreachable, the RecoveredN>0 notice never fired, and the queue looked
empty.

The salvage now reports the read failure, and that failure is not turned
into a manifest: the corrupt bytes stay in place as evidence, the store
comes up paused with unrecovered=true on the snapshot, and no commit can
overwrite the evidence while the bodies are unreadable. An absent or empty
blobs directory is still an empty queue, not a failure.

Cause: (items, err) collapsed to (nil, nil) for directory-level and
per-entry read errors.
Blast radius: sessioninbox Open/Snapshot plus the commit path; new
additive InboxSnapshot.Unrecovered field, no on-disk schema change.
Neighbouring behaviours tested: readable-dir salvage, absent/empty blobs
dir, healthy manifest untouched, symlink blob refusal.
Why this layer: the store owns the manifest/blob transaction, so the
"unknown vs empty" distinction belongs where the read fails.
2026-10-18 18:57:20 +08:00
esengine 9e0c35100c docs(dsh): say that an installed plugin starts off and must be switched on
Found while demonstrating the install through Discover: until the switch and
both components read Running there is no reasonix tool, and the model falls
back to running reasonix -p in its own shell, which only looks like a
delegation.
2026-10-18 03:55:34 -07:00
esengine 5910c8ae10 fix(peers): the text of a message from another session is not read for file references
An @token in a peer message or a background-job notice was frozen into the
prompt as a file reference, so a sender could make the receiver read a file
into its prompt, and an unreadable one paused the queue behind it. Items the
host authored are data and are no longer scanned.
2026-10-18 03:53:52 -07:00
YHH 08f01f2d79 Merge pull request #13776 from esengine/feat/mcp-panel
feat(tui): add and remove MCP servers from the /mcp panel
2026-10-18 03:52:00 -07:00
esengine d226841381 fix(cli): name the terminal's feedback surface in its first build so /feedback works from the start 2026-10-18 03:48:37 -07:00
SivanCola d5cee35e81 merge: preserve composition authority across current Studio session moves
Problem: current Studio conflicts in the state-root inventory and adds a move
path that strips the system message containing an immutable composition binding.
That could silently reopen a bound conversation with ordinary capabilities.

Root cause: cross-project adoption intentionally omits project instructions and
permissions, but the new mover did not distinguish immutable composition metadata
from an ordinary system prompt.

Fix: normally merge exact Studio0363720bd. Retain both rules and agent-presets
state roots. Refuse bound conversations in the common MoveSession owner before
copy/delete, preserve the source, and project the typed refusal with bilingual
HTTP wording. Keep ordinary unbound moves, ACP authentication/new-conversation
contracts, directory grants, exact dispatch inheritance and the accepted mode UI.
The preceding source-authority fix remains intact.

Verification: failing-first store and HTTP move tests now refuse blank/started
bound moves and retain the original. All226 root packages pass: mandatory2 plus
the inventoried other224; boot340.765s. Affected race suites pass. The full boot
race invocation previously reached its10minute aggregate limit; final evidence
uses one compiled source snapshot and four disjoint groups covering all753
tests exactly once, without raising the timeout. Static gates, Desktop/SDK,
frontend675files5489tests/typecheck/build/census and browser8/native4 scenes pass.
Local review/security review recorded separately from required maintainer review.
2026-10-18 18:34:12 +08:00
esengine 1b556ddbc8 feat(cli): refuse every remote subcommand that changes the book or opens a link when an agent tool started it 2026-10-18 03:33:32 -07:00
esengine 76e579923f fix(release): name the AppImage explicitly and make the verifier say what it wanted 2026-10-18 03:31:48 -07:00
esengine 97e4675986 Merge remote-tracking branch 'origin/studio' into a5/wsl-cli 2026-10-18 03:31:04 -07:00
esengine dd6b73380d Merge remote-tracking branch 'origin/studio' into a5/sched-pkg 2026-10-18 03:30:59 -07:00
esengine c4fa9c73f7 fix(schedule): the scheduled child drains the usage catalogs itself, with a test through the host's entry 2026-10-18 03:30:57 -07:00
esengine 3da8e6b23e test(agent): the origin ledger is on the restarted side of a new task 2026-10-18 03:30:51 -07:00
esengine 069e5b54f3 style(agent): use slices.Contains for the origin list 2026-10-18 03:30:51 -07:00
esengine cbeed94933 style(agent): keep execute_one and run_loop at the size ceiling 2026-10-18 03:30:51 -07:00
esengine 1de856a0f6 feat(agent): count a call whose arguments copy text an external result gave the task
The host keeps the text of a task's external results and counts, per turn, how
many were recorded and how many later non-read-only calls carried a verbatim
string from one. The numbers ride the completion-report audit; nothing is
blocked or shown to the model.
2026-10-18 03:30:51 -07:00
esengine d136ac2828 feat(release): build and verify an AppImage of Studio on Linux (download only) 2026-10-18 03:25:38 -07:00
YHH edcbc47c06 Merge pull request #13764 from esengine/feat/13694-mod-ui-3
feat(studio): Claude Code mods in the window: install preview, the band above the prompt, mod questions (settings block hidden)
2026-10-18 03:23:05 -07:00
SivanCola 17f816d11c Merge studio WSL transport and cover remote host boundaries
Problem: Review requested WSL boundary evidence and removal of the unrelated
design-qa.md diff after studio introduced non-SSH machines.

Root cause: The new WSL transport supersedes a blanket rejection at shared
remote endpoints. SSH construction must still reject WSL entries, while
generic probe, browse and open must select their configured transport.
The earlier QA cleanup also deleted a document already owned by studio.

Fix: Integrate studio through 0363720bd and cover the real Hub/remotehost/pool
route with disposable WSL and SSH factories. Assert WSL failures survive the
HTTP boundary and never construct an SSH client. Cover Windows, distribution,
empty and fallback default paths. Restore design-qa.md byte-for-byte from
studio so it no longer appears in the PR diff. Retain the upstream SSH form's
refusal to overwrite non-SSH entries.

Verification: Frontend 5529 tests and production build; full serve,
remotehost, remote, attach, wslconn, config, builtin and boot suites; gofmt,
root vet, make lint and make check. No live Windows/WSL qualification claimed.
2026-10-18 18:21:13 +08:00
SivanCola 643902d35a Merge studio project moves with host-qualified sidebar actions
Problem: PR #13638 became conflicting after studio introduced moving a
conversation to another project.

Root cause: HubPort acquired moveSessionTo beside the archive declaration
that this branch extends with an optional host. Automatic merging also
omitted the errors import needed by the upstream WSL edit guard.

Fix: Keep both capabilities and restore the required import. Retain the
upstream local-only move workflow and the host-qualified pin/archive routes.
Remove an inaccurate rename comment so the combined component stays within
its existing size ceiling without widening the baseline.

Verification: Frontend suite (5529 tests), production build, full serve,
sessionstore, config, builtin and boot tests; gofmt, root vet, make lint and
make check. Transport coverage exercises local project moves beside remote
flags; an interaction regression covers the shared keyboard menu and dialog.
An isolated development browser confirms menu access, project selection and
cancel with hover controls off. Hosted CI remains a separate observation.
2026-10-18 18:13:32 +08:00
esengine 8aa66a6521 Merge remote-tracking branch 'origin/studio' into a5/wsl-ui 2026-10-18 03:11:48 -07:00
esengine 447cc6ae39 ci: run checks against studio 2026-10-18 03:11:43 -07:00
esengine b67222e1a3 Merge remote-tracking branch 'origin/studio' into a5/wsl-cli 2026-10-18 03:11:43 -07:00
esengine 75d8b75051 refactor(schedule): the scheduled child runs from a small package the Studio host can import without the command line 2026-10-18 03:09:47 -07:00
YHH 0363720bd2 Merge pull request #13767 from esengine/a5/wsl-link
feat(remote): bring up a kernel in a WSL distribution and reach it over loopback
2026-10-18 03:08:05 -07:00
YHH 6693788cc4 Merge pull request #13771 from esengine/a9/dsh-integration-doc
feat(integrations): run Reasonix as a DeepSeek Harness subagent, with docs, a plugin package and a market index
2026-10-18 03:05:54 -07:00
YHH bc9d7a16ad Merge pull request #13773 from esengine/feat/13694-mod-receipt
feat(receipt): say when a Claude Code mod's effects were not observed
2026-10-18 03:03:11 -07:00
YHH 5375b9c8f8 Merge pull request #13772 from esengine/feat/12025-thread-badge
feat(feedback): show the reporter's level badge beside their replies in a thread
2026-10-18 03:01:01 -07:00
esengine 60938d36d9 feat(cli): add, test and connect a WSL machine from the command line 2026-10-18 03:00:14 -07:00
esengine d3139f5a4f fix(studio): the mod preview promises no re-confirmation the kernel does not enforce 2026-10-18 03:00:13 -07:00
esengine cebdb37431 fix(studio): the mock mods settings say the switch is not available yet 2026-10-18 03:00:12 -07:00
esengine 8951095309 test(desktop): settings search leaves out the hidden mod entry 2026-10-18 03:00:12 -07:00
esengine 01e775312e feat(studio): keep the mod host's settings out of sight until the kernel allows it 2026-10-18 03:00:12 -07:00
esengine 2cf8510775 test(desktop): the mod UI guard, release note and the strip's focus-ring room 2026-10-18 03:00:12 -07:00
esengine e2e642a488 feat(studio): the band above the prompt, mod questions with their source, and a button with no action id does nothing 2026-10-18 03:00:12 -07:00
esengine 61d2cfd5a8 feat(studio): the mod block in the install preview: full trust, static listing, hidden imports and not-previewed 2026-10-18 03:00:11 -07:00
esengine 721b61eb40 feat(studio): the Claude Code mod block in settings: switch, hook time limit and each mod's state by code 2026-10-18 03:00:11 -07:00
esengine 70d164489f feat(tui): add and remove MCP servers from the /mcp panel 2026-10-18 02:59:58 -07:00
YHH 598de997fa Merge pull request #13770 from esengine/feat/12073-union-list
feat(feedback): show linked devices' reports in My feedback and link from the terminal
2026-10-18 02:53:06 -07:00
YHH dcd4632f8f Merge pull request #13739 from esengine/feat/13693-subagent-steer
feat(delegation): guide a running sub-agent through task_id or from the window
2026-10-18 02:48:06 -07:00
SivanCola 78e164b54c fix(composition): refuse project-controlled launch overrides
Problem: selecting a repository preset could change an already authorized MCP
server's package arguments or destination and reuse its executable authority or
credentials. The same provenance issue applies to extension args and env.

Root cause: composition parameter validation checked shape and credential
references but did not distinguish repository declarations from personal ones.
Pinning an executable does not authorize the program selected by its arguments.

Fix: enforce source authority in the shared contract. Project MCP/extension
parameters allow only supported timeouts. Validate declarations, imports, old
revisions, and both definition/resolved halves of retained executable bindings.
Keep structural history validation separate so legacy unsafe bindings remain
readable without regaining execution. Align Mock save/import, document the source
table in both languages, and add the release note. Preserve Standard/Minimal,
unbound defaults, persona, additional directories and exact dispatch bindings.

Verification: failing-first boot tests prove changed args start a subprocess and
changed URL contacts another endpoint before the fix; both now refuse before
startup. User positive controls, scope/parameter table, store/revision/old-record
and event-log history tests pass. Format/vet/lint/makecheck, required builtin/boot,
full root, contract/store/sessionstore/serve/ACP race, Desktop/SDK Go and frontend typecheck/build pass;
671 frontend files / 5472 tests pass. Local review/security review recorded;
maintainer approval and new-head hosted CI remain separate requirements.
The full boot race invocation reached its existing aggregate 10-minute limit
as another test started; disjoint test-list groups are used to finish the whole
inventory without raising that timeout or skipping tests.
2026-10-18 17:42:48 +08:00
esengine 84e70760b7 feat(feedback): show the reporter's level badge beside their replies in a thread 2026-10-18 02:31:55 -07:00
YHH 4014c87a38 Merge pull request #13768 from esengine/feat/11995-sync-close
feat(feedback): close the mirror issue of a withdrawn report
2026-10-18 02:31:46 -07:00
esengine f3bcb117f7 Merge remote-tracking branch 'origin/studio' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 4f80c12e2b Merge branch 'a5/wsl-link' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 8b58ce9eed feat(desktop): add a WSL distribution as a remote machine from Settings 2026-10-18 02:31:25 -07:00
esengine f02ef7d620 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
The index pins the commit that last changed the package; scripts/dsh-market-index.mjs
pins it and validates it with DSH's own validator. docs/DSH.md explains Discover
and the Studio release runbook gains a precondition that keeps the pin current.
2026-10-18 02:30:46 -07:00
esengine 79430cf8cf feat(integrations): website, author and doc links on the DeepSeek Harness bundle 2026-10-18 02:30:29 -07:00
esengine e22b515af1 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
A format-1 index pinned to the commit that last changed the package, and
scripts/dsh-market-index.mjs to pin and validate it with DSH's own validator.
2026-10-18 02:30:29 -07:00
esengine 8ba8c1cf72 feat(integrations): a ready-made DeepSeek Harness bundle that runs Reasonix as a subagent
A configuration-only package (no code) in integrations/dsh-plugin, installed
from this repository's subdirectory with pnpm's path: selector, plus the
integration guide docs/DSH.md.
2026-10-18 02:30:29 -07:00
esengine 34eb44fcd0 docs: using Reasonix from DeepSeek Harness as an ACP subagent
How to wire dsh's subagent-acp provider to reasonix acp, what each permission
policy does to Reasonix, what the parent sees, and the limits, verified against
dsh 0.2.1-rc.2.
2026-10-18 02:30:29 -07:00
esengine 957bdaa323 chore(i18n): keep the message tables at their size ceiling 2026-10-18 02:27:40 -07:00
esengine 2f28235ab6 feat(receipt): say that a Claude Code mod's effects were not observed, and count a live mod as an unseen writer 2026-10-18 02:27:40 -07:00
YHH 7c56994e82 Merge pull request #13705 from esengine/a9/acp-auth-required
fix(acp): a session that cannot open for want of credentials is the authentication-required error
2026-10-18 02:26:40 -07:00
esengine 84d16a1508 Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:24:49 -07:00
esengine 36044d1515 style(remote): match exit errors with errors.As in the Linux-only tests 2026-10-18 02:24:47 -07:00
YHH 1a11a6691d Merge pull request #13766 from esengine/a9/rules-dir
feat(rules): rules directories, delivered on the turn and scoped by paths
2026-10-18 02:20:33 -07:00
esengine 119f392248 feat(delegation): guide a running sub-agent through task_id or from the window 2026-10-18 02:17:23 -07:00
YHH d4d7ef809e Merge pull request #13752 from esengine/a5/schedule-ticker
feat(schedule): the Studio host runs the ticker while supervised and visible
2026-10-18 02:16:16 -07:00
esengine 74b4c5d21b feat(feedback): show linked devices' reports in My feedback and link from the terminal 2026-10-18 02:15:42 -07:00
SivanCola f975d35816 merge: preserve session bindings across current Studio entry points
Problem: newer Studio changes conflict with the ACP session contract and HTTP
runtime opening after the previous assembly integration.

Root cause: Studio adds a new-conversation execution-mode flag where this branch
also owns explicit composition selection and immutable inherited bindings.

Fix: normally merge Studio 4d3c09211. Add NewConversation to the one SessionParams
definition and retain it in HTTP build options. Resolve moved transcript paths
before reading either branch settings or the original composition. Keep exact
dispatch inheritance, workspace refusal, candidate rollback, ACP directories and
the accepted Standard/Minimal composer and management design.

Verification: format, vet, lint, make check, mandatory builtin/boot, full root
Go, Desktop Go, frontend typecheck/build and census pass. Frontend 671 files /
5465 tests and eight browser/four native renderer scenes pass. Affected
boot/ACP/serve/control/remote/delegation race tests pass. Provider request bytes
equal actual Studio with and without persona. Local review/security review are
recorded separately from maintainer review. The unchanged macOS computer
coverage baseline remains 88.2% below 89.8%; no floor is changed.
2026-10-18 17:12:02 +08:00
YHH d70edac126 Merge pull request #13737 from esengine/feat/session-move-import
feat(studio): move a conversation to another project, carrying the conversation and not what the old project allowed
2026-10-18 02:11:57 -07:00
esengine f9c6f37b78 fix(remote): stage WSL launches in a private home directory and bound the wait by the session 2026-10-18 02:04:21 -07:00
esengine ae0f9f8fee feat(feedback): close the mirror issue of a withdrawn report 2026-10-18 02:03:13 -07:00
esengine 057d8094ba fix(acp): a session that cannot open for want of credentials is the authentication-required error
session/new answered -32603 for a model with no API key or no configured
provider, although initialize advertises a setup auth method. The cause is now
a provider.ErrKeyMissing / ErrNoProvider identity and the frontend maps it to
the ACP schema's -32000.
2026-10-18 02:01:16 -07:00
esengine ab3f88ac9b Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:00:31 -07:00
SivanCola de5627d5e0 merge: reconcile Agent compositions with current Studio assembly
Problem: Studio advancement made the Agent composition PR conflict in boot
assembly while adding trace export and a workspace-aware mod runtime source.

Root cause: wireMCP now has a composition error result, and extension preflight
filters immutable bindings where Studio introduced its package source.

Fix: normally merge Studio ed1c127d6. Keep OTLP cleanup before returning from
wireMCP and retain workspace-aware discovery before exact composition filtering.
Preserve upstream tighten-only dispatch, replay protection, ACP elicitation and
the withheld mod switch. Keep Standard/Minimal, opt-in binding, ACP directories,
exact dispatch inheritance and the accepted composer layout.

Verification: gofmt, vet, lint, make check, required builtin/boot, full root Go,
affected boot/extension/modhost/ACP/serve/control race suites, Desktop and SDK
tests pass. Frontend typecheck/build, census, 668 files / 5450 tests, Electron
unit tests and eight browser/four native renderer scenes pass. Raw provider
requests equal actual Studio with both persona and empty persona. Local review
and security review found no new issue. Coverage gate still reports only the
unchanged macOS computer baseline, 88.2% versus 89.8%; no floor is lowered.
2026-10-18 17:00:30 +08:00
esengine 137a5831e9 feat(rules): rules directories, delivered on the turn and scoped by paths
Rule files under .reasonix/rules, .claude/rules and the user's rules folder are
read from disk each turn. Rules without paths: join the turn as a block owed
like the standing instructions; rules with paths: are owed once the session has
touched a matching file and retracted when they stop applying. Glob parsing and
matching stay with skills' paths: through one exported entry point. Neither layer
reaches the cache-stable prefix; capability diagnostics report what was skipped.
2026-10-18 01:57:13 -07:00
YHH 4d3c09211b Merge pull request #13725 from esengine/feat/13714-skill-remove
feat(skills): remove a skill from Settings, and never take its whole folder root with it
2026-10-18 01:56:43 -07:00
esengine 2bf3bd7b7e ci: run checks against studio 2026-10-18 01:56:02 -07:00
esengine 9e74ed1806 Merge remote-tracking branch 'origin/studio' into a5/schedule-ticker 2026-10-18 01:56:00 -07:00
esengine c013a4f20b fix(serve): the move target is matched against declared project roots and never reaches the file system 2026-10-18 01:55:42 -07:00
YHH 113523c5f8 Merge pull request #13686 from esengine/feat/11721-browser-full
feat(studio): the browser and workbench can be viewed full screen with the conversation folded away
2026-10-18 01:50:05 -07:00
YHH 0f13cf54c5 Merge pull request #13765 from esengine/a5/wsl-kind
feat(remote): a host entry has a kind, and a WSL distribution is one
2026-10-18 01:46:19 -07:00
YHH 5c99c45ded Merge pull request #13750 from esengine/feat/13693-subagent-rate
feat(delegation): report an estimated token rate while a sub-agent streams
2026-10-18 01:46:13 -07:00
YHH 92ca1b2180 Merge pull request #13753 from esengine/feat/11995-withdraw-client
feat(feedback): withdraw your own report from My feedback or /feedback withdraw
2026-10-18 01:42:08 -07:00
esengine dd9cf72abf style(remote): range over a count in the live test 2026-10-18 01:40:06 -07:00
esengine 6cd443fc94 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:38:43 -07:00
esengine 34c17c5abf style(config): range over the name's length 2026-10-18 01:38:40 -07:00
YHH ae0af1d5ed Merge pull request #13760 from esengine/a9/default-mode
feat(agent): start new conversations in plan mode when the user chose it
2026-10-18 01:35:23 -07:00
esengine ce73697390 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:33:54 -07:00
esengine 3ed0deb61d Merge remote-tracking branch 'origin/studio' into a5/wsl-kind 2026-10-18 01:33:39 -07:00
esengine f91eba7de2 fix(config): a WSL distribution name is checked against the characters WSL allows 2026-10-18 01:33:35 -07:00
esengine 89fbc59761 feat(remote): translate Windows workspaces for a WSL machine and refuse a distribution without ps 2026-10-18 01:31:49 -07:00
YHH c9c258067d Merge pull request #13761 from esengine/a5/bootstrap-fs
refactor(remote): bootstrap depends on the file calls it uses, not on SFTP
2026-10-18 01:31:33 -07:00
esengine d235c1fce8 feat(remote): bring up a kernel in a WSL distribution and reach it over loopback 2026-10-18 01:28:00 -07:00
esengine 03725a6939 test(desktop): the full-screen guard waits out the entrance and keeps the pointer off the rail before it shoots 2026-10-18 01:23:09 -07:00
esengine ff5242bcfc docs(release-notes): shorten the full-screen browser line 2026-10-18 01:23:09 -07:00
esengine 00ecd029d4 feat(studio): the browser and workbench can be viewed full screen with the conversation folded away 2026-10-18 01:23:09 -07:00
YHH 74772ab76a Merge pull request #13744 from esengine/fix/1q3k-moved-root-path
fix(hub): open a session by its pre-move path after the state root moves
2026-10-18 01:21:59 -07:00
esengine a67c05616d feat(feedback): withdraw your own report from My feedback or /feedback withdraw 2026-10-18 01:19:11 -07:00
esengine a280324b78 feat(agent): start new conversations in plan mode when the user chose it
agent.default_mode (user file only, off by default) is applied by the assembly
for builds that open a fresh conversation: Studio panes without a saved
session, the TUI without a resume, and ACP session/new. A resume, a headless
run and sub-agents never take it. Settings gets the switch.
2026-10-18 01:11:28 -07:00
YHH ddc01a3793 Merge pull request #13762 from xxoingr/fix/completion-receipt-cut-at-character
fix(completion): cut a declined check's command at a character boundary
2026-10-18 01:10:52 -07:00
esengine 764fad7299 fix(hub): open a session by its pre-move path after the state root moves 2026-10-18 01:04:35 -07:00
esengine 45f7b33165 Merge branch 'a5/bootstrap-fs' into a5/wsl-link 2026-10-18 01:04:11 -07:00
YHH fc4c0e8e5b Merge pull request #13757 from esengine/feat/7950-btw
feat(tui): /btw asks a read-only side question that is not saved to the session
2026-10-18 01:03:26 -07:00
esengine 4025ef24dc feat(remote): a host entry has a kind, and a WSL distribution is one 2026-10-18 01:01:42 -07:00
YHH ed1c127d65 Merge pull request #13747 from esengine/a5/schedule-tick
feat(schedule): a tick that runs what is due, and the schedule commands
2026-10-18 00:55:49 -07:00
xxoingr a94ba07bab fix(completion): cut a declined check's command at a character boundary
The unverified-change gap names the command the host declined, bounded
at 80 bytes by command[:80]. A check with non-ASCII paths, common in a
Chinese project, could be cut inside a character, and the delivery card
then showed a replacement glyph before the cut mark. Step back to the
start of the character instead, keeping the budget and the mark.
2026-10-18 15:54:15 +08:00
YHH cdb7d2318a Merge pull request #13724 from esengine/feat/13694-mod-host
feat(modhost): run Claude Code mods as tighten-only extension runtimes
2026-10-18 00:54:01 -07:00
esengine b39058d42b feat(tui): /btw asks a read-only side question that is not saved to the session 2026-10-18 00:50:18 -07:00
esengine d91e7f8d17 refactor(remote): bootstrap depends on the file calls it uses, not on SFTP 2026-10-18 00:48:06 -07:00
YHH d31d8daf07 Merge pull request #13743 from esengine/fix/dispatch-proof-replay
fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned
2026-10-18 00:46:31 -07:00
YHH 8b1895f05d Merge pull request #13746 from esengine/a5/acp-names-cancel
feat(acp): cancel one request, message ids, elicitation
2026-10-18 00:41:44 -07:00
esengine c52d0c325c Merge remote-tracking branch 'origin/a5/schedule-tick' into a5/schedule-ticker
# Conflicts:
#	docs/GUIDE.md
2026-10-18 00:41:40 -07:00
esengine 24c3fe0f87 feat(schedule): refuse agent-started commands, confirm by typing the plan id, record who created it
Subprocesses the host starts for the agent carry REASONIX_AGENT_CHILD=1, and the
person-only schedule commands refuse when it is set, so a pty opened by a tool
no longer passes the terminal check. add prints the whole plan and requires its
short id typed back; list shows how each schedule was confirmed. The Windows
preview test compares against the resolved workspace path.
2026-10-18 00:39:59 -07:00
esengine adee10bd33 fix(modhost): read the package through a root, and run the mod effect tests with the sandbox off 2026-10-18 00:35:49 -07:00
esengine 971346ff56 docs(release): the user-visible change in the mod host core is the MCP ruling, not a switch that is still withheld 2026-10-18 00:35:49 -07:00
esengine 58a6432e55 test(modhost): wait for the script's stderr line instead of reading it the instant the reply lands 2026-10-18 00:35:49 -07:00
esengine 035453edfb refactor(agent): one gate method for the trusted MCP path 2026-10-18 00:35:49 -07:00
esengine 92c7f9d107 fix(desktop): English wording for the withheld mods switch 2026-10-18 00:35:49 -07:00
esengine 338264c6b7 fix(modhost): an extension's ruling also reaches installed MCP tools, and the mods switch stays withheld until the gaps merge 2026-10-18 00:35:49 -07:00
esengine 905eb76143 refactor(modhost): satisfy the linters 2026-10-18 00:35:48 -07:00
esengine fea2a123f6 refactor(modhost): keep the touched files inside their size and comment budgets 2026-10-18 00:35:48 -07:00
esengine 7dad28cf6b fix(desktop): the mods routes and refusal wording the parity tests ask for 2026-10-18 00:35:48 -07:00
esengine 00d6b85d34 docs(mods): the mod host, its trust and what it serves 2026-10-18 00:35:40 -07:00
esengine 95428e8f17 feat(mods): the mods status and settings port, its routes, and the environment a host states 2026-10-18 00:35:39 -07:00
esengine ec49147dee feat(permission): a tighten-only extension's ask is a fresh human approval of the one call 2026-10-18 00:35:39 -07:00
esengine 2234a3d68b test(modhost): effect tests for the prefix, a refused allow and a mod that dies or hangs 2026-10-18 00:35:39 -07:00
esengine f2a2da4120 feat(modhost): a mod installs, previews and runs through the real boot assembly 2026-10-18 00:35:39 -07:00
esengine 2a035ffdb1 feat(config): the [mods] switch and hook budget, user file only 2026-10-18 00:35:39 -07:00
esengine 152db8582e feat(modhost): synthesise a runtime for an installed mod and supervise it through the sidecar client 2026-10-18 00:35:27 -07:00
esengine 3d624951db feat(ext): dynamic tools and kill-on-timeout for single-threaded runtimes; the mod host serves tools, commands and the band 2026-10-18 00:35:27 -07:00
esengine 047b9e96b0 style(modhost): gofmt the harness 2026-10-18 00:35:27 -07:00
esengine a21d8d8b64 feat(modhost): the host script and its protocol tests under a real node 2026-10-18 00:35:27 -07:00
esengine 3219135e7e feat(modhost): resolve the JavaScript runtime a mod runs on 2026-10-18 00:35:27 -07:00
esengine 674712eadf feat(ext): a static preview of a mod's hooks module from its syntax tree 2026-10-18 00:35:27 -07:00
esengine 0154163c84 feat(ext): tighten-only runtimes and the wire types for Claude Code mods 2026-10-18 00:35:27 -07:00
esengine e906f7b4d5 docs(peers): say what a dispatched session inherits, including YOLO's reach over ask rules 2026-10-18 00:32:28 -07:00
YHH 8d7a55e8ad Merge pull request #13742 from esengine/a5/otlp-export
feat(otlp): opt-in OpenTelemetry trace export, user config only
2026-10-18 00:29:50 -07:00
YHH c73eb851bf Merge pull request #13749 from esengine/docs/sandbox-in-containers
docs(sandbox): what the Linux sandbox does in a container, measured
2026-10-18 00:24:43 -07:00
SivanCola c6a421a97f merge: integrate landed persona and current Studio contracts
Problem: Agent compositions still carried the persona prerequisite diff and
missed newly added session lifecycle contracts on Studio.

Root cause: the branch predated the landed persona, ACP additional directories
and project-dispatch assembly paths. Combining their independent additions
also exceeded existing file/scalar-state budgets.

Fix: normally merge Studio 896552483 after verifying #13588 actually landed.
Resolve the Persona/composition imports and keep one ACP SessionParams owner.
Clone vetted extra directories through composition switches. Pass the exact
private binding into dispatched runtime assembly; reject cross-workspace
bindings before opening, and reject fresh assembly errors without a history
fallback. Ordinary unbound dispatch remains unchanged. Extract the command
catalog and Mock memory projection, and place the MCP guard with catalog
refresh state. No budget baseline is raised.

Keep Standard/Minimal cards, the left mode-name-only picker, right run status,
default-off/unbound Standard behavior and the landed user-config persona.
Update bilingual behavior/API documentation and release notes.

Verification: format, vet, lint, make check, required builtin/boot tests, full
root count1 atomic-coverage tests, the complete CI race package group, Desktop
and SDK suites; frontend typecheck/build and 667 files / 5443 tests. Electron
unit176 pass with one existing skip. Browser8 and Electron4 controlled rendering
scenes cover locale/theme/width, selection, mode cards, locked/new flows and
left/right layout. Real HTTP requests match landed Studio byte-for-byte with
user persona29139 bytes and empty persona29110 bytes, with fixed probe PATH.
ACP/dispatch regressions fail before propagation and pass afterward. Local
review and security review cover MCP contracts and dispatch/ingress authority.

Known local gate: make coverage-gate reports the untouched macOS computer
package at88.2 below89.8. The exact command on landed Studio reproduces the
same result and uncovered lines. No floor is lowered; hosted Linux gates
remain required. Hosted results for this new head are reported separately.
2026-10-18 15:23:19 +08:00
esengine 92b2e46f69 docs(sandbox): what the Linux sandbox does in a container, measured 2026-10-18 00:08:54 -07:00
esengine 4cddc3390c feat(schedule): the Studio host runs the ticker while it is supervised and visible
The host also answers 'schedule exec' by running the CLI's entry, so the
supervisor can start it as the child. One kernel at a time ticks, under the
machine's ticker lock.
2026-10-18 00:08:22 -07:00
esengine e453fe539e Merge studio into a5/otlp-export 2026-10-18 00:01:14 -07:00
esengine 20d807cb80 Merge remote-tracking branch 'origin/studio' into a5/schedule-tick 2026-10-18 00:00:24 -07:00
esengine c85b77bee2 Split the ceilings in the scheduled runs guide 2026-10-18 00:00:06 -07:00
esengine da001ded22 Shorten the release note fragment 2026-10-17 23:58:24 -07:00
esengine 3d71e57313 Shorten the release note fragment 2026-10-17 23:58:20 -07:00
esengine fb99e0effd feat(schedule): a tick that claims and runs what is due, and the person-facing schedule commands
Scheduler.Tick reaps dead runs, claims at most one due slot inside its grace
and hands it to the supervisor; the schedule add, list, pause, resume, renew,
rm and tick commands drive it. Creating, resuming and deleting need a
terminal.
2026-10-17 23:58:11 -07:00
esengine 6d700b071b Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:57:46 -07:00
esengine b09691402d Keep ACP docs and protocol.go within the size standards 2026-10-17 23:57:44 -07:00
esengine b3ec1e2e87 feat(delegation): report an estimated token rate while a sub-agent streams 2026-10-17 23:56:26 -07:00
YHH 896552483e Merge pull request #13588 from SivanCola/feature/new_system_person_20261017
feat(persona): add a system persona to Studio / 新增 Studio 系统人设
2026-10-17 23:54:08 -07:00
esengine e6ba328ad4 Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:52:15 -07:00
esengine ba8900aba9 fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned 2026-10-17 23:48:36 -07:00
esengine 57ca36e635 fix(studio): the move dialog says checkpoints, task logs and queued messages stay behind 2026-10-17 23:47:27 -07:00
esengine 5fdd45af4b feat(acp): cancel one request, message ids, tool names on permission requests, elicitation
$/cancel_request cancels only the named request and answers -32800;
agent message chunks carry an opaque messageId; an MCP server's form or
page goes through elicitation/create when the client advertised the mode.
2026-10-17 23:43:20 -07:00
SivanCola cc629b6424 merge: isolate persona imports from evolving settings types
Problem: Studio's new SearXNG connection probe conflicts with the persona
types in three transport import lines, blocking this PR again.

Root cause: independent settings features append types to the same line.

Fix: normally merge Studio 6f68a14a9, preserving the search probe and persona
contracts. Keep persona-only imports separate so subsequent upstream type
additions do not repeatedly conflict. Remove one upstream trailing space.
Retain the upstream MCP scope upgrades, ACP session directories, Gemini
provider, session ordering and rail-search behavior without modifying their
implementations. Persona prompt, user-only scope and guarded writes remain
unchanged, as do the approved composition UI and default-off behavior.

Verification: three-way simulations of future settings imports merge cleanly
in all three files. Format, vet, lint, make check, required builtin/boot tests,
full root Go tests with atomic coverage, full CI race package group and Desktop
Go pass. Frontend typecheck/build and 662 files / 5425 tests pass. Chromium
checks cover persona navigation in both locales, light/dark, wide/390px;
rail search, session ordering, web access, settings rows and font scaling.
Local review and security review check the integration and fresh MCP consent;
they do not replace maintainer approval. No gate or budget was relaxed.
2026-10-18 14:37:59 +08:00
esengine 157ca8533c Merge studio into a5/otlp-export 2026-10-17 23:32:08 -07:00
SivanCola c190fe4bf4 Merge studio into unified project sidebar
Problem: the sidebar PR conflicts with studio's new per-session move actions.

Root cause: both branches extend the row key handler and the mock tree's
session projection. Sorting shortcuts also bubbled into tree navigation,
moving focus to a different session after a reorder.

Fix: retain keyboard context menus and session moves together, compose mock
placement with pin/archive decoration, and consume sorting keys at their
owning row. Keep the existing remote host and body-size guards unchanged.

Verification: 5478 frontend tests across 669 files; production frontend
build; full serve, sessionstore, builtin and boot suites; gofmt, root vet,
make lint and make check. A failing-first keyboard regression and isolated
browser checks prove reordered rows retain focus and keyboard menus still
expose pin, archive and move actions.
2026-10-18 14:32:00 +08:00
YHH 6943f966da Merge pull request #13718 from esengine/a5/video-url
feat(video): hand a public video address the user wrote to the model vendor (off by default)
2026-10-17 23:30:55 -07:00
esengine de67ca98e8 Merge remote-tracking branch 'origin/studio' into a5/otlp-export 2026-10-17 23:15:49 -07:00
esengine 436fa1754a Fix lint findings in the otlp package 2026-10-17 23:15:12 -07:00
esengine cf745b383c feat(studio): move a conversation to another project, carrying the conversation and not what the old project allowed 2026-10-17 23:13:36 -07:00
esengine 790943ac97 Merge studio into a5/video-url 2026-10-17 23:12:21 -07:00
YHH 6f68a14a9d Merge pull request #13732 from esengine/a5/tui-mcp-signin
feat(tui): sign in to an MCP server, or grant it more access, from /mcp
2026-10-17 23:12:14 -07:00
esengine 032e9b771d test(perf): the web access guard expects the video address as its own third list 2026-10-17 23:11:10 -07:00
esengine 089a348753 Merge studio into a5/otlp-export 2026-10-17 23:09:23 -07:00
YHH d87dc94c26 Merge pull request #13659 from esengine/a5/searxng-probe
feat(search): test a SearXNG address from settings and say why it does not answer
2026-10-17 23:07:45 -07:00
YHH 7c35e9bfab Merge pull request #13733 from esengine/feat/session-manual-order-2
feat(studio): move a conversation up or down in its project's list, kept by the kernel
2026-10-17 23:07:42 -07:00
esengine e667bbcd59 fix(desktop): declare the trace export block in the settings catalogue 2026-10-17 23:05:01 -07:00
esengine 9057c3211e feat(otlp): serve route, Studio settings group, docs 2026-10-17 23:04:15 -07:00
esengine b6dbbde594 feat(otlp): opt-in trace export from the user's config, with settings, boot wiring and effect tests 2026-10-17 22:55:38 -07:00
esengine 065d5200da feat(otlp): an exporter that turns a run into GenAI traces 2026-10-17 22:48:43 -07:00
YHH 8e14cdff21 Merge pull request #13731 from esengine/a5/acp-additional-dirs
feat(acp): session-scoped additionalDirectories as write roots
2026-10-17 22:45:40 -07:00
esengine 74e594faed Keep the i18n catalogs under the size ceiling 2026-10-17 22:43:43 -07:00
esengine 6a93a3d01e fix(sessionstore): the order index is a new format now that a row carries its rank 2026-10-17 22:40:36 -07:00
esengine f821e2be49 feat(studio): move a conversation up or down in its project's list, kept by the kernel 2026-10-17 22:40:36 -07:00
esengine 8ce6be77d8 feat(tui): sign in to an MCP server, or grant it more access, from the /mcp panel 2026-10-17 22:40:12 -07:00
esengine e60cc114e8 Merge remote-tracking branch 'origin/studio' into a5/video-url 2026-10-17 22:32:55 -07:00
YHH bbe38032c3 Merge pull request #13730 from esengine/a5/mcp-stepup
feat(mcp): let the user grant a server the access it asked for
2026-10-17 22:32:27 -07:00
esengine bb43735206 feat(acp): take additionalDirectories as session-scoped write roots
Each folder gets the vetting a user-granted root gets; one refused folder
refuses the request with a typed code. Nothing reaches the user's grants.
2026-10-17 22:31:06 -07:00
esengine 1571fe64dc feat(skills): remove a skill from Settings, and never take its whole folder root with it 2026-10-17 22:22:41 -07:00
YHH fc4626cf94 Merge pull request #13726 from esengine/fix/subagent-view-live-after-completion
fix(delegation): a run recorded complete is not live while its owner has yet to let go
2026-10-17 22:10:16 -07:00
YHH cbff25b46b Merge pull request #13715 from esengine/fix/rail-hit-first-click
fix(studio): the rail search box stays open while it holds a query, so the first press on a result is a click
2026-10-17 22:08:33 -07:00
esengine 33b05e79d8 Keep the OAuth authorization flow under the size limits 2026-10-17 22:05:28 -07:00
esengine cacc7c6789 Let the user grant an MCP server the access it asked for
A 403 insufficient_scope from a signed-in server now stops the model and
tells the user; granting more is a fresh approval asked every time, from
the server row or reasonix mcp auth --upgrade.
2026-10-17 22:05:28 -07:00
esengine 3f61753054 feat(mcp): type a 403 insufficient_scope answer, extend access by scope, tell the model by code and the user once 2026-10-17 22:05:28 -07:00
YHH 92d8795b31 Merge pull request #13721 from esengine/a9/acp-vendor-optin
fix(acp): vendor notifications reach only clients that declare them; transcriptPath rides _meta
2026-10-17 21:57:07 -07:00
SivanCola 576e89b111 merge: preserve persona alongside session references
Problem: the latest Studio session-reference settings overlap the persona
port imports and development proxy, leaving this PR in conflict.

Root cause: both features add settings types at the same transport boundary.

Fix: normally merge Studio 8c311b02b, retaining both Persona and SessionRef
contracts, Mock state, SSE methods, routes and settings panels. The upstream
dispatch-consent, bounded-fold and subagent-view implementations stay intact.
Persona prompt assembly, user-only scope, guarded settings and save timing
remain unchanged. The preceding test-only commit synchronizes cancellation
observation without changing the mock provider or relaxing error/close checks.

Verification: formatting, vet, lint, make check, required builtin/boot tests,
full root Go tests with atomic coverage, the full CI race package group and
Desktop Go tests. Frontend typecheck/build and 660 files / 5411 tests pass.
Chromium checks cover persona/settings navigation in Chinese and English,
light/dark and wide/390px layouts; upstream session-reference interactions,
settings rows, font scaling and explorer counts are covered as well.
2026-10-18 12:53:02 +08:00
YHH f249ab3e0e Merge pull request #13712 from esengine/a5/gemini-kind
feat(gemini): a native Gemini provider on the Interactions API, with video (experimental)
2026-10-17 21:50:26 -07:00
esengine 4618349369 Merge origin/studio into fix/rail-hit-first-click 2026-10-17 21:48:03 -07:00
esengine 27dbf237af Merge studio into a5/searxng-probe 2026-10-17 21:47:46 -07:00
esengine 83fde1922f fix(delegation): a run recorded complete is not live while its owner has yet to let go 2026-10-17 21:47:36 -07:00
SivanCola 8bebe521d6 test(agent): synchronize mock stream cancellation observation
Problem: the hosted race group failed the mock provider cancellation test
when the next received chunk was an already-pending tool call.

Root cause: cancellation does not prioritize the Done case over a send that
is also ready in select. The test canceled the context and immediately read
again without establishing that the producer had observed cancellation.

Fix: acknowledge cancellation through the test context's Err method before
draining the stream. Retain the strict cancellation-error and subsequent
channel-close assertions, plus the unchanged pre-canceled request test.
The hang guard uses the existing test budget. No mock/product behavior,
runtime prompt, UI, thresholds or workflow approvals change.

Verification: reproduced one failure in 10000 race-instrumented iterations
with GOMAXPROCS=2 before the change; 10000 final iterations pass afterward.
A temporary explicit-marker mutation that ignores cancellation is rejected.
The complete CI race package group passes, alongside gofmt, vet, lint,
make check and builtin/boot tests. Prior frontend/coverage evidence applies
to the unchanged product tree.
2026-10-18 12:39:10 +08:00
esengine eacf0618e8 fix(acp): vendor notifications reach only clients that declare them; transcriptPath rides _meta
The prompt result carried a top-level transcriptPath the ACP schema does not
allow, and status_update / mcpStatus_update went to every client, which
standard SDK clients answer with a method-not-found error each. The two
notifications now need clientCapabilities._meta["reasonix.io"].statusUpdates
and the path moves under _meta["reasonix.io"].
2026-10-17 21:28:25 -07:00
YHH 8c311b02bd Merge pull request #13708 from esengine/feat/13687-session-reference
feat(studio): reference a past conversation of this workspace from the message box
2026-10-17 21:27:41 -07:00
esengine 7b775ed15e test(video): an address another session delivered is not the user's, and a reopened conversation keeps who wrote what 2026-10-17 21:23:55 -07:00
YHH 64c5ff3273 Merge pull request #13717 from esengine/feat/13693-subagent-view
feat(delegation): read a sub-agent run's conversation while it runs
2026-10-17 21:19:13 -07:00
esengine 291d02c884 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 21:14:41 -07:00
SivanCola f40ab35660 merge: reconcile persona with the settings interface
Problem: newer Studio settings UI and persona translations both appended to
the shared English catalog, leaving the PR conflicting. Its new setting-open
notices also lacked a destination for the guarded persona fields.

Root cause: translations for an independent settings domain shared an append
point, and the newly introduced frontend routing table did not know persona.

Fix: normally merge Studio 15de9ec33, preserve both sides' translations and
extract persona's unchanged entries into its own catalog module. Route the
three guarded persona setting ids to their editor and cover actual notice
clicks. Preserve the persona's user scope, supplemental prompt semantics,
save timing, settings navigation and existing authorization boundaries.

Verification: format, vet, lint, make check, complete root Go tests with
atomic coverage, Desktop Go, frontend typecheck/build and 657 files / 5400
tests. Verified all 25 extracted translations unchanged. Chromium navigation
passed in Chinese/English, light/dark and wide/390px layouts; settings rows
and font scaling pass. The later dispatch config/catalog combination also
passes an explicit-marker build overlay. No thresholds or approvals changed.
2026-10-18 12:06:50 +08:00
esengine b96ad582a6 feat(video): the Settings switch for handing a video address to the vendor, with docs 2026-10-17 21:04:31 -07:00
YHH 42c505b50e Merge pull request #13711 from esengine/feat/13320-bound-fold
fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops
2026-10-17 21:03:07 -07:00
YHH 5c38676052 Merge pull request #13709 from esengine/feat/dispatch-open-switch
feat(peers): the dispatch switch is the consent between a session and the sessions it opens
2026-10-17 21:01:08 -07:00
esengine 58e2da8db2 feat(video): read_video takes an address the user wrote, when the user turned that on 2026-10-17 20:58:33 -07:00
esengine 4b483d3897 fix(control): cut a referenced conversation at a character boundary and quote its attributes with %q 2026-10-17 20:57:29 -07:00
esengine 085f20c576 feat(video): an address the vendor visits, on the Zhipu and Gemini wires 2026-10-17 20:54:12 -07:00
esengine 3e10e6faf6 Merge branch 'a5/gemini-kind' into a5/video-url 2026-10-17 20:52:28 -07:00
esengine 8f1fe5965d fix(gemini): follow only an upload address on the configured host and only Files-shaped names; say experimental in the config 2026-10-17 20:52:08 -07:00
esengine f1fa8538d5 wip(video-url): the user-only switch in the config 2026-10-17 20:49:18 -07:00
esengine 4d2c7cd4d0 fix(studio): the rail search box stays open while it holds a query, so the first press on a result is a click 2026-10-17 20:48:09 -07:00
esengine 863ed17e70 feat(delegation): read a sub-agent run's conversation while it runs 2026-10-17 20:47:52 -07:00
esengine a2aef0b953 fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops 2026-10-17 20:42:45 -07:00
YHH 15de9ec334 Merge pull request #13706 from esengine/a9/acp-readiness-pause
fix(acp): a host-paused turn ends with end_turn, a paused outcome and a receipt
2026-10-17 20:41:07 -07:00
YHH ec207aabf7 Merge pull request #13707 from esengine/feat/13697-user-message-time
feat(chat): show when your own message was sent in its action row
2026-10-17 20:40:46 -07:00
YHH bec46d99ab Merge pull request #13704 from esengine/fix/13699-user-action-icons
fix(chat): the action icons under your own message match the reply's size
2026-10-17 20:40:35 -07:00
esengine 992e0155a5 Merge remote-tracking branch 'origin/studio' into a5/gemini-kind 2026-10-17 20:36:53 -07:00
esengine 3b5ef3dff1 style(gemini): satisfy the linters in the tests 2026-10-17 20:36:43 -07:00
YHH 33bb46e95a Merge pull request #13698 from esengine/a5/settings-ui
feat(settings): draw the setting card and point at a setting only the user may change
2026-10-17 20:30:15 -07:00
esengine 14206bc978 test(settings): the dispatch ceilings share the words of the dispatch switch 2026-10-17 20:29:24 -07:00
esengine da0c5c6a9f test(peers): range over SplitSeq with one variable 2026-10-17 20:29:24 -07:00
esengine 038732325c fix(settings): declare the two dispatch ceilings 2026-10-17 20:29:24 -07:00
esengine ffe30b62bf test(peers): range over SplitSeq 2026-10-17 20:29:24 -07:00
esengine eb48decd12 fix(desktop): the peers settings fixtures carry the dispatch ceilings 2026-10-17 20:29:24 -07:00
esengine e7d19e3d65 chore(peers): fit the repolint budgets 2026-10-17 20:29:24 -07:00
esengine 2002ef72ea feat(peers): ceilings, error identities and the dispatch tool's own words follow the switch being the consent 2026-10-17 20:29:24 -07:00
esengine ddba148a36 feat(peers): the dispatch switch is the consent between a session and the ones it opens 2026-10-17 20:29:24 -07:00
esengine 8720820542 Merge studio into a5/gemini-kind 2026-10-17 20:28:35 -07:00
esengine ec8a91e1e1 feat(studio): reference a past conversation of this workspace from the message box 2026-10-17 20:22:18 -07:00
esengine 25d9f7f028 style(gemini): split the stream loop and trim comments to the repo's limits 2026-10-17 20:19:25 -07:00
esengine aa80cdce2e feat(gemini): effect test, docs, release note and the desktop kind label 2026-10-17 20:18:28 -07:00
esengine 5b4f35502f fix(acp): a host-paused turn ends with end_turn, a paused outcome and a receipt
The final-readiness pause answered session/prompt with the non-standard
stopReason "error", so a client that reads only message text took unverified
work for finished. The turn now ends with end_turn, the typed outcome in _meta
and a host-written [paused] line at the end of the streamed text.
2026-10-17 20:16:53 -07:00
SivanCola fbdb85dc4e merge: retain guarded persona behavior with the settings tool
Problem: newer Studio introduces the model-facing setting tool across the
same catalog, boot assembly and controller used by the persona feature.

Root cause: validating only the earlier base would leave their combined
authorization and request contracts untested.

Fix: normally merge Studio 4840df982 and retain the existing guarded persona
entries, user-only loading and supplemental prompt assembly. Add a real
boot.Build effect test for all three persona fields: even in YOLO a model
proposal receives a guarded refusal, produces no approval card, preserves
the user file and discloses no value through refusal or settings listing.

Verification: gofmt, vet, lint, make check, full root Go tests with atomic
coverage, Desktop Go and SDK suites, frontend typecheck/build and 654 files
/ 5384 tests; Chromium settings navigation, row layout and font-scaling
checks in light/dark and wide/narrow layouts. Earlier Electron checks apply
unchanged. No baselines, thresholds, workflow approvals or draft state change.
2026-10-18 11:16:47 +08:00
esengine 006616829a feat(chat): show when your own message was sent in its action row 2026-10-17 20:16:31 -07:00
esengine b9510a1cc2 Merge remote-tracking branch 'origin/studio' into a5/settings-ui 2026-10-17 20:13:06 -07:00
YHH 0d9bae435a Merge pull request #13703 from esengine/a9/dsh-acp
fix(acp): a failed session/prompt turn is a JSON-RPC error with a typed code
2026-10-17 20:12:47 -07:00
esengine fbf2495a4c feat(gemini): the native preset, with only models whose pages were read 2026-10-17 20:08:07 -07:00
SivanCola 724d94af8c merge: reconcile persona with current Studio contracts
Problem: the approved persona head failed hosted Go tests and coverage, and
the combined Settings/config files exceeded their line budgets.

Root cause: CI tested synthetic merge 6309314327 against newer Studio
5e53be32a. Its attachment ceiling key had no settings declaration, while
independent additions accumulated in the shared settings/config files.

Fix: normally merge Studio 667f2ca16, including the upstream guarded
attachment-key declaration and ProviderEntry method extraction. Keep all
three guarded persona keys and their user-scope/prompt behavior. Extract
the unchanged settings navigation into SettingsNav, retaining keyboard
navigation, status values and the new version dot. Assert that the
attachment storage ceiling stays guarded.

Verification: gofmt, vet, lint, make check, complete root Go tests with
atomic coverage, independent Desktop Go tests, Electron tests, frontend
typecheck/build and 654 files / 5381 tests. Chromium fs-scale and settings
row guards pass, with light/dark 1280px/390px navigation checks. No budgets,
test thresholds, workflow approvals or draft state are changed.
2026-10-18 11:07:54 +08:00
esengine e24b481e40 fix(chat): the action icons under your own message match the reply's size 2026-10-17 20:05:22 -07:00
YHH 4840df9825 Merge pull request #13690 from esengine/a5/settings-tool
feat(settings): the model can propose a setting change and the user confirms it
2026-10-17 20:03:48 -07:00
esengine 8c4e0ef1e8 feat(gemini): video through inline data or the Files API, with WebM and MOV sniffed natively 2026-10-17 20:02:52 -07:00
YHH fadd893983 Merge pull request #13701 from esengine/fix/search-button-toggle
fix(find): the title bar search button closes an open find bar
2026-10-17 20:02:14 -07:00
esengine 12fb14abb6 feat(pricing): Google's Gemini rates, with a dated generation for the announced step 2026-10-17 19:57:22 -07:00
esengine 001d33f232 fix(acp): a failed session/prompt turn is a JSON-RPC error with a typed code
stopReason "error" is not in the ACP prompt-turn enumeration, and the cause
travelled only on a vendor notification, so a text-only client saw an
unexplained failure. The turn now ends with -32603, the redacted cause as the
message and data.code turn_failed.
2026-10-17 19:57:07 -07:00
YHH 667f2ca166 Merge pull request #13688 from SivanCola/fix/skill-spec-info-key
fix(studio): key each skill metadata row / 技能元数据行补上 key
2026-10-17 19:56:36 -07:00
esengine 33219e58c5 feat(gemini): a native provider kind on the Interactions API 2026-10-17 19:53:46 -07:00
esengine f6c18ac5a3 Merge branch 'a5/settings-tool' into a5/settings-ui 2026-10-17 19:50:31 -07:00
esengine fc80457d14 fix(settings): the card shows the whole value or the value is refused, no proposals in plan mode, no values for boundary rows 2026-10-17 19:50:12 -07:00
esengine 5d99b3a628 fix(find): the title bar search button closes an open find bar 2026-10-17 19:44:03 -07:00
esengine 96d21061db Merge remote-tracking branch 'origin/studio' into a5/settings-tool 2026-10-17 19:43:10 -07:00
SivanCola 7392199b3c Merge remote-tracking branch 'origin/studio' into fix/skill-spec-info-key 2026-10-18 10:41:01 +08:00
YHH 1d1a8c7ad8 Merge pull request #13691 from esengine/fix/config-size-budget
refactor(config): move ProviderEntry's model and key methods out of config.go
2026-10-17 19:40:28 -07:00
SivanCola fb84c5af25 fix(studio): refuse unknown SSH hosts and keep hover actions opt-in
Problem: remote sidebar mutations could dial an unconfigured SSH target,
accept unbounded JSON, and enable hover actions without a user choice.

Root cause: a missing host resolved to an empty workspace without an error;
remote JSON handlers lacked a shared size boundary, and the preference
treated every value except explicit off as enabled.

Fix: classify unknown hosts before link reuse or acquisition, share a
64 KiB single-body decoder across remote writes, and restore explicit-on
hover preferences with a safe storage-error default. Preserve configured
hosts, far-kernel refusals, menu access and declared workspace resolution.
Align synchronization fixtures and keep translated directory prose in the
interface font.

Verification: failing-first unknown-host and oversized-body regressions;
full serve, builtin and boot tests; 5389 frontend tests; production build;
gofmt, root go vet, make lint and make check; isolated browser interaction
checks for default-off, opt-in persistence, menu access and opt-out.
2026-10-18 10:32:18 +08:00
esengine c24492d2c0 feat(settings): draw the setting card, point at a setting only the user may change, and offer the choice over ACP 2026-10-17 19:29:39 -07:00
esengine c1c7d99ecb Merge remote-tracking branch 'origin/fix/declare-attachments-key' into fix/config-size-budget 2026-10-17 19:24:54 -07:00
esengine 02ff3c3352 fix(settings): the receipt notice names the setting and carries no machine payload 2026-10-17 19:23:30 -07:00
SivanCola cc14bcb75e fix: display the server-owned remote workspace default
Problem: The SSH project menu re-derived a default from the project list when
the host response omitted workspace.

Root cause: A client fallback duplicated the kernel's configured-default rule.

Fix: Pass the host response's workspace directly to the menu. Align the fixture
with the server response and cover both an explicit default that differs from
list order and an omitted default that must stay unset.

Verification: The omitted-default regression failed before repair. All 17
remote project/worktree interaction tests and the production build passed.
gofmt, root go vet, make lint, make check and builtin/boot tests passed.
2026-10-18 10:16:54 +08:00
esengine 14c5dcbcee style(search): slices helpers in the dial guard, tidy the probe tests 2026-10-17 19:15:00 -07:00
esengine 623e792df3 fix(search): probe only the saved SearXNG address and never a link-local or metadata one 2026-10-17 19:13:09 -07:00
esengine a6329ea5f4 fix(settings): declare attachments.max_stream_bytes as Guarded
The key landed with the streaming attachment ceiling without a class, so the
settings package panics at registration and every test importing it fails.
2026-10-17 19:11:19 -07:00
esengine a8dc464e9a refactor(config): move ProviderEntry's model and key methods out of config.go
config.go is over its file-size budget on studio after two merged changes,
which fails lint for every new pull request. The model-price/override and
API-key methods belong with ProviderEntry; the one function that reads the
process environment stays in config.go so no process-input debt moves.
2026-10-17 19:09:37 -07:00
SivanCola 321e3bbaf0 fix(studio): scale explorer counts with interface text
Problem: the Studio browser guard on the persona PR measured file and
change counts staying at 10px when interface text was enlarged.

Root cause: both explorer headers share a small element without an explicit
font size, leaving the size response to the browser's smaller-text rules.
The stylesheet is inherited from studio; macOS Chromium did not reproduce
the Linux hosted failure.

Fix: give the shared counter rule an explicit 10px base multiplied by the
existing interface scale. Preserve the default appearance and the persona
and composition behavior. Add a bilingual release note.

Verification: gofmt, vet, lint, make check, builtin/boot tests, frontend
typecheck, production/perf builds and 651 files / 5370 tests; Chromium
fs-scale, text-size and explorer guards. In light and dark, both counts
measure 10px, 11.5px and 13px across the three interface size steps.
2026-10-18 10:05:34 +08:00
esengine 5393e818e2 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 19:04:52 -07:00
YHH 5e53be32ab Merge pull request #13685 from esengine/fix/13682-versions-nav-update-dot
fix(settings): mark the Version row when an update is known
2026-10-17 19:00:48 -07:00
esengine aab760b0b2 feat(settings): declare attachments.max_stream_bytes as a guarded size limit 2026-10-17 18:59:07 -07:00
SivanCola a72134c0ef fix(studio): key each skill metadata row
The metadata list mapped entries to an unkeyed fragment, so React warned about the missing key when Settings -> Extensions rendered a skill's spec. Give the fragment the entry key and add a regression test that fails without it.
2026-10-18 09:58:59 +08:00
esengine 530ccbf688 style(config): fold two comments so config.go is back inside its size budget 2026-10-17 18:57:53 -07:00
esengine 9c6c91da9c style(settings): errors.As in tests, slices.Contains, keep config.go within its size budget 2026-10-17 18:57:27 -07:00
esengine c7e6ef9a52 feat(settings): offer the setting tool, name the switch on a disabled dispatch, and document both 2026-10-17 18:57:27 -07:00
esengine f3a5a6bcce feat(settings): the setting tool, the host that confirms a change, and the structured remedy on refusals 2026-10-17 18:57:27 -07:00
SivanCola f5ebeb1bd6 merge(studio): reuse the guarded persona settings foundation
Problem: the latest studio settings catalog must know the persona fields, and
opt-in Standard mode must retain access to the prerequisite user-persona UI.

Root cause: the new catalog landed during the composition review, while the
initial composition page had replaced persona editing with a default adapter.

Fix: integrate the corrected persona parent and current studio, reuse its
editor in Standard mode, preserve settings-search navigation, and distinguish
user persona edits from immutable composition bindings. Keep the accepted two
cards, compact left picker and right run information.

Verification: gofmt, vet, lint, make check, root Go suite including boot/builtin,
frontend typecheck/build and 656 files / 5,385 tests pass. Parent persona keys
are Guarded; the actual request-byte and old-reader probes remain unchanged.
2026-10-18 09:52:37 +08:00
YHH 2d6656cd14 Merge pull request #13544 from SivanCola/fix/native-file-streaming-studio
fix(studio): reference native files and stream attachment uploads / 大文件路径引用与流式上传
2026-10-17 18:49:56 -07:00
SivanCola 6fe0fd6867 fix(persona): declare guarded keys in the settings catalog
Problem: the new studio settings catalog rejects config fields without an
explicit classification, including the three persona fields added here.

Root cause: the catalog foundation landed after the persona implementation.

Fix: integrate studio and classify persona prefix, suffix and optional runtime
context as user-owned Guarded settings. Plain file writes do not rebuild a
runtime. Reject model proposals before any persona value can be changed.

Verification: root Go tests including settings, boot and builtin pass; gofmt,
vet, lint and make check pass; frontend typecheck/build and 5,370 tests pass.
2026-10-18 09:46:59 +08:00
SivanCola f3930c0c7f fix(composition): require explicit selection for session presets
Problem: default selections bound every new session and migrated the user's
persona, changing the ordinary path and making downgrade impossible.

Root cause: entry points manufactured a selection, persona settings were
adapted into the preset store, and blank unbound rewrites were not persisted.

Fix: keep unselected and Standard sessions unbound, preserve the user persona,
require explicit non-Standard selection, and restore durable state on failed
publication from either origin. Reuse the corrected persona parent, preserve
Standard/Minimal cards and the compact unified picker, and remove committed
composition captures. Freeze both skill discovery paths without copying locks.

Verification: root Go suite, vet, lint, make check, Desktop/SDK suites, Electron
unit tests and focused frontend suites pass. Real Build HTTP bytes match the
persona parent; its original reader can continue a new unbound transcript.
Project authorization, rollback and skill discovery regressions pass.
2026-10-18 09:45:52 +08:00
YHH ce7c69ca75 Merge pull request #13681 from esengine/fix/browser-minimized-click
fix(desktop): raise a minimized window unseen so the agent's clicks reach the built-in browser
2026-10-17 18:42:53 -07:00
esengine edaccd5f4c fix(settings): mark the Version row when an update is known 2026-10-17 18:37:55 -07:00
YHH 8dbf869fa8 Merge pull request #13680 from esengine/a5/settings-catalog
feat(settings): declare every user-config key with a class, an effect timing and a secret flag
2026-10-17 18:37:34 -07:00
YHH 0ea504ea77 Merge pull request #13679 from esengine/fix/dispatch-switch-off-identity
fix(peers): say that the dispatch switch is off, apart from a host that cannot open panes
2026-10-17 18:30:26 -07:00
YHH e88e02556a Merge pull request #13676 from esengine/fix/11577-editor-text-size
fix(desktop): the code editor's text and find panel follow the interface text size
2026-10-17 18:24:46 -07:00
esengine 2529f757f4 test(desktop): restore the focused-window assertion 2026-10-17 18:22:57 -07:00
SivanCola e7d50cf0e9 fix(persona): preserve first-party guidance and user scope
Problem: project configuration could replace the user persona, and complete
mode could suppress built-in guidance. The UI misstated rebuild semantics.

Root cause: persona was absent from the user-scope hold, replacement was a
supported runtime option, and settings metadata described only new sessions.

Fix: hold and clone the user's persona across project decoding, retire complete
throughout config/runtime/API/UI, and describe next-build activation. Integrate
current studio without dropping its settings or transport contracts.

Verification: gofmt, vet, lint, make check, root and Desktop Go suites, boot
scope/prompt regressions, frontend typecheck/build and 5,369 tests pass.
2026-10-18 09:22:31 +08:00
esengine 466e44779b fix(desktop): a window that fails to raise gets its opacity and mouse back 2026-10-17 18:22:18 -07:00
esengine f9714be60d feat(settings): a writable value is judged by the config's own setter, a bound, or a stated freedom 2026-10-17 18:21:48 -07:00
esengine 8ae413c9ef fix(settings): never read a structured key, and declare protection-relaxing keys Elevated 2026-10-17 18:20:05 -07:00
esengine 6efead8677 fix(settings): read and write an optional key through its pointer 2026-10-17 18:19:40 -07:00
esengine 792c1a0fcd fix(settings): declare only effect timings a plain file write delivers 2026-10-17 18:17:16 -07:00
esengine 8a968bb900 test(desktop): the live browser check submits each turn after the last has ended 2026-10-17 18:16:55 -07:00
esengine 2180caa3d8 test(desktop): the person who restores the window also activates it 2026-10-17 18:16:55 -07:00
esengine 8e105ba1fd fix(desktop): raise a minimized window unseen for the agent's input to the built-in browser 2026-10-17 18:16:55 -07:00
esengine 245928be33 test(desktop): the live browser check uses the controls the panel has now 2026-10-17 18:16:55 -07:00
esengine 0b3c709ed1 style(settings): use reflect.TypeFor and range over int 2026-10-17 18:16:09 -07:00
esengine 32a79410d2 feat(settings): declare every user-config key with a class, an effect timing and a secret flag 2026-10-17 18:15:44 -07:00
esengine aa67e223cc fix(peers): say that the dispatch switch is off, apart from a host that cannot open panes 2026-10-17 18:12:06 -07:00
YHH bafb4c0818 Merge pull request #13674 from esengine/fix/flaky-frontend-tests
fix(desktop): the branch menu survives the chip settling, and its list is re-read when the root moves
2026-10-17 18:03:41 -07:00
YHH b79d5d0836 Merge pull request #13675 from esengine/a5/parity-exemptions
chore(repolint): scope five frontend-parity edges where a frontend reaches the same behaviour another way
2026-10-17 17:40:31 -07:00
esengine 7a1bbc51b9 fix(desktop): the code editor's text and find panel follow the interface text size 2026-10-17 17:37:37 -07:00
esengine 791c00f496 chore(repolint): scope five frontend-parity edges where a frontend reaches the same behaviour another way 2026-10-17 17:29:55 -07:00
YHH 31f230cf49 Merge pull request #13673 from esengine/docs/studio-2.44.0-notes
docs(release): studio 2.44.0 notes
2026-10-17 16:19:24 -07:00
esengine f6a3680d09 docs(release-notes): name the pull request in the fragment 2026-10-17 16:19:00 -07:00
esengine adc02158fb docs(release): studio 2.44.0 notes 2026-10-17 16:11:02 -07:00
esengine 4fd0dab507 fix(desktop): the branch menu survives the chip settling, and its list is re-read when the root moves 2026-10-17 16:09:42 -07:00
YHH 481aa4a0eb Merge pull request #13672 from esengine/fix/shell-probe-test-hedge
test(boot): count launches of the planted bash, not hedged candidates, in the proven-shell effect test
2026-10-17 15:46:19 -07:00
esengine 7bf97efccb test(boot): count launches of the planted bash, not hedged candidates, in the proven-shell effect test 2026-10-17 15:27:48 -07:00
YHH b50bb0c29e Merge pull request #13470 from esengine/fix/13197-slash-catalog-memo
fix(skill): memoize slash-list discovery for two seconds
2026-10-17 15:21:14 -07:00
esengine 1c718f8547 test(skill): a skill added outside the store shows once the slash-list memo has aged out 2026-10-17 15:08:07 -07:00
YHH 1339e794d8 Merge pull request #13530 from esengine/fix/12900-adopted-hint
feat(desktop): say what a shipped report is on the feedback level card
2026-10-17 15:04:52 -07:00
YHH 6a6342446d Merge pull request #13668 from esengine/feat/open-document-default-app
feat(studio): open a local HTML, SVG or PDF file in the system default program
2026-10-17 15:04:23 -07:00
YHH f743552038 Merge pull request #13671 from esengine/feat/13526-provider-retries
feat(agent): agent.max_provider_retries sets how often a failed provider connection is retried, 0 until cancelled
2026-10-17 15:03:52 -07:00
esengine 188f100cc5 fix(serve): check an opened document's path against the roots as strings before touching the disk 2026-10-17 14:37:08 -07:00
YHH e1211b7715 Merge branch 'studio' into fix/12900-adopted-hint 2026-10-17 14:34:21 -07:00
YHH c926b5306b Merge branch 'studio' into fix/13197-slash-catalog-memo 2026-10-17 14:34:17 -07:00
esengine 0fe1bbc31b test(boot): name the status the retry probe returns 2026-10-17 13:17:16 -07:00
esengine 3b0ab63f4a feat(agent): agent.max_provider_retries sets how often a failed provider connection is retried, 0 until cancelled 2026-10-17 13:07:38 -07:00
YHH df91037c1c Merge pull request #13670 from esengine/feat/peers-wire-parity
test(repolint): hold the desktop's PeersSettings mirror to the kernel's
2026-10-17 12:59:33 -07:00
esengine 1bb970d834 test(repolint): hold the desktop's PeersSettings mirror to the kernel's 2026-10-17 12:48:51 -07:00
YHH 1593ac424b Merge pull request #13661 from esengine/a5/acp-mcp-session-control
feat(acp): reconnect and disconnect MCP servers per session, submit extension forms
2026-10-17 12:40:31 -07:00
esengine a4781fa524 Merge origin/studio into a5/acp-mcp-session-control 2026-10-17 12:26:20 -07:00
YHH 1d4c43e98b Merge pull request #13669 from esengine/test/machine-list-explicit-times
test(cli): the machine-list fixture keeps the update times it asks for
2026-10-17 12:23:12 -07:00
esengine 47addbe945 test(opendoc): hold the Windows opener's argument list whole, and make the opener tests path-portable 2026-10-17 12:15:49 -07:00
esengine c0cf65568c test(cli): the machine-list fixture keeps the update times it asks for 2026-10-17 12:10:41 -07:00
YHH 987c13b4cb Merge pull request #13667 from esengine/a5/goal-usage
feat(goal): show a goal's usage and stop cause in the desktop and the terminal
2026-10-17 12:05:31 -07:00
YHH 414bec6f4d Merge pull request #13664 from esengine/fix/pricecheck-deepseek-cny
feat(pricecheck): check DeepSeek's yuan page as well as the dollar one
2026-10-17 12:01:36 -07:00
esengine 42d4313907 feat(studio): open a local HTML, SVG or PDF file in the system default program 2026-10-17 11:56:49 -07:00
esengine 813884f9d2 feat(goal): show a goal's usage and stop cause in the desktop and the terminal 2026-10-17 11:38:18 -07:00
esengine d6b6acb309 feat(pricecheck): check DeepSeek's yuan page as well as the dollar one 2026-10-17 11:35:16 -07:00
YHH b2f9c25ae7 Merge pull request #13656 from esengine/fix/skill-warnings-once
fix(skill): write each distinct discovery diagnostic once per store
2026-10-17 11:31:31 -07:00
YHH 504465f8de Merge pull request #13657 from esengine/fix/narrow-line-limit
refactor(config): side conversations and queued prompts are held with the other agent limits
2026-10-17 11:14:40 -07:00
esengine 2301cb0fd7 feat(acp): scope the persistent MCP capabilities away, with the trust gate documented 2026-10-17 11:14:37 -07:00
esengine c1617dfc69 feat(acp): reconnect and disconnect MCP servers for one session, submit extension forms 2026-10-17 11:14:37 -07:00
YHH 876a4ae39d Merge pull request #13658 from esengine/docs/win-port-note
docs(release): word the Windows port note for users
2026-10-17 11:14:18 -07:00
esengine 299a10f39a docs(release): word the Windows port note for users 2026-10-17 11:03:48 -07:00
esengine 7c9d16c82f refactor(config): side conversations and queued prompts are held with the other agent limits 2026-10-17 10:59:04 -07:00
esengine fc501c8219 fix(skill): write each distinct discovery diagnostic once per store 2026-10-17 10:55:42 -07:00
esengine e858a46f70 feat(search): say why a SearXNG instance does not answer, and test the address from settings 2026-10-17 10:54:09 -07:00
YHH ee92b3f4d7 Merge pull request #13654 from esengine/a5/narrow-size
refactor(config): move the workspace grants out of heldScope.narrow
2026-10-17 10:53:54 -07:00
esengine e6cb790416 fix(skill): memoize slash-list discovery for two seconds 2026-10-17 10:48:44 -07:00
esengine fcb4264282 refactor(config): move the workspace grants out of heldScope.narrow 2026-10-17 10:43:07 -07:00
YHH 34efa76dd3 Merge pull request #13572 from esengine/test/resource-diag
test(boot): a red resource-tool run names what the schema cache held
2026-10-17 10:38:45 -07:00
YHH 43a515dc27 Merge pull request #13599 from esengine/fix/subagent-done-status
fix(studio): a finished sub-agent says it is handed back, not only how long it took
2026-10-17 10:38:34 -07:00
YHH 1caab22b6a Merge pull request #13651 from esengine/fix/subagent-view-image-by-own-model
fix(delegation): a child on an image model gets view_image whatever the parent's model reads
2026-10-17 10:36:02 -07:00
YHH af3e7e973b Merge pull request #13633 from esengine/feat/13526-queued-inputs
feat(inbox): the session queue holds as many prompts as asked unless the user sets agent.max_queued_inputs
2026-10-17 10:32:56 -07:00
YHH b4c126d66e Merge pull request #13648 from esengine/fix/tree-open-pane-identity
fix(serve): a pane belongs to a folder however the folder is spelled, and a pane with no row is logged
2026-10-17 10:28:49 -07:00
YHH 5e6fe553c7 Merge pull request #13649 from esengine/fix/win-port-retry-eacces
fix(cli): a Windows bind refused as forbidden moves the web server to the next port
2026-10-17 10:27:51 -07:00
YHH f0ab419902 Merge pull request #13646 from esengine/a5/acp-session-info
feat(acp): report session info and configured MCP servers
2026-10-17 10:22:41 -07:00
YHH d4f5a36c1b Merge pull request #13647 from esengine/test/pipe-status-product-bash
test(builtin): the pipe-status probe runs under the bash the product resolves
2026-10-17 10:20:41 -07:00
YHH 73dbd8e8c4 Merge pull request #13645 from esengine/fix/13565-versions-memo
fix(update): share one version-catalog read between Settings, the versions panel and the update check
2026-10-17 10:16:43 -07:00
esengine e7b2cbd8f5 fix(delegation): a child on an image model gets view_image whatever the parent's model reads 2026-10-17 10:14:32 -07:00
esengine c6b36276a8 fix(cli): a Windows bind refused as forbidden moves the web server to the next port 2026-10-17 10:08:40 -07:00
esengine 2fa53d3cc4 test(builtin): the pipe-status probe runs under the bash the product resolves, not the first bash on PATH 2026-10-17 10:07:06 -07:00
YHH 6f653cbfaf Merge pull request #13643 from esengine/a5/video-windows-skip
test(model): skip the swap-after-open video tests on Windows
2026-10-17 10:06:42 -07:00
esengine 2f1c6648b1 feat(acp): report session info and configured MCP servers, scope four lifecycle capabilities 2026-10-17 10:04:56 -07:00
YHH 75883d7f04 Merge pull request #13641 from esengine/chore/release-fragments-2.44
docs(release): merge the two session-messages fragments and rewrite the scratch-spill one
2026-10-17 10:04:00 -07:00
esengine 544d17f6dd fix(serve): a pane belongs to a folder however the folder is spelled, and a pane with no row is logged 2026-10-17 10:02:45 -07:00
esengine fafb92b935 test(model): skip the swap-after-open tests on Windows, which cannot replace an open file 2026-10-17 09:54:22 -07:00
YHH 9696d762ed Merge pull request #13602 from esengine/feat/mcp-signin-follow-ui
feat(desktop): sign in to an MCP server that answered 401, and follow a server's tool changes
2026-10-17 09:50:55 -07:00
esengine 9f1d20cd46 fix(update): share one version-catalog read between Settings, the versions panel and the update check 2026-10-17 09:50:12 -07:00
esengine 70e5060ec3 docs(release): merge the two session-messages fragments and rewrite the scratch-spill one 2026-10-17 09:49:20 -07:00
SivanCola 4522bb569a merge(studio): retain local bindings during native video projection
Problem: studio's native-video projection refactor overlaps the local-only
Agent composition metadata filter at the provider boundary.

Fix: keep the shared projection predicate and include composition metadata in
its host-only fields. Preserve stored bindings/video references and remove
answered video payloads only from model requests. Add regressions for a binding
as the sole copy trigger and for binding/video projection together.

Verification: gofmt, go vet, make lint/check, uncached root Go tests,
provider/OpenAI/boot/builtin suites, Desktop Go tests, frontend typecheck,
production build and 5,348 frontend tests. Existing mode layouts are preserved.
2026-10-18 00:48:01 +08:00
SivanCola 914bb02fc0 merge(studio): preserve pinned compositions with followed MCP tools
Problem: studio's MCP-follow changes and session compositions both own the
publication of a server tool catalog.

Root cause: the new followed-tool metadata must travel with the same catalog
whose fingerprint the immutable session binding validated.

Fix: retain follow metadata and the composition check before catalog publication.
Preserve upstream account localization and MCP-follow behavior. Add refresh,
stale-adapter and reconnect regressions with following enabled and disabled,
plus a real boot/provider test showing old bindings block drift while new
conversations can bind the changed server.

Verification: gofmt, go vet, make lint/check, root Go and boot/builtin suites,
Desktop and SDK Go suites, composition-guard race tests, frontend typecheck,
production build and 5,347 frontend tests. Browser evidence remains in design QA.
2026-10-18 00:37:35 +08:00
esengine b3b39feefe Merge origin/studio into feat/mcp-signin-follow-ui 2026-10-17 09:35:51 -07:00
YHH 9374e1eda7 Merge pull request #13617 from esengine/a5/video-native
feat: let read_video hand an MP4 to a model that watches video natively
2026-10-17 09:34:52 -07:00
SivanCola 525d397009 feat(studio): offer Standard and Minimal Agent modes
Problem: the shipped catalog and composer exposed more mode choices and status
badges than the requested Standard/Minimal workflow needs.

Fix: ship two native compositions, with a full-configuration Standard mode
and a terminal/image Minimal mode using a concise persona. Preserve retained
bindings and report retired defaults explicitly. Separate built-in cards from
custom presets, keep only the mode name in the trigger, and place run status
at the right. Keep source/lock details in the accessible label and menu.

Verification: gofmt, go vet, make lint/check, required boot/builtin tests,
complete root and Desktop Go suites, frontend typecheck/build and 5,343 tests.
Provider-boundary and custom read-only fixtures retain capability-denial
coverage. Browser checks cover light/dark, 1440px/390px and locked controls.
2026-10-18 00:28:15 +08:00
SivanCola eebc6d2a3c fix: make remote workspace binding explicit before link reuse
Problem: Host reads could borrow another pane's workspace or reuse an existing
link after its host was disabled. Review also requested explicit defaults,
boundary regressions and removal of development notes from the tree.

Root cause: remoteLink returned the first host pane before reading current
user configuration, so neither workspace identity nor the disabled policy
governed the reuse path.

Fix: Resolve the user-host default before reuse and accept only a matching
host/workspace pane. Share the declared-default-first rule with host views,
show actual and default connection paths separately, and document the rule
in both GUIDE languages. Remove three root QA notes and seven captures;
preserve their useful context in the PR discussion.

Verification: Reproduced both incorrect read behaviors before repair. Added
HTTP regressions for declared defaults, fallback, empty configuration,
reordered extras, explicit opens, project injection and disabled hosts with
existing panes. Focused frontend tests (80), production build, owning Go
serve/config and builtin/boot tests, gofmt, go vet, lint and make check passed.
2026-10-18 00:19:23 +08:00
YHH 37ccec6449 Merge pull request #13595 from esengine/feat/12356-follow-tool-changes
feat(mcp): follow a server's tool changes instead of accepting each one
2026-10-17 09:18:23 -07:00
YHH a75e30b5de Merge pull request #13637 from xxoingr/fix/desktop-account-panel-language
fix(desktop): the account panel says its failures in the window's language
2026-10-17 09:15:38 -07:00
SivanCola 8143adc062 merge(studio): preserve Agent composition UI during upstream integration
Problem: the session-composition branch conflicted with the latest studio
settings, composer, peer-thread and runtime-construction changes.

Root cause: both branches extended shared port declarations, provider wiring
and the composer status row; failed-build cleanup also gained a separate
cancellable construction lifetime upstream.

Fix: retain both sets of settings and providers, preserve the top-row unified
Agent menu and responsive anchoring, and combine lifetime cancellation with
composition resource cleanup. Keep upstream accessible status labels and
text-size scaling. Add provider-boundary coverage showing the new image tool
cannot exceed review/research tool selections, plus updated visual evidence.

Verification: gofmt, go vet, make lint/check, required boot/builtin tests,
root and Desktop Go suites, frontend typecheck/build and 5,342 tests, Electron
unit tests, and browser light/dark checks at 1440px and 390px.
2026-10-18 00:13:18 +08:00
esengine 443d9afb6d docs(model): give the unavailable-video code its own line 2026-10-17 09:08:42 -07:00
esengine 30b2cf89de fix(model): open only the videos a tool in this process handed out
A stored reference carried a path the adapter opened as written, so a session
file that was edited, restored or imported could name a file no tool had
checked. read_video now records the path it verified under the file's hash in a
process-local registry; the adapter resolves through it and reports an
ungranted reference as video.file_unavailable.
2026-10-17 09:08:20 -07:00
SivanCola 8709b556f0 merge: integrate current studio project contracts
Problem: The unified sidebar conflicted with newer Studio worktree grouping
and text scaling changes.

Root cause: Both branches evolved project identity and row presentation from
the same baseline, including helpers extracted by the sidebar change.

Fix: Preserve workspaceContains across grouped local and SSH projects, keep
isolated session directories in open and palette routes, retain isolation and
peer-thread markers, and preserve font scaling. Include reviewed synthetic UI
captures and a release note for the relocated controls.

Verification: The integrated frontend suite passed 5381 tests. Four focused
grouped SSH/palette regressions and the production build passed. gofmt, go vet,
make lint, make check and builtin/boot tests passed without widening baselines.
2026-10-18 00:02:12 +08:00
SivanCola f2e2f1f460 feat(studio): unify Agent presets in the composer status row
Problem: Session composition controls competed with task actions in the
bottom toolbar, and the adjacent ellipsis did not explain how to manage
presets.

Root cause: Composition selection and management were rendered as separate
controls below the input. Floating menu placement also missed fold changes
published after window resize and the end of layout transitions.

Fix: Place one Agent presets menu beside the run status, show sources and
capability notes, and group management below a separator. Preserve locked
bindings and explicit new-conversation actions. Reuse the existing Picker
with per-choice action identities, disabled-row keyboard navigation and
current-state semantics; refresh shared floating anchors after reflow.
Update bilingual guides, release notes and before/after screenshot evidence.

Verification: All 633 frontend test files / 5,225 tests pass, including
selection/management, keyboard focus, pending/failure, lock/new-session,
source identity and responsive positioning regressions. Typecheck,
production build, gofmt, go vet, make lint, make check, and required
boot/builtin suites pass. Browser light/dark, wide/390px and resize recovery
were verified with synthetic fixtures.
2026-10-18 00:01:26 +08:00
xxoingr 783584d3f1 fix(desktop): the account panel says its failures in the window's language
Account caught with String(e), so a coded refusal such as
account.signin_disabled printed "Error: " and the kernel's English
fallback instead of the wording kernel.ts already has, and its
expired-request message was set without t() and had no English. The
port's accountPoll also hand-rolled its fetch and threw the raw
response text, a JSON envelope with no code to word.

Catch with reason(e) like every other panel, put the expiry message
through t() with an English entry, and send accountPoll through post0.
2026-10-18 00:01:00 +08:00
esengine 1010be9737 refactor(plugin): copy the approval map with maps.Copy 2026-10-17 09:00:29 -07:00
esengine 8d7870b067 feat(mcp): follow a server's tool changes instead of accepting each one 2026-10-17 09:00:29 -07:00
SivanCola da051abb2c feat: unify local and SSH project navigation
Problem: Machine-grouped navigation made project switching cumbersome, and
session hover actions had inconsistent selection and keyboard behavior.

Root cause: Local and remote lists owned separate presentation and opening
flows. Remote defaults, paused stream costs, mutation deduplication and focus
restoration did not consistently survive asynchronous navigation.

Fix: Present one project list with source metadata, a common project picker,
fold-all control and bounded recent-session previews. Share neutral selection,
leading pin and trailing archive actions across local and SSH conversations.
Route new sessions and quick switching through the active environment, retain
latest navigation intent, and guard pending mutations and keyboard focus.
Forward remote session flags to the owning kernel and preserve typed errors,
configured workspaces and dedicated-stream connection costs.

Verification: 5263 frontend tests passed, production TypeScript/Vite build
passed, and focused browser and real SSH navigation checks are documented in
the QA reports. Owning Go serve/remotehost/attach suites passed. Pre-commit
gofmt, go vet, make lint, make check and builtin/boot tests passed without
widening the repository lint baseline.
2026-10-17 23:48:21 +08:00
SivanCola 18eeb0c8ff fix(attachments): enforce a user-owned streaming disk ceiling
Problem: ordinary binary uploads can consume unbounded workspace disk, and the
review needs direct coverage of file versus directory grants at read consumers.

Root cause: the streaming saver copies until EOF without a byte policy; the
project-config merge must not become an authority for a user disk safeguard.

Fix: enforce a named 1 GiB default while counting real bytes, retain rooted
cleanup on overflow, and return typed ceiling/source facts. Allow user config
to set the limit or explicit zero, hold it across project merges, render it
only at user scope, and strictly read it without degraded config fallback.
Cover selected-file siblings and directory link escape through actual reference
and tool consumers. Incorporate current Studio without rewriting history.

Verification: the former 8-byte/9-byte acceptance fails first and now refuses
cleanly. Full config race and focused control/serve/builtin races, frontend
tests/build, actual Electron rejection/retry, native Windows ARM64 boundary
and cleanup regressions, pinned lint and repolint pass.
2026-10-17 23:38:14 +08:00
YHH d7707f5183 Merge pull request #13597 from esengine/feat/web-access-settings-ui
feat(desktop): web search and fetch backends are set from Settings, and consent to be billed is its own switch
2026-10-17 08:37:04 -07:00
YHH cb2059a3df Merge pull request #13634 from esengine/fix/brand-badge-outline
fix(studio): draw the STUDIO badge as an outline
2026-10-17 08:29:31 -07:00
YHH 26fcf1134a Merge pull request #13632 from esengine/fix/spxx-file-image
test(desktop): live checks that a local image and the side question are seen with the built-in browser open
2026-10-17 08:24:53 -07:00
esengine efc38208bd fix(perf): restore the web-access harness override's closing brace after the merge 2026-10-17 08:21:37 -07:00
esengine e644290329 fix(studio): draw the STUDIO badge as an outline 2026-10-17 08:03:05 -07:00
esengine 00fcacca11 Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/i18n/en.ts
#	desktop/frontend-next/src/styles/peers.css
2026-10-17 08:01:04 -07:00
SivanCola 8b94e2d5da merge: reconcile attachment intake with current Studio composer
Problem: the attachment PR conflicts with Studio's extracted composer helpers
and the newer worktree setup surface.

Root cause: both branches changed the Composer's chip type and helper ownership.

Fix: merge origin/studio, use its shared composerChips owner and worktree setup
flow, and retain cancellable attachment uploads and native path references.
Remove the redundant local chip type instead of duplicating the shared owner.

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

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

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

Only runs whose split was actually recorded now fold in, and a suite where no
run recorded one says the boundary was never split instead of printing zeros.
The all-measured medians are unchanged.
2026-10-17 19:55:50 +08:00
esengine f40b87d144 test: Windows waits sized for a loaded runner (walk re-stat window, grant-lock helper retry) 2026-10-17 04:55:39 -07:00
YHH 132deb676b Merge pull request #13611 from esengine/a5/title-wait
fix(serve): a stale title result must not overwrite a newer one, and a changed first message is a new request
2026-10-17 04:53:46 -07:00
YHH 75a4e7e2fe Merge pull request #13598 from SivanCola/codex/workspace-flow
Complete workspace preparation and branch switching / 补齐工作目录准备与分支切换流程
2026-10-17 04:47:59 -07:00
YHH a45f8abe4d Merge pull request #13552 from esengine/feat/peer-threads
feat(desktop): an icon on each session this one has written to, saying where the exchange stands
2026-10-17 04:42:48 -07:00
SivanCola 9717c4ff2c fix(session): preserve composition admission and idle handoffs
Problem: an admitted turn could fail before adding its user message and unlock
its composition. Controller-owned continuations could strand ACP config changes,
and MCP disconnect could remove code needed by a subsequent reconnect.

Root cause: the started latch followed message insertion, ACP drained only its
own prompt returns, and connection cleanup owned session package resources.

Fix: durably latch composition admission before executing or acknowledging a
turn and refuse work while the latch is pending. Notify owners at real idle
transitions and drain ACP changes through its existing serialization domain.
Separate transport state from package lifetime, retain script sibling bundles,
exclude VCS metadata, reject linked transcript exports and freeze late tool names.
Add ACP exact-binding/history tests, actual old-reader evidence, bilingual notes
and the complete light/dark wide/390px before-and-after screenshot set.

Verification: deterministic async/sync admission and failed-save regressions,
ten staged background-continuation/config-switch runs, real MCP reconnect cycles,
shared lifecycle race tests, package coverage, vet, lint and repository checks.
Frontend full suite and independent Desktop/SDK/Electron suites remain green.

Refs: #13613
2026-10-17 19:36:35 +08:00
esengine 6c53e4d6a4 refactor: walk the messages backward with slices.Backward 2026-10-17 04:35:17 -07:00
esengine 506ef7e5de style: align the native video line with the card body 2026-10-17 04:35:17 -07:00
esengine d45454a300 feat: show a natively sent video on the card and in the terminal; document it 2026-10-17 04:35:17 -07:00
esengine d0f220096b refactor: keep the video change inside the file, function and comment budgets 2026-10-17 04:35:17 -07:00
esengine d1488d5d0c feat(boot): wire native video through the real build and prove it at the vendor request boundary 2026-10-17 04:35:17 -07:00
esengine a91aaa6c4d feat(config): a user-level ceiling below the documented native video limit 2026-10-17 04:35:17 -07:00
esengine 1502533094 feat(tools): read_video hands an MP4 to a model that watches video natively 2026-10-17 04:35:17 -07:00
esengine e47de51cab feat(model): send a referenced video to endpoints that declare a protocol, and say so when one cannot 2026-10-17 04:35:17 -07:00
esengine 4b297506db feat(provider): a message can reference a video file; it rides only the request before the model answers 2026-10-17 04:35:17 -07:00
esengine 2a51c686bc feat(config): declare which models read video natively and how a file reaches them 2026-10-17 04:35:16 -07:00
YHH 189f7672c7 Merge pull request #13614 from xxoingr/fix/control-pdf-cancel-bounded-taskkill
fix(control): cancel a PDF extraction through proc's bounded tree kill
2026-10-17 04:32:12 -07:00
YHH ffe2885867 Merge pull request #13446 from chuiwenwei/fix/e2ebench-unmeasured-run-not-accounted
fix(e2ebench): account a sidecar-recovered run as a lower bound
2026-10-17 04:27:53 -07:00
esengine f11d657c32 Merge remote-tracking branch 'origin/studio' into feat/peer-threads
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 04:26:24 -07:00
SivanCola 5284c18764 fix: restore worktree preparation focus and truthful terminal states
Problem: creating a worktree dropped keyboard focus when the composer became
inert, returning to editing did not restore it, and terminal pages still
reported waiting or mapped cleanup failure to a confirmation state.

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

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

Verification: failure/cancellation interaction regressions, phase/disclosure
unit tests, real-browser focus and draft-retention checks, English dark-page
labels and shared button hierarchy, frontend typecheck/build/census, and
repository lint/check; full frontend: 634 files and 5,257 tests passed.
No Git or backend behavior changed.
2026-10-17 19:25:03 +08:00
xxoingr 2234b0f8a0 fix(control): cancel a PDF extraction through proc's bounded tree kill
setShellKillTree on Windows ran its own taskkill /F /T inside
exec.Cmd.Cancel with no time limit. #13323 bounded the taskkill in
proc.KillTree because Wait does not return until the cancel hook has,
but this hook never went through KillTree, so a PDF text extraction past
its 8 s deadline could still hang on a taskkill that never answers.

Delegate to proc.SetCancelKillsTree, the helper proc.RunCommand already
uses, so the only taskkill left is the bounded one in proc.
2026-10-17 19:12:32 +08:00
esengine 71cd039be4 Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 04:10:40 -07:00
SivanCola 4fe345ec44 feat(studio): bind immutable Agent compositions to sessions
Problem: Studio persona settings cannot select an independent tool and module
combination for each conversation, or preserve that combination across reloads.

Root cause: discovery and runtime construction follow mutable shared configuration
without a versioned session composition or a shared admission boundary.

Fix: introduce versioned scoped definitions, retained resources and authoritative
session bindings. Assemble isolated runtime selections, enforce tool ceilings,
pin MCP contracts, and share the lifecycle gate with first-prompt admission.
Expose independent HTTP, CLI, ACP and Studio management and selection controls,
with migration, native import/export, diagnostics and read-only history recovery.

Verification: composition mechanism tests, provider-wire cache tests, lifecycle
rollback tests, sensitive-boundary regressions, frontend typecheck and all 5,219
frontend tests; gofmt, vet, lint and repository checks. Browser light/dark wide
and 390px screenshots and real Electron backend interactions are included.

Refs: #13588
2026-10-17 19:09:49 +08:00
Will 27c92d2dfb fix(e2ebench): a lower-bound run stays partial across legs and out of the meter ratio
Review follow-up: Partial now accumulates across legs (a killed first leg plus a
complete second leg is still a lower bound), and the meter comparison skips a
Partial run the same way it skips an unaccounted one, since its missing tail is
not a divergence. Rebased onto current studio; the stub tests use requireShellStub
so test (windows-latest) can run.
2026-10-17 19:06:09 +08:00
SivanCola 865399363f fix: preserve MCP shutdown metadata and confine worktree rollback
Problem: hosted atomic coverage intermittently lost the MCP resource tool,
and CodeQL reported an unchecked filesystem probe during worktree cleanup.

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

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

Verification: deterministic shutdown/cache and wrong-boundary regressions;
plugin atomic coverage and race tests; boot atomic coverage; worktree/serve
race checks; native Windows rollback cases; local CodeQL path query; root
lint, Windows lint and changed-file repository checks.
2026-10-17 19:05:38 +08:00
Will d74e5ad218 fix(e2ebench): account a sidecar-recovered run as a lower bound
A leg whose final record was missing but whose <metrics>.partial snapshot was
read was marked Unaccounted, which gatherSuiteStats skips outright. That
contradicted Partial's meaning — real numbers that stop at the last snapshot
and belong in the totals — so the recovered tokens, cost and lower-bound hint
were all unreachable. Only a leg with neither record is now Unaccounted; a
recovered snapshot stays accounted and sets Partial, matching the semantics
swebench mode already uses.

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

Tests: the copied audit case that asserted unaccounted=1 for a sidecar-recovered
run encoded the wrong semantics and now asserts Partial=true, Unaccounted=false
and that 1200/80 and the cost reach the aggregates; the divergence fixture that
relied on zero tokens sets Unaccounted explicitly, and a new case pins that a
persisted zero is priced on both sides. The record-less case and the healthy-run
control are unchanged in intent.
2026-10-17 19:05:20 +08:00
YHH 6d74605fd0 Merge pull request #13609 from esengine/fix/13605-browser-steals-focus
fix(desktop): the agent's built-in browser no longer takes the keyboard from the page you are typing in
2026-10-17 04:03:12 -07:00
Will fd9792e536 i18n(desktop): translate inbox.foreign_run 2026-10-17 19:00:33 +08:00
esengine df88dde9ba fix(serve): a stale title result must not overwrite a newer one, and a changed first message is a new request 2026-10-17 03:45:53 -07:00
esengine b8fa5ed0f6 docs(release): note for the agent browser keyboard fix 2026-10-17 03:45:51 -07:00
esengine 3f6e122e30 test(desktop): stage the person's click the way the platform delivers it 2026-10-17 03:45:51 -07:00
esengine 1ce9cb84c5 test(desktop): the person's own click into the agent's page keeps the keyboard there 2026-10-17 03:45:51 -07:00
esengine 03c71ce0ee fix(desktop): hand the keyboard back a turn after the platform gives it to the agent's page 2026-10-17 03:45:51 -07:00
esengine 02bdb8b8bc wip debug 2026-10-17 03:45:50 -07:00
esengine 41318d5da9 fix(desktop): the agent's browser no longer takes the keyboard from the app page 2026-10-17 03:45:50 -07:00
esengine 5bea200f3a wip probe 2026-10-17 03:45:50 -07:00
esengine 639a53278c test(desktop): a live check of who owns the keyboard while the agent drives its browser 2026-10-17 03:45:50 -07:00
YHH d53132fdf6 Merge pull request #13603 from esengine/a5/mux-attach-baseline
fix: keep the open pane across a failed pane-list read and resume a first mux attach from where it attached
2026-10-17 03:42:47 -07:00
YHH e5d01a32ce Merge pull request #13561 from par73e/fix/11860-compaction-cache-prefix
fix(compaction): reuse eligible sent request prefixes safely
2026-10-17 03:41:27 -07:00
SivanCola f58bd3866b fix(worktree): normalize Windows rollback paths and explain recovery
Problem: failed or cancelled worktrees remain registered on Windows, and the
cleanup-failed screen leaves users without a concrete recovery procedure.
Root cause: native backslash paths are compared directly with Git porcelain's
slash paths. Cleanup uncertainty deliberately fences replacement creation.
Fix: normalize both registered and expected paths while preserving symlink
resolution and the unchanged-head guard. Cover native rollback and changed
branches, wire the Windows regressions into PR CI, and explain draft retention,
manual reconciliation, runtime closure and retry from the source folder.
Restore the existing design QA content and metadata, append only workspace
acceptance, and remove all eight screenshots from the final PR tree.
Verification: native Windows regression fails before the fix; the fixed worktree
package passes 36 tests with 6 platform skips. macOS worktree suite, focused
serve/worktree race tests, Windows lint, workflow contracts, 5,245 frontend tests,
typecheck/build, census and repository lint pass. Hosted CI still needs approval.
2026-10-17 18:37:23 +08:00
esengine a10b5f6758 Merge remote-tracking branch 'origin/studio' into feat/peers-settings-ui
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 03:28:54 -07:00
esengine f0a9ce4720 feat(serve): read and save the web search and fetch settings over HTTP 2026-10-17 03:28:46 -07:00
YHH 8e4fe65a87 Merge pull request #13580 from esengine/feat/11577-text-size
feat(desktop): an interface text size, separate from the interface zoom
2026-10-17 03:27:56 -07:00
esengine b5c2f8216d Merge remote-tracking branch 'origin/studio' into feat/peer-threads
# Conflicts:
#	.github/workflows/studio.yml
2026-10-17 03:27:24 -07:00
esengine 8b536210dd fix(acp): offer a saved choice for paid search consent and keep it on a replayed card 2026-10-17 03:25:57 -07:00
SivanCola 595f9cbbef test(boot): await discovery before disabled MCP assertions
Problem: Windows smoke reported an empty live catalog in the disabled-MCP
effect test's fresh always-loaded phase; the fixture expected one read tool.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verification: Failing original-head HTTP/path regressions pass after the fix.
Frontend tests (629 files, 5,221 tests), production build, focused race tests,
go vet, make lint and make check pass. Added a 100-root tree benchmark and a
deterministic delayed-header test; repository suites and browser guards cover
adjacent behavior. Hosted checks must validate the replacement PR head.
2026-10-17 17:28:59 +08:00
esengine 29e3125d07 Merge remote-tracking branch 'origin/studio' into feat/web-access-settings-ui 2026-10-17 02:27:36 -07:00
YHH b952a3fbd6 Merge pull request #13575 from esengine/feat/11577-fs-scale
refactor(desktop): every interface font size rides one scale, so a text-size setting can reach it
2026-10-17 02:27:33 -07:00
YHH e35748e0ba Merge pull request #13589 from esengine/feat/granted-folder-file-open
feat(workbench): open a file in a folder the user granted, read-only
2026-10-17 02:17:58 -07:00
xxoingr d0f3e547fa test(secrets): let the sink registry walk past an in-tree temp folder
TestCredentialSinkRegistry walks internal/frontend/serve, where serve's
own tests create and remove extra-* folders through tempAside. Run in
parallel with those tests, the walk can list such a folder and fail when
it is gone a moment later, the same race that failed the storage
state-path guard. Skip a vanished entry with testenv.VanishedMidWalk.
2026-10-17 17:14:33 +08:00
esengine 4db4e8b319 feat(mcp): follow a server's tool changes instead of accepting each one 2026-10-17 02:13:47 -07:00
esengine 842aa364ce Merge remote-tracking branch 'origin/feat/11577-fs-scale' into feat/11577-text-size 2026-10-17 02:12:53 -07:00
esengine 1eecbc113b Merge remote-tracking branch 'origin/studio' into feat/11577-fs-scale
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/sidepanel.css
2026-10-17 02:05:28 -07:00
SivanCola ce29aae765 docs(release): note for the Studio persona page 2026-10-17 17:03:44 +08:00
SivanCola 27434c885f feat(studio): a persona page that writes the user config for new sessions 2026-10-17 17:01:53 +08:00
SivanCola 21d8cc2fa9 feat(persona): compose a user persona into the cache-stable system prompt 2026-10-17 17:01:51 +08:00
esengine a3337ebbf7 feat(mcp): sign-in asks which host the server sends you to before the browser opens 2026-10-17 01:52:04 -07:00
SivanCola 86c3630d17 fix: group managed worktree chats under their source project
Problem: Studio lists a managed worktree and its source checkout as two
same-named sidebar projects, scattering one project's conversations.

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

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

Verification: Serve/worktree/workspace identity and builtin/boot suites,
focused race tests, Desktop Go tests, frontend tests and production build,
go vet, make lint, and make check passed. Browser interactions and an installed
macOS package verified grouping and original/isolated conversation navigation.
2026-10-17 16:51:22 +08:00
esengine b72c6b4506 feat(workbench): open a file in a folder the user granted, read-only
GET /workspace/file takes an optional root, checked against the grant list and re-vetted on read through the decision the picture route uses. The workbench carries the folder, never saves, and says why.
2026-10-17 01:51:12 -07:00
YHH 3d983ca0f3 Merge pull request #13550 from esengine/feat/13526-concurrency
feat(delegation): sub-agent concurrency and parallel writers default to no limit; depth keeps a named stop at 8
2026-10-17 01:49:28 -07:00
esengine b334cd31d9 fix(desktop): session message settings follow the kernel's refusal codes and its available flag 2026-10-17 01:42:53 -07:00
esengine 747c79dcc8 test(peers): compare wrapped errors with errors.Is 2026-10-17 01:27:16 -07:00
YHH f2b47cdfe0 Merge pull request #13567 from esengine/fix/13253-composer-feel
feat(desktop): the composer keeps the 1.x feel — a border sweep on hover and focus, no state word inside the box
2026-10-17 01:26:54 -07:00
YHH 0e740d3299 Merge pull request #13581 from esengine/a5/mux-soak-clean
test(serve): per-pane delivery metrics on the multiplexed stream and a hundred-pane soak
2026-10-17 01:26:32 -07:00
YHH 522b9f61e4 Merge pull request #13577 from esengine/feat/13540-view-image
feat(tools): view_image shows a model the picture in a workspace file, for models that read images
2026-10-17 01:22:56 -07:00
esengine c127511bf2 feat(peers): English wording for the new refusal codes 2026-10-17 01:22:34 -07:00
esengine 5c27e9c6f3 docs(release): shorten the session-messages note 2026-10-17 01:16:32 -07:00
esengine 978914283b feat(desktop): session messages are switched on from Settings, and a held message can be let through for its project 2026-10-17 01:16:32 -07:00
esengine a0a1a860e8 feat(peers): a real switch for messages between sessions, applied at the next turn 2026-10-17 01:16:13 -07:00
esengine 0bd263426b test(serve): per-pane delivery metrics on the multiplexed stream and a hundred-pane soak 2026-10-17 01:08:45 -07:00
esengine 8300f72870 feat(tools): view_image shows a model the picture in a workspace file, for models that read images 2026-10-17 01:08:33 -07:00
YHH b4e9ae74bb Merge pull request #13574 from esengine/a5/relay-harness
test(relay): drive a hosted page against a real Studio through a stand-in relay in CI
2026-10-17 01:05:49 -07:00
YHH 9a67cbaa26 Merge pull request #13558 from par73e/fix/13288-side-panel-layout
fix(desktop): collapse side questions without losing their state
2026-10-17 01:03:21 -07:00
YHH 824e406f40 Merge pull request #13576 from esengine/fix/mcp-same-name-activation
fix(mcp): projects of one repository that declare a server by the same name keep their enables side by side
2026-10-17 00:55:28 -07:00
YHH 8809ed83bb Merge pull request #13548 from esengine/feat/0b-dispatch
feat(peers): a model tool that hands a task to a session in another project, default off
2026-10-17 00:54:34 -07:00
esengine 14db96264c fix(plugin): the MCP handshake cache keeps one entry per server name and spec 2026-10-17 00:48:38 -07:00
YHH ddb3d81907 Merge pull request #13556 from par73e/fix/12750-windows-remote-script
fix(remote): launch detached Windows kernels from a script file
2026-10-17 00:47:42 -07:00
YHH 154c8a8d9e Merge pull request #13551 from esengine/a5/ds-fetch
feat(fetch): DeepSeek Fetch API as a paid fallback for web_fetch, behind the billing-consent card
2026-10-17 00:41:17 -07:00
esengine d0c3dd5212 docs(release): shorten the text-size note 2026-10-17 00:40:54 -07:00
esengine bf02618c64 feat(desktop): an interface text size, separate from the interface zoom 2026-10-17 00:40:29 -07:00
YHH 5c0ea6f90a Merge branch 'studio' into fix/13288-side-panel-layout 2026-10-17 00:36:55 -07:00
YHH 295a981d83 Merge pull request #13568 from esengine/fix/job-stalled-notice-localized
fix(jobs): word the stalled background job warning in the interface language
2026-10-17 00:31:24 -07:00
par73e 121c0cf7ca fix(desktop): collapse side questions without losing their state 2026-10-17 15:31:06 +08:00
esengine f2e3ff8433 ci(relay): build the host from the repository root, where its module is 2026-10-17 00:31:05 -07:00
esengine 0648b575d0 feat(serve): read and save the web search and fetch settings over HTTP 2026-10-17 00:30:03 -07:00
esengine 1a0732b97e fix(mcp): projects of one repository that declare a server by the same name keep their enables side by side 2026-10-17 00:27:54 -07:00
YHH e9388db72b Merge pull request #13547 from esengine/a5/ds-search-paid
feat(search): DeepSeek Search API as a paid fallback behind a billing-consent card
2026-10-17 00:26:06 -07:00
esengine 5d99a796ce test(desktop): the type-scale tests read sizes that ride the text-size scale 2026-10-17 00:21:51 -07:00
esengine af765d6b82 refactor(desktop): every interface font size rides one scale, so a text-size setting can reach it 2026-10-17 00:21:51 -07:00
esengine 4eef35a0b5 ci(relay): pass the base ref through the environment 2026-10-17 00:20:56 -07:00
YHH 720dc69c75 Merge pull request #13570 from esengine/fix/state-root-walk-wait
test(config): the churn in the walk test never reuses a directory name
2026-10-17 00:19:26 -07:00
esengine 81729ee851 test(relay): drive a hosted page against a real Studio through a stand-in relay in CI 2026-10-17 00:19:05 -07:00
esengine 28af7a784f feat(inbox): say why a queued line with a picture is waiting for the turn to end
A queued message that froze an image cannot be admitted as mid-turn guidance. The manifest now records that (images), and the queue row says so instead of 'not delivered yet'.
2026-10-17 00:16:06 -07:00
par73e bb62bb8057 fix(remote): launch detached Windows kernels from a script file 2026-10-17 15:14:22 +08:00
esengine fdb5096f0c Merge a5/ds-search-paid into a5/ds-fetch 2026-10-17 00:11:34 -07:00
esengine e930472cf7 Merge studio into a5/ds-search-paid 2026-10-17 00:08:48 -07:00
esengine a2d77f97f5 test(boot): a red resource-tool run names what the schema cache held 2026-10-17 00:07:14 -07:00
esengine d6302bfae2 test(config): the churn in the walk test never reuses a directory name 2026-10-17 00:04:07 -07:00
YHH 8bf412accd Merge pull request #13564 from esengine/fix/mux-open-timeout
fix(studio): panes read their own event streams when the shared one never opens
2026-10-16 23:50:55 -07:00
YHH 72de55dc11 Merge pull request #13490 from xxoingr/test/storage-guard-vanished-walk
test(storage): let the state-path guard walk past an in-tree temp folder
2026-10-16 23:49:20 -07:00
esengine 0264316a83 fix(jobs): word the stalled background job warning in the interface language
recordStalled emitted a notice with no code, so the frontend showed the kernel's English sentence as is. It now carries job_stalled and a JobNotice payload with the quiet span, like the other job notices; the English text stays the fallback.
2026-10-16 23:48:02 -07:00
esengine a0efc71493 feat(desktop): the composer keeps the 1.x feel — a border sweep on hover and focus, no state word inside the box 2026-10-16 23:41:10 -07:00
YHH 507debd468 Merge branch 'studio' into feat/13526-concurrency 2026-10-16 23:32:00 -07:00
YHH 4e57ca2009 Merge branch 'studio' into feat/0b-dispatch 2026-10-16 23:31:56 -07:00
esengine 99ec610785 test(studio): every way a hub is built has to put a kernel frame into the pane 2026-10-16 23:28:26 -07:00
YHH f19d2fc208 Merge pull request #13545 from esengine/a5/ds-search
feat(search): web_search can answer from the user's own SearXNG, through an ordered backend chain
2026-10-16 23:27:12 -07:00
esengine faa5520f45 docs(release): shorten the open-wait note 2026-10-16 23:26:46 -07:00
esengine 9ab3feecde docs(release): note for the shared-stream open wait 2026-10-16 23:26:28 -07:00
esengine b6e34eb144 fix(studio): panes read their own event streams when the shared one never opens 2026-10-16 23:25:50 -07:00
YHH 801f53bf46 Merge pull request #13555 from esengine/feat/13526-workspaces
feat(desktop): the sidebar remembers as many projects as you open; desktop.max_workspaces is your own limit
2026-10-16 23:22:43 -07:00
YHH 28fc37a14a Merge pull request #13557 from esengine/fix/13539-side-panel-anchor
fix(desktop): the side-conversation panel opens above the composer, inside its pane
2026-10-16 23:22:35 -07:00
YHH a51bb50d07 Merge pull request #13559 from esengine/fix/project-mcp-tests-share-cache
test(boot): run the three tests that share the docs-helper cache entry one after another
2026-10-16 23:19:54 -07:00
YHH 91aa427a7c Merge pull request #13562 from tongsailong/fix/web-remote-mux
fix(web): preserve live remote updates with older Studio kernels
2026-10-16 23:15:16 -07:00
阿稳 b543d8629e fix(web): keep remote event streams compatible with older kernels
Hosted Web enables the multiplexed event stream for every pane, but a
Studio kernel without GET /events/mux answers it with the SPA shell. The
mux client waits for the stream's open frame to name its connection, so
no pane ever subscribed and none fell back to its own stream: the page
kept loading and stopped receiving events until a reload rebuilt it from
a snapshot.

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

The event contract is unchanged: attach, resume, cursor, watermark and
the pending-prompt attach all keep the code paths PR #13499 fixed.
2026-10-17 14:02:37 +08:00
esengine 7f5cdad445 test(boot): run the three tests that share the docs-helper cache entry one after another 2026-10-16 22:37:53 -07:00
SivanCola f18883380c fix(attachments): retain rooted storage ownership through upload cleanup
Problem: CodeQL reports uncontrolled paths in streamed upload cleanup and
attachment creation. A replaced storage directory can redirect failed-upload
cleanup to an external same-name file; a linked .reasonix parent can escape too.

Root cause: filename validation and path-based writes/removals do not preserve
directory identity across filesystem changes during I/O.

Fix: use os.Root for workspace-contained directory/file creation, share an owned
file lifecycle between streamed and legacy byte writes, and retain the directory
handle for failure cleanup. Publish the existing untracked marker atomically
through the same rooted directory rather than reopening its pathname.

Verification: full control/serve/fileutil suites, focused race tests, vet,
pinned linter and repolint passed. Native Windows 11 ARM64 regressions passed
with directory junctions and protected cleanup, plus large bounded streaming.
The independent replay test failure also reproduces with the PR's Go changes
removed through a base-source overlay; no replay behavior or CI gate is changed.
2026-10-17 13:08:06 +08:00
YHH 2b3b7c4d0c Merge pull request #13554 from esengine/docs/studio-2.43.0-notes-2
docs(release): fold the remaining fragments into the studio 2.43.0 notes
2026-10-16 21:55:15 -07:00
YHH 2d21bbc582 Merge pull request #13553 from esengine/fix/gitcmd-expired-ctx
fix(gitcmd): a git stopped by the deadline is the context's error, not a refusal of the folder
2026-10-16 21:52:58 -07:00
esengine a49133cc46 fix(desktop): the side button draws its own panel into the composer and takes the focus back 2026-10-16 21:50:26 -07:00
esengine a481f2d0b1 fix(desktop): the side-conversation panel opens above the composer, inside its pane 2026-10-16 21:45:42 -07:00
esengine 63618012a3 feat(desktop): the sidebar remembers as many projects as you open; desktop.max_workspaces is your own limit 2026-10-16 21:39:16 -07:00
esengine 83437904bb docs(release): fold the remaining fragments into the studio 2.43.0 notes
Fold the fragments merged after the first notes commit into 2.43.0.md and remove every consumed fragment, so the release commit leaves release-notes/studio/unreleased empty.
2026-10-16 21:38:42 -07:00
esengine ebe180c771 review: refuse names that cannot be public before a URL goes to DeepSeek; say the query string is sent as written 2026-10-16 21:34:57 -07:00
esengine 9fb2bbb542 review: refuse names that cannot be public before a URL goes to DeepSeek; say the query string is sent as written 2026-10-16 21:33:48 -07:00
esengine ccb2a7ad64 fix(gitcmd): a git stopped by the deadline is the context's error, not a refusal of the folder 2026-10-16 21:32:13 -07:00
esengine 1dffc620bb Merge remote-tracking branch 'origin/studio' into feat/peer-threads
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/main.tsx
2026-10-16 21:28:53 -07:00
esengine 38c0e02514 refactor(desktop): the first-run read leaves App so the file stays inside its budget 2026-10-16 21:28:41 -07:00
esengine db06675c87 feat(delegation): sub-agent concurrency and parallel writers default to no limit; depth keeps a named stop at 8 2026-10-16 21:21:30 -07:00
esengine 955040469a feat(desktop): an icon on each session this one has written to, saying where the exchange stands 2026-10-16 21:20:53 -07:00
YHH 97c9c5b24e Merge pull request #13536 from esengine/fix/13467-gate-stops-readiness
fix(control): a recorded await_user ends the readiness rounds the host would start
2026-10-16 21:16:41 -07:00
YHH d74c6bec95 Merge pull request #13502 from esengine/feat/13478-await-auto
feat(control): continue a task list handed back to the user once the ask timeout passes unanswered
2026-10-16 21:14:47 -07:00
YHH 639b852ed1 Merge pull request #13537 from esengine/feat/mux-page
feat(studio): carry every local pane on the hub's one multiplexed event stream
2026-10-16 21:14:28 -07:00
YHH 55b61653ef Merge pull request #13546 from esengine/feat/card-grammar-all
feat(desktop): card grammar slices 3–5 and the one-row brand lockup
2026-10-16 21:14:27 -07:00
YHH e997e19599 Merge pull request #13535 from esengine/fix/lease-wait-flake
test(workspacelease): wait for the first wait report instead of a fixed deadline
2026-10-16 21:10:46 -07:00
esengine 427d1a24b4 feat(peers): where each hand-off stands: waiting, working, replied or declined 2026-10-16 21:06:21 -07:00
esengine 16d82778ff feat(fetch): DeepSeek Fetch API as a paid fallback for web_fetch, behind the billing-consent card 2026-10-16 21:05:11 -07:00
esengine 88260228ad Merge origin/studio into feat/mux-page
Resolved by taking the page commit as applied on top of studio, where the
stream budget it builds on already landed under a different commit.
2026-10-16 20:57:50 -07:00
YHH c13c7fde60 Merge branch 'studio' into fix/lease-wait-flake 2026-10-16 20:56:10 -07:00
YHH 436726ef56 Merge branch 'studio' into fix/13467-gate-stops-readiness 2026-10-16 20:56:07 -07:00
YHH 52a10d8793 Merge branch 'studio' into feat/13478-await-auto 2026-10-16 20:56:03 -07:00
YHH 880a9e0b94 Merge branch 'studio' into feat/card-grammar-all 2026-10-16 20:55:58 -07:00
esengine a317b0998e feat(peers): a model tool that hands a task to a session in another project, default off 2026-10-16 20:54:32 -07:00
YHH 58e94afbf3 Merge pull request #13542 from esengine/test/replay-wait-for-log
test(serve): wait for the ask to reach the log before asserting what a resume replays
2026-10-16 20:53:33 -07:00
esengine f61017127a review: snippet cut test, cap rationale, privacy note for always allow 2026-10-16 20:49:30 -07:00
esengine 49c08b17c7 feat(search): DeepSeek Search API as a paid fallback behind a billing-consent card 2026-10-16 20:43:48 -07:00
SivanCola fc65229584 test(studio): validate Windows intake and document streaming bounds
Problem: Review requested a user-visible release fragment and clearer memory
bounds. The live regression also assumed new-session activation must create
a second runtime, which is false when an existing blank pane is reused.

Root cause: A workspace may already own a reusable blank pane, particularly
when native Windows roots have matching canonical spelling. The old setup
waited for a POST and a new pane identity instead of editor readiness.

Fix: Wait for boot completion at a stable viewport, allow a blank-pane reuse,
and preserve assertions for the captured workspace, one shared intake route,
saved upload size and four ready attachments. Allow an explicit test host
binary. Add bilingual release notes and state that memory, not total disk
consumption, is bounded by streaming.

Verification: Windows 11 ARM64 control, stream, HTTP upload/image-limit and
exact-file read-tool regressions passed. Electron 44.2.0 exercised picker,
paste and drop of a 111 MiB file and a 65 MiB streamed File with four ready
attachments. Release-note tests, repository checks and script syntax passed.
2026-10-17 11:42:29 +08:00
YHH dbb6ded60a Merge pull request #13483 from esengine/fix/history-search-test-race
test(history): close the shared catalog before resetting it in the session-search test
2026-10-16 20:42:07 -07:00
esengine 5304594bb0 Merge remote-tracking branch 'origin/studio' into feat/card-grammar-all 2026-10-16 20:39:11 -07:00
YHH cd81a8aea4 Merge branch 'studio' into fix/13467-gate-stops-readiness 2026-10-16 20:38:34 -07:00
YHH b6b6001e06 Merge branch 'studio' into feat/13478-await-auto 2026-10-16 20:38:29 -07:00
YHH 4e4597c630 Merge branch 'studio' into test/replay-wait-for-log 2026-10-16 20:38:25 -07:00
esengine 31370255fb test(studio): the card shell guard counts four cards now that a remembered fact is a row 2026-10-16 20:38:20 -07:00
YHH 7d870bdef3 Merge pull request #13533 from esengine/a5/busy-mux
feat(serve): carry every local pane on one multiplexed event stream
2026-10-16 20:36:39 -07:00
YHH 370b5a252c Merge pull request #13534 from esengine/fix/mcp-add-persists-schema
fix(plugin): a server connected by hand leaves its handshake in the cache
2026-10-16 20:34:00 -07:00
esengine 4c6ea3b000 docs(release): one fragment for the card grammar slices and the brand lockup 2026-10-16 20:31:08 -07:00
esengine c23b749298 Merge remote-tracking branch 'origin/feat/brand-lockup-badge' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 20:30:58 -07:00
esengine c37f2656d8 Merge remote-tracking branch 'origin/feat/card-grammar-slice5' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:30:58 -07:00
esengine 1cb9b4300c Merge remote-tracking branch 'origin/feat/card-grammar-slice4' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:30:57 -07:00
esengine 1f424fbff0 Merge remote-tracking branch 'origin/feat/card-grammar-slice3' into feat/card-grammar-all
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 20:30:57 -07:00
SivanCola 6f1e59b750 test(studio): certify upload cancellation signal listeners
Problem: The full frontend suite rejects the upload abort listener as an
unresolved interaction registration.

Root cause: The syntax-based census does not infer a Request.signal member's
AbortSignal type and cannot distinguish it from a possible user-input target.

Fix: Keep an explicitly typed upload signal and teach the shared receiver
certifier to recognize scoped AbortSignal bindings. Preserve unresolved
receivers and refuse borrowing an outer signal type across shadowing.
Do not add a file exception or raise any census or lint ceiling.

Verification: Abort receiver, actions census, cloud upload and attachment
regressions, frontend typecheck, production build and repository checks pass.
2026-10-17 11:15:10 +08:00
esengine 3ae1b7baef fix(i18n): English wording for the search backend notices 2026-10-16 20:11:48 -07:00
esengine 0d68e0a986 feat(search): web_search can answer from the user's own SearXNG, through an ordered backend chain 2026-10-16 20:11:48 -07:00
YHH 19640573dc Merge pull request #13516 from esengine/feat/13458-task-description
feat(tools): bash takes an optional description that names a background job
2026-10-16 20:09:12 -07:00
SivanCola 7f53665cd0 fix(studio): reference native files and stream attachment uploads
Problem: Large ordinary file attachments cause avoidable whole-file
allocations and exceed the legacy JSON attachment transport limits.

Root cause: Picker and paste intake Base64-encode files even when the shell
already knows their native paths. Pathless uploads also pass through JSON.

Fix: Share native path references across picker, paste and drop. Stream raw
ordinary-file bodies to bounded disk writes and clean up failed or cancelled
uploads. Grant external-file access only to the selected file. Preserve
image, legacy JSON and cloud relay limits; bound cloud intake before
materializing an oversized body. Document the transport and live regression.

Verification: Owning Go package suites, focused race tests, go vet, lint,
repository checks, frontend typecheck, production build and attachment
regressions passed on the original tested baseline. Real macOS Electron
exercised native picker, paste and drop with a 111 MiB file and an exact
65 MiB pathless upload. Windows native runtime verification remains open.
2026-10-17 11:08:58 +08:00
esengine 79e0e9e73d fix(plugin): a server connected by hand leaves its handshake in the cache 2026-10-16 20:08:11 -07:00
esengine ec014ebd0a Merge remote-tracking branch 'origin/studio' into feat/brand-lockup-badge
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 20:05:02 -07:00
esengine 2b103885e8 Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice5
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:04:26 -07:00
esengine b46a1e11ec Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice4
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/main.tsx
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:03:37 -07:00
esengine 3306732c50 Merge origin/studio into a5/busy-mux 2026-10-16 20:03:09 -07:00
esengine ca2da5c53a Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice3
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 20:02:28 -07:00
esengine b5d3c529f2 test(serve): wait for the ask to reach the log before asserting what a resume replays 2026-10-16 19:56:37 -07:00
YHH 0cac9ae4b9 Merge pull request #13518 from esengine/perf/13176-cold-list
perf(sessionstore): read the sidecars of a cold session folder on a bounded pool
2026-10-16 19:47:43 -07:00
esengine e5d3291c03 fix(jobs): bound the model-written description for display 2026-10-16 19:38:28 -07:00
YHH 03f9c22072 Merge pull request #13538 from esengine/fix/tidy-perf-guards
test(studio): the receipt card guard turns the card on, and the reask probe draft goes
2026-10-16 19:36:25 -07:00
esengine dbfaf19aae Merge remote-tracking branch 'origin/studio' into feat/13458-task-description
# Conflicts:
#	.github/workflows/studio.yml
2026-10-16 19:28:52 -07:00
esengine f375354301 chore(studio): drop the committed perf build output and ignore every dist-perf directory 2026-10-16 19:28:47 -07:00
YHH 6fbeb4ce21 Merge pull request #13499 from tongsailong/fix/v2.42.0-dev
fix(web): hand pending prompts to late clients and stop false stale reports
2026-10-16 19:22:57 -07:00
esengine dfb0ff4bc4 test(studio): the receipt card guard turns the card on, and the reask probe draft goes 2026-10-16 19:16:19 -07:00
esengine 1f5aa55677 fix(control): a recorded await_user ends the readiness rounds the host would start 2026-10-16 19:14:39 -07:00
esengine 21200e3312 test(workspacelease): wait for the first wait report instead of a fixed deadline 2026-10-16 19:07:58 -07:00
YHH 3dcc52b6d2 Merge pull request #13529 from esengine/feat/13526-side-unlimited
feat(side): no cap on open side conversations; agent.max_side_sessions is the user's own limit
2026-10-16 19:07:37 -07:00
YHH 872ed93d49 Merge branch 'studio' into perf/13176-cold-list 2026-10-16 19:07:05 -07:00
YHH 574723954c Merge branch 'studio' into fix/history-search-test-race 2026-10-16 19:07:01 -07:00
YHH f5e9b01e91 Merge pull request #13496 from esengine/feat/card-grammar-slice2
feat(desktop): draw host notices as a one-line bar, severity in the glyph ink only
2026-10-16 19:05:43 -07:00
esengine 477ce369bb feat(studio): carry every local pane on the hub's one multiplexed event stream 2026-10-16 19:05:41 -07:00
esengine f46ac4f856 feat(control): continue a task list handed back to the user once the ask timeout passes unanswered 2026-10-16 19:05:39 -07:00
esengine 0d64b3ec14 fix(desktop): only a few panes hold a live event stream, the rest resume from their cursor 2026-10-16 19:02:00 -07:00
YHH 4aae38f890 Merge pull request #13522 from esengine/fix/peers-body-read
fix(peers): reduce a hand-written message body where the spool is read
2026-10-16 18:57:36 -07:00
YHH 1778e4cc43 Merge pull request #13520 from esengine/fix/peers-read-side-names
fix(peers): reduce the names in another session's record where they are read
2026-10-16 18:54:12 -07:00
esengine 6a2e9af854 test(serve): a retired mux subscription leaves no goroutine behind 2026-10-16 18:53:17 -07:00
esengine 5e5996d059 Merge remote-tracking branch 'origin/studio' into feat/brand-lockup-badge 2026-10-16 18:49:52 -07:00
esengine f1a5b77c80 Merge remote-tracking branch 'origin/studio' into feat/card-grammar-slice5
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 18:49:20 -07:00
YHH 6397a75614 Merge pull request #13531 from esengine/fix/13491-stream-limit
fix(desktop): only a few panes hold a live event stream, the rest resume from their cursor
2026-10-16 18:49:09 -07:00
esengine 7ad1771a91 feat(desktop): say what a shipped report is on the feedback level card
The card showed "已落地 N 条" without saying what counts. One muted line
under the progress bar now states it.
2026-10-16 18:48:35 -07:00
esengine 07705bb0d1 Merge studio into feat/card-grammar-slice4
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
#	desktop/frontend-next/src/ui/cards/RememberCard.tsx
2026-10-16 18:46:19 -07:00
esengine 618807518b Merge studio into feat/card-grammar-slice3
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
2026-10-16 18:45:16 -07:00
esengine 95936b5167 feat(serve): carry every local pane on one multiplexed event stream
A window that gives each pane its own EventSource spends one HTTP/1.1 connection per pane, and a browser origin holds six, so the sixth stream starves every stop, submit and read behind it. GET /events/mux is one stream for all panes of a window; PUT/DELETE /events/mux/{conn}/panes/{id} subscribe a pane with its own cursor. /events and /events/replay are unchanged.
2026-10-16 18:44:09 -07:00
esengine 84199bf82d Merge studio into feat/card-grammar-slice2
# Conflicts:
#	.github/workflows/studio.yml
#	desktop/frontend-next/src/styles/LAYOUT.md
#	desktop/frontend-next/src/ui/cards/NoticeCard.tsx
2026-10-16 18:42:12 -07:00
YHH d40dc23eab Merge pull request #13504 from esengine/feat/13478-egress-open
feat(sandbox): a user setting that stops the egress question for hosts outside allowed_domains
2026-10-16 18:35:20 -07:00
esengine 450243769c fix(desktop): only a few panes hold a live event stream, the rest resume from their cursor 2026-10-16 18:34:32 -07:00
esengine 63a4755b12 feat(side): no cap on open side conversations; agent.max_side_sessions is the user's own limit 2026-10-16 18:27:07 -07:00
esengine b13f478809 feat(sandbox): a user setting that stops the egress question for hosts outside allowed_domains 2026-10-16 18:25:27 -07:00
YHH bcf0f3f5cb Merge pull request #13515 from esengine/perf/13176-tree-versions
perf(serve): the sidebar tree no longer resolves or opens every session on each refresh
2026-10-16 18:24:27 -07:00
YHH 4296a8365b Merge pull request #13524 from esengine/fix/13472-chevron-risk-colour
fix(desktop): colour the permission picker's chevron and icon like its label
2026-10-16 18:11:01 -07:00
YHH f8747c5abd Merge pull request #13521 from esengine/fix/13345-storage-shared-root
fix(storage): do not credit a root with the entries a neighbour declares
2026-10-16 18:04:56 -07:00
esengine bc89385120 fix(desktop): colour the permission picker's chevron and icon like its label
In the risk state the chevron kept its grey and, in the light theme, the
warning icon used a lighter orange than the label.
2026-10-16 17:56:56 -07:00
esengine 20eb088e61 fix(peers): reduce a hand-written message body where the spool is read 2026-10-16 17:53:33 -07:00
YHH d596bf63c2 Merge pull request #13517 from esengine/fix/13513-idle-cost-placeholder
fix(desktop): show no session cost before anything has been priced
2026-10-16 17:52:36 -07:00
阿稳 6560ae0c62 fix(web): keep the tree stream's attach asking past the watermark
RemoteEventSource now leaves the attach cursor empty so the kernel hands a late client the pending prompt. The tree stream shares that class but reads the tree rather than history, so an empty attach for it re-read the tree on every poll and failed tree_events.test.ts. The attach query is now chosen once from the path: the main stream attaches with no cursor, the tree attaches past the watermark as it always did.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verification: Twelve new composer regressions reproduce the old behavior
and cover files, directories, nested Tab descent, pointer acceptance,
escaped paths, send preferences, Shift keys, IME, and Escape. The full
frontend suite passes (609 files, 5095 tests), alongside typecheck, build,
UI census, make check, go vet, and make lint. Browser interaction was
verified with the local MockPort workspace.
2026-10-16 22:14:53 +08:00
esengine ae30efbfca feat(desktop): messages between sessions in Studio (S3 of #11376) 2026-10-16 07:13:42 -07:00
esengine 23eafa84f5 fix(shellsafe): git config reads one key and never a credential; remote show needs -n 2026-10-16 06:55:36 -07:00
esengine 07a5c0d8d3 feat(shellsafe): classify read-only git listings as reads 2026-10-16 06:38:49 -07:00
YHH 1fbb650324 Merge pull request #13450 from esengine/feat/shellsafe-gh-readonly
feat(shellsafe): classify read-only gh calls as reads
2026-10-16 06:20:23 -07:00
YHH e3be160d52 Merge branch 'studio' into feat/shellsafe-gh-readonly 2026-10-16 06:04:38 -07:00
YHH eaff7c8ae2 Merge pull request #13430 from esengine/feat/11376-s2-peer-send
feat(peers): send messages to another session, /peer and HTTP verbs
2026-10-16 05:59:00 -07:00
YHH 7f37c01a91 Merge pull request #13445 from esengine/feat/tool-result-to-end
feat(desktop): bring a tool call's result to the end of the transcript
2026-10-16 05:56:04 -07:00
esengine 7a41081c4f feat(desktop): bring a tool call's result to the end of the transcript
A button on a finished tool card repeats that call's own recorded card in a
section at the end of the transcript, as a view only: nothing is stored or
sent, and the original stays where it happened (#13394).
2026-10-16 05:44:38 -07:00
esengine 7dace9cfb0 Merge studio into feat/11376-s2-peer-send 2026-10-16 05:43:03 -07:00
YHH a4cf9d0591 Merge pull request #13437 from chuiwenwei/fix/sessionstore-unreadable-sidecar-not-absent
fix(sessionstore): an unreadable sidecar is not an absent one
2026-10-16 05:40:45 -07:00
YHH 4c549631c5 Merge pull request #13448 from esengine/fix/e2ebench-windows-stubs
test(e2ebench): skip the shell-stub tests on Windows
2026-10-16 05:39:31 -07:00
esengine 42a1d24662 fix(shellsafe): a gh filter that loads jq modules is not a read 2026-10-16 05:39:12 -07:00
esengine 783fef7409 fix(shellsafe): a gh filter that can reach the process environment is not a read 2026-10-16 05:36:44 -07:00
esengine f5116c49fa docs(release-notes): shorten the note for #13450 2026-10-16 05:33:11 -07:00
esengine 423c906b25 docs(release-notes): note for #13450 2026-10-16 05:32:48 -07:00
esengine 82e8118db2 feat(shellsafe): classify read-only gh calls as reads 2026-10-16 05:31:41 -07:00
esengine 0836d7a2c6 test(e2ebench): skip the shell-stub tests on Windows 2026-10-16 05:18:27 -07:00
Will 6ac611bdf1 fix(sessioninbox): the lock-free read may not adopt another run's queue
The transaction-free fallback loaded the disk manifest without the RunID
isolation the locked path applies, so a queue another run (or a previous run)
left behind could be dispatched while the lock was momentarily unavailable.
Refuse a foreign manifest with ErrForeignRun, which the dispatcher already maps
to a retry, and give the frontend a coded refusal for it.
2026-10-16 20:16:21 +08:00
YHH 06a4b4622a Merge pull request #13447 from esengine/fix/13441-notice-english
fix(i18n): give the fifteen untranslated notices English and guard the table
2026-10-16 05:13:39 -07:00
YHH 1a1b953233 Merge pull request #13443 from esengine/fix/provider-key-origin
fix(providers): a draft address on another origin never borrows the saved key
2026-10-16 05:01:43 -07:00
YHH 7c406be221 Merge pull request #13440 from esengine/fix/import-keeps-source-titles
fix(sessionstore): an imported 1.x conversation keeps the title 1.x showed
2026-10-16 04:59:11 -07:00
esengine b456bd34e1 fix(i18n): give the fifteen untranslated notices English and guard the table 2026-10-16 04:57:09 -07:00
YHH b7b7687e17 Merge pull request #13442 from esengine/fix/13433-host-exit-keeps-reason
fix(host): a failing exit keeps its crash file with the code and the reason
2026-10-16 04:47:16 -07:00
esengine 7eeebe89be fix(peers): a sent message carries no process writer id, which can exceed the id limit 2026-10-16 04:42:45 -07:00
YHH c929e444b9 Merge pull request #13427 from chuiwenwei/fix/plugin-cache-upgrade-not-silently-unpersisted
fix(plugin): a legacy cache upgrade is not claimed unless it reached disk
2026-10-16 04:40:09 -07:00
YHH 6b383ad920 Merge pull request #13423 from esengine/feat/cli-ships-page
feat(release): the CLI archives carry the web page
2026-10-16 04:38:19 -07:00
YHH 8cb8af0c14 Merge pull request #13428 from chuiwenwei/fix/e2ebench-unmeasured-run-not-accounted
fix(e2ebench): report a suite run whose metrics never landed as unmeasured
2026-10-16 04:38:17 -07:00
esengine 8cf0b5e94f fix(host): a failing exit keeps its crash file with the code and the reason 2026-10-16 04:36:41 -07:00
esengine c0aad054f4 fix(providers): a draft address on another origin never borrows the saved key 2026-10-16 04:33:13 -07:00
YHH d952d8c7fd Merge pull request #13426 from chuiwenwei/fix/sessionstore-unreadable-checkpoint-blocks-rewrite
fix(sessionstore): refuse a degraded rewrite when the checkpoint cannot be read
2026-10-16 04:32:46 -07:00
YHH 1e312b66d8 Merge pull request #13425 from chuiwenwei/fix/stats-flush-reports-write-failure
fix(stats): Flush reports the first append that failed
2026-10-16 04:32:12 -07:00
esengine eb384283d1 fix(sessionstore): an imported 1.x conversation keeps the title 1.x showed 2026-10-16 04:29:55 -07:00
Will 457c68e30d fix(sessioninbox): surface a queue read failure instead of an empty one
NextQueued returns (InboxItemMeta, bool, error) and, when the disk
transaction is unavailable, reads the published manifest without the lock
(commits land by atomic rename, so that snapshot is complete). The
dispatcher maps a read error to inboxDispatchRetry instead of reporting
the inbox idle.

Narrowed per review: the permanent 1s long stop (armInboxDispatchLongStop,
its constant, its arming and its stop/reset wiring) is removed, so the
retry schedule past the existing bounded burst is exactly what it was
before this branch. The dispatcher audit case is rewritten to assert the
in-scope contract - a failed queue read schedules a retry and does not
report idle, and the durable item survives - rather than delivery after
the burst was exhausted, which was the removed policy.
2026-10-16 19:29:33 +08:00
YHH 5829f2a1c5 Merge pull request #13422 from esengine/feat/upgrade-refreshes-page
feat(cli): upgrade keeps the page beside the binary in step with the kernel
2026-10-16 04:25:52 -07:00
Will 71c7cacf55 fix(sessioninbox): report a queue that could not be read instead of an empty one
NextQueued discarded beginDiskTransactionLocked's error and answered from
the in-memory snapshot, so a transaction that could not run — another
process holding the lock, EACCES, ENOSPC, a transient EIO — looked exactly
like an empty queue. dispatchInboxOnce read that as inboxDispatchIdle and
stopped with the durable item still queued, permanently.

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

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

A read error maps to inboxDispatchRetry, so an unreadable queue retries
instead of ending dispatch.
2026-10-16 19:07:03 +08:00
esengine 8370d9cf45 feat(peers): send messages to another session, /peer and HTTP verbs (S2 of #11376) 2026-10-16 04:05:54 -07:00
esengine bfba90b356 feat(cli): upgrade keeps the page beside the binary in step with the kernel
When an install already has frontend-next/dist beside the binary, upgrade
reads the page out of the verified archive, validates it in memory, swaps it
in before the binary and puts it back if the binary does not land (#13406).
2026-10-16 04:04:18 -07:00
Will 2a3756b9e5 fix(sessionstore): an unreadable sidecar is not an absent one
ListSessionOrder keeps a row whose branch-meta sidecar cannot be read, so one
broken sibling cannot hide a folder, but every column of that row is then a zero
value. recoveryArchiveLineage consumed that row as 'not a recovery copy of this
conversation' and SetSessionLineageArchived reported success while a copy of the
row stayed active and listed on its own. The lineage walk now reads each listing
row's sidecar itself and refuses the transaction, so the archive endpoint
reports the read failure instead of a partial success.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #13295
2026-10-15 16:48:04 -07:00
YHH 55c3d81bbc Merge pull request #13339 from esengine/docs/release-studio-2.42.0
docs(release): Studio 2.42.0 notes
2026-10-15 16:40:16 -07:00
esengine ffd73e115e docs(release): Studio 2.42.0 notes 2026-10-15 16:28:01 -07:00
YHH 1a33ce5692 Merge pull request #13337 from esengine/fix/13335-lease-domain-notice
fix(workspacelease): say which git repository folds two folders into one write domain
2026-10-15 14:06:21 -07:00
esengine 6e2ed58a34 fix(workspacelease): say which git repository folds two folders into one write domain 2026-10-15 13:56:32 -07:00
YHH 2be1dba2da Merge pull request #13336 from esengine/fix/projection-drop-attribution
fix(control): say why a saved fold was not reused when a session is opened
2026-10-15 13:14:19 -07:00
esengine a717691db9 docs(release-notes): shorten the fold-not-reused fragment 2026-10-15 13:01:14 -07:00
esengine dfdbfbd4d8 fix(control): say why a saved fold was not reused when a session is opened 2026-10-15 12:59:32 -07:00
YHH 7b7af1a78c Merge pull request #13334 from esengine/fix/overview-cell-align
fix(studio): overview figures on one row share a baseline
2026-10-15 12:57:08 -07:00
YHH 59526e5156 Merge pull request #13333 from esengine/docs/release-note-gaps-1016
docs(release-notes): fragments for the expired-pin fix and the branch-switch latency
2026-10-15 12:54:14 -07:00
esengine 935200d9e8 fix(studio): overview figures on one row share a baseline 2026-10-15 12:46:41 -07:00
YHH 9006ff804c Merge pull request #13332 from esengine/feat/overview-context
feat(studio): the context window in the session overview
2026-10-15 12:45:42 -07:00
esengine c57cf43aeb docs(release-notes): fragments for the expired-pin fix and the branch-switch latency 2026-10-15 12:43:14 -07:00
esengine a9e2a12927 docs(release-notes): shorten the overview window fragment 2026-10-15 12:30:49 -07:00
esengine 97dee44a3d fix(studio): the overview's hand-over button has its own action value 2026-10-15 12:28:53 -07:00
esengine 2192bfb20c feat(studio): the context window in the session overview, stated as the next request's content 2026-10-15 12:28:09 -07:00
YHH 5516d5a7d8 Merge pull request #13331 from esengine/fix/context-card-guard
test(studio): scope the context-card hover guard to the context anchor
2026-10-15 12:18:39 -07:00
esengine d10871190c test(studio): scope the context-card hover guard to the context anchor 2026-10-15 12:09:08 -07:00
YHH ccdd8a721f Merge pull request #13330 from esengine/feat/session-overview
feat(studio): a session overview behind the cache meter, each figure beside what it counts
2026-10-15 12:03:58 -07:00
SivanCola 1d90810a02 feat(studio): configure session worktrees and unify composer menus
Problem: concurrent tasks need separate code directories rooted at a chosen
branch, and composer configuration menus use inconsistent visual scales.

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

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

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

Fixes #13264
2026-10-15 23:56:52 +08:00
xxoingr c02541014b fix(cli): stop offering the retired light execution setting
agentpreset.Normalize maps "light" and "economy" to balanced, and All()
lists balanced and delivery; light's own policy no longer exists. The CLI
still put light forward as a choice, in seven hand-written strings: the
--preset help of run, tui and serve, the --profile deprecation warning of
each, and parseRuntimeProfile's refusal ("want light, balanced, or
delivery"). The guides went further and described a Light policy (direct-
first planning, targeted verification) that nothing enforces, so a user
who picked it got balanced without being told.

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

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

This is the failure behind the unsealed evidence bundles on Windows CI.
TestPlanCriterionIsVerifiedByItsStepsCommands (studio run 38664267053)
reported "bundle not sealed: failure trusted_state.unwritable, contract
failure """ after 3.09 s: sealShadowBundle's two-second sealWait covers
the contract seal and object writes as well as the lock wait, so slow
writes spent it, and the bundle's own Append then met a free stream lock
with an expired ctx. With the first write slowed by 2.2 s, 1 of 6 turns
ended unsealed with that exact signature before this change, 0 of 6 after.
2026-10-15 18:14:22 +08:00
Will 9044392d84 fix(session): audit every automatic-recall suppression identically
recallTail had three outcomes and only two reached the audit channel: a
carried memory note and a normal recall each recorded a content-free
decision, while the retrieval ablation switch returned silently and left
lastRecall pointing at an earlier turn. A trajectory could not tell
"retrieval switched off" from "recall found nothing" from "suppressed by a
carried note".

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

The ablated turn also records lastRecall, matching the note path: the
diagnostic surface is defined as the last real turn's decision, and that
turn did decide not to retrieve.
2026-10-15 15:13:33 +08:00
Will c8054adf8e fix(checkpoint): keep turn numbering above unreadable checkpoint files
load() dropped a turn file that existed but would not read, so a resumed
session numbered the next turn below it and Begin overwrote the only copy
of that snapshot. The store now reserves those turn numbers in memory:
NextTurn() counts them and UnreadableTurns() names them, while Bounds()
and List() still expose only readable checkpoints. No new on-disk state.
2026-10-15 14:51:40 +08:00
Will a345688087 test(checkpoint): pin turn-number reuse after an unreadable checkpoint
A persisted turn-N.json that cannot be read is dropped by load() exactly
like an absent one, so NextTurn() restarts below it and the next Begin
overwrites the only copy. This test operates on a real truncated file
written by the store's own persist path.
2026-10-15 14:51:37 +08:00
2403 changed files with 140471 additions and 8289 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
*.bat text eol=crlf
*.css text eol=lf
*.go text eol=lf
*.golden text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
+37
View File
@@ -0,0 +1,37 @@
name: acp-conformance
# The agent is driven over stdio by the official ACP TypeScript SDK, and every
# message it writes is held to the schema that SDK ships. Not a required check:
# it runs when the ACP frontend or the harness changes, and on request.
on:
pull_request:
branches: [main-v2, studio]
paths:
- "internal/frontend/acp/**"
- "tools/acp-conformance/**"
- ".github/workflows/acp-conformance.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: acp-conformance-${{ github.ref }}
cancel-in-progress: true
jobs:
conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "22"
- run: make acp-conformance
+7 -1
View File
@@ -88,7 +88,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/cli-archive-contents.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -205,6 +205,12 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
+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@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
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@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+32
View File
@@ -0,0 +1,32 @@
name: Deploy website
# GitHub requires workflow_dispatch to be registered on the default branch.
# The production implementation is the same workflow path on website.
on:
workflow_dispatch:
inputs:
release_version:
description: "Optional product release version associated with this publication"
required: false
default: ""
type: string
release_request:
description: "Optional release workflow correlation ID"
required: false
default: ""
type: string
studio_version:
description: "Studio version that must be present in the generated changelog"
required: false
default: ""
type: string
permissions: {}
jobs:
require-website:
runs-on: ubuntu-latest
steps:
- run: |
echo "::error::Deploy the website with --ref website; production is restricted to that branch."
exit 1
+49 -13
View File
@@ -389,6 +389,14 @@ jobs:
version="${{ needs.resolve.outputs.version }}"
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
- name: Verify the AppImage
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-$arch.AppImage" "$arch"
# 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
@@ -900,12 +908,31 @@ jobs:
go-version-file: go.mod
cache: true
# The page every archive carries beside the executable, built once here
# rather than once per target: serve.FindPage looks for
# frontend-next/dist next to the binary.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- name: Build the page
working-directory: desktop/frontend-next
run: pnpm install --frozen-lockfile && pnpm build
# Every target the release line claims to support, from one runner: the
# CLI is CGO-free, so none of them needs a toolchain of its own.
- name: Cross-compile and archive
env:
VERSION: ${{ needs.resolve.outputs.version }}
SHA: ${{ needs.resolve.outputs.sha }}
PAGE_DIR: desktop/frontend-next/dist
run: |
set -euo pipefail
mkdir -p dist
@@ -917,11 +944,7 @@ jobs:
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
-o "reasonix$ext" ./cmd/reasonix
if [ "$os" = windows ]; then
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
else
tar czf "dist/reasonix-$os-$arch.tar.gz" "reasonix$ext"
fi
scripts/package-cli-archive.sh "$os" "$arch" "reasonix$ext" "$PAGE_DIR" dist
rm -f "reasonix$ext"
done
# The digest list the remote verifies against. Resolved on the
@@ -933,13 +956,7 @@ jobs:
# A release that published the archives without the digest list is worse
# than one that published neither: the routes resolve SHA256SUMS first.
- name: Check the set is complete
run: |
set -euo pipefail
for want in reasonix-darwin-amd64.tar.gz reasonix-darwin-arm64.tar.gz \
reasonix-linux-amd64.tar.gz reasonix-linux-arm64.tar.gz \
reasonix-windows-amd64.zip reasonix-windows-arm64.zip SHA256SUMS; do
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
run: python3 scripts/check-cli-archives.py dist
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
@@ -1190,7 +1207,7 @@ jobs:
fi
{
printf '%s\n\n' '---'
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 "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。也提供 \`.AppImage\`(只提供下载,不会自更新;需要 FUSE 2,或用 \`--appimage-extract-and-run\` 启动)。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
@@ -1285,6 +1302,25 @@ jobs:
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
website:
name: publish the Studio website changelog
needs: [resolve, publish]
if: github.repository == 'esengine/DeepSeek-Reasonix' && needs.resolve.outputs.candidate != 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
actions: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
- name: Deploy and verify the website changelog
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve.outputs.version }}
run: bash scripts/publish-studio-changelog.sh
# A CLI channel that moves while 1.x still publishes would have two lines
# writing it, so every CLI job waits for this check. It runs no repository code.
cli-gate:
+107 -4
View File
@@ -144,10 +144,19 @@ jobs:
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/explorersize.mjs
node perf/git-panel.mjs
node perf/session-move-to.mjs
node perf/shell-refused.mjs
node perf/rail-search-hold.mjs
node perf/rail-toggle-panes.mjs
node perf/session-move.mjs
node perf/session-ref.mjs
node perf/browser-full.mjs
node perf/mod-ui.mjs
node perf/codeeditor-size.mjs
node perf/action-icons.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
@@ -155,18 +164,108 @@ jobs:
node perf/branch-tooltip.mjs
node perf/branch-menu.mjs
node perf/image-preview.mjs
node perf/user-images.mjs
node perf/toast-anchor.mjs
node perf/addfolder-rest.mjs
node perf/scroll-jump.mjs
node perf/add-project-selects.mjs
node perf/setup-defer.mjs
node perf/search-toggle.mjs
node perf/narrow.mjs
node perf/settings-rows.mjs
node perf/narrow-steps.mjs
node perf/job-names.mjs
node perf/mcp-actions.mjs
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/output-cap.mjs
node perf/progress-tail.mjs
node perf/trust-wait.mjs
node perf/nest-nodes.mjs
node perf/provider-ports.mjs
node perf/peer-threads.mjs
node perf/text-size.mjs
node perf/fs-scale.mjs
node perf/composer-feel.mjs
node perf/side-panel.mjs
node perf/card-shell.mjs
node perf/line-cards.mjs
node perf/detail-panel.mjs
node perf/brand-lockup.mjs
node perf/notice-line.mjs
node perf/brought-line.mjs
node perf/modal-focus.mjs
node perf/focus-ring.mjs
node perf/narrow-forms.mjs
node perf/i18n-rendered.mjs
node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip
# What a hosted page does over the relay, end to end: the production bundle,
# the real Studio host and kernel, a real Chromium; only the accounts API and
# the relay are stand-ins (tools/relayfake). Event-transport changes are
# judged here, because a unit test of one side cannot see the other.
relay-harness:
name: relay harness
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Transport changed?
id: scope
env:
BASE_REF: ${{ github.base_ref }}
run: |
paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] &&
git diff --quiet "origin/$BASE_REF...HEAD" -- $paths; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- if: steps.scope.outputs.run == 'true'
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- if: steps.scope.outputs.run == 'true'
name: Build the host, the stand-ins and the page
run: |
go build -o desktop/frontend-next/relay/bin/studio-host ./cmd/reasonix-studio-host
go build -o desktop/frontend-next/relay/bin/relayfake ./tools/relayfake
cd desktop/frontend-next
pnpm install --frozen-lockfile
pnpm exec vite build --outDir dist-relay --emptyOutDir
pnpm exec playwright install --with-deps chromium
- if: steps.scope.outputs.run == 'true'
name: Scenarios (hosted page over the relay; local page on a flapping network)
working-directory: desktop/frontend-next
env:
RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host
RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake
RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay
run: |
node relay/live.mjs
node relay/storm.mjs
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
# moves the Windows and macOS legs onto an ubuntu runner with every step
# skipped, so the check names still report without a scarce runner. A push to
@@ -272,7 +371,7 @@ jobs:
arm64=false
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
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-linux-appimage|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
@@ -516,6 +615,10 @@ jobs:
if: runner.os == 'Linux'
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
- name: The AppImage carries its architecture, the kernel and the page
if: runner.os == 'Linux'
run: bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.AppImage" "${{ matrix.arch }}"
# Recorded, not asserted: the real bundle layout, so later work argues
# from what electron-builder produced rather than from its documentation.
- name: Record the macOS bundle layout
+1
View File
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
# running, which on Windows is every rebuild while Studio is open.
*.exe
*.exe~
/relayfake
+8 -1
View File
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck acp-conformance check test studio-test sdk-test sdk-test-race hooks cross clean studio
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -87,6 +87,13 @@ lint-cross:
test: studio-test sdk-test
go test ./...
# Drives `reasonix acp` with the official ACP TypeScript SDK and holds every
# message it writes to the schema that SDK ships. Needs node and network for
# `npm ci`, which runs without install scripts against a lockfile.
acp-conformance: build
cd tools/acp-conformance && npm ci --ignore-scripts --no-audit --no-fund
cd tools/acp-conformance && REASONIX_BIN=$(CURDIR)/bin/reasonix$(GOEXE) npm test
# The shell is a nested module, so the root `test` target never reaches it.
studio-test:
cd desktop && go test ./...
+18 -6
View File
@@ -93,6 +93,16 @@ Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setu
All four are the same program underneath. What follows is for advanced users and contributors.
## Use it from DeepSeek Harness
DeepSeek Harness (`dsh`) can hand a task to Reasonix as a subagent over ACP. This repository publishes a plugin index for it:
1. Run `reasonix setup` on the machine that runs `dsh`. In the DSH Web UI turn on Custom plugin markets (Plugins, More) and add this index in the Discover tab: `https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. Install "Reasonix subagent" from it.
3. Open the plugin's page and turn its switch on. While it is off the model has no `reasonix` tool and may run `reasonix -p` in its own shell, which looks like a delegation and is not one.
Setup details, the permission policy and limits are in [Using Reasonix from DeepSeek Harness](./docs/DSH.md).
## 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.
@@ -151,7 +161,7 @@ pre-releases):
| --- | --- | --- |
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
| Linux | `.deb`, `.rpm`, or `.AppImage` (download only) | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
Every package ships with a `.minisig` signature, and the release carries
`SHA256SUMS`. Once installed, Studio updates itself in place.
@@ -240,12 +250,14 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
## Documentation
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md) ·
[Use from DeepSeek Harness](./docs/DSH.md)
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and updates](./docs/RECOVERY.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Agent presets:** [Session Agent compositions](./docs/AGENT_COMPOSITIONS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
@@ -277,10 +289,10 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**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) |
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+17 -6
View File
@@ -91,6 +91,16 @@ Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
## 在 DeepSeek Harness 里使用
DeepSeek Harness(`dsh`)可以通过 ACP 把任务交给 Reasonix 作为子智能体去做。本仓库为此发布了一个插件索引:
1. 在运行 `dsh` 的机器上先运行 `reasonix setup`。在 DSH 网页界面打开“自定义插件市场”(插件,更多),再到 Discover 页添加这个索引:`https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. 从中安装“Reasonix subagent”。
3. 打开这个插件的页面,把它的开关打开。关着的时候模型没有 `reasonix` 工具,可能改在自己的 shell 里直接运行 `reasonix -p`,看起来像委派,其实不是。
配置细节、权限策略和限制见 [在 DeepSeek Harness 中使用 Reasonix](./docs/DSH.md)。
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
@@ -139,7 +149,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| --- | --- | --- |
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
| Linux | `.deb`、`.rpm` 或 `.AppImage`(只提供下载) | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
应用内自行更新。
@@ -221,12 +231,13 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.md) ·
[ACP 编辑器接入](./docs/ACP.md)
[ACP 编辑器接入](./docs/ACP.md) · [在 DeepSeek Harness 中使用](./docs/DSH.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[恢复与安全模式](./docs/RECOVERY.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
- **Agent 预设:** [会话级组合](./docs/AGENT_COMPOSITIONS.zh.md)
- **工程与迁移:** [规格](./docs/SPEC.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
[工具合约](./docs/TOOL_CONTRACT.md) ·
@@ -259,10 +270,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**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) |
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+4
View File
@@ -373,6 +373,10 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
- sensitive: internal/platform/gitpush/**
- sensitive: internal/session/control/git_push.go
- sensitive: internal/frontend/serve/git_push.go
- sensitive: internal/frontend/serve/host_routes.go
One declaration, two effects: the same list is also the coverage gate's subject.
`make coverage-gate` holds each path to the coverage it already has, because
@@ -0,0 +1,35 @@
# Compaction prefix probe
## Method
1. Measured on 2026-10-17 against the official `https://api.deepseek.com` OpenAI adapter, using the model returned by `/models`: `deepseek-flash`. Thinking was disabled. Inputs were synthetic; no workspace history or credentials are recorded here.
2. Each of three text and three rich-history trials sent a main request to warm the prefix, then called the actual agent summary path, then an independent summary control. Rich history included a synthetic closed `read_file` call, result and reasoning. Warmup outputs were discarded, never executed.
3. A summary had a 1,024-token output cap; warmups had 64. Success means a heading, preservation of the fixture's `ParseDuration` and zero-value constraint, no emitted tool calls, and `finish_reason=stop`. This checks the fixture's contract, not arbitrary-summary factual completeness.
4. First-attempt reuse success counts successful reused summaries / six reused summaries. Fallback rate counts independent requests initiated by the reuse coordinator / six reuse trials; the six explicit independent controls are excluded. Both were 6/6 success and 0/6 fallbacks. Independent controls also succeeded 6/6.
5. The raw result file records provider-reported usage and full stream latency, measured from adapter `Stream` until channel close. Token rows below sum only the three indicated summary requests; they exclude warmup, model listing and any earlier exploratory requests. Cache residency was uncontrolled; prior probes had warmed these synthetic prefixes.
## Results
| History | Shape | N | Prompt | Hit | Miss | Output | Median ms | Estimated USD |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Text | Reused | 3 | 3,714 | 3,072 | 642 | 636 | 1,713 | 0.000487116 |
| Text | Independent | 3 | 1,218 | 768 | 450 | 644 | 1,610 | 0.000456204 |
| Rich | Reused | 3 | 3,780 | 3,072 | 708 | 742 | 1,774 | 0.000560616 |
| Rich | Independent | 3 | 1,341 | 768 | 573 | 921 | 1,815 | 0.000640854 |
1. Estimates use the [official pricing](https://api-docs.deepseek.com/quick_start/pricing/) checked on the measurement date: off-peak Flash USD per million tokens, hit 0.003, miss 0.15, output 0.6. October 17 was a Saturday. Formula: `(hit*0.003 + miss*0.15 + output*0.6)/1_000_000`. These are token estimates, not account deductions.
2. Reuse hit fractions were 82.7% (text) and 81.3% (rich), versus 63.1% and 57.3% for controls. This small fixture does **not** establish cost savings: retaining the system/catalog increased both total input and input cost. Text total cost increased 6.8%; rich total decreased 12.5% because output was shorter, not because input was cheaper.
3. The six warmups consumed 7,032 prompt and 231 output tokens, estimated USD 0.000346680. All 18 recorded generation calls together cost an estimated USD 0.002491470. No failed-digest retry occurred in these live trials; the deterministic fallback tests establish its behavior and accounting.
4. A rejected nonempty reused answer earns one independent-shape fallback. A separate truncation retry may enlarge the output cap, for at most three logical model calls total. Every actual attempt emits and records its own spend; returned usage is their sum. Empty output, stream errors and cancellation do not earn shape retries.
5. These sequential, six-pair trials establish interoperability for one model, mode and short fixture. They do not estimate production failure probability, thinking-mode behavior, other models, cold-cache cost, or large-history savings.
## Reproduction
1. Provide a test key through an existing secret environment configuration. Do not put credentials in source, command arguments or captured logs. Select a model currently returned by `/models`.
2. Run the opt-in probe; it skips without `DEEPSEEK_API_KEY`. The dedicated build tag avoids enabling unrelated live tests.
```sh
REASONIX_LIVE_MODEL=deepseek-flash go test -tags live_compaction ./internal/runtime/agent -run '^TestLiveCompactionPrefix$' -v -count=1
```
3. This probe exercises actual summary construction and the HTTP adapter. The hermetic `TestEffectCompactionSharesThePreviouslySentPrefix` separately exercises manual, automatic pressure and rich history through `boot.Build`, comparing actual JSON request-prefix and schema bytes. Its sparse-history case also proves an interleaved retained user turn survives the fold.
@@ -0,0 +1,512 @@
[
{
"model": "deepseek-flash",
"rich": false,
"trial": 1,
"results": [
{
"kind": "warm",
"milliseconds": 828,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 2006,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 234,
"TotalTokens": 1472,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1817,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 234,
"TotalTokens": 640,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": false,
"trial": 2,
"results": [
{
"kind": "warm",
"milliseconds": 820,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1633,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 187,
"TotalTokens": 1425,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1555,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 202,
"TotalTokens": 608,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": false,
"trial": 3,
"results": [
{
"kind": "warm",
"milliseconds": 905,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1713,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 215,
"TotalTokens": 1453,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1610,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 208,
"TotalTokens": 614,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 1,
"results": [
{
"kind": "warm",
"milliseconds": 561,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1774,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 256,
"TotalTokens": 1516,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 2271,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 381,
"TotalTokens": 828,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 2,
"results": [
{
"kind": "warm",
"milliseconds": 610,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 2263,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 276,
"TotalTokens": 1536,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1783,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 242,
"TotalTokens": 689,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 3,
"results": [
{
"kind": "warm",
"milliseconds": 834,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1575,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 210,
"TotalTokens": 1470,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1815,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 298,
"TotalTokens": 745,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
}
]
+1 -1
View File
@@ -282,7 +282,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
t.Fatal(err)
}
}
read, err := auditAnswerTrajectories(dir, task{ID: "demo", answerRoot: root}, 2)
read, err := auditAnswerTrajectories(dir, "demo", task{ID: "demo", answerRoot: root}, 2)
if err != nil || !read {
t.Fatalf("earlier answer read was missed: read=%t err=%v", read, err)
}
+4 -4
View File
@@ -133,7 +133,7 @@ func scanAnswerRead(path string, bodyLines []string) (bool, error) {
return false, nil
}
func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
func auditAnswerTrajectories(dir, stem string, t task, segments int) (bool, error) {
if segments < 1 {
return false, fmt.Errorf("run recorded no segments")
}
@@ -142,7 +142,7 @@ func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
return false, err
}
for index := 1; index <= segments; index++ {
path := segmentTrajectoryPath(dir, t.ID, segment{index: index}, segments)
path := segmentTrajectoryPath(dir, stem, segment{index: index}, segments)
read, err := scanAnswerRead(path, lines)
if err != nil || read {
return read, err
@@ -170,11 +170,11 @@ func answerTrajectoryDir(dir string, required bool) (string, func(), error) {
return dir, cleanup, nil
}
func auditAnswerRun(r *result, dir string, t task) bool {
func auditAnswerRun(r *result, dir, stem string, t task) bool {
if t.answerRoot == "" {
return true
}
read, err := auditAnswerTrajectories(dir, t, r.Segments)
read, err := auditAnswerTrajectories(dir, stem, t, r.Segments)
if err != nil {
markAnswerAuditIncomplete(r, err)
r.Note = "answer audit: " + err.Error()
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
// writeKilledAgentStub writes a stand-in for the reasonix binary that leaves
// exactly what a killed child leaves behind: no final metrics record, but the
// in-flight snapshot the metrics sink writes every two seconds.
func writeKilledAgentStub(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "stub-agent.sh")
script := "#!/bin/sh\n" +
"metrics=\"\"\n" +
"prev=\"\"\n" +
"for arg in \"$@\"; do\n" +
" if [ \"$prev\" = \"--metrics\" ]; then metrics=\"$arg\"; fi\n" +
" prev=\"$arg\"\n" +
"done\n" +
"printf '%s' '{\"prompt_tokens\":1200,\"completion_tokens\":80,\"steps\":9," +
"\"cost\":0.0123,\"currency\":\"USD\",\"complete\":false}' > \"$metrics.partial\"\n" +
"exit 137\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
func killedRun(t *testing.T) result {
t.Helper()
requireShellStub(t)
work := testenv.TempDir(t)
tk := task{ID: "stub-task", Prompt: "do the thing"}
r := result{task: tk}
cfg := suiteConfig{bin: writeKilledAgentStub(t, work)}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err == nil {
t.Fatal("the stub exits 137; a leg that died must be reported as failed")
}
return r
}
// writeSilentAgentStub writes a stand-in that leaves neither a final record nor
// a snapshot: what it spent is simply gone, so nothing can bound it.
func writeSilentAgentStub(t *testing.T, dir string) string {
t.Helper()
requireShellStub(t)
path := filepath.Join(dir, "silent-agent.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run recovered from its snapshot is still accounted: its numbers are real and
// stop at the last write, which is exactly what Partial says. Marking it
// Unaccounted would drop them from every total and hide the lower bound.
func TestSuiteSidecarRecoveredRunStaysAccountedAsALowerBound(t *testing.T) {
r := killedRun(t)
if r.Unaccounted || !r.Partial {
t.Fatalf("a run recovered from its snapshot was reported as unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 1 {
t.Fatalf("a recovered run changed category:\n"+
" unaccounted=%d accounted=%d partial=%d", stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 1200 || stats.cTok != 80 || stats.cost != 0.0123 {
t.Fatalf("the recovered numbers never reached the aggregates:\n"+
" tokens=%d/%d cost=%.4f, want 1200/80 at 0.0123", stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the lower-bound hint is unreachable:\n%s", out)
}
}
// A leg that leaves neither record has no surviving evidence at all, so the run
// is unmeasured and must stay out of the totals rather than count as zero.
func TestSuiteRunWithNoRecordAtAllIsUnaccounted(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "silent-task", Prompt: "do the thing"}
r := result{task: tk}
cfg := suiteConfig{bin: writeSilentAgentStub(t, work)}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("the stub exits 0; a silent leg must not fail the run: %v", err)
}
if !r.Unaccounted || r.Partial {
t.Fatalf("a run that left no record at all was not marked unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=false unaccounted=true", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 1 || stats.accounted != 0 {
t.Fatalf("a record-less run was counted as accounted:\n"+
" unaccounted=%d accounted=%d", stats.unaccounted, stats.accounted)
}
if stats.pTok != 0 || stats.cTok != 0 || stats.cost != 0 {
t.Fatalf("a record-less run contributed numbers: tokens=%d/%d cost=%.4f",
stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "Accounting incomplete for 1 of 1 instances") {
t.Fatalf("the record-less run was not disclosed as unaccounted:\n%s", out)
}
}
// The killed child did leave its numbers: the metrics sink snapshots in flight
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path must
// do the same rather than let the directory removal destroy the evidence.
func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
r := killedRun(t)
if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
t.Fatalf("the child's in-flight snapshot was dropped:\n"+
" prompt_tokens=%d completion_tokens=%d cost=%.4f partial=%v unaccounted=%v\n"+
" want the 1200/80 the <metrics>.partial sidecar carries",
r.PromptTokens, r.CompletionTokens, r.Cost, r.Partial, r.Unaccounted)
}
}
// The meter-vs-harness divergence line divides one side by the other. Whether a
// run has a harness side is the suite's Unaccounted state, not the token values:
// a run with no record at all has no side, and pricing it against the meter
// invents a divergence and blames the harness for it.
func TestMeterDivergenceDoesNotPriceAMetricslessRun(t *testing.T) {
r := result{task: task{ID: "killed"}, Unaccounted: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("a run with no harness-side number was priced as a self-report divergence:\n%s", got)
}
}
// A persisted record that says zero tokens is a measurement of zero, not an
// absence: the harness side exists, so it belongs on both sides of the ratio
// even when the number it reports disagrees with the meter.
func TestMeterAccountingKeepsAPersistedZeroOnBothSides(t *testing.T) {
r := result{task: task{ID: "zero"}, Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r})
if !strings.Contains(got, "-100.0% (harness 0 vs meter 1,280 over 1 runs)") {
t.Fatalf("a real zero-token measurement was dropped as if it were missing:\n%s", got)
}
}
func TestCorrectBoundaryMediansExcludeRunsWithNoRecordedSplit(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
measured := result{
task: task{ID: "measured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1, RoundsBeforeCorrect: 3, RoundsAfterCorrect: 4,
CallsAfterCorrect: 5, VerifyAfterCorrect: 2,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
// the same run twice, but -trajectories was omitted: no digest, so no split
// was ever taken and the before/after tallies stay at their zero value.
unmeasured := result{
task: task{ID: "unmeasured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1,
}
unmeasured2 := unmeasured
unmeasured2.task = task{ID: "unmeasured-2"}
// The audit read the rendered line as plain text; the renderer emits
// markdown emphasis around each label, so compare with it stripped.
const want = "**rounds before** 3 · **rounds after** 4"
got := renderSolveProfiles([]result{measured, unmeasured, unmeasured2})
if !strings.Contains(got, want) {
t.Fatalf("medians folded in a run whose trajectory was never split:\n%s\n"+
"want %q (the measured run only)", got, want)
}
}
// Control (a): when every run recorded its split, the exclusion must not touch
// a single number — the medians stay the measured ones.
func TestCorrectBoundaryMediansAllMeasuredUnchanged(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
mk := func(id string, before, after, verify, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
RoundsBeforeCorrect: before, RoundsAfterCorrect: after,
VerifyAfterCorrect: verify, MutationsBeforeCorrect: mutations,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
}
got := renderSolveProfiles([]result{mk("a", 2, 6, 1, 0), mk("b", 3, 4, 2, 1), mk("c", 4, 5, 3, 2)})
const want = "**mutations before** 1 · **rounds before** 3 · **rounds after** 5 · **verifications after** 2"
if !strings.Contains(got, want) {
t.Fatalf("all-measured medians changed:\n%s\nwant %q", got, want)
}
}
// Control (b): with -trajectories off no split was ever taken for any run, so
// there is nothing to average. Printing zeros would publish them as
// observations; the report must say the boundary is unmeasured instead.
func TestCorrectBoundaryWithNoRecordedSplitSaysNothingToAverage(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
run := func(id string, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: mutations,
}
}
got := renderSolveProfiles([]result{run("a", 1), run("b", 3)})
if strings.Contains(got, "rounds before") || strings.Contains(got, "verifications after") {
t.Fatalf("a boundary that was never split printed as a measurement:\n%s", got)
}
if !strings.Contains(got, "**mutations before** 3") {
t.Fatalf("the checkpoint-derived mutation count is measured without a trajectory and must still be printed:\n%s", got)
}
if !strings.Contains(got, "nothing to average") {
t.Fatalf("the report must say the boundary was never measured:\n%s", got)
}
}
+19 -7
View File
@@ -195,7 +195,7 @@ func renderSolveProfiles(results []result) string {
}
line := "**Solve profile**: "
parts := []string{}
regressed, withCorrect := 0, 0
regressed, withCorrect, split := 0, 0, 0
var roundsBefore, roundsAfter, verifyAfter, mutationsBefore []int64
for _, r := range results {
if len(r.Checkpoints) == 0 {
@@ -203,10 +203,16 @@ func renderSolveProfiles(results []result) string {
}
if r.FirstCorrectMs > 0 {
withCorrect++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
// Fold in only runs whose trajectory was read and split (Trajectory
// is set exactly when -trajectories ran): a run without one has no
// measurement, and its zeros are absence, not a zero.
mutationsBefore = append(mutationsBefore, int64(r.MutationsBeforeCorrect))
if r.Trajectory != nil {
split++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
}
}
if r.RegressedAfterCorrect {
regressed++
@@ -247,9 +253,15 @@ func renderSolveProfiles(results []result) string {
ttfum = append(ttfum, r.FirstUsefulMs)
}
}
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
if split == 0 {
// The split counts are absent; the checkpoint-derived ones are not.
line += fmt.Sprintf("\n\n**Correct boundary**: **mutations before** %d · **regression-after-correct** %s — nothing to average: the runs reached a correct state, but none recorded a trajectory, so the boundary was never split",
median(mutationsBefore), pct(regressed, withCorrect))
} else {
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
}
if len(ttfum) > 0 {
line += fmt.Sprintf(" · **TTFUM median** %s", dur(median(ttfum)))
}
+13 -10
View File
@@ -41,19 +41,21 @@ func requestsBySourceLine(bySource map[string]sourceUsage) string {
return "**Requests by source:** " + strings.Join(parts, " · ") + "\n\n"
}
// armStats is one arm's aggregate over a -json report, using the same
// accounting conventions as renderBody: spend totals cover accounted runs
// (failures included) and per-solved figures divide by accounted solves.
// armStats is one arm's aggregate over a -json report: spend totals and
// per-solved figures cover accounted runs (failures included), while wall
// rates (Solved/hour, wall/task) cover every run.
type armStats struct {
Ran, Pass1, Solved, AccountedSolved int
Steps, Tools, Rounds, PlannerCalls int
Tokens, Hit, Miss int
Cost float64
WallMs int64
FirstHit, FirstMiss int64
Damaged, WithCorrect int
TTCS, TTFT []int64
ByClass map[string]classStats
// WallMs is all-run wall (a killed run's wall was still spent);
// AccountedWallMs is the accounted subset the per-solved wall cost divides.
WallMs, AccountedWallMs int64
FirstHit, FirstMiss int64
Damaged, WithCorrect int
TTCS, TTFT []int64
ByClass map[string]classStats
// Fanout covers every run in the arm, no-solution tasks included: work the
// agent delegated is work it paid for whether or not the task was solvable.
Fanout fanoutMetrics
@@ -107,6 +109,7 @@ func aggregateArm(results []result) armStats {
}
c.WallMs += r.WallMs
s.ByClass[label] = c
s.WallMs += r.WallMs
if r.Unaccounted {
continue
}
@@ -119,7 +122,7 @@ func aggregateArm(results []result) armStats {
s.Hit += r.CacheHitTokens
s.Miss += r.CacheMissTokens
s.Cost += r.Cost
s.WallMs += r.WallMs
s.AccountedWallMs += r.WallMs
s.PlannerCalls += r.UsageBySource["planner"].Calls
if r.Trajectory != nil {
s.Rounds += r.Trajectory.ModelRounds
@@ -309,7 +312,7 @@ func compareReports(pathA, pathB string) (string, error) {
fmt.Fprintf(&b, "| Model rounds / solved | %s | %s |\n", perSolved(float64(a.Rounds), a.AccountedSolved), perSolved(float64(bStats.Rounds), bStats.AccountedSolved))
fmt.Fprintf(&b, "| Tool calls / solved | %s | %s |\n", perSolved(float64(a.Tools), a.AccountedSolved), perSolved(float64(bStats.Tools), bStats.AccountedSolved))
fmt.Fprintf(&b, "| Tokens / solved | %s | %s |\n", perSolved(float64(a.Tokens), a.AccountedSolved), perSolved(float64(bStats.Tokens), bStats.AccountedSolved))
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.WallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.WallMs)/1000, bStats.AccountedSolved))
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.AccountedWallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.AccountedWallMs)/1000, bStats.AccountedSolved))
fmt.Fprintf(&b, "| Cost / solved | %s | %s |\n", perSolved(a.Cost, a.AccountedSolved), perSolved(bStats.Cost, bStats.AccountedSolved))
b.WriteString(marginalUtilitySection(a, bStats))
b.WriteString(memoryUtilitySection(pathA, pathB))
@@ -0,0 +1,220 @@
package main
// Wall population rule: a rate figure divides every run's wall (a killed run
// still spent it); a per-solved cost divides the accounted wall of solved runs.
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
// reportSolvedPerHour reads the "Solved/hour" number the suite report prints.
func reportSolvedPerHour(t *testing.T, report string) string {
t.Helper()
m := regexp.MustCompile(`\*\*Solved/hour\*\* ([\d.]+)`).FindStringSubmatch(report)
if m == nil {
t.Fatalf("suite report has no Solved/hour line:\n%s", report)
}
return m[1]
}
// compareSolvedPerHour reads the Solved/hour column out of the compare table's
// one arm row.
func compareSolvedPerHour(t *testing.T, report string) string {
t.Helper()
for line := range strings.SplitSeq(report, "\n") {
if !strings.HasPrefix(line, "| `arm` |") {
continue
}
cells := strings.Split(line, "|")
if len(cells) < 8 {
t.Fatalf("compare row has %d cells:\n%s", len(cells), line)
}
return strings.TrimSpace(cells[7])
}
t.Fatalf("compare table has no arm row:\n%s", report)
return ""
}
// Solved/hour is a rate, not a per-solved figure: the suite report divides every
// run's wall (a killed run's tokens are unknown, its wall was still spent), so
// aggregateArm must not drop the unaccounted run's wall from that denominator.
func TestCompareSolvedPerHourUsesTheSameWallAsTheReport(t *testing.T) {
dir := testenv.TempDir(t)
results := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
}
path := writeArm(t, dir, "arm.json", results)
want := reportSolvedPerHour(t, renderBody(results))
report, err := multiCompareReport([]string{path, path})
if err != nil {
t.Fatalf("multiCompareReport: %v", err)
}
if got := compareSolvedPerHour(t, report); got != want {
t.Fatalf("compare Solved/hour = %s, suite report says %s for the same artifact\n"+
"1600s of known wall (one 30-minute killed run) went missing from the compare denominator:\n%s",
got, want, report)
}
}
// Same root cause, second printed number: the per-class table counts every
// run's wall (c.WallMs is accumulated before the Unaccounted continue) while the
// global marginal-utility line divided only accounted wall by the same task
// denominator. One section, two populations: the class row says A cost +870.0s
// per task more, the global line said -30.0s less.
func TestMarginalUtilityGlobalWallMatchesThePerClassRows(t *testing.T) {
dir := testenv.TempDir(t)
mk := func(id string, wallMs int64, unaccounted bool) result {
r := result{task: task{ID: id, Class: "bugfix"}, Passed: true, WallMs: wallMs}
r.Unaccounted = unaccounted
return r
}
a := []result{
mk("ok", 60_000, false),
mk("killed", 1_800_000, true),
}
b := []result{
mk("ok", 60_000, false),
mk("killed2", 60_000, false),
}
pathA := writeArm(t, dir, "a.json", a)
pathB := writeArm(t, dir, "b.json", b)
report, err := compareReports(pathA, pathB)
if err != nil {
t.Fatalf("compareReports: %v", err)
}
var row string
for line := range strings.SplitSeq(report, "\n") {
if strings.HasPrefix(line, "| bugfix |") {
row = line
}
}
if row == "" {
t.Fatalf("no bugfix class row:\n%s", report)
}
global := regexp.MustCompile(`wall/task ([+-][\d.]+)s`).FindStringSubmatch(report)
if global == nil {
t.Fatalf("no global wall/task line:\n%s", report)
}
// The one class row reports A 930.0s/task, B 60.0s/task, i.e. +870.0s.
if global[1] != "+870.0" {
t.Fatalf("global wall/task = %ss while the per-class row beside it says +870.0s "+
"(A 930.0s vs B 60.0s): the global figure drops the unaccounted run's wall, "+
"the class row keeps it\n%s\n%s", global[1], row, report)
}
}
// compareOutput pins a compare rendering byte for byte. RX_UPDATE_GOLDEN
// rewrites it; the no-unaccounted fixture is the population where the two wall
// totals coincide, so the readout must not move when only the accounting does.
func compareOutput(t *testing.T, dir, name, got string) {
t.Helper()
path := filepath.Join(dir, name)
if os.Getenv("RX_UPDATE_GOLDEN") != "" {
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden: %v", err)
}
if got != string(want) {
t.Fatalf("%s: compare output changed\n--- got ---\n%s\n--- want ---\n%s", name, got, want)
}
}
// A fixture with no unaccounted runs is where every wall is accounted wall:
// both rates must render exactly as they did before the population split.
func TestCompareOutputUnchangedWithoutUnaccountedRuns(t *testing.T) {
dir := testenv.TempDir(t)
goldenDir, err := filepath.Abs("testdata")
if err != nil {
t.Fatalf("testdata dir: %v", err)
}
t.Chdir(dir)
a := []result{
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 60_000, Attempt: 1, TTCSMs: 60_000,
runMetrics: runMetrics{Steps: 7, ToolCalls: 10, PromptTokens: 900, CompletionTokens: 100, Cost: 0.02, Currency: "$"}},
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
}
b := []result{
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 45_000, Attempt: 1, TTCSMs: 45_000,
runMetrics: runMetrics{Steps: 5, ToolCalls: 10, PromptTokens: 700, CompletionTokens: 100, Cost: 0.01, Currency: "$"}},
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
}
writeArm(t, ".", "a.json", a)
writeArm(t, ".", "b.json", b)
ab, err := compareReports("a.json", "b.json")
if err != nil {
t.Fatalf("compareReports: %v", err)
}
compareOutput(t, goldenDir, "no_unaccounted_ab.golden", ab)
multi, err := multiCompareReport([]string{"a.json", "b.json"})
if err != nil {
t.Fatalf("multiCompareReport: %v", err)
}
compareOutput(t, goldenDir, "no_unaccounted_multi.golden", multi)
}
// Wall seconds / solved divides the accounted wall by accounted solves: the
// unaccounted run's wall must stay out of that ratio even though it now enters
// every rate figure.
func TestWallSecondsPerSolvedKeepsAccountedPopulation(t *testing.T) {
dir := testenv.TempDir(t)
a := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
}
b := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
}
report, err := compareReports(writeArm(t, dir, "a.json", a), writeArm(t, dir, "b.json", b))
if err != nil {
t.Fatalf("compareReports: %v", err)
}
// A: 60s accounted wall over 1 accounted solve; its all-run wall (1860s)
// would print 1860.0 here, or 930.0 over all solves. B has no unaccounted
// run, so both walls coincide at 60.0.
if !strings.Contains(report, "| Wall seconds / solved | 60.0 | 60.0 |") {
t.Fatalf("Wall seconds / solved is not accounted wall / accounted solves:\n%s", report)
}
for _, wrong := range []string{"| Wall seconds / solved | 1860.0 |", "| Wall seconds / solved | 930.0 |"} {
if strings.Contains(report, wrong) {
t.Fatalf("Wall seconds / solved divided the all-run wall (%s):\n%s", wrong, report)
}
}
}
// The suite report's Solved/hour convention is the reference the compare mode
// now follows: every attempt's wall, solved runs over that total.
func TestSuiteReportSolvedPerHourCountsEveryRun(t *testing.T) {
withKilled := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
}
if got := reportSolvedPerHour(t, renderBody(withKilled)); got != "3.9" {
t.Errorf("suite report Solved/hour = %s, want 3.9 (2 solves over 1860s)", got)
}
allAccounted := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
}
if got := reportSolvedPerHour(t, renderBody(allAccounted)); got != "60.0" {
t.Errorf("suite report Solved/hour = %s, want 60.0 (2 solves over 120s)", got)
}
}
+4 -3
View File
@@ -272,7 +272,7 @@ func main() {
ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off ("+ablation.ModuleList()+"; none|all)")
foldIndexFlag := flag.String("fold-index", "", "fold-index arm: how much of the model-visible address list to keep (default|half|quarter|off)")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory")
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory (<task-id>.trial-N.trajectory.jsonl per trial under -trials)")
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
@@ -585,10 +585,11 @@ func runTask(cfg suiteConfig, t task) result {
}
mtr.record(&r)
if trajPath = lastSegmentTrajectory(runTrajDir, t.ID, r.Segments); trajPath != "" {
stem := trajectoryStem(t.ID, cfg.trial, cfg.trials)
if trajPath = lastSegmentTrajectory(runTrajDir, stem, r.Segments); trajPath != "" {
recordTaskTrajectory(&r, trajPath, t)
}
if !auditAnswerRun(&r, runTrajDir, t) {
if !auditAnswerRun(&r, runTrajDir, stem, t) {
return r
}
// A killed child never writes metrics, so the deadline is the only place
+9 -5
View File
@@ -172,10 +172,11 @@ func scanMemoryRecall(path string, markers []string, markersInPrefix bool) memor
defer f.Close()
type record struct {
MemoryRecall *struct {
Hits []struct{ ID string } `json:"hits"`
UsedChars int `json:"used_chars"`
Suppressed string `json:"suppressed"`
ShadowHits []struct{ ID string } `json:"shadow_hits"`
Hits []struct{ ID string } `json:"hits"`
UsedChars int `json:"used_chars"`
Suppressed string `json:"suppressed"`
SuppressionCode string `json:"suppression_code"`
ShadowHits []struct{ ID string } `json:"shadow_hits"`
} `json:"memory_recall"`
Event *struct {
Kind string `json:"kind"`
@@ -205,7 +206,10 @@ func scanMemoryRecall(path string, markers []string, markersInPrefix bool) memor
if len(mr.ShadowHits) > 0 && mr.ShadowHits[0].ID == mr.Hits[0].ID {
stats.ShadowAgree++
}
} else if mr.Suppressed != "" {
} else if mr.SuppressionCode != "" || mr.Suppressed != "" {
// The typed code is the identity. A record written before the
// code existed carries only the sentence, whose presence still
// means recall stayed silent.
stats.Suppressed++
}
}
+4 -3
View File
@@ -17,15 +17,16 @@ func TestScanMemoryRecallCountsAndPointOfUse(t *testing.T) {
`{"seq":1,"event":{"kind":"tool_result","tool":{"args":"{\"command\":\"make check-fast --tag=MEMKEY-EARLY\"}"}}}`,
`{"seq":2,"memory_recall":{"hits":[{"id":"a"},{"id":"b"}],"used_chars":420}}`,
`{"seq":3,"event":{"kind":"tool_result","tool":{"args":"{\"path\":\"answer.txt\",\"content\":\"make check-fast --tag=MEMKEY-USED\"}"}}}`,
`{"seq":4,"memory_recall":{"suppressed":"generic user turn"}}`,
`{"seq":4,"memory_recall":{"suppression_code":"generic-turn","suppressed":"generic-turn: generic user turn"}}`,
`{"seq":5,"event":{"kind":"text","text":"done, MEMKEY-TEXT too"}}`,
`{"seq":6,"memory_recall":{"suppressed":"generic user turn"}}`,
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
stats := scanMemoryRecall(path, []string{"MEMKEY-USED", "MEMKEY-TEXT", "MEMKEY-EARLY", "MEMKEY-NEVER"}, false)
if stats.RecallEvents != 1 || stats.RecallHits != 2 || stats.RecallChars != 420 || stats.Suppressed != 1 {
t.Fatalf("stats = %+v, want 1 event / 2 hits / 420 chars / 1 suppressed", stats)
if stats.RecallEvents != 1 || stats.RecallHits != 2 || stats.RecallChars != 420 || stats.Suppressed != 2 {
t.Fatalf("stats = %+v, want 1 event / 2 hits / 420 chars / 2 suppressed", stats)
}
// MEMKEY-EARLY appears only BEFORE the recall: not point-of-use evidence.
if stats.MarkersUsed != 2 {
+8 -7
View File
@@ -116,14 +116,15 @@ func renderMeterAccounting(results []result) string {
miss += r.Meter.CacheMissTokens
unmeasured += r.Meter.WithoutUsage
injected += r.Meter.Injected
// A run whose metrics file never landed has no harness number, so it
// stays out of both sides of the comparison; folding its meter tokens
// against a silent zero would invent a divergence.
if !r.Unaccounted {
selfReported++
harnessTokens += r.PromptTokens + r.CompletionTokens
comparableTokens += r.Meter.PromptTokens + r.Meter.CompletionTokens
// Whether the harness has a side to compare is the suite's state, not a
// token count: Unaccounted has no record and Partial stops at its last
// snapshot, so neither side is whole. A persisted zero is a measurement.
if r.Unaccounted || r.Partial {
continue
}
selfReported++
harnessTokens += r.PromptTokens + r.CompletionTokens
comparableTokens += r.Meter.PromptTokens + r.Meter.CompletionTokens
}
if metered == 0 {
return ""
+38
View File
@@ -0,0 +1,38 @@
package main
import "reasonix/internal/contract/tool"
// callNesting tells the model's own calls from calls made on its behalf: a
// subagent's (stamped with ParentID) and those a running tool makes through
// the agent's invoker (run_script's), which carry an id derived from their
// caller's and arrive inside its dispatch→result window. Either kind ran
// inside one top-level call, so it bought no model request of its own.
type callNesting struct {
open map[string]bool // top-level calls dispatched and not yet resulted
}
// child reports whether a tool event belongs to a call made on the model's
// behalf, and tracks which top-level calls are still running.
func (n *callNesting) child(kind, id, parentID string) bool {
if parentID != "" {
return true
}
for caller := range n.open {
if tool.IsNestedCallOf(id, caller) {
return true
}
}
if id == "" {
return false
}
switch kind {
case "tool_dispatch":
if n.open == nil {
n.open = map[string]bool{}
}
n.open[id] = true
case "tool_result":
delete(n.open, id)
}
return false
}
+1
View File
@@ -99,6 +99,7 @@ type trajScan struct {
gapReason, gapCompl, gapPrompt int64
delegationToolMs map[string]int64
nesting callNesting
}
// modelAttempt is one sampling attempt's wall interval; planner marks attempts
+4 -2
View File
@@ -25,6 +25,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
}
defer f.Close()
inModel := true
var nesting callNesting
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
for sc.Scan() {
@@ -32,7 +33,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
}
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
continue
}
after := rec.TS > cutoffUnixMs
@@ -80,6 +81,7 @@ func roundEnds(path string) []int64 {
var ends []int64
var lastResult int64
inModel := true
var nesting callNesting
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
for sc.Scan() {
@@ -87,7 +89,7 @@ func roundEnds(path string) []int64 {
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
}
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
continue
}
switch rec.Event.Kind {
+64 -3
View File
@@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -21,15 +23,22 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
// call on the harness's own token counts, and another found this directory
// by grepping the temp root for its task — moving it only helps unnamed.
metricsDir, err := os.MkdirTemp("", "e2ebench-metrics-")
// A snapshot recovered from the sidecar is the only surviving evidence of
// what that leg spent, so its directory is kept instead of removed.
keepMetrics := false
if err != nil {
metricsDir = work
} else {
defer func() { _ = os.RemoveAll(metricsDir) }()
defer func() {
if !keepMetrics {
_ = os.RemoveAll(metricsDir)
}
}()
}
var runErr error
for _, seg := range segs {
metricsPath := filepath.Join(metricsDir, fmt.Sprintf(".run-metrics-%d.json", seg.index))
trajPath := segmentTrajectoryPath(trajDir, t.ID, seg, len(segs))
trajPath := segmentTrajectoryPath(trajDir, trajectoryStem(t.ID, cfg.trial, cfg.trials), seg, len(segs))
args := buildSegmentArgs(cfg, seg, metricsPath, trajPath)
cmd := exec.CommandContext(ctx, cfg.bin, args...)
@@ -42,7 +51,17 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
cmd.WaitDelay = 10 * time.Second
runErr = cmd.Run()
if m, err := readMetrics(metricsPath); err == nil {
m, partial, err := legMetrics(metricsPath)
if err != nil {
// The leg left no record at all: nothing survives to say what it
// spent, so the run is unmeasured rather than priced at zero.
r.Unaccounted = true
} else {
// A snapshot is real evidence, so the run stays accounted and only
// Partial says its numbers stop at the last write. It is sticky: a
// complete later leg cannot restore the tail an earlier one lost.
r.Partial = r.Partial || partial || !m.Complete
keepMetrics = keepMetrics || partial
foldSegmentMetrics(r, m, seg.index)
}
// A leg that died takes the run with it: resuming a session the child
@@ -55,6 +74,48 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
return runErr
}
// legMetrics reads one leg's accounting. The final record is authoritative and
// the in-flight snapshot is only read when the final one is absent, so the two
// can never be counted together.
func legMetrics(path string) (m runMetrics, partial bool, err error) {
if m, err = readMetrics(path); err == nil {
return m, false, nil
}
if m, err = readMetrics(path + ".partial"); err == nil {
return m, true, nil
}
return m, false, err
}
// trajectoryStem names one run's trajectory files: the task id, and the trial
// when -trials repeats the task, so a later trial cannot overwrite an earlier
// one. A single run keeps the bare id every reader already knows.
func trajectoryStem(id string, trial, trials int) string {
if trials < 2 {
return id
}
return fmt.Sprintf("%s.trial-%d", id, trial)
}
// trajectoryTaskID is the task a trajectory file recorded: its name without
// the trial mark of trajectoryStem or the leg mark of segmentTrajectoryPath.
func trajectoryTaskID(path string) string {
id := strings.TrimSuffix(filepath.Base(path), ".trajectory.jsonl")
id = trimNumberedMark(id, ".seg")
return trimNumberedMark(id, ".trial-")
}
func trimNumberedMark(id, mark string) string {
i := strings.LastIndex(id, mark)
if i < 0 {
return id
}
if _, err := strconv.Atoi(id[i+len(mark):]); err != nil {
return id
}
return id[:i]
}
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
// the record of the one before it.
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
+214
View File
@@ -0,0 +1,214 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
// stubScript builds a stand-in agent that records the --metrics path it was
// given, writes the metrics body it was built with to that path plus suffix,
// and exits with code. A non-empty suffix is what a killed child leaves: the
// in-flight snapshot and no final record.
func stubScript(t *testing.T, name, body, suffix string, code int) string {
t.Helper()
requireShellStub(t)
script := fmt.Sprintf(`#!/bin/sh
metrics=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
prev="$arg"
done
printf '%%s' "$metrics" > metrics-path.txt
printf '%%s' '%s' > "$metrics%s"
exit %d
`, body, suffix, code)
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// recordedMetricsPath reads back the --metrics path the stub was handed.
func recordedMetricsPath(t *testing.T, work string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(work, "metrics-path.txt"))
if err != nil {
t.Fatalf("the stub did not record its metrics path: %v", err)
}
got := strings.TrimSpace(string(b))
if got == "" {
t.Fatal("the stub recorded an empty metrics path")
}
return got
}
// twoLegStub writes a stand-in whose legs differ: the first leaves only the
// in-flight snapshot, the second lands its final record. Both exit 0, which is
// what lets the run reach the second leg after the first one stopped short.
func twoLegStub(t *testing.T, name string) string {
t.Helper()
requireShellStub(t)
script := `#!/bin/sh
metrics=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
prev="$arg"
done
case "$metrics" in
*-1.json)
printf '%s' '{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,"currency":"USD","complete":false}' > "$metrics.partial"
;;
*)
printf '%s' '{"prompt_tokens":300,"completion_tokens":20,"steps":2,"cost":0.001,"currency":"USD","complete":true}' > "$metrics"
;;
esac
exit 0
`
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run that lands its final metrics record is untouched by the recovery
// path: it stays accounted, keeps its real numbers and is still not partial.
func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "healthy-task", Prompt: "do the thing"}
body := `{"prompt_tokens":900,"completion_tokens":60,"steps":4,"cost":0.02,` +
`"currency":"USD","complete":true}`
cfg := suiteConfig{bin: stubScript(t, "stub-ok.sh", body, "", 0)}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("a healthy leg must not fail the run: %v", err)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 0 {
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d partial=%d",
stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 900 || stats.cTok != 60 || stats.cost != 0.02 {
t.Fatalf("a healthy run's numbers changed: tokens=%d/%d cost=%.4f",
stats.pTok, stats.cTok, stats.cost)
}
if r.Partial || r.Unaccounted {
t.Fatalf("a healthy run was flagged: partial=%v unaccounted=%v", r.Partial, r.Unaccounted)
}
// The metrics directory is disposable once the final record was read.
dir := filepath.Dir(recordedMetricsPath(t, work))
if dirExists(dir) {
t.Fatalf("a healthy run left its metrics directory behind: %s", dir)
}
}
// The snapshot a killed child leaves is the only evidence of what it spent, so
// recovering the numbers must not also destroy the file they came from.
func TestSuiteRunKeepsTheSnapshotItRecovered(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "killed-task", Prompt: "do the thing"}
body := `{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,` +
`"currency":"USD","complete":false}`
cfg := suiteConfig{bin: stubScript(t, "stub-partial.sh", body, ".partial", 137)}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err == nil {
t.Fatal("the stub exits 137; a leg that died must be reported as failed")
}
path := recordedMetricsPath(t, work)
if fileExists(path) {
t.Fatalf("a killed child cannot have written a final record: %s", path)
}
if !fileExists(path + ".partial") {
t.Fatalf("the recovered snapshot was deleted with the metrics directory: %s.partial", path)
}
if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
t.Fatalf("the snapshot was kept but not recovered: tokens=%d/%d",
r.PromptTokens, r.CompletionTokens)
}
// Recovering the numbers must not also mark the run unmeasured: the snapshot
// is evidence, so the run stays accounted and Partial marks it a lower bound.
if r.Unaccounted || !r.Partial {
t.Fatalf("a recovered snapshot was not reported as accounted but partial: partial=%v unaccounted=%v",
r.Partial, r.Unaccounted)
}
}
// A lower bound belongs to the run, not to its last leg: a complete second leg
// cannot restore the tail a killed first leg stopped short of, so once any leg
// ends at a snapshot the whole run stays partial — and still counts.
func TestSuitePartialFirstLegMakesTheWholeRunALowerBound(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "two-leg-partial", Prompt: "do the thing", MaxSteps: 10}
cfg := suiteConfig{bin: twoLegStub(t, "stub-two-leg.sh"), segments: 2}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("both legs exit 0; the run must not fail: %v", err)
}
if r.Segments != 2 {
t.Fatalf("the run did not take both legs: segments=%d", r.Segments)
}
// The second leg's completeness says nothing about the first leg's tail.
if !r.Partial || r.Unaccounted {
t.Fatalf("a complete last leg erased the first leg's lower bound:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
// Partial bounds the account; it does not exclude it. Both legs are counted.
if r.PromptTokens != 1500 || r.CompletionTokens != 100 {
t.Fatalf("the legs' spend was not folded together: tokens=%d/%d, want 1500/100",
r.PromptTokens, r.CompletionTokens)
}
stats := gatherSuiteStats([]result{r})
if stats.accounted != 1 || stats.unaccounted != 0 || stats.partial != 1 || stats.pTok != 1500 {
t.Fatalf("a two-leg partial run changed category:\n"+
" accounted=%d unaccounted=%d partial=%d pTok=%d, want 1/0/1/1500",
stats.accounted, stats.unaccounted, stats.partial, stats.pTok)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the run was counted but not disclosed as a lower bound:\n%s", out)
}
}
// TestMeterAccountingSkipsAnUnaccountedRunWithTokens pins the guard: a run the
// suite marked unmeasured stays out of the ratio even when a harness number is
// present, so a run with no record is not priced.
func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) {
r := result{
task: task{ID: "killed"},
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
Unaccounted: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("an unaccounted run with a harness number was priced as a self-report divergence:\n%s", got)
}
}
// The same guard has to cover a lower bound: a Partial run's harness side stops
// at its last snapshot while the meter saw the whole leg, so pricing it reads as
// a divergence that is only the missing tail.
func TestMeterAccountingSkipsAPartialRunWithTokens(t *testing.T) {
r := result{
task: task{ID: "lower-bound"},
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
Partial: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("a partial run's missing tail was priced as a self-report divergence:\n%s", got)
}
// Its meter tokens are still a real measurement, so the totals keep them.
if !strings.Contains(got, "tokens 1,280") {
t.Fatalf("skipping the ratio also dropped the run's metered tokens:\n%s", got)
}
}
+1 -2
View File
@@ -153,8 +153,7 @@ func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error]
}
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")]
id := trajectoryTaskID(path)
records, digest, err := readSFTSnapshot(path)
if err != nil {
return nil, err
+60
View File
@@ -0,0 +1,60 @@
## e2ebench A/B: `a.json` vs `b.json`
| Metric | A | B |
|---|---:|---:|
| Solved | 1/2 (50%) | 1/2 (50%) |
| Pass@1 | 50% | 50% |
| TTFT median | 0ms | 0ms |
| TTCS median | 1m00s | 45.0s |
| TTCS p90 | 1m00s | 45.0s |
| Cache hit | n/a | n/a |
| First-request cache hit | n/a | n/a |
| Overthinking damage | n/a | n/a |
| Model requests / solved | 16.0 | 14.0 |
| Planner requests / solved | 0.0 | 0.0 |
| Model rounds / solved | 0.0 | 0.0 |
| Tool calls / solved | 24.0 | 24.0 |
| Tokens / solved | 2400.0 | 2200.0 |
| Wall seconds / solved | 150.0 | 135.0 |
| Cost / solved | 0.1 | 0.0 |
**Marginal utility (A − B):** accuracy +0.0pp · wall/task +7.5s
| Class | A solved | B solved | Δ accuracy | A wall/task | B wall/task | Δ wall |
|---|---:|---:|---:|---:|---:|---:|
| bugfix | 1/1 | 1/1 | +0.0pp | 60.0s | 45.0s | +15.0s |
| perf | 0/1 | 0/1 | +0.0pp | 90.0s | 90.0s | +0.0s |
### Pareto: accuracy vs TTCS
```
Accuracy
100% |
|
|
|
|
|
| B ✗
|
40% |
+----------------------------------------------→ TTCS
43.5s 1m01s
A=a B=b
```
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
### Reproduction
- capability set: unknown (a report records no capability set)
- task prompts: same 2 task ids and prompts
- not compared: commit, build, configuration, grader, task files other than the prompt
| Rate | A | B | B − A (95% CI) | |
|---|---:|---:|---:|---|
| grader pass (solvable) | 1/2 | 1/2 | +0.0pp (-57.3, +57.3) | within noise |
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves.</sub>
+36
View File
@@ -0,0 +1,36 @@
## e2ebench comparison: 2 arms
| Arm | Pass@1 | Solved | TTFT | TTCS median | TTCS p90 | Solved/hour | 1st-req cache | Requests/solved | Tokens/solved | Cost/solved |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `a` | 50% | 1/2 | 0ms | 1m00s | 1m00s | 24.0 | n/a | 16.0 | 2,400 | 0.0500 |
| `b` | 50% | 1/2 | 0ms | 45.0s | 45.0s | 26.7 | n/a | 14.0 | 2,200 | 0.0400 |
### Pareto: accuracy vs TTCS
```
Accuracy
100% |
|
|
|
|
|
| B ✗
|
40% |
+----------------------------------------------→ TTCS
43.5s 1m01s
A=a B=b
```
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
### Per-class winners
| Class | `a` | `b` | Winner |
|---|---:|---:|---|
| bugfix | 100% · 1m00s | 100% · 45.0s | b |
| perf | 0% · — | 0% · — | a |
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves; TTCS charges a retried solve with its failed attempts' wall.</sub>
+25 -9
View File
@@ -20,9 +20,9 @@ type trajectorySummary struct {
SpanMs int64 `json:"span_ms"`
ToolMs int64 `json:"tool_ms"`
ModelMs int64 `json:"model_ms"` // SpanMs − tool wall clock, floored at zero
// Round decomposition: a round's gap runs from the turn start (or the
// batch's last tool_result) to the next top-level tool_dispatch; the
// final segment to the last record is the answer round.
// ModelRounds counts executor round trips: one per batch of top-level calls
// plus the answer segment. Calls a subagent or a running tool (run_script)
// makes are not top-level and cut no round; retries in a gap add none.
ModelRounds int `json:"model_rounds"`
ModelGapTotalMs int64 `json:"model_gap_total_ms"`
ModelGapP95Ms int64 `json:"model_gap_p95_ms"`
@@ -105,6 +105,9 @@ type trajectorySummary struct {
PromptTokensSeen int64 `json:"prompt_tokens_seen,omitempty"` // Σ prompt tokens (schema share denominator)
PrefixResets int `json:"prefix_resets,omitempty"` // usage events with prefixChanged
ConnectCalls int `json:"connect_calls,omitempty"` // connect_tool_source dispatches
// NestedCalls counts results of calls a running tool made through the
// agent's invoker; they are outside TopLevelCalls and every round figure.
NestedCalls int `json:"nested_calls,omitempty"`
// Cold/warm evidence: the first top-level request's cache split. A cold
// session pays the whole prefix as miss; a warmed one starts near-hit.
@@ -421,9 +424,21 @@ func (t *trajScan) record(rec trajectoryRecord) {
}
t.compFrom = 0
}
if rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
// Subagent calls overlap the parent's wall clock; counting them
// would double-book the span and split the parent's rounds.
t.recordTool(rec)
}
// recordTool books a top-level tool event to its round and batch.
func (t *trajScan) recordTool(rec trajectoryRecord) {
tl := rec.Event.Tool
if tl == nil {
return
}
if t.nesting.child(rec.Event.Kind, tl.ID, tl.ParentID) {
// Nested calls overlap their caller's wall clock; counting them would
// double-book the span and split the caller's round.
if tl.ParentID == "" && rec.Event.Kind == "tool_result" {
t.s.NestedCalls++
}
return
}
switch rec.Event.Kind {
@@ -434,9 +449,10 @@ func (t *trajScan) record(rec trajectoryRecord) {
}
}
// closeGap ends one model round; recovery-tainted rounds are booked apart so
// clean latency stays comparable across providers with different flake rates.
// The gap is queued until its batch closes and can classify the round.
// closeGap ends one model round: the gap from the turn start or the last
// top-level tool_result to the next top-level tool_dispatch. Recovery-tainted
// rounds are booked apart so clean latency stays comparable across providers
// with different flake rates. The gap is queued until its batch classifies it.
func (t *trajScan) closeGap(gap int64) {
t.gaps = append(t.gaps, gap)
t.pendingGaps = append(t.pendingGaps, gapInfo{
+59
View File
@@ -625,3 +625,62 @@ func TestSummarizeTrajectoryCountsRefusedRoundsAsWaste(t *testing.T) {
t.Error("a refused round must carry its gap time into the waste breakdown")
}
}
// A run_script call's own calls run under ids derived from the script's, inside
// its dispatch→result window. They are one model request, not one per call, so
// they must neither cut model rounds nor reach round outcomes as top-level work.
func TestSummarizeTrajectoryFoldsScriptCallsIntoTheirRound(t *testing.T) {
path := filepath.Join(testenv.TempDir(t), "script.trajectory.jsonl")
lines := []string{
`{"ts":1000,"event":{"kind":"turn_started"}}`,
`{"ts":1900,"event":{"kind":"usage","usage":{"promptTokens":10}}}`,
`{"ts":2000,"event":{"kind":"tool_dispatch","tool":{"id":"call_s","name":"run_script"}}}`,
`{"ts":2100,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.1","name":"read_file","readOnly":true}}}`,
`{"ts":2200,"event":{"kind":"tool_result","tool":{"id":"call_s.1","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2100,"endedAt":2200}}}`,
`{"ts":2300,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.2","name":"read_file","readOnly":true}}}`,
`{"ts":2400,"event":{"kind":"tool_result","tool":{"id":"call_s.2","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2300,"endedAt":2400}}}`,
`{"ts":2500,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.3","name":"edit_file"}}}`,
`{"ts":2600,"event":{"kind":"tool_result","tool":{"id":"call_s.3","name":"edit_file","durationMs":100,"startedAt":2500,"endedAt":2600}}}`,
`{"ts":2700,"event":{"kind":"tool_result","tool":{"id":"call_s","name":"run_script","durationMs":700,"startedAt":2000,"endedAt":2700}}}`,
`{"ts":3900,"event":{"kind":"usage","usage":{"promptTokens":20}}}`,
`{"ts":4000,"event":{"kind":"turn_done"}}`,
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
s, err := summarizeTrajectory(path)
if err != nil {
t.Fatalf("summarizeTrajectory: %v", err)
}
if s.ExecutorRequests != 2 {
t.Fatalf("executor requests = %d, want 2", s.ExecutorRequests)
}
if s.ModelRounds != 2 {
t.Errorf("model rounds = %d, want 2 (script round + answer), one per model request", s.ModelRounds)
}
if s.ModelGapTotalMs != 2300 {
t.Errorf("model gap total = %d, want 2300 (1000 before the script, 1300 after it)", s.ModelGapTotalMs)
}
if s.TopLevelCalls != 1 || s.ToolBatches != 1 {
t.Errorf("top-level calls/batches = %d/%d, want 1/1", s.TopLevelCalls, s.ToolBatches)
}
if s.NestedCalls != 3 {
t.Errorf("nested calls = %d, want 3", s.NestedCalls)
}
if s.ToolMs != 700 {
t.Errorf("tool ms = %d, want 700 (nested time is inside the script's)", s.ToolMs)
}
total := 0
for _, n := range s.RoundOutcomes {
total += n
}
if total != 2 || s.RoundOutcomes["finalization"] != 1 {
t.Errorf("round outcomes = %v, want two rounds, one of them finalization", s.RoundOutcomes)
}
if ends := roundEnds(path); len(ends) != 1 || ends[0] != 2700 {
t.Errorf("round ends = %v, want [2700]", ends)
}
if split := splitAtCorrect(path, 0); split.RoundsAfter != 1 || split.CallsAfter != 1 {
t.Errorf("split = %+v, want one round and one call after the cutoff", split)
}
}
+85
View File
@@ -1,8 +1,13 @@
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func trialResult(id string, trial int, passed bool) result {
@@ -68,3 +73,83 @@ func TestTrialsLineIsSilentWithoutTrials(t *testing.T) {
t.Fatalf("line = %q, want nothing for a run without trials", got)
}
}
// trajectoryAgent stands in for the reasonix binary and writes a one-record
// trajectory wherever --trajectory points, so a test can see which files a run
// leaves behind.
func trajectoryAgent(t *testing.T) string {
t.Helper()
requireShellStub(t)
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
while [ $# -gt 0 ]; do
case "$1" in
--trajectory) printf '{"ts":1,"event":{"kind":"turn_done"}}\n' > "$2"; shift 2;;
--metrics) printf '{"steps":1,"complete":true}' > "$2"; shift 2;;
*) shift;;
esac
done
exit 0
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return bin
}
// Each trial records its own trajectory: a shared name would leave only the
// last trial's file, and every digest of the earlier ones would read it.
func TestTrialsKeepOneTrajectoryPerTrial(t *testing.T) {
dir, taskDir := testenv.TempDir(t), testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(taskDir, "prompt.md"), []byte("p"), 0o644); err != nil {
t.Fatal(err)
}
cfg := suiteConfig{bin: trajectoryAgent(t), trials: 2, trajDir: dir}
total := 0
results := runTrials(cfg, task{ID: "demo", Prompt: "p", MaxSteps: 4, TimeoutSec: 60, dir: taskDir}, &total)
if len(results) != 2 {
t.Fatalf("results = %d, want 2", len(results))
}
got, err := filepath.Glob(filepath.Join(dir, "*.trajectory.jsonl"))
if err != nil {
t.Fatal(err)
}
for i := range got {
got[i] = filepath.Base(got[i])
}
slices.Sort(got)
want := []string{"demo.trial-1.trajectory.jsonl", "demo.trial-2.trajectory.jsonl"}
if !slices.Equal(got, want) {
t.Fatalf("trajectory files = %v, want %v", got, want)
}
for _, r := range results {
if r.Trajectory == nil || filepath.Base(r.Trajectory.Path) != want[r.Trial-1] {
t.Errorf("trial %d digest = %+v, want it read from %s", r.Trial, r.Trajectory, want[r.Trial-1])
}
}
}
// A single trial keeps the name every reader already knows.
func TestSingleTrialKeepsTheTaskTrajectoryName(t *testing.T) {
if got := trajectoryStem("demo", 1, 1); got != "demo" {
t.Fatalf("stem = %q, want demo", got)
}
if got := trajectoryStem("demo", 0, 0); got != "demo" {
t.Fatalf("stem = %q, want demo", got)
}
}
// Readers that need the task behind a file strip the trial and segment marks.
func TestTrajectoryTaskIDReadsEveryFileShape(t *testing.T) {
for name, want := range map[string]string{
"demo.trajectory.jsonl": "demo",
"demo.trial-2.trajectory.jsonl": "demo",
"demo.seg3.trajectory.jsonl": "demo",
"demo.trial-2.seg3.trajectory.jsonl": "demo",
"a.b.trial-10.trajectory.jsonl": "a.b",
} {
if got := trajectoryTaskID(filepath.Join("d", name)); got != want {
t.Errorf("trajectoryTaskID(%s) = %q, want %q", name, got, want)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"os/exec"
"strings"
"testing"
)
// The host runs a scheduled child through the small schedulerun package. The
// command line is not part of what it ships, and importing it again for one
// entry point would bring back every command it carries.
func TestTheHostDoesNotCarryTheCommandLine(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Skipf("go list is not available: %v", err)
}
for pkg := range strings.FieldsSeq(string(out)) {
if pkg == "reasonix/internal/frontend/cli" {
t.Fatal("the studio host depends on the command line package")
}
}
}
+62 -8
View File
@@ -18,7 +18,9 @@ import (
"os"
"os/signal"
"path/filepath"
"reasonix/internal/ext/modhost"
"strings"
"sync"
"syscall"
"time"
@@ -32,6 +34,7 @@ import (
"reasonix/internal/contract/provider"
"reasonix/internal/contract/surface"
"reasonix/internal/frontend/remotehost"
"reasonix/internal/frontend/schedulerun"
"reasonix/internal/frontend/serve"
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
@@ -47,6 +50,7 @@ import (
// Kinds register from init, so a binary builds only what it links. Without
// these every Anthropic model answers "unknown kind" at switch time.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
)
@@ -120,6 +124,10 @@ func main() {
if handled, code := update.MaybeRunTreeHandoff(os.Args[1:]); handled {
os.Exit(code)
}
// A scheduled run is this same binary started by the in-kernel ticker.
if scheduleChildArgs(os.Args[1:]) {
os.Exit(schedulerun.Exec(os.Args[3:]))
}
page := flag.String("page", "", "directory holding the built Studio page")
identity := flag.Bool("instance-id", false, "print the Studio instance this data home belongs to, and exit")
// The shell around this process knows which build it is; this process does
@@ -139,6 +147,8 @@ func main() {
if *stripGrants {
os.Exit(stripPackageGrants(os.Stdout, os.Stderr, *studioApp))
}
// Studio's own binary runs a Claude Code mod's module as Node.
modhost.SetEmbedder(*studioApp, os.Environ())
// Which launches are the same Studio is Reasonix's question, not a shell's:
// the answer is the canonicalized data home, and a shell asks for it rather
// than working it out from an environment it does not resolve.
@@ -156,14 +166,47 @@ func main() {
}
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell, driver)
releaseFatalLog()
releaseFatalLog, exitedFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
logs := &lastLine{w: os.Stderr}
code := run(parentLease(os.Stdin), os.Stdout, logs, *page, shell, driver)
if code == 0 {
releaseFatalLog()
} else {
exitedFatalLog(code, logs.last())
}
os.Exit(code)
}
// logsDirOf is the shell's logs directory: the crash directory it passed is
// the "crash" folder inside it.
// lastLine passes everything through and remembers the last line written, which
// is where run says why it is returning a failure code.
type lastLine struct {
w io.Writer
mu sync.Mutex
line string
}
func (l *lastLine) Write(p []byte) (int, error) {
for line := range strings.SplitSeq(strings.TrimRight(string(p), "\r\n"), "\n") {
if strings.HasPrefix(line, "reasonix-studio-host:") {
l.mu.Lock()
l.line = strings.TrimSpace(line)
l.mu.Unlock()
}
}
return l.w.Write(p)
}
func (l *lastLine) last() string {
l.mu.Lock()
defer l.mu.Unlock()
if l.line == "" {
return "no reason was reported"
}
return l.line
}
func logsDirOf(crashDir string) string {
if strings.TrimSpace(crashDir) == "" {
return ""
@@ -216,6 +259,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
// After the socket has drained, never before: a pane torn down under a live
// request answers it with a half-closed kernel.
defer hub.Shutdown()
startScheduleTicker(ctx, lease != nil, hub, logs)
bound, err := bind(hub.Handler())
phases.mark(startupBind)
@@ -342,7 +386,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
// This window is the only client of its kernel, so a system notification
// reaches the person who asked for it. Every pane gets the same wrapper,
// not just the one the launch started with.
notifications, notifySet := hostNotifications(cfg)
notifications, notifySet, phoneLink := hostNotifications(cfg)
// One fold behind the status icon: every pane's events on the way through,
// so the tray surface answers from what the panes did rather than from a
// count the shell kept for itself.
@@ -430,7 +474,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
return nil, err
}
hub.StartRecoveryGC(ctx)
startCloudRemote(ctx, cfg, logs, hub)
startCloudRemote(ctx, cfg, logs, hub, phoneLink)
phases.mark(startupBackground)
return hub, nil
}
@@ -456,7 +500,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
}
}
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService, phones *notify.PhoneLink) {
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
fmt.Fprintf(logs, "remote cloud: %v\n", err)
@@ -485,6 +529,12 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
}
})
}
// Phone notifications leave through this connection, under this Studio's own
// device credential.
phones.Set(host)
if registrar, ok := tasks.(interface{ SetCloudPhones(serve.CloudPhones) }); ok {
registrar.SetCloudPhones(host)
}
go host.Run(ctx)
}
@@ -529,13 +579,17 @@ func hostServeConfig(cfg config.ServeConfig) config.ServeConfig {
// says today, because deciding here would make "notify me" mean "notify me
// after a restart". The shared [notifications] config is the one setting this
// window and the CLI both answer to.
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings) {
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings, *notify.PhoneLink) {
set := notify.NewSettings(config.NotificationsConfig{})
if cfg != nil {
set.Store(cfg.Notifications)
}
sender := notify.NewPlatformSender()
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set) }, set
// One reporter for every runtime, so a burst across panes folds into one
// report; its sender is the account connection, attached once that is up.
link := &notify.PhoneLink{}
phone := notify.NewPhone(link)
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set).WithPhone(phone) }, set, link
}
// studioTray answers for a shell that can put an icon back whenever the setting
+43 -1
View File
@@ -480,7 +480,7 @@ func TestHostNotificationsCarryTheSharedSettingLive(t *testing.T) {
{Notifications: config.NotificationsConfig{Enabled: true}},
nil,
} {
wrap, set := hostNotifications(cfg)
wrap, set, _ := hostNotifications(cfg)
if got := wrap(plain); got == event.Sink(plain) {
t.Fatalf("runtime was left unwrapped, so the switch could never reach it (cfg %+v)", cfg)
}
@@ -551,3 +551,45 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
t.Fatalf("after the window started, the desktop posture is %q, want the shipped auto released", got)
}
}
type phoneReports chan [2]any
func (p phoneReports) NotifyPhones(_ context.Context, kind string, count int) error {
p <- [2]any{kind, count}
return nil
}
// A runtime's own events, through the wrapper every pane gets, reach the account
// connection as a kind and a count and nothing else, and only once the switches
// ask for it.
func TestAnApprovalOnAPaneReachesThePhoneLinkAsATypeAndACount(t *testing.T) {
cfg := &config.Config{Notifications: config.NotificationsConfig{Phone: config.PhoneNotificationsConfig{Enabled: true, Approval: true}}}
wrap, set, link := hostNotifications(cfg)
reports := make(phoneReports, 4)
link.Set(reports)
sink := wrap(serve.NewBroadcaster())
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "1", Tool: "bash", Subject: "git push --force"}})
select {
case got := <-reports:
if got != [2]any{"approval", 1} {
t.Fatalf("reported %v, want a type and a count and no text", got)
}
case <-time.After(2 * time.Second):
t.Fatal("an approval on a pane did not reach the phone link")
}
sink.Emit(event.Event{Kind: event.AskRequest})
select {
case got := <-reports:
t.Fatalf("reported %v for a kind whose switch is off", got)
case <-time.After(100 * time.Millisecond):
}
set.Store(config.NotificationsConfig{})
sink.Emit(event.Event{Kind: event.ApprovalRequest})
select {
case got := <-reports:
t.Fatalf("reported %v after the master switch was turned off", got)
case <-time.After(100 * time.Millisecond):
}
}
@@ -0,0 +1,63 @@
package main
import (
"context"
"fmt"
"io"
"path/filepath"
"reasonix/internal/base/filelock"
"reasonix/internal/frontend/serve"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/runtime/scheduler"
)
// scheduleChildArgs says argv is a supervised scheduled run: the supervisor
// starts this binary as `schedule exec <id>`, and this host has no CLI of its own.
func scheduleChildArgs(args []string) bool {
return len(args) >= 2 && args[0] == "schedule" && args[1] == "exec"
}
// scheduleEligible is when this kernel may start scheduled runs: it has a
// supervised lifetime (a parent that ends it) and something the person can see.
// An orphan, or a kernel nobody can see, never spends on anyone's behalf.
func scheduleEligible(supervised, iconLive bool, panes int) bool {
return supervised && (iconLive || panes > 0)
}
// startScheduleTicker runs the in-kernel driver of scheduled runs. Every kernel
// on the machine may run it; the ticker lock lets one tick at a time.
func startScheduleTicker(ctx context.Context, supervised bool, hub *serve.Hub, logs io.Writer) {
if !supervised {
return
}
loop := &scheduler.Loop{
Eligible: func() bool { return scheduleEligible(supervised, (&studioTray{}).IconLive(), len(hub.Runtimes())) },
Lock: func() (func(), error) {
store, _, err := scheduler.OpenUserStore(nil)
if err != nil {
return nil, err
}
return filelock.TryAcquire(filepath.Join(store.Dir(), "scheduler.lock"))
},
Tick: func(ctx context.Context) (scheduler.Result, error) {
store, policy, err := scheduler.OpenUserStore(nil)
if err != nil {
return scheduler.Result{}, err
}
return (&scheduler.Scheduler{Store: store, Policy: policy, Runner: &schedrun.Supervisor{Store: store, Policy: policy}}).Tick(ctx)
},
Done: func(res scheduler.Result, err error) {
for _, r := range res.Reports {
fmt.Fprintf(logs, "schedule: ran %s: %s %s\n", r.TriggerID, r.State, r.Code)
}
for _, s := range res.Skipped {
fmt.Fprintf(logs, "schedule: skipped %s: %s\n", s.ScheduleID, s.Reason)
}
if err != nil {
fmt.Fprintln(logs, "schedule:", err)
}
},
}
go loop.Run(ctx)
}
@@ -0,0 +1,31 @@
package main
import (
"strings"
"testing"
)
func TestAScheduleChildIsRecognisedByItsArguments(t *testing.T) {
for args, want := range map[string]bool{"schedule exec sch_x/1": true, "schedule exec": true, "schedule list": false, "exec schedule": false, "": false} {
if got := scheduleChildArgs(strings.Fields(args)); got != want {
t.Errorf("%q = %v, want %v", args, got, want)
}
}
}
// An orphan, or a kernel nobody can see, never starts a scheduled run.
func TestSchedulesStartOnlyForASupervisedKernelThatCanBeSeen(t *testing.T) {
cases := []struct {
supervised, icon bool
panes int
want bool
}{
{true, true, 0, true}, {true, false, 1, true}, {true, true, 3, true},
{true, false, 0, false}, {false, true, 2, false}, {false, false, 0, false},
}
for _, c := range cases {
if got := scheduleEligible(c.supervised, c.icon, c.panes); got != c.want {
t.Errorf("%+v = %v", c, got)
}
}
}
+1
View File
@@ -12,6 +12,7 @@ import (
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
_ "reasonix/internal/tools/builtin"
+279 -1
View File
@@ -2,11 +2,102 @@
status: active
owner: @esengine
backup: @SivanCola
reviewed: 2026-10-13
reviewed: 2026-10-18
---
# Foundation palette verification
## Current preset editor verification / 当前预设编辑页验收
final result: blocked
This section supersedes earlier preset-card visual acceptance below. The selected
source is the user's attached `exec-e196c6a9-d8d9-437d-a6ee-0e99a3651845.png`,
1487 × 1058 image pixels; CSS viewport and density are unspecified.
The image specifies a catalog followed by a separate content editor, not a drawer. The existing
Settings shell remains the application-owned frame.
The latest editor instruction supersedes the original advanced disclosure:
the user's `codex-clipboard-c3951359-4571-4572-ad30-9f4517a2f7db.png` reference
places both textareas and the runtime-context switch directly in the editor.
Scope and ID are also exposed. Settings navigation now reads Persona / 人设.
本节取代下方旧预设卡片的视觉验收。采用用户重新指定的首张效果图,列表与独立
编辑页切换,保留现有设置外框。编辑态为新建、名称与用途已填写、能力及高级设置
收起、个人范围、取消与保存操作固定在底部。
- Implementation screenshot: unavailable. Browser acquisition of the local
preview was rejected by the browser URL security policy. No alternative browser
surface or raw browser command was used to bypass the refusal.
- Viewport, implementation pixel dimensions, CSS size and density normalization:
unmeasured. Full-view and focused visual comparisons remain blocked.
- Typography: existing Studio family and weight tokens retained; rendering,
wrapping and optical weight require browser comparison.
- Spacing/layout: separate editor, expandable rows and flex footer implemented;
actual proportions, scrolling and narrow-screen overflow remain unverified.
- Colors: existing surface, text, focus and border tokens reused; rendered theme
contrast remains unverified.
- Assets: existing product icons retained; no generated product assets or new
handcrafted icon geometry. Source brand is supplied by the existing shell.
- Copy: name, purpose, persona, capabilities, scope and ID follow the selected
references, with Chinese/English labels. Existing backend limits remain
authoritative rather than adopting illustrative mockup character limits.
- Interaction regressions cover creation, copy, literal persona fields, invalid
parameters, failed saves, navigation/close confirmation, pending saves and focus.
These are jsdom checks and do not substitute for browser visual evidence.
The catalog-button follow-up uses the user's 1374 × 916 browser annotation as
the before evidence. The create button now uses one horizontal flex row, a
36px minimum height and 16px horizontal padding.
The heading uses a two-column
grid, with its description on a separate full-width row. After-render visual
verification remains blocked by the browser policy described above.
Remaining checklist:
1. Acquire the authorized preview and capture the specified creation state and catalog.
2. Compare the full view and editor regions; verify light/dark, narrow scrolling and console output.
3. Resolve P0–P2 findings before marking visual acceptance passed.
Earlier passing sections below are historical evidence for their own changes.
待办:恢复浏览器访问后,在相同编辑状态比较整页与局部,并检查深浅主题、窄屏
滚动及控制台;解决 P0–P2 问题后再将视觉验收标记为通过。
## Built-in cards and new-task default / 内置卡片与新任务默认
final result: blocked
Source: the user's selected Harness screenshot
`codex-clipboard-be3c8df5-ada5-4683-a66d-81cdf31010e7.png`, 1178 × 760 image pixels.
It is a section crop; viewport, CSS size and density are unspecified.
Implementation capture and full-view/focused comparison remain unavailable
because browser observation was rejected by the URL security policy. No
alternative browser surface was used to bypass that refusal.
- Typography: inherited Studio font, scale and weight tokens. Rendering remains
unverified; source-specific font substitution was not introduced.
- Layout: responsive two-column cards, large rounded borders, equal-height bodies,
separate footer actions and single-column narrow layouts. Rendered rhythm and
scrolling remain unverified.
- Color: existing surface, border, text and selected tokens. The selected badge
uses the text/page pair. Theme contrast still needs browser inspection.
- Assets: supplied product icon library reused for copy; no new raster assets
or handcrafted icon geometry.
- Content: Standard/Minimal remain the shipped modes; IDs remain canonical.
Details, usage and copy remain separate from the card's default selection.
- Behavior: card clicks persist a machine-local built-in choice. New local and
remote requests carry it explicitly; history and explicit per-task choices
retain priority. Old server preferences alone remain inert.
待浏览器恢复后,对照所选卡片截图检查字体、留白、颜色、图标和文案,并覆盖
深浅主题、窄屏、默认状态及展开的使用说明。当前功能测试不能替代视觉验收。
## Historical palette verification / 历史配色验收
final result: passed
Clear Blue and Tea Green implement the two approved colour directions for
@@ -84,3 +175,190 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow.
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
# Composer composition placement / 输入框组合布局
final result: passed
The annotated request moves the existing composition selector and management
entry beside the run status. It preserves the existing screen, visual tokens
and session behavior. / 按标注将组合选择与管理入口移到运行状态旁,沿用现有界面。
## Evidence / 证据
- Source visual truth: author-approved composer annotation (1310 × 1826 pixels; browser chrome included, density unspecified). The user attachment remains private; the synthetic baseline below records the same controls.
- Matched source capture: `docs/images/agent-compositions/composer-before-light-wide.png`.
- Implementation: `docs/images/agent-compositions/composer-light-wide.png`.
- Full-view comparison: the matched before/after captures above were opened together, each 1440 × 1000 pixels, 1440 × 1000 CSS px, device scale factor 1. No density resampling.
- Focused comparison: composer controls from those captures were compared together at original pixel density; `composer-context-light.png` records the final control arrangement.
- Additional captures: `composer-light-390.png`, `composer-dark-390.png` (390 × 844 CSS/pixel dimensions), and `composer-dark-wide.png` (1440 × 1000), all in the same image directory.
- State: local Mock preview, Chinese, idle, General selected, existing input draft and missing-key notice. No provider request submitted.
## Findings / 结论
No actionable P0/P1/P2 differences remain. / 无需修复的 P0/P1/P2 差异。
- Typography: existing UI family, 11 px medium-weight controls and status typography retained; long names remain bounded by the selector.
- Layout: selector begins 16 px after run status; controls occupy the previously unused top row. The bottom toolbar fits one row at 1440 px. At 390 px the selector, status and management entry remain visible, with document width exactly 390 px.
- Colors: existing semantic theme tokens and hover/focus styles reused in both light and dark mode; no new palette.
- Assets: logo and icons preserved; no generated or replacement imagery.
- Content: composition names, source labels, error notice and management action retained. The placement is the only intentional visual change.
- Accessibility: composition controls sit outside `role="status"` and its live announcements. Accessible names and existing lock behavior are retained.
## Checks / 验证
- Switched General → Code review → General through the relocated selector.
- Opened Agent presets through the relocated management entry, then closed it.
- Confirmed the selector is absent from the bottom toolbar in the real DOM.
- Browser warning/error log after interaction: empty.
- Typecheck and `make check`: passed.
- Focused interaction tests: 92 passed; full frontend suite: 632 files, 5,220 tests passed.
- Locked-session/new-session flow covered by the existing picker interaction test.
## Comparison history / 对照记录
First rendered comparison passed; no P0/P1/P2 visual repair iteration was needed.
The matched before/after captures demonstrate the requested move and reduced
bottom-toolbar wrapping. / 首轮对照通过,上移后底栏在宽屏减少一行。
## Limits and next check / 范围
Browser-owned layout only; native titlebar and shell behavior are unchanged and
were not re-tested. No provider execution or new CI result is claimed for this
layout edit. / 本次验证浏览器布局,不涉及原生窗口或模型执行。
Checklist complete: placement, selection, management, live-region separation,
light/dark, wide/narrow, typecheck, affected and full frontend tests.
# Unified composition menu / 统一组合菜单
final result: passed
This section supersedes the separate selector/management controls above.
The annotation asks to combine those entries without changing the surrounding
composer. / 本节替代上述独立选择器与管理按钮,按标注合并为一个入口。
## Source and capture / 来源与截图
- Source visual truth: author-approved annotation showing the composition selector and adjacent management button, 452 × 312 pixels; density and full viewport unspecified. The user attachment remains private.
- Current-run baseline: `docs/images/agent-compositions/composition-menu-before.png`, 1440 × 1000 CSS/pixel dimensions, scale factor 1.
- Implementation: `composition-menu-light-wide.png`, `composition-menu-dark-wide.png` (1440 × 1000) and `composition-menu-light-390.png`, `composition-menu-dark-390.png` (390 × 844), all in the same directory, scale factor 1.
- Focused implementation: `composition-menu-detail.png`, 344 × 364 pixels at scale factor 1, including menu and trigger.
- Full-view comparison: `composition-menu-before.png` and `composition-menu-light-wide.png` were opened together. Baseline is closed; implementation is open to assess the added menu and unchanged surrounding composer.
- Focused comparison: the annotation and `composition-menu-detail.png` were opened together without density resampling. Source typography cannot support a 1:1 pixel comparison; the matched viewport supplies the product typography/layout reference.
- State: local Mock preview, Chinese, idle, Code review selected. No provider execution.
## Flow audit / 流程检查
1. Read the active composition: healthy after combining the entries. A single trigger shows name and localized source. The separate ellipsis had no visible explanation; its action is now explicit.
2. Choose a composition: healthy. Menu rows show capability notes and source, mark the current choice, and close after selection. Evidence: `composition-menu-light-wide.png` and `composition-menu-selected.png`.
3. Manage presets: healthy. A separated footer opens the existing management page. Evidence: `composition-menu-manage.png`. Management costs one extra click; frequent switching retains the same click count.
## Fidelity and interaction / 视觉与交互
- Typography: existing UI family and weights; 13 px choice titles and 11 px notes. Source labels sit beside names rather than consuming another row.
- Spacing: existing composer-menu recipe, 320 px menu width, 12 px radius and 8 px inner padding. At 390 px, the menu spans x=64…384 and the document remains exactly 390 px wide.
- Colors: existing surface, overlay, text, muted and focus tokens work in both themes; no new palette.
- Assets: existing chevron and brand assets retained; no new image assets.
- Copy: localized Built in/Personal/Project labels replace raw source identifiers. Management is a named footer action.
- Accessibility: trigger name includes the active composition and lock state; selected rows expose current state. Keyboard arrows skip disabled rows; Enter selects, Escape closes and restores trigger focus. This is scoped interaction evidence, not a full accessibility compliance claim.
- Safety of interaction: started/busy conversations never rebind through this menu. The same menu offers explicitly labeled new-conversation actions and management; hosts without creation support still expose management.
## Iteration and validation / 迭代与验证
- First inspection found source metadata on an extra line, making each choice unnecessarily tall. The item title now uses a flex row; revised captures show compact name/source pairs. No actionable P0/P1/P2 visual issues remain.
- A later resize check found a P2 shared positioning defect: after 390 → 1440 px, menu left remained 88 px while its trigger settled at 570 px. Window resize preceded fold publication and sidebar transition completion.
- Shared floating placement now responds to fold publication and transition completion. The revised dark-wide capture and browser rectangles show both left edges at 570 px.
- A deterministic real-Picker regression fails without the positioning fix and also checks cleanup after closing.
- Verified selection, management, Escape and responsive placement in the browser; warning/error log was empty.
- Regression tests cover keyboard focus, distinct action identities, failed/pending selections, locked sessions, management without creation support, equal names from different sources, and deleted bound presets.
- Final validation after the resize repair: typecheck passed; frontend full suite passed, 633 files and 5,225 tests. Repository checks passed without increasing a budget.
- Native shell behavior and provider execution are outside this layout change; hosted CI and publication status are reported separately in the pull request.
Checklist complete: one entry, grouped choices/actions, clear current state,
retained locks, keyboard access, themes, narrow viewport and regression checks.
## Studio integration verification / Studio 合并验证
The upstream integration preserves the top-row Agent composition trigger and
the unified selection/management menu. The upstream screen-reader-only run
label remains inside the live status region; interactive controls remain
outside it. Composition menu text follows the shared text-size setting.
合并后保留顶部 Agent 组合入口与统一的选择、管理菜单,同时沿用上游运行状态的
读屏处理。浏览器复核覆盖组合切换、管理入口,以及菜单展开时从宽屏缩至窄屏
的定位与换行。完整前端测试通过:651 个文件、5,342 个用例。
| Browser | Light | Dark |
| --- | --- | --- |
| 1440 × 1000 | Capture (local capture) | Capture (local capture) |
| 390 × 844 | Capture (local capture) | Capture (local capture) |
These captures use synthetic Mock data. Native Electron shell behavior was
not requalified by this browser integration check.
## Standard and Minimal modes / 标准与精简模式
The management page references the supplied Harness card hierarchy while using
Studio typography and surface tokens. Only Standard and Minimal are shipped;
custom definitions remain separate. Standard retains its existing stable ID.
Minimal uses native terminal/image tools and compaction without Cordis plugins.
管理页保留内置卡片、默认标记、模式说明与独立自定义区域。宽屏并排,390px
窄屏纵向排列且可滚动。默认选择与当前会话绑定分离;复制后继续使用原编辑器。
入口只展示模式名和箭头,位于左侧;运行状态与 token 位于右侧,来源与锁定
信息保留在菜单、提示和无障碍名称中。
| Browser | Light | Dark |
| --- | --- | --- |
| 1440 × 1000 | Cards (local capture) | Cards (local capture) |
| 390 × 844 | Cards (local capture) | Cards (local capture) |
Composer idle (local capture) and
running (local capture) captures
show the compact mode trigger with separate run information. These are browser
Mock captures; the source annotation image is not published.
The full frontend suite passes 651 files and 5,343 tests. Mechanism checks retain
explicit custom read-only fixtures for proxy, late-registration and child-agent
ceilings. Minimal's terminal is intentionally allowed. Text-only models report
an unavailable image dependency instead of silently changing the tool set.
After the subsequent MCP-follow integration, the full frontend suite passes
651 files and 5,347 tests. Provider-boundary and race regressions confirm that
automatic server-tool approval cannot replace an existing composition's pinned
contract; a new conversation can bind the updated catalog.
The native-video integration passes 651 frontend files and 5,348 tests. Combined
projection regressions retain bindings/video references in stored history while
removing local binding metadata and answered video payloads from model requests.
## Workspace preparation / 工作目录准备
The workspace palette and preparation states reuse existing typography and
tokens. Real Git checks cover branches, creation, retry and cancellation; visual
QA passed. Captures remain in
[PR #13598](https://github.com/esengine/DeepSeek-Reasonix/pull/13598).
工作目录面板与准备状态复用现有字号、图标和表面样式;真实 Git 验证了分支搜索、
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
## Maintainer opt-in revision / 默认关闭调整
Standard and Minimal cards, the left-side name-only picker and right-side run
information are preserved. Preferences are labeled as preferences; they do not
activate a mode. No selection and Standard remain unbound. Complete replacement
is removed from the editor; selected personas supplement first-party guidance.
保留标准/精简卡片、左侧仅模式名的入口及右侧运行信息。首选项不自动启用;
未选择及标准模式不绑定。编辑器移除完整替换开关,人设仅补充内置指令。
Per maintainer request, composition screenshots are local evidence and are not
committed. Historical capture references above describe earlier verification.
Current browser checks and hosted workflow results are reported separately.
按维护者要求,组合截图仅保留为本地证据,不提交到仓库。上方记录为历史验证;
当前浏览器检查与远端工作流结论分开报告。
+6 -4
View File
@@ -135,14 +135,16 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
}
}
// installable reports whether an artifact installs itself rather than expecting
// the reader to place it: a disk image to drag from, an installer to run, a
// package for dpkg.
// installable reports whether an artifact is a download a person is offered: a
// disk image to drag from, an installer to run, a package for dpkg or rpm, or an
// AppImage to run in place. The AppImage is a download only; no native_packages
// key names it, so no client replaces one.
func installable(name string) bool {
return strings.HasSuffix(name, ".dmg") ||
strings.HasSuffix(name, "-installer.exe") ||
strings.HasSuffix(name, ".deb") ||
strings.HasSuffix(name, ".rpm")
strings.HasSuffix(name, ".rpm") ||
strings.HasSuffix(name, ".AppImage")
}
// macArchiveKeys maps a macOS archive to the platforms it can install. The
+33
View File
@@ -340,3 +340,36 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
}
}
}
// An AppImage is a download a person is offered and nothing an updater
// replaces: it must not appear as a platform asset or a native package, and
// adding it must not move any entry a shipped client reads.
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
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, _ := os.ReadFile(filepath.Join(dir, "latest.json"))
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, name := range []string{"ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
a, ok := m.Downloads[name]
if !ok || a.Sig != a.URL+".minisig" {
t.Errorf("downloads[%s] = %+v, %v; want a signed download", name, a, ok)
}
}
if len(m.NativePackages) != 1 || !strings.HasSuffix(m.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
t.Errorf("native packages = %+v, want only the .deb", m.NativePackages)
}
if len(m.Platforms) != 0 {
t.Errorf("platforms = %+v: an AppImage must not resolve as an update", m.Platforms)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

+8
View File
@@ -99,11 +99,19 @@ linux:
target:
- deb
- rpm
# Download only: it does not update itself, and nothing here installs it.
- AppImage
icon: assets/icon.png
category: Development
maintainer: esengine <[email protected]>
artifactName: ReasonixStudio-linux-${arch}.${ext}
appImage:
# Stated rather than inherited: the release vocabulary is the same for every
# Linux file, and canonical-artifact-names.js turns whatever architecture
# spelling electron-builder puts here (x64, x86_64) into amd64 or fails.
artifactName: ReasonixStudio-linux-${arch}.${ext}
deb:
# Named, not defaulted. electron-builder takes the package name from
# package.json's `name` unless told otherwise, which would publish
+10 -2
View File
@@ -15,14 +15,22 @@
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_live.js",
"browser-fit-pages": "electron test/browser_fit_pages.js",
"image-live": "electron test/image_live.js",
"image-pane-live": "electron test/image_pane_live.js",
"sidepanel-live": "electron test/sidepanel_live.js",
"focus-live": "electron test/focus_live.js",
"ime-live": "electron test/ime_live.js",
"freeze-live": "electron test/freeze_live.js",
"windowstate-live": "electron test/windowstate_live.js",
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
"electron": "44.7.0",
"electron": "44.7.2",
"electron-builder": "^26.15.3",
"playwright": "1.64.0"
"playwright": "1.64.1"
},
"pnpm": {
"onlyBuiltDependencies": [
+11
View File
@@ -13,6 +13,10 @@ const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
// macOS tints a template image itself, so the menu-bar glyph is the mark alone,
// black on transparent, framed square around it with a one-pixel margin at 18px.
const TEMPLATE_VIEWBOX = 'viewBox="277 277 470 470"';
const TEMPLATE_SCALES = [[18, ""], [36, "@2x"]];
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
// Linux keep the full-canvas artwork the platform templates expect.
@@ -79,6 +83,13 @@ async function main() {
for (const [scale, suffix] of TRAY_SCALES) {
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
}
const markSvg = svg
.replace(/<rect [^>]*\/>\s*/g, "")
.replace('fill="url(#gold)"', 'fill="#000"')
.replace('viewBox="0 0 1024 1024"', TEMPLATE_VIEWBOX);
for (const [size, suffix] of TEMPLATE_SCALES) {
fs.writeFileSync(path.join(ASSETS, `trayTemplate${suffix}.png`), await render(page, markSvg, size));
}
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
const icoPngs = [];
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
+14 -14
View File
@@ -9,14 +9,14 @@ importers:
.:
devDependencies:
electron:
specifier: 44.7.0
version: 44.7.0([email protected])
specifier: 44.7.2
version: 44.7.2([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
playwright:
specifier: 1.64.0
version: 1.64.0
specifier: 1.64.1
version: 1.64.1
packages:
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
[email protected].0:
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
[email protected].2:
resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -762,13 +762,13 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
[email protected].0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
[email protected].1:
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
engines: {node: '>=20'}
hasBin: true
[email protected].0:
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
[email protected].1:
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
engines: {node: '>=20'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected].0([email protected]):
[email protected].2([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
@@ -1942,11 +1942,11 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
[email protected].0: {}
[email protected].1: {}
[email protected].0:
[email protected].1:
dependencies:
playwright-core: 1.64.0
playwright-core: 1.64.1
[email protected]:
dependencies:
+156 -11
View File
@@ -3,12 +3,20 @@ const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typed } = require("./browserguard");
const { InputWake } = require("./inputwake");
const { KeyboardKeeper } = require("./keyboard");
// The size a page lays out at while nobody is looking at it: the kernel drives
// pages whether or not the panel is open, and a page sized to nothing reads as
// a phone layout or not at all.
const AGENT_VIEWPORT = { width: 1280, height: 900 };
// How long a page may be held out of the window while its navigation runs. A
// server that accepts the connection and never answers would otherwise keep the
// panel blank, with nothing turning, for as long as it likes; past this the page
// is shown again, loaded or not.
const LEAVE_LIMIT_MS = 4000;
// CSS cannot clip a native view, so the page's corners are rounded here; the
// value is .bview's border-radius in studio.css, whose rect the view fills.
const PAGE_RADIUS = 8;
@@ -30,6 +38,8 @@ class BrowserViews {
// for this run only: a restart, or a different certificate, asks again.
this.trusted = new Set();
this.shown = "";
this.keyboard = new KeyboardKeeper({ win });
this.wake = new InputWake({ win });
// Moves with every show and hide, so a freeze that finishes after one does
// not put away a page shown since.
this.placed = 0;
@@ -106,26 +116,43 @@ class BrowserViews {
}
onEvent(method, params);
});
const entry = { view, onDownload, typed: 0 };
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
this.watchLoads(targetId, entry);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
this.entries.delete(targetId);
if (!this.win.isDestroyed()) this.win.contentView.removeChildView(view);
clearTimeout(entry.returning);
if (entry.attached && !this.win.isDestroyed()) this.win.contentView.removeChildView(view);
entry.attached = false;
if (!contents.isDestroyed()) contents.close();
onClosed();
};
contents.once("destroyed", close);
contents.on("render-process-gone", close);
this.entries.set(targetId, entry);
this.win.contentView.addChildView(view);
this.putAway(view);
// The page loads before the window holds it: a page that finishes loading in a
// view already attached is given the keyboard by the platform, and the
// person's composition in the app page ends with it.
void contents.loadURL(url).catch(() => {});
return {
targetId,
send: (method, params) => contents.debugger.sendCommand(method, params),
send: (method, params) => {
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
try {
params = scaleInput(method, params, entry.scale);
} catch (e) {
return Promise.reject(e);
}
// A capture puts the page's emulation back without its scale.
if (method === "Page.captureScreenshot") return contents.debugger.sendCommand(method, params).finally(() => void this.fit(entry, true));
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
entry.synthUntil = Date.now() + 250;
return this.wake.run(() => contents.debugger.sendCommand(method, params));
},
close,
mainFrame: () => mainFrame,
};
@@ -146,7 +173,7 @@ class BrowserViews {
// which during a turn is constantly, and nearly always the same rectangle:
// re-adding the view and setting the bounds it already has is work for
// every one of those.
if (this.shown === targetId && sameRect(entry.at, at)) return;
if (this.shown === targetId && sameRect(entry.panel, at)) return;
this.placed++;
for (const [id, other] of this.entries) {
if (id !== targetId) this.putAway(other.view, other);
@@ -155,19 +182,92 @@ class BrowserViews {
// A page that was parked comes back as a view that is attached but whose
// widget never repaints until the window's geometry changes; attaching it
// again is what wakes it, the same as after a minimize.
if (!entry.at) this.win.contentView.removeChildView(entry.view);
if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
entry.attached = true;
entry.panel = at;
this.fit(entry);
this.keepKeyboard();
}
// fit puts the view where its page is drawn whole and tells the page how it is
// scaled; a page parked out of sight is not scaled. force sends the scale again
// though it has not changed, for a capture that put the page's emulation back.
fit(entry, force = false) {
if (!entry.panel) return Promise.resolve();
const { at, scale } = placement(entry.panel, entry.override && { width: entry.override.width, height: entry.override.height });
entry.view.setBounds(at);
entry.at = at;
return this.scaled(entry, scale, force);
}
scaled(entry, scale, force = false) {
if (!entry.override || (entry.scale === scale && !force)) return Promise.resolve();
entry.scale = scale;
return this.override(entry).catch(() => {});
}
override(entry) {
return entry.view.webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { ...entry.override, scale: entry.scale });
}
// emulate records the viewport a page was told to lay out in, which the view
// is then fitted to; null is the page going back to the size of its view.
async emulate(entry, params) {
entry.override = sized(params) ? { ...params } : null;
if (!entry.override) {
entry.scale = 1;
if (entry.panel) this.fit(entry);
return entry.view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {});
}
entry.scale = 1;
if (entry.panel) {
const { at, scale } = placement(entry.panel, entry.override);
entry.view.setBounds(at);
entry.at = at;
entry.scale = scale;
}
return this.override(entry);
}
// Attaching a view can leave nobody holding the keyboard, and the platform
// settles that a moment later, so the page is handed it back twice.
keepKeyboard() {
this.keyboard.reclaim();
setTimeout(() => this.keyboard.reclaim(), 150);
}
reattach() {
for (const [id, entry] of this.entries) {
if (this.win.isDestroyed() || entry.view.webContents.isDestroyed()) continue;
if (this.win.isDestroyed() || entry.view.webContents.isDestroyed() || !entry.attached) continue;
this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
if (id !== this.shown) this.putAway(entry.view, entry);
}
this.keepKeyboard();
}
// A navigation that starts in an attached view takes the keyboard from the app
// page when it finishes, and a composition in progress there is committed as
// the raw letters. While the app page holds the keyboard the view is held out of
// the window for the length of the navigation, and put back when it ends; a
// person who is in the page themselves keeps it where it is.
leave(entry) {
if (!entry.attached || this.win.isDestroyed() || this.keyboard.owner !== "app") return;
this.win.contentView.removeChildView(entry.view);
entry.attached = false;
clearTimeout(entry.returning);
entry.returning = setTimeout(() => this.settle(entry), LEAVE_LIMIT_MS);
}
settle(entry) {
clearTimeout(entry.returning);
entry.returning = undefined;
if (entry.attached || this.win.isDestroyed() || entry.view.webContents.isDestroyed()) return;
this.win.contentView.addChildView(entry.view);
entry.attached = true;
if (entry.at) entry.view.setBounds(entry.at);
else this.putAway(entry.view, entry);
}
// freeze answers with a picture of the page on screen, taken before it is put
@@ -202,7 +302,11 @@ class BrowserViews {
putAway(view, entry) {
const { height } = this.win.getContentBounds();
view.setBounds({ x: 1 - AGENT_VIEWPORT.width, y: height - 1, ...AGENT_VIEWPORT });
if (entry) entry.at = null;
if (entry) {
entry.at = null;
entry.panel = null;
void this.scaled(entry, 1);
}
}
control(targetId, action) {
@@ -255,8 +359,11 @@ class BrowserViews {
watchLoads(targetId, entry) {
const contents = entry.view.webContents;
contents.on("did-start-navigation", (details) => {
if (details.isMainFrame && !details.isSameDocument) this.report(targetId, null);
if (!details.isMainFrame || details.isSameDocument) return;
this.report(targetId, null);
this.leave(entry);
});
contents.on("did-stop-loading", () => this.settle(entry));
contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => {
// -3 is ERR_ABORTED: a navigation replaced by another, or stopped. A
// typed address reports through loadTyped, which may still retry it.
@@ -366,6 +473,44 @@ async function loadResult(contents, url) {
}
}
// Where a page's view sits in the rectangle the panel gave it. A page laid out
// in a viewport of its own is shown whole: scaled down to fit and never up,
// centred, with the view sized to exactly what it draws.
const sized = (v) => !!v && Number.isFinite(v.width) && Number.isFinite(v.height) && v.width > 0 && v.height > 0;
function placement(panel, emulated) {
if (!sized(emulated)) return { at: panel, scale: 1 };
const scale = Math.min(1, panel.width / emulated.width, panel.height / emulated.height);
const width = Math.max(1, Math.round(emulated.width * scale));
const height = Math.max(1, Math.round(emulated.height * scale));
const x = panel.x + Math.round((panel.width - width) / 2);
const y = panel.y + Math.round((panel.height - height) / 2);
return { at: { x, y, width, height }, scale };
}
// Input that carries a point. The page reads it in the viewport it was told it
// has, the view draws that viewport scaled, and a point meant for the page lands
// where it is drawn only after the same scaling. The mouse is measured to; the
// rest are not, and a point that silently lands elsewhere is worse than a
// refusal, so they are refused while the page is scaled.
const POINTED_INPUT = new Set([
"Input.dispatchTouchEvent",
"Input.dispatchDragEvent",
"Input.emulateTouchFromMouseEvent",
"Input.synthesizeTapGesture",
"Input.synthesizeScrollGesture",
"Input.synthesizePinchGesture",
]);
function scaleInput(method, params, scale) {
if (scale === 1) return params;
if (method === "Input.dispatchMouseEvent") return { ...params, x: params.x * scale, y: params.y * scale };
if (POINTED_INPUT.has(method)) {
throw Object.assign(new Error(`${method} is not available while the page is scaled to fit the panel`), { code: "input_unscaled" });
}
return params;
}
function sameRect(a, b) {
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
@@ -374,4 +519,4 @@ function validRect(rect) {
return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
}
module.exports = { BrowserViews, AGENT_VIEWPORT };
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS, placement, scaleInput };
+22
View File
@@ -0,0 +1,22 @@
"use strict";
// The kernel's gate answers every request without the launch credential with
// 403, and a cookie belongs to the host, not the port, so anything that removes it (an
// eviction, a page clearing cookies for 127.0.0.1) leaves the window refused
// until a relaunch. The shell holds the token, so it puts the cookie back.
function keepCredential(ses, cookie, log) {
const host = new URL(cookie.url).hostname;
const restore = (cause) => {
log(`credential cookie removed (${cause}); restoring`);
ses.cookies.set(cookie).catch((err) => log(`credential cookie restore failed: ${err.message}`));
};
const onChanged = (_event, changed, cause, removed) => {
if (!removed || changed.name !== cookie.name || cause === "overwrite") return;
if (changed.domain.replace(/^\./, "") !== host) return;
restore(cause);
};
ses.cookies.on("changed", onChanged);
return () => ses.cookies.removeListener("changed", onChanged);
}
module.exports = { keepCredential };
+17
View File
@@ -0,0 +1,17 @@
"use strict";
// A person needs longer than this to read a panel and choose in it. A panel
// that closes sooner was never drawn: on Linux a missing file-chooser portal
// answers the same way as a dismissal, so the page would be told the person
// declined and would offer nothing else.
const UNDRAWN_MS = 400;
// folderAnswer is what the page is told about a folder panel: the path chosen,
// "" for a panel the person closed, and null for a panel that could not be shown,
// which the page answers by asking for the path in words.
function folderAnswer(result, elapsedMs, platform) {
if (!result.canceled && result.filePaths.length > 0) return result.filePaths[0];
return platform === "linux" && elapsedMs < UNDRAWN_MS ? null : "";
}
module.exports = { folderAnswer, UNDRAWN_MS };
+64
View File
@@ -0,0 +1,64 @@
"use strict";
// A page in a minimized Windows window has no surface to hit-test, so the mouse
// input the agent sends it is dropped while the call still answers and typed
// text still lands. Input is therefore sent with the window raised without
// activation, fully transparent and deaf to the mouse, and the window is
// minimized again once the agent has been quiet for a moment.
const SETTLE_MS = 300;
const HOLD_MS = 500;
class InputWake {
constructor({ win, platform = process.platform, settleMs = SETTLE_MS, holdMs = HOLD_MS }) {
this.win = win;
this.enabled = platform === "win32";
this.settleMs = settleMs;
this.holdMs = holdMs;
this.raised = null;
this.running = 0;
this.release = this.release.bind(this);
}
// run performs one input call with the window able to take it.
async run(call) {
this.running++;
clearTimeout(this.timer);
try {
await this.raise();
return await call();
} finally {
if (--this.running === 0) this.timer = setTimeout(this.release, this.holdMs);
}
}
raise() {
const win = this.win;
if (!this.enabled || win.isDestroyed()) return Promise.resolve();
if (this.raised) return this.raised;
if (!win.isMinimized()) return Promise.resolve();
this.raised = new Promise((resolve) => setTimeout(resolve, this.settleMs));
try {
win.setOpacity(0);
win.setIgnoreMouseEvents(true);
win.showInactive();
} catch (err) {
this.release();
throw err;
}
return this.raised;
}
release() {
if (!this.raised) return;
this.raised = null;
const win = this.win;
if (win.isDestroyed()) return;
const theirs = win.isFocused();
win.setIgnoreMouseEvents(false);
win.setOpacity(1);
if (!theirs) win.minimize();
}
}
module.exports = { InputWake, SETTLE_MS, HOLD_MS };
+63
View File
@@ -0,0 +1,63 @@
"use strict";
// Who owns the keyboard while the agent drives a page. A person typing in the
// app page keeps typing there: a page the agent opens, navigates or puts on
// screen is given the keyboard by the platform, and nothing the agent does is a
// reason for it to have it. The one reason that counts is the person's own: a
// click or a key that reached the page from the window, which is theirs to
// have chosen. The agent's own input arrives over the debugging protocol and
// is recognised as such, because it reaches the page the same way a click does.
const PERSON_MS = 400;
class KeyboardKeeper {
constructor({ win, now = Date.now }) {
this.win = win;
this.now = now;
this.owner = win.webContents.isFocused() ? "app" : "none";
win.webContents.on("focus", () => {
this.owner = "app";
});
}
// watch follows one page. synthetic() says whether input reaching it right now
// is the agent's own.
watch(contents, synthetic = () => false) {
let personAt = -Infinity;
contents.on("before-input-event", () => {
personAt = this.now();
});
contents.on("input-event", (_event, input) => {
if (input && input.type === "mouseDown" && !synthetic()) personAt = this.now();
});
contents.on("focus", () => {
if (this.now() - personAt < PERSON_MS) {
this.owner = "view";
return;
}
if (this.owner !== "app" || !this.live()) {
this.owner = "view";
return;
}
// The platform is still handing the page the keyboard while this event is
// delivered, and a focus asked for from inside it is lost; one a turn later
// is not, as long as nothing of the person's has arrived by then.
setTimeout(() => {
if (this.now() - personAt < PERSON_MS || this.owner !== "app" || !this.live()) return;
if (contents.isFocused()) this.win.webContents.focus();
}, 0);
});
}
// reclaim gives the keyboard back to the app page when attaching or moving a
// view left nobody holding it, while the window itself is the one in front.
reclaim() {
if (this.owner === "app" && this.live() && !this.win.webContents.isFocused()) this.win.webContents.focus();
}
live() {
return !this.win.isDestroyed() && this.win.isFocused();
}
}
module.exports = { KeyboardKeeper, PERSON_MS };
+12 -5
View File
@@ -22,6 +22,7 @@ const { reveal, revealWorkspace, revealLogs, openWorkspace, openFile } = require
const { appIcon } = require("./appicon");
const { setUnreadBadge } = require("./badge");
const layout = require("./layout");
const { folderAnswer } = require("./folderpanel");
const { offerCleanup } = require("./legacy");
const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
@@ -31,10 +32,12 @@ const { groundFor, themeSourceFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { stateFile, openWindow } = require("./windowstate");
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { processMemoryHandler } = require("./procmem");
const { captureHandler } = require("./capture");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
const { raise } = require("./raise");
const { keepCredential } = require("./credential");
const { actHandler } = require("./acts");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
@@ -279,7 +282,7 @@ function cleanUpLegacyInstalls() {
// it, Strict because no cross-site navigation ever needs to carry it, and no
// expiry so it dies with this session rather than outliving the launch.
async function armCredential(ready) {
await session.defaultSession.cookies.set({
const cookie = {
url: ready.origin + "/",
name: TOKEN_COOKIE,
value: ready.token,
@@ -287,7 +290,9 @@ async function armCredential(ready) {
httpOnly: true,
secure: false,
sameSite: "strict",
});
};
await session.defaultSession.cookies.set(cookie);
keepCredential(session.defaultSession, cookie, (line) => logs.shell.line(line));
}
// The close button hides where an icon can bring the window back, and quits
@@ -391,6 +396,7 @@ nativeTheme.on("updated", () => {
});
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
ipcMain.handle("window:unread-badge", (event, unread) => {
const target = fromWindow(event);
@@ -472,17 +478,18 @@ ipcMain.handle("dialog:save-bytes", (event, name, bytes) =>
// createDirectory is the half that carries meaning: a panel that can only open
// what exists reads as an app that cannot start a project, which is what the
// Wails picker was reported as before it said so. A dismissed panel answers ""
// like the save dialogs, and startIn is dropped when it names nothing.
// like the save dialogs, a panel that could not be drawn answers null, and startIn
// is dropped when it names nothing.
ipcMain.handle("dialog:pick-folder", async (event, startIn) => {
const target = fromWindow(event);
if (!target) return "";
const shownAt = Date.now();
const picked = await dialog.showOpenDialog(target, {
defaultPath: startIn || undefined,
// Tools keep their working folders behind a leading dot, so those are listed too.
properties: ["openDirectory", "createDirectory", "showHiddenFiles"],
});
if (picked.canceled || !picked.filePaths.length) return "";
return picked.filePaths[0];
return folderAnswer(picked, Date.now() - shownAt, process.platform);
});
// Named before any path is derived from it: userData hangs off the app name,
+2
View File
@@ -30,6 +30,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
captureWindow: () => ipcRenderer.invoke("window:capture"),
// What the shell's graphics are running as this launch.
graphics: () => ipcRenderer.invoke("shell:graphics"),
// The window's processes as type and two sizes, for the memory a report may carry.
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
+31
View File
@@ -0,0 +1,31 @@
"use strict";
const TYPES = { Tab: "renderer", Browser: "browser", GPU: "gpu" };
// The kernel adds its own row, and a report holds a dozen.
const MAX_ROWS = 11;
const kb = (v) => (Number.isFinite(v) && v > 0 ? Math.min(Math.round(v), 2 ** 31) : 0);
// What app.getAppMetrics says, cut down to what a report may carry: a fixed
// type per process and two whole numbers. No pid, name, path or command line
// is read, so none can be sent.
function processMemoryRows(metrics) {
return (Array.isArray(metrics) ? metrics : []).slice(0, MAX_ROWS).map((m) => ({
process: TYPES[m?.type] ?? "utility",
workingSetKB: kb(m?.memory?.workingSetSize),
privateKB: kb(m?.memory?.privateBytes),
}));
}
function processMemoryHandler(app, fromWindow) {
return (event) => {
if (!fromWindow(event)) return [];
try {
return processMemoryRows(app.getAppMetrics());
} catch {
return [];
}
};
}
module.exports = { processMemoryRows, processMemoryHandler };
+5 -3
View File
@@ -19,10 +19,12 @@ function trayAsset(scaleFactor) {
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
}
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
// platforms choose among the @Nx files themselves.
// macOS draws a Template-suffixed pair (black on transparent) in the menu bar's
// own tint. Windows rebuilds the tray icon from the 1x bitmap, resizing it to
// the small icon metric, so the file is loaded as 1x pixels of exactly that
// size. Other platforms choose among the @Nx files themselves.
function trayImage(nativeImage, scaleFactor) {
if (process.platform === "darwin") return nativeImage.createFromPath(path.join(ASSETS, "trayTemplate.png"));
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
}
+146
View File
@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const require = createRequire(import.meta.url);
const { _electron } = require(path.join(root, "desktop/frontend-next/node_modules/playwright"));
const executablePath = process.env.REASONIX_TEST_ELECTRON || require("electron");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-attachments-live-"));
const workspace = path.join(home, "workspace");
const nativeBytes = 111 * 1024 * 1024;
const streamBytes = 65 * 1024 * 1024;
const artifacts = process.env.REASONIX_ATTACHMENT_ARTIFACTS;
let app;
let passed = false;
try {
fs.mkdirSync(workspace);
fs.writeFileSync(path.join(home, "config.toml"), [
'default_model = "smoke/smoke-model"', "[desktop]", "welcomed = true",
"[[providers]]", 'name = "smoke"', 'kind = "openai"',
'base_url = "http://127.0.0.1:9/v1"', 'models = ["smoke-model"]',
'default = "smoke-model"', 'api_key_env = "SMOKE_API_KEY"', "",
].join("\n"));
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=fixture\n");
const apk = path.join(home, "large.apk");
const file = fs.openSync(apk, "w");
fs.ftruncateSync(file, nativeBytes);
fs.closeSync(file);
app = await _electron.launch({
executablePath,
cwd: workspace,
args: [path.join(root, "desktop/electron"), "--user-data-dir=" + path.join(home, "electron-profile")],
env: {
...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: home,
REASONIX_CACHE_HOME: path.join(home, "cache"),
REASONIX_STUDIO_HOST: process.env.REASONIX_STUDIO_HOST || path.join(root, "desktop/electron/bin/reasonix-studio-host" + (process.platform === "win32" ? ".exe" : "")),
REASONIX_STUDIO_PAGE: path.join(root, "desktop/frontend-next/dist"),
},
});
let page;
for (let i = 0; i < 200; i++) {
page = app.windows().find(candidate => candidate.url().startsWith("http://127.0.0.1:"));
if (page) break;
await new Promise(resolve => setTimeout(resolve, 200));
}
assert.ok(page, "shell must open its loopback page");
await page.waitForLoadState("domcontentloaded");
await page.setViewportSize({ width: 1440, height: 900 });
await page.locator("#boot").waitFor({ state: "hidden", timeout: 60000 });
const status = await page.evaluate(async root => {
const response = await fetch("/runtimes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ root }) });
return response.status;
}, workspace);
assert.equal(status, 200);
const project = page.locator(".wsrow").filter({ has: page.locator(".wsname").filter({ hasText: /^workspace$/ }) });
await project.locator("button.wsadd").click();
// A blank pane in this workspace may be reused without creating a runtime.
await page.waitForFunction(() => document.querySelector('.pane[data-active] input[type=file][data-action-change="session.attach"]') && !document.querySelector('.panes[aria-busy="true"]'));
const traffic = [];
page.on("request", request => {
if (/\/(drop|attachments)/.test(request.url())) traffic.push({ method: request.method(), url: request.url(), contentType: request.headers()["content-type"] });
});
const ready = count => page.waitForFunction(n => document.querySelectorAll('.pane[data-active] .shots .shot[data-state="ready"]').length >= n, count);
await page.locator('.pane[data-active] input[type=file][data-action-change="session.attach"]').setInputFiles(apk);
await ready(1);
assert.equal(traffic.length, 1);
assert.ok(traffic[0].url.endsWith("/drop"));
await page.evaluate(() => {
const source = document.createElement("input"); source.type = "file"; source.id = "stream-source"; document.body.append(source);
});
await page.locator("#stream-source").setInputFiles(apk);
for (const [index, kind] of ["paste", "drop"].entries()) {
const answer = page.waitForResponse(response => response.url().endsWith("/drop"));
await page.evaluate(kind => {
const transfer = new DataTransfer(); transfer.items.add(document.querySelector("#stream-source").files[0]);
const target = document.querySelector('.pane[data-active] textarea[data-action-paste="session.attach"]');
const event = kind === "paste" ? new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true }) : new DragEvent("drop", { dataTransfer: transfer, bubbles: true });
target.dispatchEvent(event);
}, kind);
assert.equal((await answer).status(), 200);
await ready(index + 2);
}
assert.equal(traffic.length, 3);
assert.ok(traffic.every(request => request.url.endsWith("/drop")));
assert.equal(new Set(traffic.map(request => request.url)).size, 1);
const uploaded = page.waitForResponse(response => response.url().includes("/attachments/upload"));
await page.evaluate(bytes => {
const source = document.querySelector("#stream-source").files[0];
const file = new File([source.slice(0, bytes)], "stream.apk", { type: "application/vnd.android.package-archive" });
if (window.reasonixHost.pathForFile(file)) throw Error("virtual file unexpectedly has a native path");
const transfer = new DataTransfer(); transfer.items.add(file);
const input = document.querySelector('.pane[data-active] input[type=file][data-action-change="session.attach"]');
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
}, streamBytes);
assert.equal((await uploaded).status(), 200);
await ready(4);
const saved = fs.readdirSync(path.join(workspace, ".reasonix/attachments")).find(file => file.endsWith(".apk"));
assert.ok(saved, "streamed attachment must be saved");
assert.equal(fs.statSync(path.join(workspace, ".reasonix/attachments", saved)).size, streamBytes);
assert.equal(traffic.length, 4);
assert.equal(traffic[3].contentType, "application/octet-stream");
const configPath = path.join(home, "config.toml");
fs.appendFileSync(configPath, "\n[attachments]\nmax_stream_bytes = 4\n");
const directory = path.join(workspace, ".reasonix/attachments");
const before = fs.readdirSync(directory).sort();
const refused = page.waitForResponse(response => response.url().includes("/attachments/upload") && response.status() === 413);
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(new File(["12345"], "over-limit.apk"));
const input = document.querySelector('.pane[data-active] input[type=file][data-action-change="session.attach"]');
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const rejection = await (await refused).json();
assert.equal(rejection.code, "attachment.too_large");
assert.equal(rejection.params.limit_bytes, 4);
assert.equal(rejection.params.limit_source, "user");
const failedChip = page.locator('.pane[data-active] .shot[data-state="failed"]');
await failedChip.locator(".why").waitFor({ state: "visible" });
assert.ok((await failedChip.locator(".why").textContent()).includes("MB"));
assert.deepEqual(fs.readdirSync(directory).sort(), before);
fs.writeFileSync(configPath, fs.readFileSync(configPath, "utf8").replace("max_stream_bytes = 4", "max_stream_bytes = 0"));
const retried = page.waitForResponse(response => response.url().includes("/attachments/upload"));
await failedChip.locator("button.retry").click();
assert.equal((await retried).status(), 200);
await ready(5);
assert.ok(await page.locator('.pane[data-active] textarea[data-action-paste="session.attach"]').isVisible());
const result = { nativeBytes, nativeEntries: ["picker", "paste", "drop"], streamBytes, readyAttachments: 5, limitRejection: rejection, unlimitedRetry: true, traffic };
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, "attachments-live.png") });
fs.writeFileSync(path.join(artifacts, "attachments-live.json"), JSON.stringify(result, null, 2) + "\n");
}
console.log(JSON.stringify(result));
passed = true;
} finally {
await app?.evaluate(({ app }) => app.exit(0)).catch(() => {});
await app?.close();
if (passed) fs.rmSync(home, { recursive: true, force: true });
else console.error("Failed run retained fixtures at " + home);
}
+106
View File
@@ -0,0 +1,106 @@
"use strict";
// A page the agent lays out in a viewport of its own (a resize) is shown whole in the panel, not cropped to its
// top-left corner: scaled down to fit and centred, with the agent's clicks still landing where they were aimed.
// Run with `pnpm browser-fit-live`; it needs no network. SHOTS=<dir> also writes pictures of the window.
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { checker } = require("./livekit");
const PAGE = `<!doctype html><title>wide</title><style>body{margin:0;font:20px sans-serif}.bar{display:flex;justify-content:space-between;background:#1a56db;color:#fff;padding:14px;width:1280px;box-sizing:border-box}.c{width:1280px;height:600px;background:linear-gradient(90deg,#fde68a,#a7f3d0);position:relative}.r,.l{position:absolute;top:10px;background:#111;color:#fff;padding:8px}.r{right:10px}.l{left:10px;top:60px}</style><div class=bar><b>LEFT edge</b><b>RIGHT edge</b></div><div class=c><span class=r id=rc onclick="document.title='hit'">right column</span><span class=l id=lc onclick="document.title='hit'">left column</span></div>`;
// Its own profile, so another Electron run on this machine does not hold this one back.
app.setPath("userData", fs.mkdtempSync(path.join(require("node:os").tmpdir(), "rx-fit-live-")));
const { check, failures } = checker();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
app.whenReady().then(async () => {
const server = http.createServer((_q, s) => { s.writeHead(200, { "Content-Type": "text/html" }); s.end(PAGE); });
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd;font:14px sans-serif'><div style='position:absolute;left:20px;top:20px'>app chrome</div>");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const page = views.create({ partition: "fit", url: `http://127.0.0.1:${server.address().port}/`, onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
const view = views.entries.get(page.targetId).view;
await wait(1500);
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
const shot = (name) => {
if (!process.env.SHOTS) return;
fs.mkdirSync(process.env.SHOTS, { recursive: true });
win.focus();
const b = win.getBounds();
execFileSync("screencapture", ["-x", "-R", `${b.x},${b.y},${b.width},${b.height}`, `${process.env.SHOTS}/${name}.png`]);
};
const click = async (id = "rc") => {
await evalIn("document.title='reset'");
const r = JSON.parse(await evalIn(`JSON.stringify(document.getElementById('${id}').getBoundingClientRect())`));
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
await page.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
}
await wait(250);
return evalIn("document.title");
};
const panel = { x: 380, y: 60, width: 560, height: 620 };
views.show(page.targetId, panel);
await wait(600);
check("no resize: the page fills the panel as before", JSON.stringify(view.getBounds()) === JSON.stringify(panel));
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
await wait(600);
const fit = view.getBounds();
check("a 1280x900 viewport in a 560x620 panel: the view is scaled to 560x394 and centred", fit.width === 560 && fit.height === 394 && fit.x === 380 && fit.y === 173, JSON.stringify(fit));
check("the page still lays out at the viewport it was given", (await evalIn("innerWidth")) === 1280);
check("an agent click aimed at the right column lands on it", (await click()) === "hit");
shot("fit-in-panel");
views.show(page.targetId, { x: 380, y: 60, width: 400, height: 500 });
await wait(600);
const narrower = view.getBounds();
check("a narrower panel re-fits the view", narrower.width === 400 && narrower.height === 281, JSON.stringify(narrower));
check("and the click still lands", (await click()) === "hit");
views.show(page.targetId, { x: 20, y: 20, width: 1400, height: 1000 });
await wait(600);
const roomy = view.getBounds();
check("a panel larger than the viewport shows it at full size, centred, never enlarged", roomy.width === 1280 && roomy.height === 900 && roomy.x === 80 && roomy.y === 70, JSON.stringify(roomy));
check("and the click lands at full size", (await click()) === "hit");
views.hide();
await wait(600);
check("parked, the page is not scaled and the click lands", (await click()) === "hit");
views.show(page.targetId, panel);
await wait(600);
check("shown again it is fitted again", view.getBounds().width === 560 && (await click()) === "hit");
const small = { x: 20, y: 20, width: 900, height: 700 };
views.show(page.targetId, small);
await page.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 600, deviceScaleFactor: 0, mobile: false });
await wait(600);
const phone = view.getBounds();
check("a viewport smaller than the panel is shown at its own size, centred, not in the panel's corner", phone.width === 390 && phone.height === 600 && phone.x === 20 + (900 - 390) / 2 && phone.y === 20 + (700 - 600) / 2, JSON.stringify(phone));
check("and a click on it lands", (await click("lc")) === "hit");
views.show(page.targetId, panel);
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
await wait(600);
const taken = await page.send("Page.captureScreenshot", { format: "png" });
const png = Buffer.from(taken.data, "base64");
check("the agent's screenshot is the size of the viewport it asked for", png.readUInt32BE(16) === 1280 && png.readUInt32BE(20) === 900, `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`);
await wait(600);
check("after the agent takes a screenshot the page is still fitted and a click still lands", (await click()) === "hit");
await page.send("Emulation.setDeviceMetricsOverride", { width: 0, height: 0, deviceScaleFactor: 0, mobile: false });
await wait(600);
check("a zero-sized override is no override: the page fills the panel and a click lands", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560 && (await click("lc")) === "hit", JSON.stringify(view.getBounds()));
await page.send("Emulation.clearDeviceMetricsOverride", {});
await wait(600);
check("clearing the viewport gives the page its panel back", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560);
shot("cleared");
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
app.exit(failures.length ? 1 : 0);
});
@@ -0,0 +1,80 @@
"use strict";
// Local html previews, an oversized image document and a PDF in the built-in browser, each with and without an
// agent viewport: the view must be filled by the page, measured on the pixels, not just sized right.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { checker } = require("./livekit");
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-fit-pages-")));
const { check, failures } = checker();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const PANEL = { x: 380, y: 60, width: 560, height: 620 };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-pages-"));
function png(w, h, rgb) {
const zlib = require("node:zlib");
const crc = (buf) => { let c, t = []; for (let n = 0; n < 256; n++) { c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } let r = 0xffffffff; for (const b of buf) r = t[(r ^ b) & 255] ^ (r >>> 8); return (r ^ 0xffffffff) >>> 0; };
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(body)); return Buffer.concat([len, body, c]); };
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => rgb).flat())]);
return Buffer.concat([Buffer.from("\x89PNG\r\n\x1a\n", "binary"), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
}
fs.writeFileSync(path.join(dir, "big.png"), png(3200, 1800, [37, 99, 235]));
fs.copyFileSync("/tmp/fl/preview.html", path.join(dir, "preview.html"));
fs.copyFileSync("/tmp/fl/page.pdf", path.join(dir, "page.pdf"));
// Share of the view's sampled pixels that are the near-white or light-grey empty background of an unfilled view.
async function emptyShare(view) {
const img = await view.webContents.capturePage();
const { width, height } = img.getSize();
const bm = img.toBitmap();
let empty = 0, blue = 0, n = 0;
for (let y = 2; y < height - 2; y += Math.max(1, Math.floor(height / 40))) {
for (let x = 2; x < width - 2; x += Math.max(1, Math.floor(width / 40))) {
const i = (y * width + x) * 4;
const b = bm[i], g = bm[i + 1], r = bm[i + 2];
n++;
if (Math.min(r, g, b) > 225 && Math.max(r, g, b) - Math.min(r, g, b) < 12) empty++;
if (b > 180 && r < 90 && g < 150) blue++;
}
}
return { share: empty / n, blue: blue / n, width, height };
}
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd'>app");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const cases = [
["local html preview, no override", "preview.html", null],
["local html preview, 1280x900 override", "preview.html", { width: 1280, height: 900 }],
["local html preview, oversized 3200x1800 override", "preview.html", { width: 3200, height: 1800 }],
["3200x1800 image document, no override", "big.png", null],
["3200x1800 image document, 3200x1800 override", "big.png", { width: 3200, height: 1800 }],
["PDF, no override", "page.pdf", null],
["PDF, 1280x900 override", "page.pdf", { width: 1280, height: 900 }],
];
for (const [name, file, override] of cases) {
const page = views.create({ partition: "p" + Math.random(), url: "file://" + path.join(dir, file), onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
const entry = views.entries.get(page.targetId);
await wait(file.endsWith(".pdf") ? 3500 : 1500);
views.show(page.targetId, PANEL);
await wait(500);
if (override) await page.send("Emulation.setDeviceMetricsOverride", { ...override, deviceScaleFactor: 0, mobile: false });
await wait(file.endsWith(".pdf") ? 2500 : 1200);
const at = entry.view.getBounds();
if (process.env.SHOTS) { fs.mkdirSync(process.env.SHOTS, { recursive: true }); fs.writeFileSync(path.join(process.env.SHOTS, name.replace(/[^a-z0-9]+/gi, "-") + ".png"), (await entry.view.webContents.capturePage()).toPNG()); }
const m = await emptyShare(entry.view);
const inside = at.x >= PANEL.x && at.y >= PANEL.y && at.x + at.width <= PANEL.x + PANEL.width && at.y + at.height <= PANEL.y + PANEL.height;
const shown = file.endsWith(".html") ? m.share < 0.15 : m.share < 0.15 && m.blue >= (file.endsWith(".png") ? 0.5 : 0.05);
const detail = `view ${JSON.stringify(at)}, empty ${(m.share * 100).toFixed(0)}%, page-coloured ${(m.blue * 100).toFixed(0)}% of ${m.width}x${m.height}`;
console.log(" measured:", name, detail);
check(`${name}: the view sits inside the panel and the page is drawn in it`, inside && shown, detail);
views.close?.(page.targetId);
}
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
app.exit(failures.length ? 1 : 0);
});
+40 -8
View File
@@ -95,15 +95,41 @@ async function main() {
const width = () => guest.getBounds().width;
// Watching the agent browse and reading what it says are one activity: the
// page is beside the conversation from the moment it opens.
const aside = await until("the page beside the conversation", async () => (!beneath() ? guest.getBounds() : null));
await until("the page beside the conversation", async () => (!beneath() ? guest.getBounds() : null));
// The conversation's column settles after the page first appears, so the place
// "beside" is read once the page has stopped moving.
const still = (a, b) => ["x", "y", "width", "height"].every((k) => Math.abs(a[k] - b[k]) <= 1);
let seen = guest.getBounds();
const aside = await until("the page to settle beside the conversation", async () => {
await wait(300);
const now = guest.getBounds();
const steady = still(now, seen);
seen = now;
return steady ? now : null;
});
check("the page is drawn beside the conversation as soon as the agent opens one",
aside.width < win.getContentBounds().width, { aside, content: win.getContentBounds() });
const js = (src) => win.webContents.executeJavaScript(src);
const reserved = () => js(`(() => { const e = document.querySelector('.bview'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
await until("the browser tab in the pane bar", () => js(`!!document.querySelector('[data-action="pane.view"][data-value="browser"]')`));
await js(`document.querySelector('[data-action="pane.view"][data-value="browser"]').click()`);
const placed = await until("the page drawn full width", async () => (width() > aside.width ? guest.getBounds() : null));
// The browser tab beside the conversation is a switch for that view itself, so
// the page is widened with the browser's own wide control, which is how the
// person makes it the whole pane.
await until("the wide control on the browser panel", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
// A page that is put away is as wide as an agent's viewport, so wide means drawn
// and wider than it was beside the conversation.
await until("the page drawn full width", async () => (!beneath() && width() > aside.width ? guest.getBounds() : null));
// The panel widens over a moment; the rectangle is read once it has stopped.
const same = still;
let last = guest.getBounds();
const placed = await until("the page to stop moving", async () => {
await wait(300);
const now = guest.getBounds();
const still = same(now, last);
last = now;
return still ? now : null;
});
const slot = await reserved();
const close = (a, b) => Math.abs(a - b) <= 1;
// The rectangle itself has to be the pane: with the conversation's column
@@ -123,11 +149,14 @@ async function main() {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
await js(`document.querySelector('[data-action="pane.view"][data-value="flow"]').click()`);
const back = await until("the page beside the conversation again", async () => (width() < placed.width ? guest.getBounds() : null));
check("leaving the full-width view puts the page back beside the conversation", back.width === aside.width && !beneath(), { back, aside });
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
const back = await until("the page beside the conversation again", async () => (!beneath() && width() < placed.width ? guest.getBounds() : null));
// The column settles a pixel or two after the first measurement, so beside means
// the same place and size to within a few pixels.
check("leaving the full-width view puts the page back beside the conversation", Math.abs(back.width - aside.width) <= 3 && Math.abs(back.x - aside.x) <= 3 && !beneath(), { back, aside });
await js(`document.querySelector('[data-action="pane.dock"]').click()`);
// The browser tab is the switch for the side-by-side view.
await js(`document.querySelector('[data-action="pane.view"][data-value="browser"]').click()`);
await until("the page put away", async () => beneath());
check("turning the side-by-side view off stops drawing the page", beneath());
@@ -144,6 +173,8 @@ async function main() {
// One request per call plus the one that reads the last result.
const calls = act === "network" ? 3 : 2;
await until(`the turn that would ${act}`, async () => requests.length > before + calls, 90000);
// The next turn is submitted only once this one has ended, or it is queued behind it.
await until(`the ${act} turn to end`, async () => ((await js(`!document.querySelector('[data-action="session.stop"]')`)) ? true : null), 30000);
return (requests.at(-1).messages || []).filter((m) => m.role === "tool").map((m) => String(m.content || "")).join("\n");
};
const askFor = async (name) => {
@@ -159,6 +190,7 @@ async function main() {
check("the agent's input reaches the page while the window is minimized", await askFor("韩梅梅"), await greeting());
win.restore();
win.focus();
const cover = new BrowserWindow({ width: 1600, height: 1000, x: 0, y: 0, alwaysOnTop: true });
await cover.loadURL("data:text/html,<h1>cover</h1>");
cover.focus();
@@ -0,0 +1,89 @@
"use strict";
// A page the agent lays out at a viewport wider than the pane has to stay whole for the
// person watching: the view shows all of it, scaled to the pane, not its left part.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const { app } = require("electron");
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
const PAGE = `<!doctype html><title>Wide</title><body style="margin:0">
<div style="width:100vw;height:100vh;background:linear-gradient(90deg,#e33,#33e);color:#fff;font:48px sans-serif">
<span style="position:absolute;left:12px;top:12px">LEFT</span>
<span style="position:absolute;right:12px;top:12px">RIGHT</span>
<button id="go" style="position:absolute;right:40px;top:300px;font:32px sans-serif" onclick="document.title='clicked'">Go</button></div>`;
const { check, failures } = checker();
const SIZE = { width: 1440, height: 1000 };
async function main() {
const page = await listen((req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
let calls = 0;
const { handler, requests } = scriptedModel((tools) => {
calls++;
if (calls === 1) return { name: "browser_open", arguments: { url: `${page.url}/` } };
if (calls === 2) return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "resize", ...SIZE }] } };
if (calls === 3) return { name: "browser_read", arguments: { tab: "t1", what: "screenshot" } };
if (calls === 4) {
const ref = (tools.join("\n").match(/button "Go" \[(e\d+)\]/) || [])[1];
return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "click", ref }] } };
}
return null;
});
const model = await listen(handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-live-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
try {
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
await client.request("POST", `${runtimes[0].base}/submit`, { input: "open the wide page and lay it out wide" });
await until("the scripted turn", async () => requests.length >= 5, 60000);
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
const guest = await until("a page view", async () => guests()[0]);
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null));
await wait(1500);
const bounds = guest.getBounds();
const inner = await guest.webContents.executeJavaScript("[innerWidth, innerHeight]");
check("the page is laid out at the viewport the agent asked for", inner[0] === SIZE.width && inner[1] === SIZE.height, inner);
const draw = (name) => {
const file = path.join(os.tmpdir(), name);
try {
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], file]);
process.stdout.write(` screenshot: ${file}\n`);
} catch (err) {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
};
const shown = await guest.webContents.capturePage();
const size = shown.getSize();
const px = shown.toBitmap();
const at = (x, y) => { const i = (y * size.width + x) * 4; return { b: px[i], g: px[i + 1], r: px[i + 2] }; };
const edge = at(size.width - 6, 6);
check("the right edge of the page is what the view draws at its right edge", edge.b > edge.r, edge);
check("the agent's click on a control at the right of the page landed", (await guest.webContents.getTitle()) === "clicked", await guest.webContents.getTitle());
draw("rx-resize-live.png");
const taken = await guest.webContents.debugger.sendCommand("Page.captureScreenshot", { format: "png" });
const dims = require("electron").nativeImage.createFromBuffer(Buffer.from(taken.data, "base64")).getSize();
process.stdout.write(` agent screenshot ${JSON.stringify(dims)}\n`);
check("a screenshot taken the way the agent takes one shows the page at the size it asked for", dims.width === SIZE.width && dims.height === SIZE.height, dims);
process.stdout.write(` bounds ${JSON.stringify(bounds)} inner ${JSON.stringify(inner)}\n`);
} catch (err) {
check("the run completed", false, String(err && err.message ? err.message : err));
}
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main);
+141
View File
@@ -0,0 +1,141 @@
"use strict";
// Who owns the keyboard while the agent drives its browser. A person typing in
// the app page must still be typing there after the agent opens a page, shows
// it, clicks in it, types into it or asks for it to come to the front: the
// agent's commands arrive over the debugging protocol, and nothing it sends may
// move the focus out of the page the person is in. Run with `pnpm focus-live`;
// it needs no network and no kernel.
const { app, BrowserWindow } = require("electron");
const { BrowserProtocol } = require("../src/browserprotocol");
const { BrowserViews } = require("../src/browserviews");
const { checker, listen, wait } = require("./livekit");
const APP = `<!doctype html><title>app</title><textarea id="compose" autofocus></textarea>`;
const PAGE = `<!doctype html><title>page</title><input id="name"><button id="go" onclick="document.title='clicked'">Go</button>`;
const { check, failures } = checker();
async function main() {
await app.whenReady();
const page = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
const win = new BrowserWindow({ width: 1000, height: 700, show: true, webPreferences: { contextIsolation: true } });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(APP));
win.show();
app.focus({ steal: true });
win.focus();
win.webContents.focus();
await wait(800);
if (!win.webContents.isFocused()) {
process.stdout.write(" SKIP this desktop would not give the test window the keyboard, so there is nothing to keep\n");
app.exit(0);
return;
}
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const frames = [];
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
frames.push(frame);
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message, sessionId) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message, ...(sessionId ? { sessionId } : {}) }) });
setTimeout(() => resolve({ timedOut: true }), 4000);
});
const where = async () => ({
app: await win.webContents.executeJavaScript("document.activeElement && document.activeElement.id"),
appFocused: win.webContents.isFocused(),
viewFocused: [...views.entries.values()].some((e) => e.view.webContents.isFocused()),
});
const keepsTheKeyboard = async (what) => {
const w = await where();
check(`${what}: the composer keeps the keyboard`, w.app === "compose" && w.appFocused && !w.viewFocused, w);
};
protocol.receive({ conn: "c", open: "focus-live" });
await keepsTheKeyboard("before the agent does anything");
const created = await send({ method: "Target.createTarget", params: { url: page.url } });
const targetId = created.result.targetId;
const attached = await send({ method: "Target.attachToTarget", params: { targetId } });
const session = attached.result.sessionId;
await wait(1200);
await keepsTheKeyboard("opening a page");
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(500);
await keepsTheKeyboard("showing it over the page");
await send({ method: "Page.bringToFront" }, session);
await wait(300);
await keepsTheKeyboard("asking for the page to come to the front");
await send({ method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.dispatchMouseEvent", params: { type: "mouseReleased", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await wait(300);
await keepsTheKeyboard("a click inside the agent's page");
await send({ method: "Input.insertText", params: { text: "hello" } }, session);
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyDown", key: "a", text: "a" } }, session);
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyUp", key: "a" } }, session);
await wait(300);
await keepsTheKeyboard("typing into the agent's page");
await send({ method: "Runtime.evaluate", params: { expression: "document.querySelector('#name').focus()" } }, session);
await wait(300);
await keepsTheKeyboard("the page focusing one of its own fields");
await send({ method: "Page.navigate", params: { url: page.url + "/next" } }, session);
await wait(1200);
await keepsTheKeyboard("navigating the agent's page");
views.hide();
await wait(300);
await keepsTheKeyboard("putting the page away");
// The other half: a person who clicks into the agent's page is typing there,
// and the agent navigating it again does not take that away from them.
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(500);
const theirs = [...views.entries.values()][0].view.webContents;
// A click that reaches the page from the window, and the focus the platform
// gives the page because of it; sendInputEvent alone does not move focus.
theirs.sendInputEvent({ type: "mouseDown", x: 40, y: 20, button: "left", clickCount: 1 });
theirs.focus();
theirs.sendInputEvent({ type: "mouseUp", x: 40, y: 20, button: "left", clickCount: 1 });
await wait(500);
const chosen = await where();
check("a click of the person's own into the agent's page gives it the keyboard", chosen.viewFocused && !chosen.appFocused, chosen);
await send({ method: "Page.navigate", params: { url: page.url + "/again" } }, session);
await wait(1200);
const stays = await where();
check("and the agent navigating it again does not pull the keyboard back", stays.viewFocused && !stays.appFocused, stays);
win.webContents.focus();
await wait(300);
win.minimize();
await wait(400);
win.restore();
await wait(600);
await keepsTheKeyboard("the window being minimized and restored");
page.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL focus-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+114
View File
@@ -0,0 +1,114 @@
"use strict";
// A local image the agent opens in its browser has to be on screen, not a blank
// panel under the image's own title. The page is opened the way the kernel opens
// one (Target.createTarget on a file: address), shown over the rectangle the
// panel reserves, and what the view actually drew is read back and judged.
// Run with `pnpm image-live`; it needs no network and no kernel.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app, BrowserWindow, nativeImage } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { BrowserProtocol } = require("../src/browserprotocol");
const { checker, listen, wait } = require("./livekit");
const { check, failures } = checker();
function picture(width, height, file) {
const bitmap = Buffer.alloc(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const at = (y * width + x) * 4;
bitmap[at] = (x * 255) / width;
bitmap[at + 1] = (y * 255) / height;
bitmap[at + 2] = 128;
bitmap[at + 3] = 255;
}
}
fs.writeFileSync(file, nativeImage.createFromBitmap(bitmap, { width, height }).toPNG());
}
// How much of what was drawn is neither the panel's own white nor transparent.
async function inked(view) {
const shot = await view.webContents.capturePage();
const { width, height } = shot.getSize();
if (!width || !height) return { width, height, share: 0 };
const bitmap = shot.toBitmap();
let ink = 0;
for (let i = 0; i < bitmap.length; i += 4) {
const blank = bitmap[i + 3] === 0 || (bitmap[i] > 245 && bitmap[i + 1] > 245 && bitmap[i + 2] > 245);
if (!blank) ink++;
}
return { width, height, share: ink / (bitmap.length / 4) };
}
async function main() {
await app.whenReady();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-image-live-"));
const small = path.join(dir, "small.png");
const large = path.join(dir, "large.png");
const html = path.join(dir, "page.html");
picture(320, 180, small);
picture(3200, 1800, large);
fs.writeFileSync(html, `<!doctype html><title>page</title><body style="margin:0;background:#2a6"><h1>hello</h1>`);
const web = await listen((req, res) => {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(fs.readFileSync(large));
});
const win = new BrowserWindow({ width: 1280, height: 800, show: true });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent("<body style='margin:0'>app"));
await wait(500);
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message }) });
setTimeout(() => resolve({ timedOut: true }), 5000);
});
protocol.receive({ conn: "c", open: "image-live" });
const cases = [
["a small local image", "file://" + (process.platform === "win32" ? "/" : "") + small.replace(/\\/g, "/")],
["a 3200x1800 local image", "file://" + (process.platform === "win32" ? "/" : "") + large.replace(/\\/g, "/")],
["a local html page", "file://" + (process.platform === "win32" ? "/" : "") + html.replace(/\\/g, "/")],
["a 3200x1800 image over http", web.url + "/big.png"],
];
for (const [name, url] of cases) {
const created = await send({ method: "Target.createTarget", params: { url } });
const targetId = created.result && created.result.targetId;
if (!targetId) {
check(`${name}: the agent got a page`, false, created);
continue;
}
await wait(1500);
const view = [...views.entries.values()].find((e) => e.view)?.view;
const entry = views.entries.get(targetId);
views.show(targetId, { x: 300, y: 100, width: 800, height: 500 });
await wait(1200);
const where = entry.view.getBounds();
const drawn = await inked(entry.view);
check(`${name}: it is placed over the reserved rectangle`, where.x === 300 && where.y === 100 && where.width === 800 && where.height === 500, where);
check(`${name}: what the view drew is not blank`, drawn.share > 0.2, drawn);
await send({ method: "Target.closeTarget", params: { targetId } });
await wait(300);
void view;
}
web.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL image-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+78
View File
@@ -0,0 +1,78 @@
"use strict";
// The agent opens a local image in the built-in browser, through the real kernel
// and the real window. The tab has to show the picture: a view over the
// rectangle the panel reserved whose pixels are the image, small or 3200x1800.
// Run with `pnpm image-pane-live`; it needs no network.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app, nativeImage } = require("electron");
const { checker, scriptedModel, listen, seedHome, settledWindow, until, wait } = require("./livekit");
const { check, failures } = checker();
const script = { url: "" };
function picture(width, height, file) {
const bitmap = Buffer.alloc(width * height * 4);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const at = (y * width + x) * 4;
bitmap[at] = (x * 255) / width; bitmap[at + 1] = (y * 255) / height; bitmap[at + 2] = 128; bitmap[at + 3] = 255;
}
fs.writeFileSync(file, nativeImage.createFromBitmap(bitmap, { width, height }).toPNG());
}
async function main() {
// The kernel only lets the agent open a local file inside the workspace.
const workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rx-image-ws-")));
const small = path.join(workspace, "small.png");
const large = path.join(workspace, "ode_solution.png");
picture(320, 180, small);
picture(3200, 1800, large);
const fileURL = (p) => "file://" + (process.platform === "win32" ? "/" : "") + p.replace(/\\/g, "/");
let calls = 0;
const model = await listen(scriptedModel((tools) => {
if (calls >= 1) return null;
calls++;
return { name: "browser_open", arguments: { url: script.url } };
}).handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-image-home-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
const base = runtimes[0].base;
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
for (const [name, file] of [["a small local image", small], ["a 3200x1800 local image", large]]) {
script.url = fileURL(file);
calls = 0;
const before = guests().length;
await client.request("POST", `${base}/submit`, { input: `show ${name}` });
const guest = await until(`${name}: a page view`, async () => guests()[guests().length - 1], 60000);
await until(`${name}: the page beside the conversation`, async () => (guest.getBounds().x >= 0 ? true : null), 30000).catch(() => null);
await wait(1500);
const b = guest.getBounds();
const shot = await guest.webContents.capturePage();
const size = shot.getSize();
const bitmap = size.width ? shot.toBitmap() : Buffer.alloc(0);
let ink = 0;
for (let i = 0; i < bitmap.length; i += 4) if (!(bitmap[i + 3] === 0 || (bitmap[i] > 245 && bitmap[i + 1] > 245 && bitmap[i + 2] > 245))) ink++;
const share = bitmap.length ? ink / (bitmap.length / 4) : 0;
check(`${name}: the view is on screen, beside the conversation`, b.x >= 0 && b.width > 100 && b.height > 100, b);
check(`${name}: the title is the image's own`, guest.webContents.getTitle().includes(path.basename(file)), guest.webContents.getTitle());
check(`${name}: what the view drew is the picture, not a blank`, share > 0.2, { share, size, bounds: b, scale: require("electron").screen.getPrimaryDisplay().scaleFactor });
void before;
}
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main).catch((err) => {
process.stdout.write(` FAIL image-pane-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+125
View File
@@ -0,0 +1,125 @@
"use strict";
// A person in the middle of an IME composition — the pinyin letters still
// underlined, the candidate list open — must not lose it when the agent's browser
// loads, navigates, reloads or is shown. Losing it is not a lost focus that comes
// back: the composition is committed as the raw letters and the word they were
// spelling is gone. Run with `pnpm ime-live`; it needs no network and no kernel.
const { app, BrowserWindow } = require("electron");
const { BrowserViews, LEAVE_LIMIT_MS } = require("../src/browserviews");
const { BrowserProtocol } = require("../src/browserprotocol");
const { checker, listen, wait } = require("./livekit");
const APP = `<!doctype html><title>app</title><textarea id="compose" autofocus></textarea>
<script>window.__ime = { start: 0, update: 0, end: 0, blur: 0 };
const t = document.getElementById("compose");
t.addEventListener("compositionstart", () => __ime.start++);
t.addEventListener("compositionupdate", () => __ime.update++);
t.addEventListener("compositionend", () => __ime.end++);
t.addEventListener("blur", () => __ime.blur++);</script>`;
const PAGE = `<!doctype html><title>page</title><input id="name"><p>hello</p>`;
const { check, failures } = checker();
async function main() {
await app.whenReady();
const page = await listen((req, res) => {
// A server that takes the connection and never answers.
if (req.url === "/hang") return;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
const win = new BrowserWindow({ width: 1000, height: 700, show: true });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(APP));
win.show();
app.focus({ steal: true });
win.focus();
win.webContents.focus();
await wait(800);
if (!win.webContents.isFocused()) {
process.stdout.write(" SKIP this desktop would not give the test window the keyboard, so there is nothing to keep\n");
app.exit(0);
return;
}
const dbg = win.webContents.debugger;
dbg.attach("1.3");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message, sessionId) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message, ...(sessionId ? { sessionId } : {}) }) });
setTimeout(() => resolve({ timedOut: true }), 4000);
});
const ime = () => win.webContents.executeJavaScript("JSON.stringify({ ...window.__ime, value: document.getElementById('compose').value })").then(JSON.parse);
const compose = async (text) => {
await win.webContents.executeJavaScript("window.__ime.start = window.__ime.update = window.__ime.end = window.__ime.blur = 0; document.getElementById('compose').value = ''; document.getElementById('compose').focus()");
await dbg.sendCommand("Input.imeSetComposition", { text, selectionStart: text.length, selectionEnd: text.length });
await wait(150);
};
const keepsComposing = async (what) => {
const s = await ime();
check(`${what}: the composition is still open`, s.start >= 1 && s.end === 0, s);
};
protocol.receive({ conn: "c", open: "ime-live" });
await compose("nihao");
await keepsComposing("before the agent does anything");
const created = await send({ method: "Target.createTarget", params: { url: page.url } });
const targetId = created.result.targetId;
const session = (await send({ method: "Target.attachToTarget", params: { targetId } })).result.sessionId;
await wait(1500);
await keepsComposing("opening a page");
await compose("zhongwen");
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(600);
await keepsComposing("showing it");
await compose("ceshi");
await send({ method: "Page.navigate", params: { url: page.url + "/next" } }, session);
await wait(1500);
await keepsComposing("navigating it");
await compose("shuaxin");
await send({ method: "Page.reload" }, session);
await wait(1500);
await keepsComposing("reloading it");
await compose("dianji");
await send({ method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.dispatchMouseEvent", params: { type: "mouseReleased", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.insertText", params: { text: "x" } }, session);
await wait(500);
await keepsComposing("the agent clicking and typing in it");
await compose("guaqi");
// Not awaited: the protocol answers a navigation only when the page does.
void send({ method: "Page.navigate", params: { url: page.url + "/hang" } }, session);
await wait(500);
const out = !win.contentView.children.includes([...views.entries.values()][0].view);
check("a navigation that has just started holds the page out of the window", out);
await wait(LEAVE_LIMIT_MS + 1000);
const back = win.contentView.children.includes([...views.entries.values()][0].view);
check("one that never answers does not keep it out for good", back);
await keepsComposing("and the composition is still open while it hangs");
page.server.closeAllConnections?.();
page.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL ime-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
@@ -0,0 +1,62 @@
"use strict";
// With the project folder gone, a tool call is refused with the typed cause and the
// window says so above the composer, with a way to choose the folder again.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const { app } = require("electron");
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
const { check, failures } = checker();
async function main() {
let calls = 0;
const { handler, requests } = scriptedModel(() => {
calls++;
if (calls === 1) return { name: "grep", arguments: { pattern: "hello" } };
return null;
});
const model = await listen(handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-live-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
try {
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
process.chdir(os.tmpdir());
const gone = `${workspace}-moved`;
fs.renameSync(workspace, gone);
await client.request("POST", `${runtimes[0].base}/submit`, { input: "look for hello" });
await until("the scripted turn", async () => requests.length >= 2, 60000);
const result = String((requests[1].messages || []).filter((m) => m.role === "tool").at(-1)?.content || "");
check("the model was told the typed cause, not a bare system error", result.startsWith("root missing: the project folder ") && result.includes("not a permission or argument problem"), result.slice(0, 200));
const js = (src) => win.webContents.executeJavaScript(src);
const banner = await until("the banner above the composer", async () => js(`(() => { const b = document.querySelector('[data-cause="root-missing"]'); return b ? b.textContent : null; })()`), 20000).catch(() => null);
check("the window shows the banner", !!banner, banner);
check("the banner offers to choose the folder again", await js(`!!document.querySelector('[data-action="root-missing.reselect"]')`));
await wait(3000);
process.stdout.write(` page: ${await js("document.body.innerText.slice(0, 300).replace(/\\n/g, ' | ')")}\n`);
const png = path.join(os.tmpdir(), "rx-root-missing-live.png");
try {
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], png]);
process.stdout.write(` screenshot: ${png}\n`);
} catch (err) {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
fs.renameSync(gone, workspace);
} catch (err) {
check("the run completed", false, String(err && err.message ? err.message : err));
}
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main);
+87
View File
@@ -0,0 +1,87 @@
"use strict";
// The side question has to be seen over the built-in browser. The browser is a
// native view drawn above the whole page, so the panel can only be seen if the
// page stops covering the place the panel is. Real kernel, real window: the agent
// opens a page, the person widens it, opens the side question, and what is judged
// is whether the view still covers the panel. Run with `pnpm sidepanel-live`.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app } = require("electron");
const { checker, scriptedModel, listen, seedHome, settledWindow, until, wait } = require("./livekit");
const { check, failures } = checker();
async function main() {
const page = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end("<!doctype html><title>Greeter</title><body style='margin:0;background:#2a6'><h1>hello</h1>");
});
let calls = 0;
const model = await listen(scriptedModel(() => (calls++ >= 1 ? null : { name: "browser_open", arguments: { url: page.url + "/" } })).handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-side-home-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-side-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
const base = runtimes[0].base;
const js = (src) => win.webContents.executeJavaScript(src);
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
await client.request("POST", `${base}/submit`, { input: "open the page" });
const guest = await until("a page view", async () => guests()[0], 60000);
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null), 30000);
await until("the wide control", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
const panelRect = () => js(`(() => { const e = document.querySelector('.sidepanel'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
const covers = (view, rect) => view.x < rect.x + rect.width && view.x + view.width > rect.x && view.y < rect.y + rect.height && view.y + view.height > rect.y;
for (const [width, height, wide] of [[1440, 900, false], [1440, 900, true], [1100, 760, false], [1100, 760, true], [900, 700, true], [700, 700, true], [1440, 900, "panel-first"], [1100, 760, "panel-first"]]) {
win.setContentSize(width, height);
await wait(900);
const label = `${width}px ${wide === "panel-first" ? "side question open, then the browser widened" : wide ? "widened" : "beside the conversation"}`;
if (wide === true && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) !== "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1200);
}
if (wide === false && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1200);
}
if (wide === "panel-first") {
// Back beside the conversation first, then the question, then the widening.
if ((await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1000);
}
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
await until("the side panel", async () => (await panelRect()) || null, 10000);
await wait(800);
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1500);
} else {
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
await until("the side panel", async () => (await panelRect()) || null, 10000);
}
await wait(1500);
const panel = await panelRect();
const view = guest.getBounds();
const parked = view.x < 0;
process.stdout.write(` [bounds] ${label}: view=${JSON.stringify(view)} panel=${JSON.stringify(panel)} parked=${parked}\n`);
check(`${label}: the side panel is drawn`, !!panel && panel.width > 100, panel);
check(`${label}: the page does not cover the panel`, parked || !covers(view, panel), { view, panel });
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
await wait(800);
}
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main).catch((err) => {
process.stdout.write(` FAIL sidepanel-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+504 -2
View File
@@ -1,4 +1,4 @@
import { test } from "node:test";
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import path from "node:path";
@@ -14,6 +14,7 @@ const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
const { groundFor, themeSourceFor, GROUND } = require("../src/ground.js");
const { folderAnswer } = require("../src/folderpanel.js");
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
const TOKEN = "a".repeat(64);
@@ -1572,6 +1573,26 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
}
});
test("the launch credential cookie is put back when something removes it, and only then", async () => {
const { keepCredential } = require("../src/credential.js");
const { EventEmitter } = require("node:events");
const jar = new EventEmitter();
const sets = [];
const ses = { cookies: Object.assign(jar, { set: async (c) => void sets.push(c) }) };
const cookie = { url: "http://127.0.0.1:5000/", name: "reasonix_token", value: "t" };
const lines = [];
keepCredential(ses, cookie, (l) => lines.push(l));
const gone = { name: "reasonix_token", domain: "127.0.0.1" };
jar.emit("changed", {}, { ...gone, name: "other" }, "explicit", true);
jar.emit("changed", {}, { ...gone, domain: "example.com" }, "explicit", true);
jar.emit("changed", {}, gone, "overwrite", true);
jar.emit("changed", {}, gone, "explicit", false);
assert.equal(sets.length, 0, "an unrelated change must not touch the jar");
jar.emit("changed", {}, gone, "evicted", true);
assert.deepEqual(sets, [cookie]);
assert.match(lines[0], /evicted/);
});
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
const { trayAsset, SCALES } = require("../src/trayimage.js");
const dim = (f) => {
@@ -1589,6 +1610,25 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
assert.equal(trayAsset(NaN).pixels, 16);
});
test("the macOS tray is a black-on-transparent Template pair, not the colour badge", () => {
const { trayImage } = require("../src/trayimage.js");
const asked = [];
const nativeImage = { createFromPath: (f) => (asked.push(f), { isEmpty: () => false }) };
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin" });
try {
trayImage(nativeImage, 2);
} finally {
Object.defineProperty(process, "platform", platform);
}
assert.match(path.basename(asked[0]), /^trayTemplate\.png$/);
for (const [file, px] of [["trayTemplate.png", 18], ["[email protected]", 36]]) {
const b = fs.readFileSync(path.join(path.dirname(asked[0]), file));
assert.deepEqual([b.readUInt32BE(16), b.readUInt32BE(20)], [px, px], file);
assert.equal(b[25], 6, `${file} must carry an alpha channel`);
}
});
function pe(machine) {
const b = Buffer.alloc(0x100);
b.writeUInt16LE(0x5a4d, 0);
@@ -2259,12 +2299,14 @@ test("a parked page is attached again when it is shown, and a page already on sc
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
views.entries.set("t1", { view, at: null });
views.entries.set("t1", { view, at: null, attached: true });
const rect = { x: 10, y: 20, width: 600, height: 400 };
views.show("t1", rect);
@@ -2278,6 +2320,115 @@ test("a parked page is attached again when it is shown, and a page already on sc
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
});
test("a page laid out wider than the pane is scaled to fit it, and keeps the viewport the agent asked for", async () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
try {
({ BrowserViews } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView() {}, removeChildView() {} },
};
const sent = [];
const view = {
setBounds() {},
webContents: { isDestroyed: () => false, debugger: { sendCommand: async (method, params) => sent.push([method, params]) } },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const entry = { view, at: null, attached: true };
views.entries.set("t1", entry);
views.keepKeyboard = () => {};
entry.override = { width: 1440, height: 1000, deviceScaleFactor: 0, mobile: false };
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
assert.equal(sent.length, 1);
assert.equal(sent[0][1].scale, 0.5, "a 1440 wide layout in a 720 wide pane is halved");
assert.equal(sent[0][1].width, 1440, "the layout viewport is still the one the agent asked for");
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
assert.equal(sent.length, 1, "the same pane sends nothing again");
views.show("t1", { x: 0, y: 0, width: 360, height: 800 });
assert.equal(sent.at(-1)[1].scale, 0.25, "a narrower pane scales further");
views.show("t1", { x: 0, y: 0, width: 2000, height: 1200 });
assert.equal(sent.at(-1)[1].scale, 1, "a pane larger than the layout never enlarges it");
const settled = sent.length;
views.fit(entry);
assert.equal(sent.length, settled, "an unchanged scale is not sent again");
views.fit(entry, true);
assert.equal(sent.length, settled + 1, "a capture that dropped the scale gets it back");
assert.equal(sent.at(-1)[1].scale, 1);
entry.override = undefined;
const before = sent.length;
views.show("t1", { x: 0, y: 0, width: 500, height: 800 });
assert.equal(sent.length, before, "with no override there is nothing to fit");
});
test("a page given its own viewport is fitted whole into its panel, centred, and never enlarged", () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let placement;
try {
({ placement } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const panel = { x: 380, y: 60, width: 560, height: 620 };
assert.deepEqual(placement(panel, null), { at: panel, scale: 1 }, "a page without a viewport of its own fills the panel");
for (const none of [{ width: 0, height: 0 }, { width: 0, height: 600 }, { width: 390, height: 0 }, { width: NaN, height: 600 }, { width: 390, height: Infinity }, { width: -1, height: 600 }]) {
assert.deepEqual(placement(panel, none), { at: panel, scale: 1 }, `${JSON.stringify(none)} is no viewport at all`);
}
const wide = placement(panel, { width: 1280, height: 900 });
assert.equal(wide.scale, 0.4375);
assert.deepEqual(wide.at, { x: 380, y: 173, width: 560, height: 394 });
const phone = placement(panel, { width: 390, height: 844 });
assert.ok(phone.scale < 1 && phone.at.height <= panel.height && phone.at.width <= panel.width);
assert.deepEqual(placement({ x: 20, y: 20, width: 1400, height: 1000 }, { width: 1280, height: 900 }), { at: { x: 80, y: 70, width: 1280, height: 900 }, scale: 1 });
});
test("input that carries a point is scaled with the page or refused, never sent to the wrong place", () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let scaleInput;
try {
({ scaleInput } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const mouse = { type: "mousePressed", x: 1200, y: 800, button: "left" };
assert.equal(scaleInput("Input.dispatchMouseEvent", mouse, 1), mouse, "an unscaled page gets the point as it is");
assert.deepEqual(scaleInput("Input.dispatchMouseEvent", mouse, 0.5), { type: "mousePressed", x: 600, y: 400, button: "left" });
for (const method of ["Input.dispatchTouchEvent", "Input.dispatchDragEvent", "Input.emulateTouchFromMouseEvent", "Input.synthesizeTapGesture", "Input.synthesizeScrollGesture", "Input.synthesizePinchGesture"]) {
const params = { x: 10, y: 20, touchPoints: [{ x: 10, y: 20 }] };
assert.equal(scaleInput(method, params, 1), params, `${method} passes on an unscaled page`);
assert.throws(() => scaleInput(method, params, 0.5), { code: "input_unscaled" }, `${method} must not reach a scaled page unscaled`);
}
const key = { type: "keyDown", key: "a" };
assert.equal(scaleInput("Input.dispatchKeyEvent", key, 0.5), key, "keys carry no point");
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
});
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
const file = path.join(dir, "prefs.json");
@@ -2441,3 +2592,354 @@ test("the window capture is scaled down past the upload-friendly width and refus
const blank = captureHandler(() => ({ webContents: { capturePage: async () => fakeImage(0, { empty: true }) } }));
assert.equal(await blank({}), null);
});
test("a folder panel that closes before anyone could read it is reported as never drawn on Linux", () => {
const closed = { canceled: true, filePaths: [] };
assert.equal(folderAnswer(closed, 20, "linux"), null);
assert.equal(folderAnswer(closed, 5000, "linux"), "");
assert.equal(folderAnswer(closed, 20, "darwin"), "");
assert.equal(folderAnswer(closed, 20, "win32"), "");
assert.equal(folderAnswer({ canceled: false, filePaths: ["/home/a/p"] }, 20, "linux"), "/home/a/p");
});
// Who keeps the keyboard while the agent drives a page.
const { KeyboardKeeper, PERSON_MS } = require("../src/keyboard.js");
function keyboardWindow({ appFocused = true, windowFocused = true } = {}) {
const contents = Object.assign(new EventEmitter(), { focused: appFocused, focus() { this.focused = true; this.emit("focus"); }, isFocused() { return this.focused; } });
const win = { webContents: contents, isDestroyed: () => false, isFocused: () => windowFocused };
return { win, contents };
}
function keyboardPage() {
return new EventEmitter();
}
const turn = () => new Promise((resolve) => setTimeout(resolve, 5));
test("a page the agent opens does not take the keyboard from the app page", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
assert.equal(contents.focused, false, "not from inside the platform's own focus event");
await turn();
assert.equal(contents.focused, true);
assert.equal(keeper.owner, "app");
});
test("a click or a key from the person into the page is theirs to have chosen", () => {
for (const arrive of [(page) => page.emit("before-input-event", {}, { type: "keyDown" }), (page) => page.emit("input-event", {}, { type: "mouseDown" })]) {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
keeper.watch(page);
arrive(page);
contents.focused = false;
page.emit("focus");
assert.equal(contents.focused, false);
assert.equal(keeper.owner, "view");
}
});
test("a click the agent itself sent over the protocol is not the person's", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page, () => true);
page.emit("input-event", {}, { type: "mouseDown" });
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, true);
});
test("the person's choice stands until they go back to the app page", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
page.emit("input-event", {}, { type: "mouseDown" });
page.emit("focus");
assert.equal(keeper.owner, "view");
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, false, "the agent navigating again does not pull it back");
contents.focus();
assert.equal(keeper.owner, "app");
});
test("nothing is taken back when the app page did not have the keyboard, or the window is not in front", async () => {
let { win, contents } = keyboardWindow({ appFocused: false });
let keeper = new KeyboardKeeper({ win });
let page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
page.emit("focus");
await turn();
assert.equal(contents.focused, false);
({ win, contents } = keyboardWindow({ windowFocused: false }));
keeper = new KeyboardKeeper({ win });
page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, false);
({ win, contents } = keyboardWindow());
keeper = new KeyboardKeeper({ win });
page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
page.emit("before-input-event", {}, { type: "keyDown" });
await turn();
assert.equal(contents.focused, false, "a key from the person in the meantime is theirs");
});
test("reclaim hands the keyboard back only when nobody holds it and the app page owned it", () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
contents.focused = false;
keeper.reclaim();
assert.equal(contents.focused, true);
keeper.owner = "view";
contents.focused = false;
keeper.reclaim();
assert.equal(contents.focused, false);
});
test("a person's click is only a click for a moment", () => {
assert.ok(PERSON_MS > 0 && PERSON_MS < 2000);
});
function loadedViews() {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
let LEAVE_LIMIT_MS;
try {
({ BrowserViews, LEAVE_LIMIT_MS } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const calls = [];
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => true,
webContents: Object.assign(new EventEmitter(), { isFocused: () => true }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
return { views, view, calls, LEAVE_LIMIT_MS };
}
test("a page navigating while the app page holds the keyboard is held out of the window, then put back where it was", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: { x: 10, y: 20, width: 600, height: 400 }, attached: true };
views.leave(entry);
assert.deepEqual(calls, ["remove"]);
assert.equal(entry.attached, false);
views.settle(entry);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
assert.equal(entry.attached, true);
views.settle(entry);
assert.equal(calls.length, 3, "settling a page that is already attached does nothing");
});
test("a parked page comes back parked", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: null, attached: true };
views.leave(entry);
calls.length = 0;
views.settle(entry);
assert.equal(calls[0], "add");
assert.match(calls[1], /^bounds -?\d+$/);
assert.equal(entry.at, null);
});
test("a page is left where it is when the person, not the app page, holds the keyboard, or when it is not attached", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: null, attached: true };
views.keyboard.owner = "view";
views.leave(entry);
assert.deepEqual(calls, []);
assert.equal(entry.attached, true);
views.keyboard.owner = "app";
const loading = { view, at: null, attached: false };
views.leave(loading);
assert.deepEqual(calls, []);
});
test("a page still loading is not put back by a window event", () => {
const { views, view, calls } = loadedViews();
views.entries.set("t1", { view, at: null, attached: false });
views.reattach();
assert.deepEqual(calls, []);
});
test("a navigation that never finishes does not keep the page out of the window for good", () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { views, view, calls, LEAVE_LIMIT_MS } = loadedViews();
const entry = { view, at: { x: 10, y: 20, width: 600, height: 400 }, attached: true };
views.leave(entry);
assert.deepEqual(calls, ["remove"]);
mock.timers.tick(LEAVE_LIMIT_MS - 1);
assert.equal(entry.attached, false, "still out just before the limit");
mock.timers.tick(1);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
assert.equal(entry.attached, true);
mock.timers.tick(LEAVE_LIMIT_MS * 3);
assert.equal(calls.length, 3, "and it is put back once");
} finally {
mock.timers.reset();
}
});
test("a navigation that does finish cancels the limit, and a second one gets a fresh one", () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { views, view, calls, LEAVE_LIMIT_MS } = loadedViews();
const entry = { view, at: null, attached: true };
views.leave(entry);
mock.timers.tick(LEAVE_LIMIT_MS - 100);
views.settle(entry);
const back = calls.length;
mock.timers.tick(LEAVE_LIMIT_MS * 2);
assert.equal(calls.length, back, "the limit did not fire after the page came back");
views.leave(entry);
mock.timers.tick(LEAVE_LIMIT_MS);
assert.equal(entry.attached, true, "the second navigation was bounded by its own limit");
} finally {
mock.timers.reset();
}
});
// Input to a page in a minimized window.
const { InputWake } = require("../src/inputwake.js");
function wakeWindow({ minimized = true, focused = false } = {}) {
const calls = [];
const win = {
destroyed: false,
isDestroyed() { return this.destroyed; },
isMinimized: () => minimized,
isFocused: () => focused,
setOpacity: (v) => calls.push(`opacity ${v}`),
setIgnoreMouseEvents: (v) => calls.push(`ignore ${v}`),
showInactive: () => { minimized = false; calls.push("showInactive"); },
minimize: () => { minimized = true; calls.push("minimize"); },
};
return { win, calls, focus: () => { focused = true; } };
}
const wakeOf = (win) => new InputWake({ win, platform: "win32", settleMs: 5, holdMs: 20 });
const later = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test("input to a minimized window is sent with the window raised unseen, then it is minimized again", async () => {
const { win, calls } = wakeWindow();
const sent = [];
await wakeOf(win).run(async () => sent.push([...calls]));
assert.deepEqual(sent[0], ["opacity 0", "ignore true", "showInactive"]);
await later(60);
assert.deepEqual(calls.slice(3), ["ignore false", "opacity 1", "minimize"]);
});
test("several inputs in a row raise the window once", async () => {
const { win, calls } = wakeWindow();
const wake = wakeOf(win);
await Promise.all([wake.run(async () => {}), wake.run(async () => {}), wake.run(async () => {})]);
await later(60);
assert.equal(calls.filter((c) => c === "showInactive").length, 1);
assert.equal(calls.filter((c) => c === "minimize").length, 1);
});
test("a window that is not minimized, or not on Windows, is left alone", async () => {
const open = wakeWindow({ minimized: false });
await wakeOf(open.win).run(async () => {});
const mac = wakeWindow();
await new InputWake({ win: mac.win, platform: "darwin", settleMs: 5, holdMs: 20 }).run(async () => {});
await later(40);
assert.deepEqual([...open.calls, ...mac.calls], []);
});
test("a window the person focused meanwhile is not minimized under them", async () => {
const { win, calls, focus } = wakeWindow();
await wakeOf(win).run(async () => focus());
await later(60);
assert.ok(!calls.includes("minimize"));
assert.deepEqual(calls.slice(-2), ["ignore false", "opacity 1"]);
});
test("a failed input still lets the window go back", async () => {
const { win, calls } = wakeWindow();
await assert.rejects(wakeOf(win).run(async () => { throw new Error("gone"); }), /gone/);
await later(60);
assert.ok(calls.includes("minimize"));
});
test("a window destroyed during the hold is not touched", async () => {
const { win, calls } = wakeWindow();
await wakeOf(win).run(async () => {});
win.destroyed = true;
const before = calls.length;
await later(60);
assert.equal(calls.length, before);
});
test("a window that fails to raise is given its opacity and mouse back", async () => {
const { win, calls } = wakeWindow();
win.showInactive = () => { throw new Error("no window"); };
await assert.rejects(wakeOf(win).run(async () => {}), /no window/);
assert.deepEqual(calls.slice(-3), ["ignore false", "opacity 1", "minimize"]);
});
const { processMemoryRows, processMemoryHandler } = require("../src/procmem.js");
test("the shell's process memory is only a fixed type and two whole numbers per process", () => {
const metrics = [
{ pid: 4740, type: "Tab", name: "C:\\Users\\someone\\x", cmd: "--user-data-dir=/home/someone", memory: { workingSetSize: 3992908, privateBytes: 3900000, peakWorkingSetSize: 9 } },
{ pid: 11, type: "Browser", memory: { workingSetSize: 120000, privateBytes: 90000 } },
{ pid: 12, type: "GPU", memory: { workingSetSize: 76000 } },
{ pid: 13, type: "Utility", memory: { workingSetSize: 6000, privateBytes: 5000 } },
{ pid: 14, type: "Zygote", memory: { workingSetSize: 1000, privateBytes: 900 } },
{ pid: 15, type: "Tab", memory: { workingSetSize: Number.NaN, privateBytes: -4 } },
];
const rows = processMemoryRows(metrics);
assert.deepEqual(rows, [
{ process: "renderer", workingSetKB: 3992908, privateKB: 3900000 },
{ process: "browser", workingSetKB: 120000, privateKB: 90000 },
{ process: "gpu", workingSetKB: 76000, privateKB: 0 },
{ process: "utility", workingSetKB: 6000, privateKB: 5000 },
{ process: "utility", workingSetKB: 1000, privateKB: 900 },
{ process: "renderer", workingSetKB: 0, privateKB: 0 },
]);
assert.ok(rows.every((r) => Object.keys(r).sort().join() === "privateKB,process,workingSetKB"));
assert.doesNotMatch(JSON.stringify(rows), /Users|pid|user-data|cmd/i);
});
test("the shell reports at most eleven processes, since the kernel adds its own row to a dozen", () => {
const many = Array.from({ length: 30 }, (_, i) => ({ pid: i, type: "Utility", memory: { workingSetSize: 1, privateBytes: 1 } }));
assert.equal(processMemoryRows(many).length, 11);
});
test("only the app's own window is answered, and nothing is read from the asker", () => {
const app = { getAppMetrics: () => [{ type: "Tab", memory: { workingSetSize: 5, privateBytes: 4 } }] };
assert.deepEqual(processMemoryHandler(app, () => true)({}), [{ process: "renderer", workingSetKB: 5, privateKB: 4 }]);
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
});
+3 -1
View File
@@ -1,6 +1,8 @@
node_modules
dist
dist-perf
dist-perf*
*.png
!public/icons/*.png
tsconfig.tsbuildinfo
dist-relay*
relay/bin
+26
View File
@@ -27,3 +27,29 @@ read it, which costs more than the seconds it saves.
`css: true` is load-bearing: the guards under `src/styles` read `app.css`
through `?raw`, and a stubbed stylesheet hands them an empty string, which
passes on nothing.
## Large-file shell regression
From the repository root, install the frontend and Electron dependencies, then
build the host and production page before running the real-shell regression:
```sh
pnpm --dir desktop/frontend-next install --frozen-lockfile
pnpm --dir desktop/electron install --frozen-lockfile
go build -o desktop/electron/bin/ ./cmd/reasonix-studio-host
pnpm --dir desktop/frontend-next build
node desktop/electron/test/attachments_live.mjs
```
The test uses a disposable workspace, Reasonix home and Electron profile. It
checks 111 MiB native-file selection, paste and drop without byte uploads,
then streams a 65 MiB virtual file and verifies its saved size and ready chip.
No provider request is sent.
Set `REASONIX_TEST_ELECTRON` to use an existing Electron executable, or
`REASONIX_ATTACHMENT_ARTIFACTS` to retain a screenshot and JSON result in a
chosen directory. Failed runs retain their fixture directory for diagnosis.
Attachment transport limits are documented in the
[English guide](../../docs/GUIDE.md#studio-file-attachments) and
[Chinese guide](../../docs/GUIDE.zh-CN.md#studio-文件附件).
+7 -7
View File
@@ -39,8 +39,8 @@
"codemirror": "^6.0.2",
"katex": "^0.19.0",
"maplibre-gl": "6.14.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-map-gl": "8.1.3",
"react-markdown": "^10.1.0",
"recharts": "3.10.1",
@@ -56,14 +56,14 @@
"devDependencies": {
"@babel/parser": "^8.0.7",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.6.8",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.2",
"playwright": "^1.64.0",
"jsdom": "^30.1.3",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.3",
"vitest": "^5.0.3"
"vite": "^8.3.4",
"vitest": "^5.0.4"
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
@@ -0,0 +1,42 @@
// The row of actions under a reply and the row under the person's own message read as one family: the same
// button and the same icon size. A smaller icon under one of them is the kind of slip a reader sees at once.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const size = (el) => el.evaluate((e) => {
const b = e.getBoundingClientRect();
return `${Math.round(b.width * 10) / 10}x${Math.round(b.height * 10) / 10}`;
});
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1200, height: 900 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".user-acts");
await page.waitForSelector(".call[data-k=\"say\"] .acts");
const mine = page.locator(".user-acts .studio-icon");
const reply = page.locator(".call[data-k=\"say\"] .acts .studio-icon");
check(`${scheme}: both rows were found`, (await mine.count()) > 0 && (await reply.count()) > 0);
const replyIcon = await size(reply.first());
const replyButton = await size(page.locator(".call[data-k=\"say\"] .acts > button").first());
for (let i = 0; i < await mine.count(); i++) {
check(`${scheme}: icon ${i + 1} of the message's own row matches the reply's`, await size(mine.nth(i)) === replyIcon, `${await size(mine.nth(i))} vs ${replyIcon}`);
}
check(`${scheme}: the buttons are the same size`, await size(page.locator(".user-acts > button").first()) === replyButton);
await page.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -19,11 +19,12 @@ for (const scheme of ["light", "dark"]) {
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".machpick");
await page.waitForSelector('.projects-heading [data-action="workspace.add"]');
const current = () => page.evaluate(() => [...document.querySelectorAll(".wsnode[data-current]")].map((el) => el.textContent ?? ""));
const was = await current();
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-before.png`) });
await page.locator(".machpick").first().click();
await page.locator('.projects-heading [data-action="workspace.add"]').click();
await page.getByRole("dialog", { name: "打开项目", exact: true }).getByRole("button", { name: "选择本机文件夹", exact: true }).click();
await page.waitForFunction(() => [...document.querySelectorAll(".wsnode[data-current]")].some((el) => el.textContent?.includes("mock-workspace")), null, { timeout: 5000 }).catch(() => {});
const now = await current();
check(`${scheme}: the added project becomes the current one`, now.length === 1 && now[0].includes("mock-workspace"), now.join("|").slice(0, 120));
@@ -1,7 +1,5 @@
// The "+" that opens a project folder sits on the machine row where a person
// looks for it, and is there before the row is hovered. It was drawn only on
// hover once, and two people on 2.39.0 reported that they could not choose a
// folder at all. Opacity and hit-testing need a real browser.
// The Projects heading's add-folder button stays visible and hit-testable
// before hover. Opacity and hit-testing need a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
@@ -18,11 +16,11 @@ try {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".machrow[data-here]");
await page.waitForSelector(".projects-heading");
await page.mouse.move(width - 2, 790);
await page.waitForTimeout(300);
const g = await page.evaluate(() => {
const row = document.querySelector(".machrow[data-here]");
const row = document.querySelector(".projects-heading");
const add = row?.querySelector('[data-action="workspace.add"]');
if (!add) return null;
const r = add.getBoundingClientRect();
@@ -32,7 +30,7 @@ try {
return { opacity: seen, width: r.width, height: r.height, hits: hit === add || add.contains(hit), shown: row.matches(":hover") };
});
const where = `${theme} ${width}`;
check(`${where}: the machine row has the add-folder button`, g !== null);
check(`${where}: the Projects heading has the add-folder button`, g !== null);
if (!g) continue;
check(`${where}: it is visible without hovering the row`, !g.shown && g.opacity === 1 && g.width > 0 && g.height > 0, JSON.stringify(g));
check(`${where}: it takes the pointer`, g.hits);
+26 -17
View File
@@ -1,5 +1,5 @@
// Opening the branch menu while the pointer is still over the branch chip must not leave the hover card showing:
// the two surfaces would stack their shadows. Visibility under :hover is a styling question, so it is asked of a real browser.
// Workspace selection must stay reachable before and after a task, with no
// legacy hover card stacked over the open palette.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
@@ -9,22 +9,31 @@ const check = (name, ok, detail = "") => {
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
try {
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 } })).newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
const chip = page.locator('.studio-branch-pop .studio-branch').first();
await chip.waitFor();
const card = page.locator(".studio-branch-card").first();
const shown = () => card.evaluate((el) => getComputedStyle(el).visibility === "visible" && Number(getComputedStyle(el).opacity) > 0.5);
await chip.hover();
await page.waitForTimeout(400);
check("the hover card shows over a closed chip", await shown());
await chip.click();
await page.waitForTimeout(500);
check("the branch menu opened", (await chip.getAttribute("aria-expanded")) === "true");
check("the hover card is gone while the menu is open", !(await shown()));
for (const turns of [0, 2]) {
const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
const url = new URL(BASE);
url.searchParams.set("turns", String(turns));
await page.goto(url.href, { waitUntil: "networkidle" });
const chip = page.getByRole("button", { name: "配置工作目录", exact: true });
await chip.hover();
await chip.click();
const palette = page.getByRole("dialog", { name: "配置工作目录", exact: true });
await palette.waitFor();
check(`${turns} turns: the workspace palette opened`, await chip.getAttribute("aria-expanded") === "true");
const stacked = await page.locator(".studio-branch-card").evaluateAll((cards) => cards.some((card) => getComputedStyle(card).visibility === "visible" && Number(getComputedStyle(card).opacity) > 0.5));
check(`${turns} turns: no legacy hover card overlaps the palette`, !stacked);
await palette.getByRole("option", { name: "main", exact: true }).waitFor();
check(`${turns} turns: the current branch is selected`, await palette.getByRole("option", { name: "main", exact: true }).getAttribute("aria-selected") === "true");
const search = palette.getByRole("searchbox", { name: "搜索分支", exact: true });
await search.fill("studio");
check(`${turns} turns: search filters the branch choices`, await palette.getByRole("option").count() === 1 && await palette.getByRole("option", { name: "studio", exact: true }).isVisible());
await search.press("Escape");
await palette.waitFor({ state: "detached" });
check(`${turns} turns: closing returns focus to the workspace entry`, await chip.evaluate((element) => element === document.activeElement));
await page.close();
}
} finally {
await browser.close();
}
@@ -0,0 +1,53 @@
// The product mark in the sidebar's top-left is one row: the wordmark and an outlined uppercase badge, vertically centred on
// each other. Where the badge was hidden before it stays hidden.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
let seen = null;
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 700]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height: 800 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".studio-brand-lockup");
await page.waitForTimeout(500);
const m = await page.evaluate(() => {
const logo = document.querySelector(".studio-brand-logo").getBoundingClientRect();
const sub = document.querySelector(".studio-brand-sub");
const r = sub.getBoundingClientRect();
const cs = getComputedStyle(sub);
return {
shown: r.width > 0,
left: r.left,
top: r.top,
dy: Math.abs(logo.top + logo.height / 2 - (r.top + r.height / 2)),
after: r.left >= logo.right - 1,
fill: cs.backgroundColor, ink: cs.color, border: cs.borderTopWidth, radius: cs.borderTopLeftRadius, upper: cs.textTransform, text: sub.textContent,
};
});
const tag = `${scheme} ${width}px`;
if (width >= 760) {
check(`${tag}: the badge is on the wordmark's row, vertically centred on it`, m.shown && m.dy <= 1.5 && m.after, JSON.stringify(m));
check(`${tag}: it is an outlined uppercase badge in both themes and the text is untouched`, m.fill === "rgba(0, 0, 0, 0)" && m.border !== "0px" && m.radius !== "0px" && m.upper === "uppercase" && m.text === "studio", JSON.stringify(m));
if (!seen) seen = { left: m.left, top: m.top };
check(`${tag}: it sits in the same place whichever theme is on`, Math.abs(m.left - seen.left) <= 0.5 && Math.abs(m.top - seen.top) <= 0.5, `${m.left},${m.top} vs ${seen.left},${seen.top}`);
} else {
check(`${tag}: the badge stays hidden on a narrow window`, !m.shown);
}
await ctx.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,57 @@
// A call brought to the end of the transcript is the same row it is where it happened, drawn open: no box, no icon
// square, no vertical track — the status glyph, the title, the subject and the meta on one row over a hairline.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1100, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "run" });
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}' } });
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}', output: "ok\n", durationMs: 900 } });
window.__feed({ kind: "text", text: "done" });
window.__feed({ kind: "turn_done" });
});
await page.waitForTimeout(400);
await page.evaluate(() => [...document.querySelectorAll('[data-action="tool.bring"]')].pop()?.click());
await page.waitForSelector(".brought-item .call");
await page.mouse.move(5, 5);
await page.waitForTimeout(200);
const m = await page.evaluate(() => {
const call = document.querySelector(".brought-item .call");
const g = call.querySelector(":scope > .g");
if (!call.closest(".flow")) return { iconColumn: "outside .flow" };
const c = call.querySelector(":scope > .c");
const cs = getComputedStyle(c);
return {
iconColumn: g ? getComputedStyle(g).display : "absent",
sides: [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth].join(","),
radius: cs.borderTopLeftRadius,
bg: cs.backgroundColor,
caption: !!document.querySelector(".brought-bar span"),
open: !!call.querySelector("details[open]"),
};
});
check(`${scheme}: no icon column or vertical track`, m.iconColumn === "none" || m.iconColumn === "absent", m.iconColumn);
check(`${scheme}: no box around the row`, m.sides === "0px,0px,0px" && m.radius === "0px" && (m.bg === "rgba(0, 0, 0, 0)" || m.bg === "transparent"), JSON.stringify(m));
check(`${scheme}: opened, with its caption above`, m.open && m.caption);
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,60 @@
// Full-screen browser: the page takes the whole pane, the conversation is folded away but stays mounted,
// and Escape (or the same button) brings the split back.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
const SHOTS = process.env.SHOTS_DIR;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-filelist button");
const read = () => page.evaluate(() => {
const pane = document.querySelector(".pane");
const dock = pane.querySelector(':scope > .scroll[data-pane="browser"]');
const shown = (el) => !!el && getComputedStyle(el).display !== "none";
return {
size: pane.getAttribute("data-browser-size"),
conversation: shown(pane.querySelector(":scope > .pbody")),
composer: shown(pane.querySelector(":scope > .compose")),
dockWidth: dock.getBoundingClientRect().width,
paneWidth: pane.getBoundingClientRect().width,
};
});
await page.waitForTimeout(1200);
await page.mouse.move(1390, 890);
await page.waitForTimeout(300);
const split = await read();
check(`${scheme}: starts split with the conversation shown`, split.size === "split" && split.conversation && split.composer);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/browser-${scheme}-split.png` });
await page.locator('[data-action="browser.full"]').first().click();
await page.waitForTimeout(500);
const full = await read();
check(`${scheme}: full hides the conversation and the composer`, full.size === "full" && !full.conversation && !full.composer);
check(`${scheme}: full gives the page the whole pane`, full.dockWidth >= full.paneWidth - 1, `${full.dockWidth}px of ${full.paneWidth}px`);
await page.mouse.move(1390, 890);
await page.waitForTimeout(300);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/browser-${scheme}-full.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
const back = await read();
check(`${scheme}: Escape brings the split and the conversation back`, back.size === "split" && back.conversation && back.composer);
await page.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+64
View File
@@ -0,0 +1,64 @@
// Cards the reader reads or acts on share one shell: the same radius, border and ground, and a header with no fill of its
// own. A kind that draws its own (a different radius, a state-tinted border, a filled strip) reads as another family.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "跑一遍" });
f({ kind: "text", text: "我来处理。" });
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: '{}' } });
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "标题", description: "一句话", scope: "project", activation: "always", body: "正文" }) } });
f({ kind: "guardian_assessment", guardian: { id: "gd", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "越界", duration_ms: 900 } });
f({ kind: "compaction_done", compaction: { trigger: "capacity", messages: 40, summary: "折叠了前 40 条。", sourceTokens: 90000, projectionTokens: 6000, coverageRequired: 6, coverageMissing: 2, boundary: "capacity", triggerTokens: 90000 } });
f({ kind: "extension_surface", extension: { pluginId: "cov", surfaceId: "c", kind: "card", card: { title: "覆盖率", text: "78.4%", fields: [{ key: "语句", value: "78.4%" }], progress: 0.78, actions: [{ actionId: "o", label: "打开" }] } } });
f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: [], unverified: [], saysSomething: true } });
f({ kind: "turn_started" });
f({ kind: "approval_request", approval: { id: "a1", tool: "bash", subject: "rm -rf build && make" } });
f({ kind: "ask_request", ask: { id: "q1", questions: [{ id: "q", header: "选一个", prompt: "走哪条路", options: [{ label: "方案 A" }, { label: "方案 B" }] }] } });
});
await page.waitForTimeout(800);
await page.mouse.move(995, 5);
const rows = await page.evaluate(() =>
[...document.querySelectorAll(".flow .call")]
.filter((c) => ["mem", "host", "extension"].includes(c.getAttribute("data-k")) || c.hasAttribute("data-prompt"))
.map((c) => {
const cs = getComputedStyle(c.querySelector(":scope > .c"));
const hl = c.querySelector(":scope > .c > .hl");
return {
kind: c.getAttribute("data-k"),
radius: cs.borderTopLeftRadius,
border: cs.borderTopColor,
bg: cs.backgroundColor,
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
};
}));
check(`${scheme}: four kinds of card found`, rows.length === 4, rows.map((r) => r.kind).join(","));
const same = (key) => new Set(rows.map((r) => r[key])).size === 1;
check(`${scheme}: one radius`, rows.length > 0 && same("radius"), [...new Set(rows.map((r) => r.radius))].join(" / "));
check(`${scheme}: one border colour, whatever the state`, rows.length > 0 && same("border"), [...new Set(rows.map((r) => r.border))].join(" / "));
check(`${scheme}: one ground`, rows.length > 0 && same("bg"), [...new Set(rows.map((r) => r.bg))].join(" / "));
check(`${scheme}: no header fill`, rows.length > 0 && rows.every((r) => r.head === "rgba(0, 0, 0, 0)"), [...new Set(rows.map((r) => r.head))].join(" / "));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+6
View File
@@ -101,6 +101,12 @@ check("回执没有重复根容器", await page.locator(".rc > .rc").count() ===
// between any two glyphs, so it is the one that must not give up its width.
const rc = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: "zh-CN", colorScheme: "dark" });
rc.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await rc.addInitScript(() => {
try {
localStorage.setItem("rx-turn-receipt", "on");
} catch {
}
});
await rc.goto(PAGE, { waitUntil: "networkidle" });
await rc.waitForSelector(".compose");
await rc.evaluate(() => {
@@ -0,0 +1,50 @@
// The code editor's text and its search field follow the interface text size, like the rest of the page.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
const SHOTS = process.env.SHOTS_DIR;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-filelist button");
await page.getByText("README.md").first().click();
await page.getByRole("button", { name: "编辑", exact: true }).click();
await page.waitForSelector(".cm-content");
await page.locator(".cm-content").click();
for (const chord of ["Control+f", "Meta+f"]) {
if (await page.locator(".cm-textfield").count()) break;
await page.keyboard.press(chord);
await page.waitForTimeout(300);
}
await page.waitForSelector(".cm-textfield");
for (const scale of [0.85, 1, 1.6]) {
await page.evaluate((v) => document.documentElement.style.setProperty("--fs-scale", String(v)), scale);
await page.waitForTimeout(1000);
const read = await page.evaluate(() => {
const px = (el) => parseFloat(getComputedStyle(el).fontSize);
return { text: px(document.querySelector(".cm-content")), field: px(document.querySelector(".cm-textfield")) };
});
check(`${scheme} x${scale}: the editor text is ${12 * scale}px`, Math.abs(read.text - 12 * scale) < 0.1, `${read.text}px`);
check(`${scheme} x${scale}: the search field is ${11 * scale}px`, Math.abs(read.field - 11 * scale) < 0.1, `${read.field}px`);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/editor-${scheme}-${scale}.png`, fullPage: false });
}
await page.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,79 @@
// The composer keeps the 1.x feel: a ring of light sweeps its border once when
// it is hovered or focused, nothing sweeps while a turn runs or when motion is
// reduced, and the run state is announced rather than printed inside the box.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const sweep = (page) => page.evaluate(() => getComputedStyle(document.querySelector(".compose"), "::before").animationName);
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const running of [false, true]) {
const label = `${scheme} ${running ? "running" : "idle"}`;
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(BASE + (running ? "&running=1" : ""), { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (running) {
await page.waitForFunction(() => typeof window.__feed === "function");
await page.waitForTimeout(400);
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
}
await page.waitForSelector(running ? ".studio-runstate[data-running]" : ".studio-runstate[data-idle]");
await page.mouse.move(5, 5);
await page.waitForTimeout(150);
const state = await page.evaluate(() => {
const status = document.querySelector(".compose .studio-runstate");
const word = status?.querySelector(".studio-runlabel > span.sr-only");
const r = word?.getBoundingClientRect();
const clock = status?.querySelector(".studio-runclock");
return {
role: status?.getAttribute("role"),
live: status?.getAttribute("aria-live"),
said: word?.textContent ?? "",
wordBox: r ? [Math.round(r.width), Math.round(r.height)] : null,
clockShown: !!clock && clock.getBoundingClientRect().width > 0,
mark: !!status?.querySelector(".rmark"),
};
});
check(`${label}: the state is announced`, state.role === "status" && state.live === "polite" && state.said.length > 0, state.said);
check(`${label}: the word is not printed in the box`, state.wordBox && state.wordBox[0] <= 2 && state.wordBox[1] <= 2, JSON.stringify(state.wordBox));
check(`${label}: the mark stays`, state.mark);
if (running) check(`${label}: the clock stays`, state.clockShown);
check(`${label}: nothing sweeps at rest`, (await sweep(page)) === "none");
await page.hover(".compose textarea");
await page.waitForTimeout(300);
const hovered = await sweep(page);
check(`${label}: ${running ? "no sweep on hover while running" : "a sweep on hover"}`, running ? hovered === "none" : hovered === "compose-sweep", hovered);
if (shotDir && !running) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-hover.png` });
await page.mouse.move(5, 5);
await page.waitForTimeout(100);
await page.focus(".compose textarea");
await page.waitForTimeout(300);
const focused = await sweep(page);
check(`${label}: ${running ? "no sweep on focus while running" : "a sweep on focus"}`, running ? focused === "none" : focused === "compose-sweep", focused);
if (shotDir) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-${running ? "running" : "idle"}.png` });
await page.close();
}
const calm = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
await calm.goto(BASE, { waitUntil: "networkidle" });
await calm.waitForSelector(".compose");
await calm.hover(".compose textarea");
await calm.focus(".compose textarea");
await calm.waitForTimeout(300);
check(`${scheme}: no sweep when motion is reduced`, (await sweep(calm)) === "none");
await calm.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
@@ -23,7 +23,7 @@ try {
const tb = await trigger.boundingBox();
await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2);
await page.waitForTimeout(250);
const card = page.locator(".chrome-context-card").first();
const card = page.locator(".studio-context-anchor .chrome-context-card");
check(`${scheme}: the card opens while the pointer is on the meter`, await card.isVisible());
const target = card.locator("button, a").first();
const cb = await target.boundingBox();
+5 -3
View File
@@ -1,4 +1,4 @@
// The session title sits right beside the project name in the title bar, at every width, and the
// The session title sits right beside the project and checkout in the title bar, at every width, and the
// project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question.
import { chromium } from "playwright";
@@ -22,9 +22,11 @@ try {
const range = document.createRange();
range.selectNodeContents(projEl);
const text = range.getBoundingClientRect();
return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) };
const tags = [...document.querySelectorAll(".chrome .crumb .isolab")].filter((el) => !el.hidden);
const edge = tags.at(-1)?.getBoundingClientRect().right ?? text.right;
return { gap: Math.round(sep.left - edge), titleRight: Math.round(title.right) };
});
check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title follows the project and checkout`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`);
const long = await page.evaluate(() => {
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
@@ -0,0 +1,46 @@
// An opened step shows its detail as a panel in place, bordered on all four sides by one hairline. A rule on one side only
// is a rail, and the transcript has no rails.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
f({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}' } });
f({ kind: "tool_result", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}', output: "ok\n", durationMs: 900 } });
f({ kind: "text", text: "done" });
f({ kind: "turn_done" });
});
await page.waitForTimeout(500);
await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
await page.waitForTimeout(400);
await page.mouse.move(995, 5);
const m = await page.evaluate(() =>
[...document.querySelectorAll(".flow .tool-disclosure > .out")].map((el) => {
const cs = getComputedStyle(el);
return { sides: [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].join(","), color: [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor] };
}));
check(`${scheme}: an opened step has a detail panel`, m.length > 0, `${m.length} found`);
check(`${scheme}: it is bordered on all four sides`, m.length > 0 && m.every((x) => x.sides === "1px,1px,1px,1px" && new Set(x.color).size === 1), JSON.stringify(m[0] ?? {}));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+15 -24
View File
@@ -1,4 +1,4 @@
// A docked browser column starts at the top of the pane, beside the navigation row, and gives the rows back when it
// A docked browser column starts at the top of the pane, and gives the rows back when it
// is undocked. Where things start is a layout question, so it is asked of a real browser.
import { chromium } from "playwright";
@@ -19,7 +19,7 @@ const geometry = (page) =>
};
const card = document.querySelector('.pane > .scroll[data-pane="browser"] > .workbench');
const divider = card ? parseFloat(getComputedStyle(card).borderLeftWidth) : 0;
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
return { pane: box(".pane"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
});
const browser = await chromium.launch();
@@ -40,26 +40,24 @@ for (const scheme of ["light", "dark"]) {
check(`${tag} ${round}: the browser column is drawn`, !!open.side);
if (open.side) {
if (label === "wide") {
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
check(`${tag} ${round}: it starts at the top of the pane`, Math.abs(open.side.top - open.pane.top) <= 1, `pane ${open.pane.top} column ${open.side.top}`);
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
check(`${tag} ${round}: its strip ends on the navigation row's line, so the two rows read as one`, !!open.strip && Math.abs(open.strip.bottom - open.tabs.bottom) <= 1, JSON.stringify([open.tabs, open.strip]));
check(`${tag} ${round}: the workbench card's own edge separates it from the conversation`, open.divider >= 1, String(open.divider));
} else {
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
check(`${tag} ${round}: the page takes the pane's full width when it stacks`, open.side.left <= open.pane.left + 1 && open.side.right >= open.pane.right - 1, JSON.stringify([open.pane, open.side]));
}
}
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
}
await page.locator('[data-action="pane.view"][data-value="flow"]').first().click();
await page.locator('[data-action="pane.view"][data-value="browser"][aria-pressed="true"]').first().click();
await page.waitForTimeout(700);
const shut = await geometry(page);
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
check(`${tag} undocked: the column is gone`, !shut.side || shut.side.left >= shut.pane.right - 1, JSON.stringify([shut.side, shut.pane]));
await page.close();
}
}
// Every room between phone and wide, not two samples: the left column shrinks with the window, and the tabs it holds
// must still fit and stay clear of the browser column.
// Every room between phone and wide, not two samples: the conversation shrinks with the window, and it must
// stay clear of the browser column, which stacks over it once the window is narrow.
{
const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
const bad = [];
@@ -70,22 +68,15 @@ for (const scheme of ["light", "dark"]) {
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
await page.waitForTimeout(400);
const g = await page.evaluate(() => {
const tabs = document.querySelector(".pane > .tabs");
const side = document.querySelector('.pane > .scroll[data-pane="browser"]');
const t = tabs.getBoundingClientRect();
const last = [...tabs.querySelectorAll(".tab")].at(-1)?.getBoundingClientRect();
const s = side?.getBoundingClientRect();
const stacked = !!s && s.top >= t.bottom - 1;
const first = tabs.querySelector(".tab")?.getBoundingClientRect();
const pad = parseFloat(getComputedStyle(tabs).paddingLeft) * 2;
const need = last && first ? last.right - first.left + pad : 0;
const pane = document.querySelector(".pane").getBoundingClientRect().width;
return { fits: tabs.scrollWidth <= tabs.clientWidth + 1, clear: stacked || !s || !last || last.right <= s.left + 1, stacked, need, pane };
const side = document.querySelector('.pane > .scroll[data-pane="browser"]')?.getBoundingClientRect();
const body = document.querySelector(".pane > .pbody")?.getBoundingClientRect();
const pane = document.querySelector(".pane").getBoundingClientRect();
const stacked = !!side && side.left <= pane.left + 40;
return { stacked, clear: stacked || !side || !body || body.right <= side.left + 1 };
});
const should = g.pane < g.need + 320 || width <= 640;
if (!g.fits || !g.clear || g.stacked !== should) bad.push(`${width}:${g.fits ? "" : "overflow "}${g.clear ? "" : "overlap "}${g.stacked !== should ? `stacked=${g.stacked} pane=${Math.round(g.pane)} need=${Math.round(g.need)}` : ""}`);
if (!g.clear || (width <= 640 && !g.stacked) || (width >= 1000 && g.stacked)) bad.push(`${width}:${g.clear ? "" : "overlap "}stacked=${g.stacked}`);
}
check("from 560 to 1440 the tabs fit their row, stay clear of the browser column, and the column stacks exactly when tabs plus its 320px floor no longer fit", bad.length === 0, bad.join(" "));
check("from 560 to 1440 the conversation stays clear of the browser column, which stacks only in a narrow window", bad.length === 0, bad.join(" "));
await page.close();
}
await browser.close();
@@ -1,39 +0,0 @@
// Over a wallpaper the tab strip sits above the conversation, whose own backdrop
// fades out on its other three sides. A strip that ends on a hard line reads as a
// seam. Whether the strip's lower edge fades is a property of the render, so it
// is read from the browser: the strip draws no bottom border over a wallpaper and
// carries a gradient below its own box.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ viewport: { width: 1300, height: 860 }, colorScheme: scheme, locale: "zh-CN" });
const page = await ctx.newPage();
await page.goto(`${BASE}?pack=photo&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".tabs");
const edge = await page.evaluate(() => {
const tabs = document.querySelector(".tabs");
const own = getComputedStyle(tabs);
const fade = getComputedStyle(tabs, "::after");
return { border: own.borderBottomColor, fadeHeight: parseFloat(fade.height) || 0, fadeImage: fade.backgroundImage };
});
check(`${scheme}: the tab strip draws no hard bottom line over a wallpaper`, edge.border === "rgba(0, 0, 0, 0)", edge.border);
check(`${scheme}: it fades into the conversation below`, edge.fadeHeight >= 12 && edge.fadeImage.includes("linear-gradient"), `${edge.fadeHeight}px`);
await ctx.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+55
View File
@@ -0,0 +1,55 @@
// Keyboard focus has to be seen. Every button the keyboard reaches in the workbench must look different when focused for a
// reason other than fading in: an outline, a shadow, a border or a background. A control that only changes opacity is
// invisible to anyone who cannot tell .45 from 1.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(700);
await page.mouse.move(1270, 5);
await page.evaluate(() => document.activeElement?.blur());
const look = (el) => {
const cs = getComputedStyle(el);
return [cs.outlineStyle === "none" ? "" : `${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.borderColor, cs.backgroundColor, cs.textDecorationLine].join("|");
};
const seen = new Set();
let stops = 0;
const bad = [];
for (let i = 0; i < 90; i++) {
await page.keyboard.press("Tab");
const r = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body || el.tagName !== "BUTTON") return null;
const cs = getComputedStyle(el);
const focused = [cs.outlineStyle === "none" ? "" : `${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.borderColor, cs.backgroundColor, cs.textDecorationLine].join("|");
const id = (el.getAttribute("aria-label") || el.textContent || el.getAttribute("data-action") || "button").trim().slice(0, 40);
el.blur();
const css2 = getComputedStyle(el);
const plain = [css2.outlineStyle === "none" ? "" : `${css2.outlineWidth} ${css2.outlineColor}`, css2.boxShadow, css2.borderColor, css2.backgroundColor, css2.textDecorationLine].join("|");
const r = el.getBoundingClientRect();
el.focus({ focusVisible: true });
return { id, key: id + Math.round(r.left) + Math.round(r.top), same: focused === plain, shown: r.width > 0 && r.height > 0 };
});
if (!r) continue;
if (seen.has(r.key)) break;
seen.add(r.key);
stops++;
if (r.shown && r.same) bad.push(r.id);
}
console.log(`${bad.length ? "FAIL" : " ok"} ${scheme}: ${stops} buttons reached by Tab${bad.length ? ", no visible focus on: " + bad.join("; ") : ""}`);
if (bad.length) fails.push(`${scheme}: ${bad.join("; ")}`);
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+90
View File
@@ -0,0 +1,90 @@
// Interface text has one size dial, --fs-scale, and the transcript has its own,
// --read. Every piece of text must answer to one of them: text that moves with
// neither is a size written into a stylesheet as a bare pixel count, which the
// text-size setting could never reach.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const SCENES = [
{ name: "conversation", q: "?pref=zh&ws=2&sess=6&turns=3" },
{ name: "files panel", q: "?pref=zh&ws=2&sess=6&turns=2", act: async (p) => { await p.locator('.navbtn[aria-label="文件"]').first().click(); await p.waitForSelector(".workbench-filelist button"); } },
{ name: "side panel", q: "?pref=zh&turns=2", act: async (p) => { await p.locator('[data-action="side.toggle"]').click(); await p.waitForSelector(".sidepanel"); } },
{ name: "narrow", q: "?pref=zh&ws=2&sess=6&turns=2", w: 390 },
{ name: "settings", q: "?pref=zh&turns=2", tabs: true },
];
const sizes = (page) => page.evaluate(() => {
window.__texts ??= (() => {
const out = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
let n;
while ((n = walker.nextNode())) {
if (![...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim())) continue;
const r = n.getBoundingClientRect();
if (r.width < 2 || r.height < 2 || !n.checkVisibility({ checkVisibilityCSS: true, contentVisibilityAuto: true })) continue;
out.push(n);
}
return out;
})();
return window.__texts.map((el) => parseFloat(getComputedStyle(el).fontSize));
});
const browser = await chromium.launch();
for (const sc of SCENES) {
const page = await browser.newPage({ viewport: { width: sc.w ?? 1400, height: 900 }, locale: "zh-CN", reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(BASE + sc.q, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
const views = [];
if (sc.tabs) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
const n = await page.evaluate(() => document.querySelectorAll(".prefs-nav button").length);
for (let i = 0; i < n; i++) views.push(async () => { await page.evaluate((k) => document.querySelectorAll(".prefs-nav button")[k].click(), i); });
} else views.push(async () => { await sc.act?.(page); });
let total = 0;
const holes = new Map();
for (const show of views) {
await show();
await page.waitForTimeout(500);
await page.evaluate(() => { window.__texts = undefined; document.documentElement.style.setProperty("--fs-scale", "1"); });
const base = await sizes(page);
await page.evaluate(() => document.documentElement.style.setProperty("--fs-scale", "1.25"));
await page.waitForTimeout(80);
const scaled = await sizes(page);
await page.evaluate(() => {
const root = document.documentElement.style;
window.__read = root.getPropertyValue("--read");
root.setProperty("--fs-scale", "1");
root.setProperty("--read", `${parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--read")) * 1.25}px`);
});
await page.waitForTimeout(80);
const reading = await sizes(page);
await page.evaluate(() => {
const root = document.documentElement.style;
if (window.__read) root.setProperty("--read", window.__read);
else root.removeProperty("--read");
});
const names = await page.evaluate(() => window.__texts.map((el) => (el.className && typeof el.className === "string" ? el.className.trim().split(/\s+/).slice(0, 2).join(".") : el.tagName.toLowerCase()) + " " + el.textContent.trim().slice(0, 10)));
base.forEach((b, i) => {
if (b <= 0) return;
total++;
const ok = Math.abs(scaled[i] / b - 1.25) < 0.03 || Math.abs(reading[i] / b - 1.25) < 0.03;
if (!ok) holes.set(`${names[i]} (${b}px)`, (holes.get(`${names[i]} (${b}px)`) ?? 0) + 1);
});
}
check(`${sc.name}: every piece of text answers to the text size or the reading size`, holes.size === 0, `${total} text nodes${holes.size ? "; stuck: " + [...holes.keys()].slice(0, 6).join(", ") : ""}`);
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+86
View File
@@ -0,0 +1,86 @@
// The Git panel in the workbench: a workspace that is one repository shows the changes as a folder tree to stage,
// discard and commit, a push that is previewed before it is confirmed, and a history whose commits open into their
// files; a folder of projects shows one section per repository, each acting on its own.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
for (const width of [1400, 700]) {
const tag = `${scheme} ${width}px`;
const open = async (query) => {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width, height: 1000 }, locale: "zh-CN", reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&ws=2&sess=3&turns=2${query}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-changes");
return page;
};
const shot = async (page, name) => {
if (!shotDir) return;
await page.mouse.move(width - 3, 995);
await page.waitForTimeout(250);
const box = await page.locator(".workbench-explorer").first().boundingBox();
// At phone width the composer's toolbar is drawn over the foot of the column; the panel's own picture stops above it.
await page.screenshot({ path: `${shotDir}/${scheme}-${width}-${name}.png`, clip: { x: box.x, y: box.y, width: box.width, height: box.height - (width <= 700 ? 150 : 0) } });
};
const one = await open("");
const tree = await one.evaluate(() => ({
folders: [...document.querySelectorAll(".workbench-change-folder")].length,
rows: [...document.querySelectorAll('.workbench-change-row[data-action="workbench.diff"]')].length,
overflow: document.querySelector(".workbench-explorer").scrollWidth > document.querySelector(".workbench-explorer").clientWidth + 1,
}));
check(`${tag}: the changes are a folder tree`, tree.folders >= 2 && tree.rows === 4, JSON.stringify(tree));
check(`${tag}: the panel does not push sideways`, !tree.overflow);
await shot(one, "changes");
await one.locator('[data-action="workbench.discard"]').first().focus();
await one.locator('[data-action="workbench.discard"]').first().click();
await one.waitForSelector(".workbench-discard-ask");
const ask = await one.evaluate(() => document.querySelector(".workbench-discard-ask").textContent);
check(`${tag}: a discard asks first and says it cannot be undone`, ask.includes("无法恢复"));
await shot(one, "discard");
await one.getByRole("button", { name: "取消", exact: true }).first().click();
await one.getByRole("button", { name: "推送…" }).click();
await one.waitForSelector(".push-card");
const push = await one.evaluate(() => document.querySelector(".push-card").textContent);
check(`${tag}: the push says where it goes before it goes`, push.includes("github.com") && push.includes("main → origin/main") && push.includes("从不强制推送"), push.slice(0, 120));
await shot(one, "push");
await one.getByRole("button", { name: "推送到 github.com", exact: false }).last().click();
await one.waitForSelector('.push-card[data-phase="done"]');
check(`${tag}: a confirmed push reports it`, (await one.locator(".push-card").textContent()).includes("已把 2 个提交推送到 github.com"));
await shot(one, "pushed");
await one.getByText("提交历史").click();
await one.getByRole("button", { name: /show the commit graph/ }).click();
await one.getByRole("button", { name: /retry\.go/ }).last().click();
await one.waitForSelector(".gh-diff");
await shot(one, "history");
await one.close();
const many = await open("&git=multi");
const heads = await many.evaluate(() => [...document.querySelectorAll(".workbench-repo-head")].map((h) => h.textContent));
check(`${tag}: a folder of projects shows a section per repository`, heads.length === 3 && heads[0].includes("api") && heads[1].includes("feature/login"), heads.join(" | "));
await shot(many, "repos");
await many.locator('.workbench-repo[data-repo="web"] [data-action="workbench.stage"]').first().click();
await many.waitForFunction(() => document.querySelector('.workbench-repo[data-repo="web"] [data-action="workbench.stage"]')?.getAttribute("aria-checked") === "true");
check(`${tag}: staging in one repository leaves the others as they were`, (await many.locator('.workbench-repo[data-repo="api"] [data-action="workbench.stage"][aria-checked="true"]').count()) === 1);
await many.close();
}
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+247 -16
View File
@@ -11,7 +11,7 @@ import "../src/styles/askskip.css";
import "../src/styles/pastekeep.css";
import "../src/styles/ctxitems.css";
import "../src/styles/sidepanel.css";
import "../src/styles/planpeek.css";
import "../src/styles/contentmatches.css";
import "../src/styles/scrolljump.css";
import "../src/styles/chart.css";
import "../src/styles/community.css";
@@ -27,9 +27,13 @@ import "../src/styles/dialogcenter.css";
import "../src/styles/treerows.css";
import "../src/styles/workbench-changes.css";
import "../src/styles/gitgraph.css";
import "../src/styles/vthumb.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
import "../src/styles/bring.css";
import "../src/styles/peerthreads.css";
import "../src/styles/line.css";
import "../src/styles/notice.css";
import "../src/styles/peers.css";
import "../src/styles/jsontext.css";
import "../src/styles/skillpin.css";
import "../src/styles/narrow.css";
@@ -38,6 +42,7 @@ import "../src/styles/workbench-launcher.css";
import "../src/styles/feedback.css";
import "../src/styles/toasts.css";
import "../src/styles/modelmenu.css";
import "../src/styles/composer-menu.css";
import "../src/styles/runsignals.css";
import "../src/styles/overview.css";
import "../src/styles/themes.css";
@@ -45,9 +50,11 @@ import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
import { fromHistory } from "../src/state/session";
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port";
import { HttpError } from "../src/port/port";
import type { AgentPort, Appearance, ChipCall, HistoryMessage, McpEntry } from "../src/port/port";
import type { UpdateProgress } from "../src/port/version";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub";
import type { WireEvent } from "../src/port/wire";
// Saves are coalesced (src/ui/trailing.ts), and a fixture answers too fast for
@@ -89,18 +96,109 @@ function benchPack(): ThemePack | null {
}
class BenchPort extends MockPort {
async browserTabs() {
if (!query.has("agentpage")) return super.browserTabs();
return [{ id: "t1", target: "view-a", url: "https://example.com/docs/install", title: "Install guide", active: true }];
// ?mcpactions=1 adds a server whose tools are withheld and one that is followed.
constructor() {
super();
// ?peerset=1 starts with session messages on and two projects already allowed through.
if (query.has("peerset")) {
this.peerSettings = {
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn", pending: query.has("pending"), available: true,
path: "/Users/you/.reasonix/config.toml",
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
};
}
// ?git=multi is a folder of projects: three repositories, each with changes of its own.
if (query.get("git") === "multi") {
this.repoRows = [
{ dir: "api", name: "api", branch: "main", detached: false },
{ dir: "web", name: "web", branch: "feature/login", detached: false },
{ dir: "tools/release", name: "release", branch: "main", detached: false },
];
this.repoChanges = {
api: [
{ path: "internal/server/routes.go", status: "M", unstaged: true, insertions: 12, deletions: 3 },
{ path: "internal/server/routes_test.go", status: "M", staged: true, insertions: 20, deletions: 0 },
{ path: "README.md", status: "??", unstaged: true },
],
web: [
{ path: "src/login/Form.tsx", status: "M", unstaged: true, insertions: 31, deletions: 9 },
{ path: "src/login/api.ts", status: "M", staged: true, unstaged: true, insertions: 4, deletions: 1 },
],
"tools/release": [],
};
}
if (!query.has("mcpactions")) return;
(this as unknown as { servers: McpEntry[] }).servers.push(
{ name: "notes", state: "ready", enabled: true, transport: "stdio", tools: 2, description: "Notes the agent can search and add to.",
held: [{ name: "mcp__notes__search", class: "changed" }, { name: "mcp__notes__add", class: "added" }], heldDigest: "digest-1" },
{ name: "wiki", state: "ready", enabled: true, transport: "http", tools: 4, followsChanges: true, description: "The team wiki." },
);
}
async workspaceFile(path: string) {
// ?netshell=1 is a connection over HTTP: a `!` line is refused as the kernel refuses it.
async submit(text: string, chips?: ChipCall) {
(window.__sent ??= []).push(text);
if (query.has("netshell") && text.trimStart().startsWith("!")) throw new HttpError(403, "shell commands are unavailable over HTTP", { code: "shell.unavailable_over_http" });
return super.submit(text, chips);
}
// ?checkfail=<name> makes a plain test of that source answer 401.
protected async answerCheck(name: string) {
if (query.get("checkfail") !== name) return super.answerCheck(name);
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
}
// ?twoports=1: two local servers on one host, told apart by port, sharing a key slot.
// ?badprice=1: DeepSeek's flash price is a card the vendor's table does not have,
// under a pinned display currency.
async providers() {
const all = await super.providers();
if (query.has("twoports")) {
const local = (name: string, port: number) => ({
...all[0], name, baseUrl: `http://127.0.0.1:${port}/v1`, keyEnv: "LOCAL_API_KEY", inUse: false, preset: false,
models: [`${name}-chat`], default: `${name}-chat`,
});
return [...all, local("local", 8756), local("local-2", 8788)];
}
if (!query.has("badprice")) return all;
return all.map((p) => p.name === "deepseek"
? { ...p, officialPricing: true, modelPrices: { "deepseek-flash": { input: 0.3, output: 1.2, cacheHit: 0, currency: "USD", official: false } } }
: p);
}
async displayCurrency() {
const got = await super.displayCurrency();
return query.has("badprice") ? { ...got, mode: "CNY" as const, unconverted: [{ provider: "deepseek", model: "deepseek-flash", currency: "USD" }] } : got;
}
async browserTabs() {
if (!query.has("agentpage")) return super.browserTabs();
const url = query.get("agentpage") === "file" ? "file:///Users/me/work/site/index.html" : "https://example.com/docs/install";
return [{ id: "t1", target: "view-a", url, title: "Install guide", active: true }];
}
// ?grant=1 grants the workspace a folder, /library, holding one document.
async workspaceRoots() {
const roots = await super.workspaceRoots();
return query.has("grant") ? { ...roots, extra: ["/library"] } : roots;
}
async workspaceFile(path: string, root?: string) {
if (root) {
const content = "# Release plan\n\nThe plan lives in the library folder you granted this project.\n\n- freeze on Friday\n- tag after the checks are green\n- announce the next morning\n";
return { path, content, revision: "r1", root, readOnly: true };
}
if (!query.has("doc") || !path.endsWith(".md")) return super.workspaceFile(path);
const content = "# Install guide\n\nDownload the package for your system, then run the installer. It places the program in your user folder and does not need administrator rights.\n\nAfter the first start, open Settings and add a model service before you begin a conversation.";
return { path, content, revision: "r1" };
}
async updateMode() {
return query.has("update") ? "notify" : super.updateMode();
}
async versions() {
if (query.has("update")) return { current: "2.42.0", pinned: "", stalePin: false, latest: "2.43.0", newer: true, versions: [] };
if (!query.has("versions")) return super.versions();
const names = ["2.38.0", "2.37.0", "2.36.0", "2.35.0", "2.34.0", "2.33.0", "2.32.0", "2.31.0", "2.30.0", "2.29.0"];
return {
@@ -138,6 +236,40 @@ class BenchPort extends MockPort {
return ONBOARDING ? { required: true, noProvider: true, provider: "", model: "" } : null;
}
async peers() {
if (query.has("peerthreads")) {
const states = ["waiting", "working", "replied", "declined"] as const;
return {
enabled: true,
peers: [],
held: [],
threads: states.map((state, i) => ({ key: `s-${i}`, title: ["文档整理", "发布脚本", "拾音", "旧迁移"][i], project: "rx-archive", sessionPath: `/sessions/w0-s${i}.jsonl`, state, since: "2026-10-16T10:00:00Z", messageId: `m-${i}` })),
};
}
if (!query.has("peers")) return super.peers();
return {
enabled: true,
peers: [{ key: "s-1a2b3c4d", title: "拾音", project: "rx-archive" }, { key: "s-5e6f7a8b", title: "文档整理", project: "docs" }],
held: [{ id: "m-1", fromSession: "s-1a2b3c4d", fromTitle: "拾音", fromProject: "rx-archive", sentAt: "2026-10-16T12:00:00Z", body: "迁移脚本跑完了,结果在 out/report.md,请你核对第三节再继续。" }],
};
}
async webAccessSettings() {
if (!query.has("webaccess")) return super.webAccessSettings();
return {
search_backends: ["searxng", "deepseek_api"], searxng_url: "http://127.0.0.1:8080", search_paid_allowed: [],
fetch_backends: ["local", "deepseek_api"], fetch_paid_allowed: [],
known_search_backends: ["searxng", "deepseek_api"], known_fetch_backends: ["local", "deepseek_api"], paid_backends: ["deepseek_api"],
deepseek_api_ready: query.get("webaccess") !== "noready", video_url_passthrough: query.has("videourl"), path: "/Users/you/.reasonix/config.toml",
};
}
async probeSearxng() {
const answer = query.get("probe");
if (!answer || answer === "ok") return super.probeSearxng();
return { ok: false, code: `search.searxng_${answer}`, status: answer === "status" ? 429 : answer === "json_disabled" ? 403 : undefined };
}
async foldWorkSettings() {
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
}
@@ -153,8 +285,15 @@ class BenchPort extends MockPort {
return PREF === null ? look : { ...look, language: PREF };
}
private settingCardSent = false;
subscribe(on: (e: WireEvent) => void) {
this.subs.add(on);
const card = query.get("settingcard");
if (card && !this.settingCardSent) {
this.settingCardSent = true;
setTimeout(() => this.feed(settingCardFrame(card)), 400);
}
return () => this.subs.delete(on);
}
@@ -183,13 +322,16 @@ class BenchPort extends MockPort {
id: `seed-${i}`,
// Lines the person queued: the panel leaves out the host's own queued
// continuations, so a seed of those would draw nothing.
intent: "followup" as const,
intent: (QUEUE_IMAGES && i === 0 ? "steer" : "followup") as "steer" | "followup",
state: "queued" as const,
...(QUEUE_IMAGES && i === 0 ? { images: true } : {}),
// 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板
// (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes,
// 所以第二条在队列里到不了 —— 但 .ovf 是通用的,天花板得有人证明它在。
preview:
i === 0
QUEUE_IMAGES && i === 0
? "@.reasonix/attachments/clipboard-20261017-141249.png reasonix 的你改好了?我这边看到的还是旧界面"
: i === 0
? "bash-1 — failed"
: i === 1
? `bash-2 — failed: ${"error[E0308]: mismatched types in the trait implementation; ".repeat(14)}`
@@ -213,19 +355,46 @@ class BenchPort extends MockPort {
private readonly jobsSince = Date.now() - 739_000;
async status() {
const st = await super.status();
const usage = query.get("goalusage");
const goalRuntime = usage
? { turnsUsed: 4, tokensUsed: 128_400, requestsUsed: 9, workDurationMs: 12 * 60_000, noProgressTurns: usage === "blocked" ? 2 : 0, ...(usage === "blocked" ? { stopCause: "goal_stuck", lastReason: "测试一直没有通过,改动没有让失败的数量减少" } : {}) }
: undefined;
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
if (!JOBS) return st;
const jobs = Array.from({ length: JOBS }, (_, i) => ({
id: `job-${i}`,
kind: "bash",
label: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i}`,
// ?jobnames=1 gives each job the name its caller wrote and the full command.
...(query.has("jobnames") ? {
description: i === 0 ? "运行桌面端测试并只看最后 40 行输出" : `跑第 ${i} 组内核测试`,
detail: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i} -count=1 -timeout 10m`,
} : {}),
status: this.stopped.has(`job-${i}`) ? "killed" : "running",
startedAt: this.jobsSince,
}));
return { ...st, jobs };
}
async switchBranch(name: string) {
if (SWITCH_MS > 0) await new Promise((r) => setTimeout(r, SWITCH_MS));
return super.switchBranch(name);
}
async goToVersion() {}
onUpdateProgress(cb?: (p: UpdateProgress) => void) {
if (cb && query.has("update")) queueMicrotask(() => cb({ version: "2.43.0", phase: "ready", received: 0, total: 0 }));
return () => {};
}
async jobOutput(jobId: string) {
if (!JOBS) return super.jobOutput(jobId);
const lines = Array.from({ length: 28 }, (_, i) => `[${String(i).padStart(2, "0")}] compiling module ${i * 7 + 3} … ok`);
return { text: lines.join("\n") + "\nwatching for changes\n", status: this.stopped.has(jobId) ? "killed" : "running", bytes: 1100, truncated: false };
}
async cancelJob(jobId: string) {
if (!JOBS) return super.cancelJob(jobId);
this.stopped.add(jobId);
@@ -239,7 +408,7 @@ function storedHistory(): HistoryMessage[] {
const msgs: HistoryMessage[] = [];
for (let i = 0; i < TURNS; i++) {
const pinned = query.get("skill") === "1" && i === TURNS - 1;
msgs.push({ role: "user", content: pinned ? SKILL_PIN : `第 ${i} 个问题,麻烦看一下这个文件。` });
msgs.push({ role: "user", content: pinned ? SKILL_PIN : `第 ${i} 个问题,麻烦看一下这个文件。`, createdAt: new Date(2026, 9, 17, 18, 33 + i).getTime() });
msgs.push({
role: "assistant",
content: `第 ${i} 段回答,说明刚才那一步做了什么。\n\n- 要点一\n- 要点二\n`,
@@ -261,12 +430,16 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
const PREF = query.get("pref");
const ONBOARDING = query.has("onboarding");
const SESSIONS = Number(query.get("sess") ?? 0);
const ROW_TITLES = ["rx home目录里有没有 obsidian 的笔记仓库,帮我整理一下", "obsidian 插件同步方案怎么选", "查一下 studio 最近一次发布改了什么", "阅读一下最近的提交记录并总结"];
// ?turns= is how long the conversation being opened already is.
const TURNS = Number(query.get("turns") ?? 0);
const STATUS_MS = Number(query.get("statusms") ?? 0);
const SWITCH_MS = Number(query.get("switchms") ?? 0);
// ?queue= is how many lines are already waiting when the window opens.
const QUEUE = Number(query.get("queue") ?? 0);
const QUEUE_BODY = query.has("queuebody");
// ?queueimages makes the first seeded line a steer that carries a picture.
const QUEUE_IMAGES = query.has("queueimages");
const JOBS = Number(query.get("jobs") ?? 0);
// ?treems= holds every tree read back until that long after load, so the
// window can be seen before the kernel has named a single folder.
@@ -277,6 +450,16 @@ const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
// ?search=1 answers every query with two conversations that say it.
async searchHistory(q: string) {
if (!query.has("search")) return super.searchHistory(q);
return [
{ path: "/sessions/mock.jsonl", id: "mock", snippet: `…上周讨论过要不要把 ${q} 的轮换放进发布流程,结论是每月一次…`, role: "assistant", messageIndex: 4, score: 2 },
{ path: "/sessions/older.jsonl", id: "older", snippet: `怎么把 ${q} 配到预发环境里?`, role: "user", messageIndex: 0, score: 1 },
];
}
private readonly settingsPort = new BenchPort();
machinePort(): AgentPort {
@@ -303,21 +486,48 @@ class BenchHub extends MockHub {
);
}
tree(): Promise<TreeWorkspace[]> {
async moveSessionTo(path: string, target: string) {
(window.__moves ??= []).push({ path, target });
return super.moveSessionTo(path, target);
}
// ?slowarchive=<ms>: one project with 27 active and 2127 archived conversations,
// whose archived read answers after that many milliseconds.
tree(window?: TreeWindow): Promise<TreeWorkspace[]> {
const slow = Number(query.get("slowarchive") ?? "");
if (slow > 0) {
const archived = window?.scope === "archived";
const rows = Array.from({ length: archived ? 8 : 6 }, (_, i) => ({
path: `/sessions/${archived ? "old" : "new"}-${i}.jsonl`, name: `${archived ? "old" : "new"}-${i}`,
title: `${archived ? "归档的" : "活跃的"}会话 ${i + 1}`, turns: i + 1, archived,
}));
const answer = [{ root: "~/projects/workspace-0", name: "workspace-0", remembered: true, open: true, total: 2154, archived: 2127, remaining: archived ? 2119 : 21, sessions: rows }];
return new Promise((resolve) => setTimeout(() => resolve(answer), archived ? slow : 0));
}
const wait = TREE_AT - performance.now();
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
if (!WORKSPACES && query.has("trustpending") && PANES > 1) {
return super.tree().then((all) => all.map((ws, i) => i > 0 ? ws : { ...ws, sessions: Array.from({ length: PANES }, (_, k) => ({
path: `/sessions/pane-${k + 1}.jsonl`, name: `pane-${k + 1}`, title: `并行会话 ${k + 1}`, turns: 3, runtimeId: `r${k + 1}`,
})) }));
}
if (!WORKSPACES) return super.tree();
return Promise.resolve(
Array.from({ length: WORKSPACES }, (_, w) => ({
root: `~/projects/workspace-${w}`,
name: `workspace-${w}`,
remembered: true,
open: w === 0,
sessions: Array.from({ length: SESSIONS }, (_, s) => ({
sessions: this.placed(`~/projects/workspace-${w}`, Array.from({ length: SESSIONS }, (_, s) => ({
path: `/sessions/w${w}-s${s}.jsonl`,
name: `w${w}-s${s}`,
title: `第 ${s} 次会话,标题是内核起的一句话`,
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
turns: (s % 40) + 1,
})),
...(query.has("rows") ? {
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
} : {}),
}))),
})),
);
}
@@ -336,6 +546,8 @@ createRoot(document.getElementById("root")!).render(<App hub={hub} />);
declare global {
interface Window {
__moves?: { path: string; target: string }[];
__sent?: string[];
// Feeds every open pane, so a split-view measurement costs what two live
// conversations really cost.
__feed: (ev: WireEvent) => void;
@@ -372,3 +584,22 @@ window.__foldCost = () => {
fromHistory(msgs);
return performance.now() - t0;
};
// ?settingcard= draws what the kernel sends when the model asks to change a
// setting: a preference card, an elevated one, or the notice that points at a
// setting only the user may change.
function settingCardFrame(kind: string): WireEvent {
if (kind === "open") {
return { kind: "notice", level: "info", code: "setting_open", text: "助手需要改动 permissions.mode,只有你本人能改。", detail: "permissions.mode" } as WireEvent;
}
const elevated = kind === "elevated";
const id = elevated ? "peers.dispatch" : "ui.show_receipt";
return {
kind: "approval_request",
approval: {
id: "s1", tool: elevated ? "setting_change_elevated" : "setting_change", subject: id, kind: "tool", fresh: true,
allowsSession: !elevated, allowsPersist: false,
reason: `助手请求把设置 ${id} 从 false 改为 true。${elevated ? "从下一回合起生效。" : "立即生效。"}\n它给出的理由:要把任务交给另一个项目里的会话。`,
},
} as WireEvent;
}
+16 -6
View File
@@ -14,10 +14,15 @@ const CONTROLS = [".studio-mode-picker", ".studio-branch", ".model-picker", ".co
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
for (const scheme of ["light", "dark"]) for (const turns of [2, 0]) {
const context = `${scheme}, ${turns ? "existing conversation" : "new conversation"}`;
const url = new URL(BASE);
url.searchParams.set("turns", String(turns));
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1100, height: 760 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose .studio-branch");
await page.goto(url.href, { waitUntil: "networkidle" });
await page.waitForSelector(turns ? ".compose .studio-branch" : ".compose .worktree-trigger");
const inline = await page.locator(".compose .studio-branch").first().evaluate((e) => !!e.closest(".turntools"));
check(`${context}: workspace control stays in the toolbar`, inline);
const seen = [];
for (const sel of CONTROLS) {
const el = page.locator(`.compose ${sel}`.replace(".compose .compose", ".compose")).first();
@@ -26,11 +31,16 @@ try {
await page.waitForTimeout(300);
seen.push([sel, await el.evaluate((e) => ({ radius: getComputedStyle(e).borderRadius, bg: getComputedStyle(e).backgroundColor }))]);
}
check(`${scheme}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
check(`${context}: the hovered controls were all found`, seen.length === CONTROLS.length, `${seen.length} of ${CONTROLS.length}`);
const radii = new Set(seen.map(([, v]) => v.radius));
const grounds = new Set(seen.map(([, v]) => v.bg));
check(`${scheme}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
check(`${scheme}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
check(`${context}: one corner radius across the row`, radii.size === 1, [...radii].join(" / "));
check(`${context}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(url.href, { waitUntil: "networkidle" });
const chip = page.locator(turns ? ".compose .studio-branch" : ".compose .worktree-trigger").first();
await chip.click();
check(`${context}: a narrow-pane pointer click opens the workspace menu`, await chip.getAttribute("aria-expanded") === "true");
await page.close();
}
} finally {
@@ -0,0 +1,64 @@
// Interface wording the catalogue test cannot see: a Chinese label kept in a table and drawn without t() passes every
// source scan and still shows up in the English window. This opens each settings section in English, fires the probes
// that fill in more of the page, and fails on any Chinese the reader would see that is not on the fixture list below.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
// Names the demo data itself carries, and names that are written in Chinese on purpose.
const FIXTURE = [
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
];
const fails = [];
const browser = await chromium.launch();
const ctx = await browser.newContext({ locale: "en-US", viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(800);
const scan = () => {
const out = [];
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n = w.nextNode(); n; n = w.nextNode()) {
const text = n.textContent.trim();
if (text && /[一-鿿]/.test(text) && !["SCRIPT", "STYLE"].includes(n.parentElement?.tagName)) out.push(text);
}
for (const el of document.querySelectorAll("[aria-label],[title],[placeholder],[alt]"))
for (const a of ["aria-label", "title", "placeholder", "alt"]) {
const v = el.getAttribute(a);
if (v && /[一-鿿]/.test(v)) out.push(v);
}
return out;
};
const seen = new Map();
const take = async (where) => {
for (const text of await page.evaluate(scan)) if (!FIXTURE.some((re) => re.test(text)) && !seen.has(text)) seen.set(text, where);
};
await take("workbench");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForTimeout(500);
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /Back up|repair/i.test(x.textContent))?.click());
await page.waitForTimeout(400);
const sections = await page.evaluate(() => [...new Set([...document.querySelectorAll('[data-action="settings.section"]')].map((b) => b.getAttribute("data-value")))]);
if (sections.length < 8) fails.push(`only ${sections.length} settings sections found; the guard is looking at the wrong page`);
for (const value of sections) {
await page.evaluate((v) => document.querySelector(`[data-action="settings.section"][data-value="${v}"]`)?.click(), value);
await page.waitForTimeout(450);
await page.evaluate(() => document.querySelector('[data-action="network.diagnose"]')?.click());
await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
await page.waitForTimeout(500);
await take(`settings:${value}`);
}
await browser.close();
for (const [text, where] of seen) fails.push(`${where}: ${text.slice(0, 80)}`);
if (fails.length) {
console.log("Chinese in the English window:\n " + fails.join("\n "));
process.exit(1);
}
console.log(`ok ${sections.length} settings sections, no unlisted Chinese in the English window`);
+52
View File
@@ -0,0 +1,52 @@
// A background job is named for a person: the list shows the name its caller gave it, and the whole name and the full command
// appear in a tip that stays inside the window, can be selected, and opens from the keyboard as well as the pointer.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [width, height] of [[1280, 860], [400, 760]]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?jobs=3&jobnames=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(800);
await page.evaluate(() => [...document.querySelectorAll("button")].find((b) => /后台任务/.test(b.textContent))?.click());
await page.waitForSelector(".jobs .job .cmd");
const tag = `${scheme} ${width}px`;
const shown = await page.evaluate(() => [...document.querySelectorAll(".jobs .job .cmd")].map((e) => e.textContent));
check(`${tag}: the list shows the names, not the commands`, shown.length === 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
await page.locator(".jobs .job .cmd").first().hover();
await page.waitForSelector(".job-tip");
const tip = await page.evaluate(() => {
const el = document.querySelector(".job-tip");
const r = el.getBoundingClientRect();
return { inside: r.left >= 0 && r.top >= 0 && r.right <= innerWidth + 1 && r.bottom <= innerHeight + 1, lines: el.children.length, select: getComputedStyle(el).userSelect, cmd: el.querySelector(".jt-cmd")?.textContent ?? "" };
});
check(`${tag}: the tip has the name and the full command, inside the window`, tip.inside && tip.lines === 2 && /npm run tauri:test/.test(tip.cmd), JSON.stringify(tip));
check(`${tag}: its text can be selected`, tip.select === "text", tip.select);
await page.mouse.move(width - 5, 5);
await page.waitForTimeout(200);
check(`${tag}: it closes when the pointer leaves`, (await page.locator(".job-tip").count()) === 0);
await page.locator(".jobs .job .cmd").nth(1).focus();
await page.waitForSelector(".job-tip");
check(`${tag}: focus from the keyboard opens it too`, /内核测试/.test(await page.locator(".job-tip .jt-name").textContent()));
await page.keyboard.press("Escape");
await page.waitForTimeout(150);
check(`${tag}: Escape closes it`, (await page.locator(".job-tip").count()) === 0);
await ctx.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+55
View File
@@ -0,0 +1,55 @@
// An allowed guardian review at low risk and a remembered fact are Lines, not cards: one row over a hairline with no box.
// A refusal, or an allow that carried real risk, keeps the card.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: "{}" } });
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "A fact", description: "why", scope: "project", activation: "always", body: "b" }) } });
f({ kind: "guardian_assessment", guardian: { id: "g1", tool: "bash", subject: "go test", outcome: "allow", risk_level: "low", user_authorization: "high", rationale: "read only", duration_ms: 5 } });
f({ kind: "guardian_assessment", guardian: { id: "g2", tool: "bash", subject: "git push -f", outcome: "allow", risk_level: "high", user_authorization: "low", rationale: "rewrites history", duration_ms: 5 } });
f({ kind: "guardian_assessment", guardian: { id: "g3", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "no", duration_ms: 5 } });
f({ kind: "turn_done" });
});
await page.waitForTimeout(600);
await page.mouse.move(995, 5);
const m = await page.evaluate(() => {
const rows = [...document.querySelectorAll(".flow .line-row")];
const box = (el) => { const cs = getComputedStyle(el); return [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth].join(","); };
return {
lines: rows.length,
boxes: rows.map(box),
radius: rows.map((r) => getComputedStyle(r).borderTopLeftRadius),
cards: [...document.querySelectorAll('.flow .call[data-k="host"]')].length,
forget: !!document.querySelector('.flow .line-row [data-action="memory.forget"]'),
oldMem: document.querySelectorAll('.flow .call[data-k="mem"]').length,
};
});
check(`${scheme}: the remembered fact and the low-risk allow are rows`, m.lines === 2, `${m.lines} rows`);
check(`${scheme}: rows carry no box`, m.lines > 0 && m.boxes.every((b) => b === "0px,0px,0px") && m.radius.every((r) => r === "0px"), JSON.stringify(m));
check(`${scheme}: the high-risk allow and the refusal keep their cards`, m.cards === 2, `${m.cards} cards`);
check(`${scheme}: the take-back sits in the row, and no memory card remains`, m.forget && m.oldMem === 0);
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -0,0 +1,60 @@
// A server that answered 401 offers to sign in beside the retry; a server whose
// tools were withheld offers to accept and follow beside the plain approval; a
// followed server says so and offers to stop. None of it may spill at a phone's
// width or crowd the row's other controls.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 1100 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&mcpactions=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("扩展"))?.click());
await page.waitForSelector(".mcp-server");
await page.evaluate(() => document.querySelectorAll("details.mcp-server").forEach((d) => { d.open = true; }));
const r = await page.evaluate(() => {
const row = (name) => [...document.querySelectorAll(".mcp-server")].find((d) => (d.querySelector("summary") ?? d).textContent.includes(name));
const label = (el) => [...el.querySelectorAll("button")].map((b) => b.textContent.trim());
const spill = (el) => { const r = el.getBoundingClientRect(); return [...el.querySelectorAll("button")].filter((b) => b.getBoundingClientRect().right > r.right + 1 || b.getBoundingClientRect().left < r.left - 1).length; };
const figma = row("figma"), notes = row("notes"), wiki = row("wiki");
const overlap = (el) => { const bs = [...el.querySelectorAll("button")].map((b) => b.getBoundingClientRect()).filter((b) => b.width > 0); return bs.some((a, i) => bs.some((c, j) => i < j && a.left < c.right - 1 && c.left < a.right - 1 && a.top < c.bottom - 1 && c.top < a.bottom - 1)); };
return {
figma: figma ? label(figma) : null, notes: notes ? label(notes) : null, wiki: wiki ? label(wiki) : null,
followNote: wiki?.textContent.includes("这个服务器的改动会自动接受") ?? false,
spill: [figma, notes, wiki].filter(Boolean).map(spill),
overlap: [figma, notes, wiki].filter(Boolean).map(overlap),
};
});
check(`${tag}: a 401 row offers sign-in beside the retry`, r.figma?.includes("登录") && r.figma.includes("重连"), r.figma?.join("|"));
check(`${tag}: a row with withheld tools offers both approvals`, r.notes?.includes("批准并重新连接") && r.notes.includes("接受并以后都自动接受这个服务器的改动"), r.notes?.join("|"));
check(`${tag}: a followed row says so and offers to stop`, r.followNote && r.wiki?.includes("取消跟随"), r.wiki?.join("|"));
check(`${tag}: no button spills out of its row`, r.spill.every((n) => n === 0), r.spill.join(","));
check(`${tag}: no two buttons overlap`, r.overlap.every((o) => !o));
if (shotDir && width === 1280) {
await page.addStyleTag({ content: ".cfgbad { display: none !important; }" });
for (const name of ["figma", "notes", "wiki"]) {
const row = page.locator(".mcp-server", { hasText: name }).first();
await row.screenshot({ path: `${shotDir}/${scheme}-${name}.png` });
}
}
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
Loaded 100 of 2403 files, more files were not shown because too many files have changed in this diff. Show more