Compare commits

...
137 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 45444a47cb test(openai): pin how usage is read from captured DeepSeek streams (stop and length) 2026-10-18 17:24:01 -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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 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
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
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
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
505 changed files with 21055 additions and 1055 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
+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
+19
View File
@@ -1302,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:
+5
View File
@@ -148,6 +148,7 @@ jobs:
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
@@ -176,6 +177,10 @@ jobs:
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
+5 -4
View File
@@ -257,6 +257,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
[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) ·
@@ -288,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.
+5 -4
View File
@@ -237,6 +237,7 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.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) ·
@@ -269,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和开场视频。
+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()
+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
+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 {
+32 -1
View File
@@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -36,7 +38,7 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
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...)
@@ -85,6 +87,35 @@ func legMetrics(path string) (m runMetrics, partial bool, err error) {
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 {
+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)
}
}
}
+267 -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
@@ -85,6 +176,163 @@ 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
@@ -96,3 +344,21 @@ QA passed. Captures remain in
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 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.
按维护者要求,组合截图仅保留为本地证据,不提交到仓库。上方记录为历史验证;
当前浏览器检查与远端工作流结论分开报告。
Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

+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)]);
+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 };
+7 -2
View File
@@ -32,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");
@@ -280,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,
@@ -288,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
@@ -392,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);
+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));
}
+75
View File
@@ -1573,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) => {
@@ -1590,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);
@@ -2868,3 +2907,39 @@ test("a window that fails to raise is given its opacity and mouse back", async (
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)({}), []);
});
+34 -13
View File
@@ -54,7 +54,7 @@ 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
@@ -99,6 +99,14 @@ class BenchPort extends MockPort {
// ?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 = [
@@ -262,15 +270,6 @@ class BenchPort extends MockPort {
return { ok: false, code: `search.searxng_${answer}`, status: answer === "status" ? 429 : answer === "json_disabled" ? 403 : undefined };
}
async peersSettings() {
if (!query.has("peerset")) return super.peersSettings();
return {
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn" as const, 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" }],
};
}
async foldWorkSettings() {
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
}
@@ -360,7 +359,7 @@ class BenchPort extends MockPort {
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 } : {}) };
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) => ({
@@ -431,6 +430,7 @@ 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);
@@ -491,9 +491,26 @@ class BenchHub extends MockHub {
return super.moveSessionTo(path, target);
}
tree(): Promise<TreeWorkspace[]> {
// ?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) => ({
@@ -504,8 +521,12 @@ class BenchHub extends MockHub {
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,
} : {}),
}))),
})),
);
+70
View File
@@ -0,0 +1,70 @@
// A long session of delegated work keeps the page light: a finished sub-agent's child calls are
// not in the document until its card is opened, so node count follows what is open, not what ran.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const AGENTS = 120;
const MAX_NODES = Number(process.env.PERF_NODES_MAX ?? 25000);
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"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?turns=3&pref=zh`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.waitForTimeout(600);
const before = await page.evaluate(() => document.querySelectorAll("*").length);
await page.evaluate((n) => {
window.__feed({ kind: "turn_started" });
const big = (tag) => Array.from({ length: 60 }, (_, k) => `${tag} line ${k}: matched in internal/pkg${k % 40}/file${k}.go`).join("\n");
for (let a = 0; a < n; a++) {
const pid = `a${a}`;
window.__feed({ kind: "tool_dispatch", tool: { id: pid, name: "task", args: JSON.stringify({ description: `子代理 ${a}`, prompt: "查" }) } });
for (let c = 0; c < 15; c++) {
const id = `${pid}c${c}`, name = c % 3 === 0 ? "bash" : c % 3 === 1 ? "grep" : "read_file";
window.__feed({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify({ command: "x" }), parentId: pid } });
window.__feed({ kind: "tool_result", tool: { id, name, args: JSON.stringify({ command: "x" }), output: big(id), parentId: pid, durationMs: 300 } });
}
window.__feed({ kind: "tool_result", tool: { id: pid, name: "task", args: "", output: "report " + a, durationMs: 9000 } });
}
}, AGENTS);
await page.waitForTimeout(800);
const after = await page.evaluate(() => document.querySelectorAll("*").length);
const kids = await page.evaluate(() => document.querySelectorAll(".nest-bd .call").length);
check(`${scheme}: ${AGENTS} finished sub-agents add ${after - before} nodes, under the ${MAX_NODES} limit`, after < MAX_NODES, `${after} in the page`);
check(`${scheme}: none of their child calls is in the document while the cards are closed`, kids === 0, `${kids}`);
await page.evaluate(() => { const g = [...document.querySelectorAll("details")].filter((d) => d.querySelector('[data-call="a0"]')).pop(); if (g) g.open = true; });
await page.waitForTimeout(400);
const shoot = async (name) => {
if (!shotDir) return;
await page.evaluate(() => document.querySelector('[data-call="a0"]')?.scrollIntoView({ block: "start" }));
await page.waitForTimeout(300);
await page.screenshot({ path: `${shotDir}/${scheme}-nest-${name}.png`, clip: { x: 360, y: 40, width: 1000, height: 420 } });
};
await shoot("closed");
const opened = await page.evaluate(() => {
const d = document.querySelector('[data-call="a0"] details.tool-disclosure');
if (!d) return -1;
d.open = true;
d.dispatchEvent(new Event("toggle"));
return 1;
});
await page.waitForTimeout(300);
const nowKids = await page.evaluate(() => document.querySelectorAll('[data-call="a0"] .nest-bd .call').length);
check(`${scheme}: opening one card mounts exactly its own 15 child calls`, opened === 1 && nowKids === 15, `${nowKids}`);
await shoot("open");
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
process.exit(1);
}
console.log("\nall passed");
+57
View File
@@ -0,0 +1,57 @@
// What the page holds of finished tool output is bounded: a long session of large results stays light,
// and a card says what it left out.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const RESULTS = Number(process.env.PERF_RESULTS ?? 600);
const HEAP_MAX_MB = Number(process.env.PERF_HEAP_MAX ?? 150);
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({ args: ["--js-flags=--expose-gc"] });
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
const cdp = await ctx.newCDPSession(page);
await cdp.send("Performance.enable");
await page.goto(`${BASE}?turns=2&pref=zh`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate((n) => {
window.__feed({ kind: "turn_started" });
const body = (i) => Array.from({ length: 300 }, (_, k) => `result ${i} line ${k}: matched in internal/pkg${k % 40}/file${k}.go — some context text`).join("\n");
for (let i = 0; i < n; i++) {
window.__feed({ kind: "tool_dispatch", tool: { id: `r${i}`, name: "bash", args: JSON.stringify({ command: "x" }) } });
window.__feed({ kind: "tool_result", tool: { id: `r${i}`, name: "bash", args: JSON.stringify({ command: "x" }), output: body(i), durationMs: 200 } });
}
}, RESULTS);
await page.waitForTimeout(800);
await cdp.send("HeapProfiler.collectGarbage");
await cdp.send("HeapProfiler.collectGarbage");
const heap = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value / 1048576;
check(`${scheme}: ${RESULTS} results of about 30 KB leave the page's heap under ${HEAP_MAX_MB} MB`, heap < HEAP_MAX_MB, `${heap.toFixed(1)} MB`);
const said = await page.evaluate(() => document.body.textContent.includes("没有放在页面里"));
check(`${scheme}: a card says what it left out`, said);
if (shotDir) {
await page.evaluate(() => {
const grp = [...document.querySelectorAll("details")].filter((d) => d.querySelector('[data-call="r599"]')).pop();
if (grp) grp.open = true;
const card = document.querySelector('[data-call="r599"] details.tool-disclosure');
if (card) { card.open = true; card.dispatchEvent(new Event("toggle")); }
document.querySelector('[data-call="r599"]')?.scrollIntoView({ block: "start" });
});
await page.waitForTimeout(400);
await page.screenshot({ path: `${shotDir}/${scheme}-output-cap.png`, clip: { x: 360, y: 40, width: 1000, height: 520 } });
}
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
process.exit(1);
}
console.log("\nall passed");
+13 -1
View File
@@ -17,7 +17,7 @@ for (const scheme of ["light", "dark"]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&peers=1&peerset=1&pending=1`, { waitUntil: "networkidle" });
await page.goto(`${BASE}?pref=zh&turns=2&peers=1&peerset=1&pending=1&ws=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForSelector(".peerbar");
@@ -37,7 +37,12 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
const navLabels = await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].map((b) => b.textContent));
check(`${tag}: session messages have a settings entry of their own`, navLabels.some((l) => l.includes("会话间消息")));
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
await page.waitForTimeout(300);
check(`${tag}: and no longer sit at the foot of tools and permissions`, (await page.locator('[role="switch"][aria-label="启用会话间消息"]').count()) === 0);
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("会话间消息"))?.click());
await page.waitForSelector('[role="switch"][aria-label="启用会话间消息"]', { timeout: 8000 });
const s = await page.evaluate(() => {
const sw = (n) => document.querySelector(`[role="switch"][aria-label="${n}"]`);
@@ -62,11 +67,18 @@ for (const scheme of ["light", "dark"]) {
check(`${tag}: a saved switch says the next turn, never at once`, s.pending && !s.immediate);
check(`${tag}: the file the switches live in is named`, s.path);
check(`${tag}: nothing spills out of the box`, s.within && s.spill <= 1, `${s.spill}px`);
const pick = page.getByRole("combobox", { name: "放行这个项目" });
const options = await pick.locator("option").count();
check(`${tag}: a project can be chosen from those already added`, options >= 1, `${options} choices`);
await page.getByRole("button", { name: "放行", exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll(".peerreleased li").length === 3);
check(`${tag}: allowing it through adds it to the list`, true);
if (shotDir && width === 1280) {
await page.addStyleTag({ content: ".cfgbad { display: none !important; }" });
await page.setViewportSize({ width, height: 1100 });
await page.waitForTimeout(250);
await page.locator("#set-peers").screenshot({ path: `${shotDir}/${scheme}-settings.png` });
await page.locator("#set-peer-projects").screenshot({ path: `${shotDir}/${scheme}-projects.png` });
}
await page.close();
}
@@ -0,0 +1,59 @@
// A command that never stops printing does not grow the page: only the end of its output is kept
// while it runs, and the card says how much came before.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const KEPT_MAX = 70_000;
const CHUNKS = Number(process.env.PERF_CHUNKS ?? 150);
const HEAP_MAX_MB = Number(process.env.PERF_HEAP_MAX ?? 60);
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({ args: ["--js-flags=--expose-gc"] });
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
const cdp = await ctx.newCDPSession(page);
await cdp.send("Performance.enable");
await page.goto(`${BASE}?turns=2&pref=zh`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "tool_dispatch", tool: { id: "long", name: "bash", args: JSON.stringify({ command: "tail -f build.log" }) } });
});
for (let i = 0; i < CHUNKS; i++) {
await page.evaluate((i) => {
const line = `2026-10-19 12:00:00 INFO worker-${i} processing a batch with a fairly long log line ` + "x".repeat(60) + "\n";
window.__feed({ kind: "tool_progress", tool: { id: "long", name: "bash", args: "", output: line.repeat(1500) } });
}, i);
}
await page.waitForTimeout(500);
await cdp.send("HeapProfiler.collectGarbage");
const heap = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value / 1048576;
const shown = await page.evaluate(() => document.querySelector('[data-call="long"] .out')?.textContent?.length ?? -1);
const note = await page.evaluate(() => document.body.textContent.includes("此前省略了"));
check(`${scheme}: the running card holds only the end of ${((CHUNKS * 1500 * 141) / 1048576).toFixed(0)} MB of output`, shown >= 0 && shown <= KEPT_MAX, `${shown} characters`);
check(`${scheme}: it says how much came before`, note);
check(`${scheme}: the page's heap stays bounded while it runs`, heap < HEAP_MAX_MB, `${heap.toFixed(1)} MB`);
if (shotDir) {
await page.evaluate(() => {
const card = document.querySelector('[data-call="long"] details.tool-disclosure');
if (card) { card.open = true; card.dispatchEvent(new Event("toggle")); }
document.querySelector('[data-call="long"]')?.scrollIntoView({ block: "start" });
});
await page.waitForTimeout(300);
await page.screenshot({ path: `${shotDir}/${scheme}-progress-tail.png`, clip: { x: 360, y: 80, width: 1000, height: 560 } });
}
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,85 @@
// Frame cost of folding the left rail with 1/3/6 panes that each hold a long transcript. The rail width is
// animated per frame, so every mounted pane is laid out again unless a hidden pane is skipped. Guard: the
// median frame with PERF_GUARD panes stays within FRAME_RATIO_MAX of the one-pane median, and a pane that
// was hidden comes back at the scroll position it left.
import { chromium } from "playwright";
const CPU = Number(process.env.PERF_CPU ?? 4);
const TURNS = Number(process.env.PERF_TURNS ?? 60);
const REPS = Number(process.env.PERF_REPS ?? 6);
const COUNTS = (process.env.PERF_N ?? "1,3,6").split(",").map(Number);
const GUARD = Number(process.env.PERF_GUARD ?? 3);
const FRAME_RATIO_MAX = Number(process.env.PERF_FRAME_RATIO ?? 2.5);
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const pct = (a, p) => [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * p))];
const browser = await chromium.launch();
const rows = [];
let kept = null;
for (const n of COUNTS) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
await page.goto(`${URL}?panes=${n}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(800);
await page.evaluate(async (k) => {
const frame = () => new Promise((r) => requestAnimationFrame(r));
for (let i = 0; i < k; i++) {
const id = `t${i}`, args = JSON.stringify({ command: `go test ./pkg${i}/...` });
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "tool_dispatch", tool: { id, name: "bash", args } });
window.__feed({ kind: "tool_result", tool: { id, name: "bash", args, output: "ok\n".repeat(20) } });
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n` });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
if (i % 10 === 9) await frame();
}
await frame();
}, TURNS);
await page.waitForTimeout(800);
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
const frames = [];
for (let r = 0; r < REPS; r++) {
const got = await page.evaluate(async () => {
const out = [];
let prev = performance.now();
const end = prev + 450;
const before = document.querySelector(".app")?.dataset.rail;
const mac = /Mac/.test(navigator.platform);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "\\", ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }));
while (performance.now() < end) {
await new Promise((res) => requestAnimationFrame(res));
const now = performance.now();
out.push(now - prev);
prev = now;
}
if (document.querySelector(".app")?.dataset.rail === before) throw new Error("the rail did not toggle");
return out;
});
frames.push(...got);
await page.waitForTimeout(150);
}
rows.push({ panes: n, frames: frames.length, p50: pct(frames, 0.5).toFixed(1), p95: pct(frames, 0.95).toFixed(1), worst: Math.max(...frames).toFixed(1), over50: frames.filter((f) => f > 50).length });
if (n === GUARD) {
await page.evaluate(() => { document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop = 400; });
await page.waitForTimeout(200);
await page.locator(".sessrow").nth(1).click();
await page.waitForTimeout(500);
await page.locator(".sessrow").nth(0).click();
await page.waitForTimeout(500);
kept = await page.evaluate(() => document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop);
}
await ctx.close();
}
await browser.close();
console.table(rows);
const one = rows.find((r) => r.panes === 1), many = rows.find((r) => r.panes === GUARD);
const fails = [];
if (one && many && Number(many.p50) > Number(one.p50) * FRAME_RATIO_MAX) {
fails.push(`median frame with ${GUARD} panes is ${many.p50} ms against ${one.p50} ms with one (limit x${FRAME_RATIO_MAX})`);
}
if (kept !== null && Math.abs(kept - 400) > 2) fails.push(`a pane hidden at scrollTop 400 came back at ${kept}`);
if (fails.length) {
console.error(fails.join("\n"));
process.exit(1);
}
+32 -2
View File
@@ -24,6 +24,18 @@ try {
<div class="sessrow sessrow-remote">
<span class="sesstitle" title="${title}"><span>${title}</span></span><span class="sessmeta">3 轮</span>
</div>
<div class="sessrow sessrow-context" data-aged>
<button class="sesspin-action" data-action="session.pin">置顶</button>
<span class="sesstitle" title="${title}"><span>${title}</span></span>
<time class="sessmeta sessage">9 天前</time><button class="sesscopies">+1</button>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sessrow-context" data-aged-only>
<button class="sesspin-action" data-action="session.pin">置顶</button>
<span class="sesstitle" title="${title}"><span>${title}</span></span>
<time class="sessmeta sessage">9 天前</time>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sesscopy">
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
</div>
@@ -55,10 +67,28 @@ try {
}
assert.equal(rows[1].top - rows[0].bottom, 1, "only the compact row margin separates sessions");
assert.equal(rows[2].top - rows[1].bottom, 1, "the remote row has the same compact spacing");
assert.equal(rows[3].top - rows[2].bottom, 1, "the earlier version has the same compact spacing");
assert.equal(rows[3].top - rows[2].bottom, 1, "the aged row has the same compact spacing");
assert.equal(rows[4].top - rows[3].bottom, 1, "the age-only row has the same compact spacing");
assert.equal(rows[5].top - rows[4].bottom, 1, "the earlier version has the same compact spacing");
assert.ok(rows[0].truncated && rows[2].truncated, "long local and remote titles truncate");
if (state === "idle") {
for (const [selector, allowed] of [[".sessrow[data-aged]", 8], [".sessrow[data-aged-only]", 2]]) {
const gap = await page.evaluate((sel) => {
const row = document.querySelector(sel);
const box = row.getBoundingClientRect();
const inner = box.right - parseFloat(getComputedStyle(row).paddingRight);
const shown = [...row.children].filter((el) => {
const cs = getComputedStyle(el);
return cs.position !== "absolute" && cs.visibility !== "hidden" && el.getBoundingClientRect().width > 0 && !el.matches(".sessquick");
});
return { free: inner - Math.max(...shown.map((el) => el.getBoundingClientRect().right)), quick: row.querySelector(".sessquick").getBoundingClientRect().width };
}, selector);
assert.ok(gap.free <= allowed, `${scheme}/${width} ${selector}: ${gap.free}px of the row's right side is held back while no button is showing; the title should have it`);
assert.equal(gap.quick, 0, `${scheme}/${width}: the hover buttons take no room until the row is hovered or focused`);
}
}
if (state !== "idle") {
const contained = await page.locator(".sessquick > button, .sesspin-action").evaluateAll((buttons) => buttons.every((button) => {
const contained = await page.locator(".sessquick > button, .sesspin-action").evaluateAll((buttons) => buttons.filter((button) => (button.parentElement.matches(".sessquick") ? button.parentElement.getBoundingClientRect().width > 0 : getComputedStyle(button).opacity !== "0")).every((button) => {
const rect = button.getBoundingClientRect();
const row = button.closest(".sessrow").getBoundingClientRect();
return rect.top >= row.top && rect.bottom <= row.bottom && rect.left >= row.left && rect.right <= row.right;
+34
View File
@@ -0,0 +1,34 @@
// A conversation out of sight that is asking whether to trust its folder says so in the
// sidebar; the one in front shows the card itself and carries no such mark.
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"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 800 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=zh&panes=2&turns=1&trustpending=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.waitForTimeout(800);
const rows = await page.evaluate(() => [...document.querySelectorAll(".sessrow[data-run]")].map((r) => r.getAttribute("data-run")));
const waiting = rows.filter((r) => r === "halt").length;
check(`${scheme}: exactly one conversation, the one out of sight, is marked as waiting`, waiting === 1, JSON.stringify(rows));
const cards = await page.evaluate(() => document.querySelectorAll('.posturenote[data-kind="trust"]').length);
check(`${scheme}: each conversation still carries its own trust card`, cards === 2, String(cards));
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-trust-wait.png`, clip: { x: 0, y: 0, width: 700, height: 460 } });
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
process.exit(1);
}
console.log("\nall passed");
+45 -14
View File
@@ -1,15 +1,5 @@
// What Studio claims a person can do, as a set of stable identities.
//
// The oracle for the census, and deliberately not something production imports:
// a button writes the id as a literal and this file writes it again, so the two
// can disagree and be caught. Sharing one constant would make the check compare
// a value with itself — the shape of false green this tree keeps removing.
//
// An id is <surface>.<intent>: the semantic class, never the instance and never
// the wording. Which entry a click is about rides on data-target, and which of
// several answers it gives rides on data-value, so a row rendered seventy times
// is still one contract, and 「取消」 — seventeen buttons, eight destinations —
// is never an identity.
// The census oracle is independent of production literals so disagreements fail.
// An id names the intent; data-target and data-value name its entity and choice.
export type ActionKind =
/** Changes only what is on screen. Must not reach the kernel. */
@@ -48,6 +38,39 @@ export interface UIAction {
}
export const ACTIONS: UIAction[] = [
{id:"agent-composition.select",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-composition.manage",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.show-picker",kind:"view",target:"none",proof:"interaction"},
{id:"agent-composition.unavailable",kind:"view",target:"none",proof:"interaction"},
{id:"agent-composition.new",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.reload",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.view",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.new-task-default",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.usage",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.create",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.leave",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.leave-cancel",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.leave-discard",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.persona",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.copy",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.scope",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.id",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.name",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.description",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.module-mode",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-id",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-parameters",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-add",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-choose",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-remove",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.save",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.default",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.workspace-default",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.delete-preview",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.delete",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.delete-cancel",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.export",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.import",kind:"view",target:"none",proof:"interaction"},
// ── The window's own switches, and the way into the turn's policy ────────
// Completion verification is adaptive now, rather than a user-facing preset.
// The legacy endpoint remains in the port for older clients and sessions,
@@ -141,6 +164,7 @@ export const ACTIONS: UIAction[] = [
{ id: "image.close", kind: "view", target: "none", proof: "interaction" },
{ id: "image.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.show-full", kind: "view", target: "none", proof: "interaction" },
{ id: "agent.open", kind: "view", target: "entity", proof: "interaction" },
{ id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
{ id: "agent.close", kind: "view", target: "none", proof: "interaction" },
@@ -277,7 +301,11 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
@@ -661,6 +689,7 @@ export const ACTIONS: UIAction[] = [
{ id: "posture-note.dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "session.import-legacy", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.import-native", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "network.diagnose", kind: "repeatable", target: "none", proof: "interaction" },
// ── Permissions, the sandbox, and the endpoints a session can reach.
@@ -720,6 +749,8 @@ export const ACTIONS: UIAction[] = [
{ id: "peers.enable", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.dispatch", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.release-revoke", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "peers.release-add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.release-pick", kind: "view", target: "none", proof: "interaction" },
{ id: "peers.deny", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
@@ -731,8 +762,8 @@ export const ACTIONS: UIAction[] = [
{ id: "persona.prefix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.suffix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.preset", kind: "view", target: "optional", proof: "interaction" },
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+7
View File
@@ -1,4 +1,5 @@
import { EN_SURFACES } from "./en_surfaces";
import { EN_COMPOSITION } from "./en_composition";
// English catalogue, keyed by the Chinese source text. A key that is missing
// here renders as Chinese rather than as a blank, so an incomplete translation
// degrades instead of breaking — and i18n.test.ts is what stops it from
@@ -23,6 +24,7 @@ import { EN_COMMUNITY } from "./en_community";
import { EN_SPONSOR } from "./en_sponsor";
export const EN: Record<string, string> = {
...EN_COMPOSITION,
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
...EN_SURFACES,
"重命名聊天": "Rename chat",
@@ -513,6 +515,11 @@ export const EN: Record<string, string> = {
"完整输出已保存({lines} 行 · {size}),模型按需读取": "Full output saved to disk ({lines} lines · {size}); the model reads it on demand",
"已显示开头部分,模型可继续往下读": "Showing the start; the model can read on from there",
"模型只收到 {kept},共 {size} — 其余未进入上下文": "The model received only {kept} of {size} — the rest never entered its context",
"中间 {n} 个字符没有放在页面里,只显示了开头和结尾": "{n} characters from the middle are not held in the page; only the start and end are shown",
"显示完整输出": "Show full output",
"正在取回…": "Fetching…",
"取回失败,请重试": "Could not fetch it; try again",
"此前省略了 {n} 个字符,这里只显示最近的输出": "{n} characters before this are left out; only the most recent output is shown",
"在 {name} 下新建会话": "Start a new session in {name}",
"关闭这个会话面板": "Close this pane",
"不会删除任何文件": "No files are deleted",
@@ -0,0 +1,129 @@
export const EN_COMPOSITION: Record<string,string> = {
"显示模式选择器": "Show mode picker",
"默认关闭。开启后在聊天输入框顶部显示模式选择器;关闭只隐藏入口,不改变会话模式。": "Off by default. Enable to show the mode picker above the chat input. Turning it off hides the picker without changing the conversation's mode.",
"无法保存模式选择器设置,请检查本地存储权限。": "Could not save the mode picker setting. Check local storage permissions.",
"标准模式": "Standard mode",
"精简模式": "Minimal mode",
"处理代码、文件和资料,适合大多数任务。创建会话时继承当前配置,按需检索、编辑和运行工具。": "Work with code, files and research for everyday tasks. Inherit current configuration when creating the conversation, then search, edit and run tools as needed.",
"只使用终端和图片读取工具,保留上下文压缩,适合基础任务与效果对比。需要支持读图的模型;终端操作仍受现有权限约束。": "Use terminal and image-reading tools with context compaction for basic tasks and comparisons. Requires a vision-capable model; terminal operations follow existing permissions.",
"新任务默认": "New task default",
"将{name}设为新任务默认": "Set {name} as the new task default",
"如何使用 {name}": "How to use {name}",
"点击卡片,设置本机新任务的默认模式;已有会话保持当前配置。": "Click a card to set the default for new tasks on this machine. Existing conversations keep their configuration.",
"无法保存新任务默认模式,请检查本地存储权限。": "Could not save the new task default. Check local storage permissions.",
"初次使用会选择日常搭档,直接说说你想做什么。你也可以点击卡片更改新任务默认,或复制一份自定义。": "Everyday partner is the initial default. Describe what you want to do, click a card to change the new-task default, or duplicate a preset to customize it.",
"在新任务输入框选择轻装搭档,再发送首条消息。需要支持读图的模型;终端操作继续遵守权限设置。": "Choose Lightweight partner in a new task's composer before sending the first message. A vision-capable model is required; terminal operations follow existing permissions.",
"预设版本": "Preset version",
"继承当前配置": "Inherit current configuration",
"返回预设列表": "Back to presets",
"基于{name}创建": "Based on {name}",
"用途说明": "Purpose",
"人设与要求": "Persona and instructions",
"描述预设的角色、工作方式与要求;内容写在系统提示词开头。": "Describe the role, approach and requirements. This text goes at the start of the system prompt.",
"能力配置": "Capabilities",
"继承当前的工具、技能与扩展配置,可按需单独调整。": "Inherit current tools, skills and extensions, or adjust them individually.",
"技能与扩展": "Skills and extensions",
"仅使用所选项": "Selected modules only",
"自动生成,用于保存和引用预设;保存后不可修改。": "Generated automatically to save and reference this preset. Cannot be changed after saving.",
"预设详情": "Preset details",
"编辑预设": "Edit preset",
"保存后供新会话选择,已有会话保持原配置。": "Available to new conversations after saving. Existing conversations keep their configuration.",
"放弃未保存的更改?": "Discard unsaved changes?",
"继续编辑": "Keep editing",
"放弃更改": "Discard changes",
"编辑日常搭档人设": "Edit Everyday partner persona",
"显式默认配置": "Explicit default configuration",
"仅在显式选择 default 时使用,与本机卡片设置的新任务默认独立。": "Used only when explicitly selecting default, separately from this machine's new-task card selection.",
"预设已删除,已有会话保持原版本。": "Preset deleted. Existing conversations keep their version.",
"导入的预设": "Imported preset",
"新会话默认使用日常搭档。选择其他模式后才会启用对应预设。": "New conversations start in Everyday partner. Other presets apply only when selected.",
"查看 {name}": "View {name}",
"编辑 {name}": "Edit {name}",
"复制 {name}": "Duplicate {name}",
"日常搭档人设": "Everyday partner persona",
"修改用户配置,与自定义预设独立保存。": "Edit user configuration, saved separately from custom presets.",
"新会话及现有会话下一次构建时生效。": "Applies to new conversations and when existing conversations are next rebuilt.",
"取消删除": "Cancel deletion",
"会话文件无效,请选择完整的原生会话导出文件。": "Invalid session file. Choose a complete native session export.",
"不支持此会话文件版本,请使用兼容版本导出。": "Unsupported session file version. Export it with a compatible version.",
"会话文件超过安全导入限制(大小或记录数)。": "The session file exceeds safe import limits (size or record count).",
"Agent 预设暂不可用": "Agent presets unavailable",
"当前会话已绑定预设,日常搭档的人设修改不会更新此绑定。": "This conversation is bound to a preset. Editing the Everyday partner persona will not update that binding.",
"默认使用日常搭档。其他模式仅在明确选择后启用;首选标记不会自动切换会话。": "Everyday partner is used by default. Other modes require an explicit choice; a preference never switches a conversation automatically.",
"日常搭档": "Everyday partner",
"轻装搭档": "Lightweight partner",
"写代码、查资料、理清思路,和你一起把事情做好。沿用你的工具与配置,按需检索、编辑和运行。": "Write code, find information and think things through together. Use your tools and configuration to search, edit and run as needed.",
"带上终端和看图工具,轻装开工。和你一起处理小任务、快速试验,也方便对比模型表现。": "Pack a terminal and image-reading tools, and get going. Work through small tasks and quick experiments together, or compare model performance.",
"首选预设": "Preferred preset",
"模式说明": "Mode details",
"如何使用": "How to use",
"新建自定义预设": "New custom preset",
"在输入框顶部选择 Agent 模式,首次提交前可切换。开始对话后,请用其他模式新建会话。轻装搭档的终端仍可修改文件,并非只读模式。": "Choose an Agent mode above the input before the first message. Once a conversation starts, create another to use a different mode. Lightweight partner can still edit files through its terminal; it is not read-only.",
"导入原生会话": "Import a native session",
"保留组合版本与资源;导入不会安装程序或授予权限。缺少授权的会话仅可查看历史。": "Keep the composition version and resources. Importing installs no programs and grants no permissions. Sessions without authorization open for history only.",
"选择会话 JSON 文件": "Choose a session JSON file",
"会话文件超过大小限制。": "The session file exceeds the size limit.",
"会话已导入,绑定版本保持不变。": "Session imported with its original composition version.",
"通用": "General",
"代码审查": "Code review",
"资料研究": "Research",
"继承当前配置,可读取、执行和修改;操作继续遵守现有权限。": "Inherit current configuration. Read, execute and edit within existing permissions.",
"读取和检索代码,依据证据审查;不运行 shell、不修改文件。": "Read and search code for evidence-based reviews. Shell execution and file edits are disabled.",
"读取代码和文档,搜索与抓取网页;不运行 shell、不修改文件。": "Read code and documents, search and fetch web pages. Shell execution and file edits are disabled.",
"Agent 组合": "Agent composition",
"该会话已开始,模式已锁定。请用其他模式新建会话。": "This conversation has started and its mode is locked. Start another conversation to use a different mode.",
"首次提交前可切换 Agent 组合": "Choose an Agent composition before the first message",
"原有会话配置": "Legacy session configuration",
"管理 Agent 预设": "Manage Agent presets",
"副本": "Copy",
"Agent 预设": "Agent presets",
"为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。": "Combine a persona, tools, skills and extensions for each conversation. Models stay independent; started conversations keep their original version.",
"读取预设中…": "Loading presets…",
"查看预设": "View preset",
"未保存的预设": "Unsaved preset",
"新建预设": "New preset",
"复制为自定义预设": "Duplicate as custom preset",
"已锁定": "Locked",
"首次提交前可切换": "Switch before the first message",
"当前会话使用旧版": "This conversation uses an older version",
"预设 ID": "Preset ID",
"存放范围": "Storage scope",
"个人:保存在本机,可跨项目使用。项目共享:保存在当前项目,可随仓库共享。": "Personal: saved on this machine for use across projects. Shared with project: saved in the current project and shareable through its repository.",
"用于保存和引用,区别于显示名称。例如 code-review,保存后不可修改。": "Used to save and reference the preset, separately from its display name. For example, code-review. Cannot be changed after saving.",
"个人": "Personal",
"项目共享": "Shared with project",
"内置预设只读,复制后即可编辑。": "Built-in presets are read-only. Duplicate one to edit it.",
"已保存,新会话使用新版": "Saved. New conversations use the updated version.",
"保存预设": "Save preset",
"已设为个人首选预设": "Set as personal preference",
"设为个人首选": "Set personal preference",
"已设为当前项目首选预设": "Set as workspace preference",
"设为项目首选": "Set workspace preference",
"导出文件": "Export file",
"删除预设": "Delete preset",
"删除后不再供新会话选择,已有会话保留绑定版本。": "Deleting removes it from new conversations. Existing conversations retain their bound version.",
"导入预设文件(先预览,再保存)": "Import a preset file (preview before saving)",
"导入预览已就绪,检查模块后保存。": "Import preview is ready. Check the modules before saving.",
"下载预设 JSON": "Download preset JSON",
"预设名称": "Preset name",
"预设说明": "Preset description",
"注入运行环境与离线提示": "Include runtime context and offline hints",
"选择方式": "Selection mode",
"创建会话时继承当前配置": "Inherit current configuration when creating the conversation",
"仅使用所选项(空列表表示禁用)": "Use selected modules only (empty disables them)",
"独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用": "Per-composition parameters (JSON); use only ${NAME} references in env and headers",
"独立参数": "Parameters",
"参数需要有效的 JSON 对象": "Parameters must be a valid JSON object",
"添加模块": "Add module",
"自动化 Hook": "Automation hooks",
"安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。": "Safety hooks and permission rules always apply; this selection controls non-blocking automation only.",
"斜杠命令": "Slash commands",
"用其他组合新建会话": "Start a conversation with another composition",
"组合资源不可用,当前会话仅可查看历史。": "Composition resources are unavailable. This conversation is open for history only.",
"预设内容无效,请检查标识、模块和参数": "Invalid preset. Check its ID, modules and parameters.",
"组合所需资源不可用,无法继续执行": "Required composition resources are unavailable; execution is blocked.",
"会话已开始或正在工作,不能切换组合": "This conversation has started or is busy; its composition cannot be changed.",
"预设已被其他操作修改,草稿已保留,请重新加载后再保存": "The preset changed elsewhere. Your draft is retained; reload before saving.",
"找不到该 Agent 预设": "Agent preset not found",
"内置预设只读,请复制后编辑": "Built-in presets are read-only. Duplicate one to edit it.",
};
+19 -2
View File
@@ -85,7 +85,6 @@ export const EN_FEEDBACK: Record<string, string> = {
"随反馈一起发送的环境信息": "Environment sent with your report",
"入口": "Surface",
"渠道": "Channel",
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
"打开日志文件夹": "Open logs folder",
"维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。": "When a maintainer asks for logs, open this folder and send us the newest shell.log and host.log. You can read them first.",
"无法打开日志文件夹": "The logs folder could not be opened.",
@@ -227,8 +226,26 @@ export const EN_FEEDBACK: Record<string, string> = {
"榜上显示的名字": "Name shown on the board",
"给反馈被采纳并发布的人。按版本看有名次,全部版本只按名字排序,不比高低。只有自己选择加入的人才会出现。": "For people whose feedback was adopted and shipped. A release has a ranking; all releases together are listed by name, not ranked. Only people who choose to join appear.",
"贡献榜": "Contribution board",
"内存": "Memory",
"只读。不包含密钥、地址或任何文件内容。内存一项只有进程类型和上面这些数字,用来判断是哪一部分占用高。": "Read-only. It holds no key, address or file content. The memory line is only the process type and the numbers above, to tell which part is using a lot.",
"{name}:{set}(私有 {own})": "{name}: {set} (private {own})",
"主进程": "Main process",
"界面进程": "Interface process",
"图形进程": "Graphics process",
"辅助进程": "Helper process",
"内核": "Kernel",
"要不要加入贡献榜": "Join the contribution board?",
"要不要加入贡献榜?": "Join the contribution board?",
"这台电脑已关联账号。加入后榜上公开显示你选的名字和被采纳的条数,不显示邮箱、安装 ID 或路径;这个提示只出现一次,随时可以退出。": "This computer is linked to your account. If you join, the board publicly shows the name you choose and how many of your suggestions were adopted, never an email, install ID or path. This is asked only once, and you can leave any time.",
"加入后榜上只显示下面这个名字和采纳数,不显示邮箱、安装 ID 或路径;随时可以退出,退出后下一次刷新就不再出现。": "Once you join, the board shows only the name below and your adopted count, never an email, install ID or path. You can leave any time and the entry is gone at the next refresh.",
"榜是公开的:任何人都能看到下面这个名字和被采纳的条数,看不到邮箱、安装 ID 或路径。随时可以在贡献榜里退出。": "The board is public: anyone can see the name below and how many suggestions were adopted, but never an email, install ID or path. You can leave from the contribution board any time.",
"请填写一个名字,或取消勾选就不加入。": "Enter a name, or untick the box to not join.",
"名字至少 2 个字符。": "The name needs at least 2 characters.",
"名字最多 30 个字符。": "The name can have at most 30 characters.",
"名字只能用字母、数字、空格、下划线或连字符,不能含 @ 或句点。": "The name may use letters, digits, spaces, underscores or hyphens only, with no @ or period.",
"已关联,但没能加入贡献榜:{why}。可以在下面的贡献榜里改个名字再加入。": "Linked, but joining the board did not work: {why}. You can pick another name in the contribution board below.",
"确认关联": "Confirm linking",
"这个版本还没有人上榜。": "Nobody is listed for this release yet.",
"这个账号已被维护者从榜单移除,暂时不能再加入。": "A maintainer removed this account from the board; it cannot join again for now.",
"退出榜单": "Leave the board",
"默认不上榜。加入后榜上只显示下面这个名字和采纳数,不显示邮箱、安装 ID 或路径;随时可以退出,退出后下一次刷新就不再出现。": "Not on the board by default. Once you join, the board shows only the name below and your adopted count, never an email, install id or path; you can leave at any time and you disappear from the next refresh.",
};
+5 -1
View File
@@ -3,6 +3,7 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"此会话的组合已绑定原项目,请在目标项目新建会话": "This conversation's composition is bound to its project. Start a new conversation in the target project",
"起始分支已不存在,请重新选择分支": "The source branch no longer exists. Choose another branch",
"会话或工作目录已改变,请新建会话后重新选择 worktree": "The session or workspace changed. Start a new session to select a worktree",
"请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
@@ -205,7 +206,8 @@ export const EN_KERNEL: Record<string, string> = {
"社区市场返回了无法识别的内容": "The community market answered with something unrecognisable",
"社区市场暂不支持只列出已固定内容的包": "The community market cannot list only pinned packages yet",
"该包的审核版本没有固定内容,需要信任发布者后才能安装": "The reviewed version is not pinned to its content; trust the publisher to install it",
"该包的审核版本指向本机或不安全的来源,不从市场安装": "The reviewed version points at a local or unsafe source, which the market does not install",
"该包的来源地址无效或不受支持,无法从市场安装;请发布者核对来源后重新提交": "This package's source URL is invalid or unsupported and cannot be installed from the market; ask the publisher to check the source and resubmit",
"插件或主题的来源地址无效:必须使用 HTTPS GitHub 目录地址,并固定到完整的 40 位小写十六进制提交号(tree/提交号);请核对地址,修正后重新提交": "Invalid plugin or theme source URL: use an HTTPS GitHub directory URL pinned to a full 40-character lowercase hexadecimal commit (tree/commit); check the URL, correct it and resubmit",
"审核版本已更新,请重新查看后再安装": "A newer version has been approved - look at it again before installing",
"来源内容已与审核版本不同,已拒绝安装": "The source no longer holds the reviewed content, so nothing was installed",
"该来源的内容无法固定,已拒绝安装": "This source cannot be pinned by content, so nothing was installed",
@@ -465,6 +467,8 @@ export const EN_KERNEL: Record<string, string> = {
"该插件未安装": "That plugin is not installed",
"图片数据不是 base64 编码": "The image data is not base64",
"HTTP 上不提供 shell 命令": "Shell commands are not served over HTTP",
"这个会话里没有这条调用的输出": "This session has no output for that call",
"要取的输出范围不对,请重新展开这条输出": "The part of the output asked for is not valid; open the output again",
"不存在「{role}」这个角色": "There is no “{role}” role",
"没有已配置的模型匹配「{model}」": "No configured model matches “{model}”",
"「{model}」是决策来源,只服务 system_one 的询问,不能用作对话模型": "“{model}” is a decision source: it only answers system_one's questions and cannot be used as a conversation model",
+3 -3
View File
@@ -19,10 +19,10 @@ export const EN_PERSONA: Record<string, string> = {
"已修改预设内容,按下面的字段保存。": "The preset text has been edited; save the fields below.",
"系统提示词开头": "Start of the system prompt",
"作为系统提示词的第一段,写在内置指令之前;留空表示不添加。": "Leads the system prompt, ahead of the built-in guidance; leave it empty to add nothing.",
"第一方指令之后": "After the first-party guidance",
"紧跟在内置指令之后、运行时上下文之前;留空表示不添加。": "Follows the built-in guidance and precedes the runtime context; leave it empty to add nothing.",
"补充要求": "Additional instructions",
"放在内置系统指令之后,留空则不添加": "Placed after the built-in system instructions. Leave blank to add nothing.",
"不注入可选运行时上下文": "Do not inject optional runtime context",
"关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
"开启后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
"写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.",
"已保存,下次会话构建时生效": "Saved; applies at the next session build",
};
@@ -21,6 +21,13 @@ export const EN_SETTINGS: Record<string, string> = {
"写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。": "Write what to tell the other session. It reaches that session\u2019s model only after its user approves.",
"发送中…": "Sending\u2026",
"会话间消息(实验)": "Messages between sessions (experimental)",
"会话间消息": "Session messages",
"放行的项目": "Projects allowed through",
"放行这个项目": "Allow this project through",
"还没有放行任何项目:每条收到的消息都会先问你。": "No project is allowed through yet: every message that arrives asks you first.",
"先启用会话间消息,再放行项目。": "Turn on session messages first, then allow projects through.",
"没有可以放行的项目。只能从你已经添加过的项目里选。": "There is no project to allow. You can only pick from the projects you have already added.",
"这些项目里的会话收到你其他会话的消息时不再询问,直接交给模型。只写在你自己的用户配置里,项目配置改不了;随时可以撤销。": "Sessions in these projects take a message from your other sessions without asking and hand it to the model. It is written only in your own user config, which a project's config cannot change, and you can take it back at any time.",
"同一台机器上的会话互相发文字。收到的文字先挂起,只有你批准才会交给模型;发出去的文字也要对方的用户批准。没有给模型用的工具,默认关闭。": "Sessions on one machine send each other text. What you receive is held until you approve it; what you send needs the other user\u2019s approval too. There is no tool for the model, and it is off by default.",
"浏览器标签": "Browser tabs",
"辅助会话": "Side question",
+14 -1
View File
@@ -27,6 +27,13 @@ export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_runni
export const savedNotApplied = (e: unknown): boolean => e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "");
const SAID: Record<string, string> = {
"agent_composition.invalid": "预设内容无效,请检查标识、模块和参数",
"agent_composition.unavailable": "组合所需资源不可用,无法继续执行",
"agent_composition.locked": "会话已开始或正在工作,不能切换组合",
"agent_composition.workspace_bound": "此会话的组合已绑定原项目,请在目标项目新建会话",
"agent_composition.conflict": "预设已被其他操作修改,草稿已保留,请重新加载后再保存",
"agent_composition.not_found": "找不到该 Agent 预设",
"agent_composition.read_only": "内置预设只读,请复制后编辑",
"surface.value_invalid": "填写内容不符合字段要求,请检查后重试",
"surface.expired": "界面已失效,请等待新的请求",
"surface.schema_invalid": "界面描述不符合约定",
@@ -45,6 +52,9 @@ const SAID: Record<string, string> = {
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
"session.archive_invalid": "会话文件无效,请选择完整的原生会话导出文件。",
"session.archive_unsupported": "不支持此会话文件版本,请使用兼容版本导出。",
"session.archive_too_large": "会话文件超过安全导入限制(大小或记录数)。",
"session.title_empty": "请输入聊天标题",
"session.title_no_message": "该会话还没有可用于命名的用户消息",
"session.title_unavailable": "未配置可用于自动命名的模型,请先配置模型",
@@ -468,6 +478,8 @@ const SAID: Record<string, string> = {
"history.query_required": "请输入要搜索的内容",
"history.search_failed": "对话内容索引暂时无法回答,稍后再试",
"job.not_running": "这个后台任务已经不在运行了",
"tool.output_unknown": "这个会话里没有这条调用的输出",
"tool.output_range": "要取的输出范围不对,请重新展开这条输出",
"mux.unknown_connection": "事件流连接已经关闭,需要重新打开并重新订阅各窗格",
"mux.pane_not_local": "这个窗格由另一台机器驱动,使用它自己的事件流",
"job.stop_in_progress": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待",
@@ -623,7 +635,8 @@ const SAID: Record<string, string> = {
"market.bad_response": "社区市场返回了无法识别的内容",
"market.filter_unsupported": "社区市场暂不支持只列出已固定内容的包",
"market.unpinned": "该包的审核版本没有固定内容,需要信任发布者后才能安装",
"market.bad_source": "该包的审核版本指向本机或不安全的来源,不从市场安装",
"market.bad_source": "该包的来源地址无效或不受支持,无法从市场安装;请发布者核对来源后重新提交",
"market.invalid_plugin_source": "插件或主题的来源地址无效:必须使用 HTTPS GitHub 目录地址,并固定到完整的 40 位小写十六进制提交号(tree/提交号);请核对地址,修正后重新提交",
"market.version_changed": "审核版本已更新,请重新查看后再安装",
"market.content_changed": "来源内容已与审核版本不同,已拒绝安装",
"market.not_pinnable": "该来源的内容无法固定,已拒绝安装",
+1
View File
@@ -5,6 +5,7 @@ import { reason } from "./i18n/kernel";
import { track as trackWidth } from "./ui/viewport";
import "./styles/tokens.css";
import "./styles/app.css";
import "./styles/composition-picker.css";
import "./styles/dock.css";
import "./styles/studio.css";
import "./styles/dialogcenter.css";
@@ -400,3 +400,62 @@ describe("a request too large for one message", () => {
expect(again[0].id).toBe(partsOf(first.socket)[0].id);
});
});
describe("a slow answer over a live link", () => {
function chunk(socket: FakeSocket, id: string, index: number, done: boolean) {
socket.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({
type: "desktop.response", id, index, done, status: 200,
body: linkCodec.bytesToBase64(new TextEncoder().encode(`part${index};`)),
}) }));
}
it("is not failed while its own chunks keep arriving, however long the whole takes", async () => {
const first = connection();
const link = new RemoteLink(first.conn, vi.fn(async () => first.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
const answer = link.fetch(request());
const settled = answer.then(() => "answered", (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(0);
const id = String(first.socket.sent[0]?.id);
for (let index = 0; index < 5; index++) {
await vi.advanceTimersByTimeAsync(20_000);
chunk(first.socket, id, index, index === 4);
}
await expect(settled).resolves.toBe("answered");
await expect((await answer).text()).resolves.toBe("part0;part1;part2;part3;part4;");
});
it("keeps waiting for a request queued behind others while the desktop is still answering them", async () => {
const first = connection();
const link = new RemoteLink(first.conn, vi.fn(async () => first.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
const queued = link.fetch(request("GET", "/rt/two/events/replay"));
const settled = queued.then(() => "answered", (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(0);
const id = String(first.socket.sent[0]?.id);
for (let beat = 0; beat < 6; beat++) {
await vi.advanceTimersByTimeAsync(20_000);
chunk(first.socket, "someone-elses-request", beat, false);
}
first.socket.reply(id, "finally");
await expect(settled).resolves.toBe("answered");
});
it("still gives up on a request nothing has answered, so a silent desktop is not waited on forever", async () => {
const first = connection();
const second = connection();
const link = new RemoteLink(first.conn, vi.fn(async () => second.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
const answer = link.fetch(request());
const settled = answer.then(() => "answered", (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(0);
for (let beat = 0; beat < 40; beat++) {
await vi.advanceTimersByTimeAsync(10_000);
chunk(first.socket, "someone-elses-request", beat, false);
}
await expect(settled).resolves.toMatch(/Remote Studio|远程 Studio/);
});
});
+21 -1
View File
@@ -62,6 +62,8 @@ interface Pending {
contentType?: string;
etag?: string;
timer?: ReturnType<typeof setTimeout>;
// When this request last made progress: sent whole, or one more chunk of its answer.
progressAt?: number;
resolve: (value: Response) => void;
reject: (reason: unknown) => void;
}
@@ -73,6 +75,11 @@ export interface RemoteLinkOptions {
const MAX_RETRY_AFTER_MS = 2 * 60 * 1000;
// The desktop answers one request at a time, so a request can wait behind others
// it knows nothing about. While the link is delivering their answers it is waited
// for, up to this long without a word of its own.
const MAX_QUEUED_MS = 5 * 60 * 1000;
// The desktop reads at most 64 KiB from the relay in one message and closes the
// link on a larger one, taking every other request on it down too. A request
// that would seal past this is refused here instead of being sent. The budget
@@ -250,6 +257,12 @@ export class RemoteLink {
while (connection.socket.bufferedAmount > BUFFER_HIGH && live()) await new Promise((resolve) => setTimeout(resolve, 20));
if (!live()) return;
}
this.arm(entry);
}
private arm(entry: Pending) {
clearTimeout(entry.timer);
entry.progressAt = Date.now();
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
}
@@ -263,6 +276,10 @@ export class RemoteLink {
try { stale.socket.close(CLOSE_RESYNC, "Resync"); } catch { /* already closing */ }
return;
}
if (Date.now() - (entry.progressAt ?? 0) < MAX_QUEUED_MS) {
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
return;
}
this.pending.delete(entry.id);
entry.reject(new Error(t("远程 Studio 暂无响应,请检查电脑是否在线后重试。")));
}
@@ -367,7 +384,10 @@ export class RemoteLink {
pending.contentType = typeof message.contentType === "string" ? message.contentType : pending.contentType;
pending.etag = typeof message.etag === "string" ? message.etag : pending.etag;
pending.chunks.set(message.index, message.body);
if (message.done !== true) return;
if (message.done !== true) {
this.arm(pending);
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
const headers = new Headers();
@@ -0,0 +1,29 @@
import definitions from "../../../../internal/contract/agentcomposition/builtins.json";
export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks" | "commands";
export interface CompositionModule { id: string; parameters?: Record<string, unknown> }
export interface CompositionSelection { mode: "inherit" | "select"; members: CompositionModule[] }
export interface AgentComposition {
schemaVersion: number;
id: string;
name: string;
description: string;
persona: { prefix: string; suffix: string; include_runtime_context: boolean };
tools: CompositionSelection;
skills: CompositionSelection;
mcp: CompositionSelection;
extensions: CompositionSelection;
hooks: CompositionSelection;
commands: CompositionSelection;
}
export interface AgentPresetVersion { definition: AgentComposition; revision: string }
export interface AgentPresetCatalog {
presets: AgentPresetVersion[];
default: string;
diagnostics: { code: string; module?: string; detail: string }[];
modules?: Partial<Record<CompositionKind,CompositionModule[]>>;
}
export interface AgentPresetImport {definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}
export interface CompositionSummary { id: string; name: string; revision: string; digest: string; started: boolean; unavailable?: string }
export const COMPOSITION_KINDS: CompositionKind[] = ["tools", "skills", "mcp", "extensions", "hooks", "commands"];
export const builtinCompositions = (): AgentComposition[] => structuredClone(definitions) as AgentComposition[];
@@ -33,6 +33,16 @@ export const FEEDBACK_WITHDRAWABLE: readonly FeedbackStatus[] = ["received", "ne
export const canWithdraw = (item: Pick<FeedbackItem, "status" | "statusUnavailable">): boolean =>
!item.statusUnavailable && FEEDBACK_WITHDRAWABLE.includes(item.status);
// One process's memory as a report carries it: a type from a fixed list and two
// sizes in kilobytes. Nothing in it names a path, a process id or a user.
export type FeedbackProcessType = "browser" | "renderer" | "gpu" | "utility" | "kernel";
export interface FeedbackProcessMemory {
process: FeedbackProcessType;
workingSetKB: number;
privateKB: number;
}
export interface FeedbackEnvInfo {
version: string;
commit: string;
@@ -43,6 +53,8 @@ export interface FeedbackEnvInfo {
locale: string;
channel: string;
providerKind: string;
// The kernel's own row; the window's shell adds the rest when a report is sent.
memory?: FeedbackProcessMemory[];
}
// uploadBytes bounds one raw file the page may hand over; the kernel shrinks
@@ -77,6 +89,8 @@ export interface FeedbackRequest {
// The UI language tag, so the issue can be triaged in the reporter's language.
locale?: string;
images: FeedbackImage[];
// The shell's process rows; the kernel keeps the fixed types and adds its own.
memory?: FeedbackProcessMemory[];
// Answer to a feedback.challenge_required refusal.
turnstileToken?: string;
}
+12
View File
@@ -2,6 +2,8 @@
// the app learns which shell it is running in. Electron exposes a preload
// bridge; a browser tab has none, and answers for itself.
import type { FeedbackProcessMemory } from "./feedback";
export type Shell = "electron" | "browser";
export interface HostInfo {
@@ -38,6 +40,9 @@ export interface HostPort {
captureWindow(): Promise<Uint8Array | null>;
/** How the shell draws this launch; null where it has no say, as in a tab. */
graphics(): Promise<GraphicsInfo | null>;
/** The shell's own processes as a type and two sizes each, for the memory a
* report may carry; empty where there is no shell to ask, as in a tab. */
processMemory(): Promise<FeedbackProcessMemory[]>;
openExternal(url: string): void | Promise<void>;
setUnreadBadge(unread: boolean): Promise<void>;
/** Where dropped files live. Empty where the shell cannot say — a browser
@@ -162,6 +167,7 @@ interface ElectronBridge {
closeWindow(): Promise<void>;
captureWindow?(): Promise<Uint8Array | null>;
graphics?(): Promise<GraphicsInfo | null>;
processMemory?(): Promise<unknown>;
openExternal(url: string): Promise<void>;
setUnreadBadge?(unread: boolean): Promise<void>;
pathForFile(file: File): string;
@@ -226,6 +232,9 @@ class ElectronHost implements HostPort {
graphics() {
return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null);
}
processMemory() {
return this.api.processMemory?.().then((rows: unknown) => (Array.isArray(rows) ? (rows as FeedbackProcessMemory[]) : [])).catch(() => []) ?? Promise.resolve([]);
}
openExternal(url: string) {
return this.api.openExternal(url);
}
@@ -328,6 +337,9 @@ class BrowserHost implements HostPort {
graphics() {
return Promise.resolve(null);
}
processMemory() {
return Promise.resolve([]);
}
openExternal(url: string) {
window.open(url, "_blank", "noopener,noreferrer");
}
@@ -3,6 +3,18 @@ import { describe, expect, it, vi } from "vitest";
import { SseHub } from "./hub";
import { HttpError } from "./port";
it("uploads native archives as opaque bodies to the owning host route", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ path: "imported" }) });
vi.stubGlobal("fetch", fetch);
try {
const file = new File(["{ not parsed here"], "session.json");
await new SseHub().importNativeSession(file, "/folder with space");
expect(fetch).toHaveBeenCalledWith("/tree/sessions/import-native?workspace=%2Ffolder+with+space",
expect.objectContaining({ method: "POST", body: file,
headers: { "content-type": "application/json" }, credentials: "same-origin" }));
} finally { vi.unstubAllGlobals(); }
});
function answer(status: number, body: string) {
vi.stubGlobal("fetch", async () => ({
ok: false,
@@ -84,6 +96,20 @@ function jsonAnswer(status: number, body: unknown) {
};
}
it("serializes the remote composition choice at the HTTP request boundary", async () => {
const fetch = vi.fn(async () => jsonAnswer(200, { id: "remote", root: "/project", host: "gpu", base: "/runtime/remote", name: "Remote" }));
vi.stubGlobal("fetch", fetch);
try {
const hub = new SseHub();
for (const agentComposition of ["builtin:minimal", "builtin:general", undefined]) {
await hub.openRemote({ host: "gpu", workspace: "/project", agentComposition });
const [url, init] = fetch.mock.lastCall as unknown as [string, RequestInit];
expect(url).toBe("/remotes/open");
expect(JSON.parse(init.body as string)).toEqual({ host: "gpu", workspace: "/project", sessionPath: "", ...(agentComposition ? { agentComposition } : {}) });
}
} finally { vi.unstubAllGlobals(); }
});
describe("host capabilities", () => {
it("does not ask a kernel picker the capability probe says is absent", async () => {
const calls: Array<[string, string]> = [];
+18 -6
View File
@@ -19,6 +19,7 @@ function operationHeaders(operationId?: string): Record<string, string> {
// RuntimeView is one open pane. Base is the prefix every request of that pane
// carries, which is also what tells the shell's bus which channel to listen on.
export interface RuntimeView {
agentComposition?: import("./composition").CompositionSummary;
id: string;
base: string;
root: string;
@@ -161,7 +162,7 @@ export interface HistoryHit {
export interface HubPort extends SharePort {
runtimes(): Promise<RuntimeView[]>;
open(req: { root?: string; sessionPath?: string }): Promise<RuntimeView>;
open(req: { root?: string; sessionPath?: string; agentComposition?: string }): Promise<RuntimeView>;
close(id: string): Promise<void>;
tree(window?: TreeWindow): Promise<TreeWorkspace[]>;
/** Conversations, in every workspace, that say these words. */
@@ -193,6 +194,7 @@ export interface HubPort extends SharePort {
autoNameSession(path: string): Promise<{ title: string }>;
exportSession(path: string): Promise<{ name: string; content: string }>;
importLegacySessions(path: string, workspace: string): Promise<LegacyImport>;
importNativeSession(file: Blob, workspace: string): Promise<{ path: string }>;
// The host book with each link's state, or null where this kernel refuses
// remote panes outright — a page served to a browser, rather than the window.
// Null is what lets the sidebar leave the whole section out instead of
@@ -200,7 +202,7 @@ export interface HubPort extends SharePort {
remoteHosts(): Promise<RemoteHost[] | null>;
// Opens a pane on another machine. Slow the first time — the kernel may be
// installing itself over there — so the caller polls remoteHosts for the step.
openRemote(req: { host: string; workspace?: string; sessionPath?: string }): Promise<RuntimeView>;
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }): Promise<RuntimeView>;
// The far machine's own workspaces, or null while nothing is open on it:
// there is no kernel over there to ask until a pane puts one there.
remoteTree(host: string): Promise<TreeWorkspace[] | null>;
@@ -315,8 +317,8 @@ export class SseHub implements HubPort {
return (await res.json()) as RuntimeView[];
}
open(req: { root?: string; sessionPath?: string }) {
return this.post<RuntimeView>("/runtimes", { root: req.root ?? "", sessionPath: req.sessionPath ?? "" });
open(req: { root?: string; sessionPath?: string; agentComposition?: string }) {
return this.post<RuntimeView>("/runtimes", { root: req.root ?? "", sessionPath: req.sessionPath ?? "", agentComposition: req.agentComposition });
}
async close(id: string) {
@@ -416,6 +418,16 @@ export class SseHub implements HubPort {
return this.post<LegacyImport>("/tree/sessions/import-legacy", { path, workspace });
}
async importNativeSession(file: Blob, workspace: string): Promise<{ path: string }> {
const path = `/tree/sessions/import-native?${new URLSearchParams({ workspace })}`;
const response = await fetch(path, {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, body: file,
});
if (!response.ok) await SseHub.fail(path, response);
return await response.json() as { path: string };
}
async remoteHosts() {
const res = await fetch("/remotes", { credentials: "same-origin" });
// Not an error to report: this kernel does not do remote panes, or answers
@@ -425,11 +437,11 @@ export class SseHub implements HubPort {
return (await res.json()) as RemoteHost[];
}
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }) {
return this.dial((operationId) =>
this.post<RuntimeView>(
"/remotes/open",
{ host: req.host, workspace: req.workspace ?? "", sessionPath: req.sessionPath ?? "" },
{ host: req.host, workspace: req.workspace ?? "", sessionPath: req.sessionPath ?? "", agentComposition: req.agentComposition },
operationId,
),
);
+4 -7
View File
@@ -9,7 +9,7 @@ import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations }
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
import { MEMORIES } from "./mock_memory";
import { MEMORIES, mockMemoryCatalog } from "./mock_memory";
import { mockModels } from "./mock_models";
import { mockUsage } from "./mock_usage";
@@ -117,11 +117,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
}
async memories(): Promise<MemoryCatalog> {
return {
memories: this.mem.map((m) => ({ ...m })),
recallQuery: "缓存命中率为什么会掉",
indexPath: "~/.reasonix/projects/reasonix/memory/MEMORY.md",
};
return mockMemoryCatalog(this.mem);
}
// The fixture reports a clean revert on the first ask and a conflicted one
@@ -360,7 +356,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
async markSessionViewed() {}
async status(): Promise<SessionStatus> {
return { ...this.state, ...(location.search.includes("tariff") && { tariff: { peak: !location.search.includes("tariff=off"), changesAt: new Date(Date.now() + 130 * 60_000).toISOString() } }) };
return { ...this.state, agentCompositionLocked: this.compositions.compositionStarted, ...(location.search.includes("tariff") && { tariff: { peak: !location.search.includes("tariff=off"), changesAt: new Date(Date.now() + 130 * 60_000).toISOString() } }) };
}
async balance(): Promise<WalletReading | null> {
@@ -694,6 +690,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
}
async submit(text: string, chips?: ChipCall): Promise<Queued | void> {
this.compositions.markCompositionStarted();
if (chips && this.queuePaused) return this.queueFollowup(text);
if (this.state.running) {
this.emit({ kind: "steer", text });
+14 -12
View File
@@ -3,6 +3,8 @@ import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, Ses
import { mockMods } from "./mock_mods";
import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
import { MockCompositions } from "./mock_composition";
import type { AgentComposition } from "./composition";
import type { PersonaDraft, PersonaSettings } from "./boundary";
// The boundary half of the fixture: what the agent is refused outright, and how
@@ -21,6 +23,16 @@ function effectiveBash(s: SandboxSettings): string {
let levelNotices: FeedbackLevelNotices = { enabled: false, path: "/Users/you/.reasonix/config.toml" };
export class MockBoundary extends MockShell {
protected compositions = new MockCompositions();
shareAgentPresetsFrom(other: MockBoundary, workspace:string) { this.compositions=other.compositions.fork(workspace); }
agentPresets() { return this.compositions.agentPresets(); }
saveAgentPreset(d: AgentComposition, expected: string) { return this.compositions.saveAgentPreset(d, expected); }
deleteAgentPreset(id: string, revision: string) { return this.compositions.deleteAgentPreset(id, revision); }
setDefaultAgentPreset(id: string, workspace: boolean) { return this.compositions.setDefaultAgentPreset(id, workspace); }
exportAgentPreset(id: string) { return this.compositions.exportAgentPreset(id); }
importAgentPreset(d: AgentComposition) { return this.compositions.importAgentPreset(d); }
agentComposition() { return this.compositions.agentComposition(); }
selectAgentComposition(id: string) { return this.compositions.selectAgentComposition(id); }
async decideWorkspaceTrust(_trust: "trusted" | "declined") {}
// One interrupted barrier and one that a later turn took over, so the card
// and the reason it disappears are both reachable without a crash.
@@ -266,7 +278,7 @@ export class MockBoundary extends MockShell {
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
max_dispatch_depth: edit.max_dispatch_depth ?? this.peerSettings.max_dispatch_depth,
max_dispatched_sessions: edit.max_dispatched_sessions ?? this.peerSettings.max_dispatched_sessions,
release_projects: keep ? this.peerSettings.release_projects.filter((p) => keep.includes(p.path)) : this.peerSettings.release_projects,
release_projects: keep ? keep.map((path) => this.peerSettings.release_projects.find((p) => p.path === path) ?? { path, name: path.split(/[\\/]/).filter(Boolean).at(-1) ?? path }) : this.peerSettings.release_projects,
pending: true,
};
return this.peersSettings();
@@ -445,17 +457,7 @@ export class MockBoundary extends MockShell {
return { ...this.watch };
}
// The fixture carries the broken file, because a banner nobody can reach is
// a banner nobody designs. Repairing it here clears it, so the whole
// interaction is reachable without a Go build.
private broken: ConfigProblem | null = {
path: "C:\\Users\\you\\AppData\\Roaming\\reasonix\\config.toml",
line: 263,
key: "plugins.command",
excerpt: 'command = "C:\\Scripts\\mcp.exe"',
repair: 'command = "C:\\\\Scripts\\\\mcp.exe"',
recovered: "last-known-good",
};
private broken: ConfigProblem | null = null;
async configProblem(): Promise<ConfigProblem | null> {
return this.broken && { ...this.broken };
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { MockHub } from "./mock_hub";
import { builtinCompositions } from "./composition";
import { MockCompositions } from "./mock_composition";
describe("remote composition ownership", () => {
it("uses each remote host's own catalog and selected composition", async () => {
const hub = new MockHub();
const local = await hub.open({root:"/same"});
const remote = await hub.openRemote({host:"gpu-box",workspace:"/same",agentComposition:"builtin:minimal"});
const another = await hub.openRemote({host:"builder",workspace:"/same"});
const custom = {...builtinCompositions()[0],id:"user:only-remote",name:"Remote only"};
await hub.portFor(remote).saveAgentPreset(custom,"");
expect(remote.agentComposition?.id).toBe("builtin:minimal");
expect((await hub.portFor(local).agentPresets()).presets.some(v=>v.definition.id===custom.id)).toBe(false);
expect((await hub.portFor(another).agentPresets()).presets.some(v=>v.definition.id===custom.id)).toBe(false);
const sibling = await hub.openRemote({host:"gpu-box",workspace:"/same",agentComposition:custom.id});
expect(sibling.agentComposition?.id).toBe(custom.id);
});
});
describe("project composition authority", () => {
it.each([
["mcp", {args:["other-package"]}],
["mcp", {url:"https://other.invalid/mcp"}],
["mcp", {env:{PATH:"${OTHER_PATH}"}}],
["mcp", {headers:{Authorization:"${OTHER_TOKEN}"}}],
["extensions", {args:["other-script"]}],
["extensions", {env:{NODE_OPTIONS:"${OTHER_OPTIONS}"}}],
] as const)("rejects project %s launch overrides on save and import", async (kind, parameters) => {
const presets = new MockCompositions();
const definition = {...builtinCompositions()[0],id:"project:launch",[kind]:{mode:"select",members:[{id:"authorized",parameters}]}};
await expect(presets.saveAgentPreset(definition,"")).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
await expect(presets.importAgentPreset(definition)).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
await expect(presets.saveAgentPreset({...definition,id:"user:launch"},"")).resolves.toMatchObject({definition:{id:"user:launch"}});
});
it("allows project timeout adjustments", async () => {
const presets = new MockCompositions();
const definition = {...builtinCompositions()[0],id:"project:timeouts",mcp:{mode:"select" as const,members:[{id:"authorized",parameters:{callTimeoutSeconds:5,startupTimeoutSeconds:10}}]}};
await expect(presets.saveAgentPreset(definition,"")).resolves.toMatchObject({definition});
await expect(presets.importAgentPreset(definition)).resolves.toMatchObject({definition});
});
});
@@ -0,0 +1,71 @@
import { HttpError } from "./http_error";
import revisions from "../../../../internal/contract/agentcomposition/builtin-revisions.json";
import { builtinCompositions } from "./composition";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
type Library = { revision:number; user:AgentPresetVersion[]; projects:Map<string,AgentPresetVersion[]>; personalDefault:string; workspaceDefaults:Map<string,string> };
export class MockCompositions {
private current: CompositionSummary | null = null;
private started = false;
constructor(private readonly library:Library = {revision:0,user:[],projects:new Map(),personalDefault:"builtin:general",workspaceDefaults:new Map()},private readonly workspace = "") {
}
get compositionStarted() { return this.started; }
fork(workspace:string) { return new MockCompositions(this.library,workspace); }
private get catalog():AgentPresetCatalog {
return {presets:[...builtinCompositions().map((definition)=>({definition,revision:revisions[definition.id as keyof typeof revisions]})),...this.library.user,...(this.library.projects.get(this.workspace) ?? [])],default:this.library.workspaceDefaults.get(this.workspace) ?? this.library.personalDefault,diagnostics:[]};
}
private summary(v:AgentPresetVersion):CompositionSummary { return {id:v.definition.id,name:v.definition.name,revision:v.revision,digest:`mock-${v.revision}`,started:false}; }
private fail(status:number,code:string):never { throw new HttpError(status,"",{code:`agent_composition.${code}`}); }
private validateAuthority(definition:AgentComposition) {
if (!definition.id.startsWith("project:")) return;
for (const kind of ["mcp","extensions"] as const) for (const member of definition[kind]?.members ?? []) {
if (Object.entries(member.parameters ?? {}).some(([key,value])=>["args","url","env","headers"].includes(key.toLowerCase()) && value!=null)) this.fail(400,"invalid");
}
}
async agentPresets() { return structuredClone(this.catalog); }
async saveAgentPreset(definition: AgentComposition, expectedRevision: string): Promise<AgentPresetVersion> {
if (definition.id.startsWith("builtin:")) this.fail(403,"read_only");
const prior=this.catalog.presets.find((v)=>v.definition.id===definition.id);
if ((prior?.revision ?? "")!==expectedRevision) this.fail(409,"conflict");
if (!/^(user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(definition.id) || !definition.name.trim()) this.fail(400,"invalid");
this.validateAuthority(definition);
const version={definition:structuredClone(definition),revision:`mock-${++this.library.revision}`};
const items=definition.id.startsWith("project:") ? this.library.projects.get(this.workspace) ?? [] : this.library.user;
const next=[...items.filter((v)=>v.definition.id!==definition.id),version];
if (definition.id.startsWith("project:")) this.library.projects.set(this.workspace,next); else this.library.user=next;
return structuredClone(version);
}
async deleteAgentPreset(id:string,revision:string) {
if (id.startsWith("builtin:")) this.fail(403,"read_only");
const old=this.catalog.presets.find((v)=>v.definition.id===id);
if (!old || old.revision!==revision) this.fail(409,"conflict");
this.library.user=this.library.user.filter((v)=>v.definition.id!==id);
this.library.projects.set(this.workspace,(this.library.projects.get(this.workspace) ?? []).filter((v)=>v.definition.id!==id));
}
async setDefaultAgentPreset(id:string,workspace:boolean) {
if (!this.catalog.presets.some((v)=>v.definition.id===id)) this.fail(404,"not_found");
if (!workspace && id.startsWith("project:")) this.fail(400,"invalid");
if (workspace) this.library.workspaceDefaults.set(this.workspace,id); else this.library.personalDefault=id;
}
async exportAgentPreset(id:string) {
const v=this.catalog.presets.find((v)=>v.definition.id===id);
if (!v) this.fail(404,"not_found");
return structuredClone(v.definition);
}
async importAgentPreset(content:AgentComposition) {
if (content.schemaVersion!==1 || !content.name || !content.persona) this.fail(400,"invalid");
this.validateAuthority(content);
return {definition:structuredClone(content),diagnostics:[]};
}
async agentComposition() { return structuredClone(this.current); }
async selectAgentComposition(id:string) {
const v=this.catalog.presets.find((v)=>v.definition.id===id);
if (!v) this.fail(404,"not_found");
if (this.current?.id===id && this.current.revision===v.revision || !this.current && id==="builtin:general") return structuredClone(this.current);
if (this.started) this.fail(409,"locked");
this.current=id==="builtin:general" ? null : this.summary(v);
return structuredClone(this.current);
}
markCompositionStarted() { this.started=true; if(this.current) this.current.started=true; }
}
@@ -7,6 +7,7 @@ const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, i
const ENV = {
version: "v2.24.0", commit: "7ee4bbb", surface: "studio", os: "windows", osVersion: "10.0.19045",
arch: "amd64", locale: "en-US", channel: "stable", providerKind: "deepseek",
memory: [{ process: "kernel" as const, workingSetKB: 196608, privateKB: 150000 }],
};
const DAY = 86_400_000;
+19 -2
View File
@@ -11,6 +11,7 @@ export class MockHub implements HubPort {
private readonly ports = new Map<string, AgentPort>();
private readonly machine = { configured: false };
private readonly settings = new MockPort();
private readonly remoteSettings = new Map<string, MockPort>();
private readonly views: RuntimeView[] = [
{ id: "r1", base: "", root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", sessionPath: "/sessions/mock.jsonl" },
];
@@ -27,7 +28,7 @@ export class MockHub implements HubPort {
return Promise.resolve([...this.views]);
}
open(req: { root?: string; sessionPath?: string }) {
async open(req: { root?: string; sessionPath?: string; agentComposition?:string }) {
const held = req.sessionPath ? this.views.find((v) => v.sessionPath === req.sessionPath) : undefined;
if (held) return Promise.resolve(held);
this.seq++;
@@ -39,6 +40,9 @@ export class MockHub implements HubPort {
name: root.split("/").pop() ?? root,
sessionPath: req.sessionPath,
};
const port=this.portFor(view);
if (req.agentComposition) await port.selectAgentComposition(req.agentComposition);
view.agentComposition=await port.agentComposition() ?? undefined;
this.views.push(view);
return Promise.resolve(view);
}
@@ -79,7 +83,7 @@ export class MockHub implements HubPort {
], host));
}
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
async openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?: string }) {
this.seq++;
const workspace = req.workspace || "/srv/training";
const view: RuntimeView = {
@@ -90,6 +94,9 @@ export class MockHub implements HubPort {
host: req.host,
sessionPath: req.sessionPath,
};
const port = this.portFor(view);
if (req.agentComposition) await port.selectAgentComposition(req.agentComposition);
view.agentComposition = await port.agentComposition() ?? undefined;
this.views.push(view);
return Promise.resolve(view);
}
@@ -298,6 +305,10 @@ export class MockHub implements HubPort {
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0, recognised: true, skipped: [] });
}
importNativeSession(_file: Blob, _workspace: string) {
return Promise.resolve({ path: `/sessions/imported-${++this.seq}.jsonl` });
}
hostCapabilities() {
return Promise.resolve({ pickFolder: true, addWorkspace: true });
}
@@ -407,6 +418,12 @@ export class MockHub implements HubPort {
const held = this.ports.get(rt.id);
if (held) return held;
const port = new MockPort();
let settings = this.settings;
if (rt.host) {
settings = this.remoteSettings.get(rt.host) ?? new MockPort();
this.remoteSettings.set(rt.host, settings);
}
port.shareAgentPresetsFrom(settings,rt.root);
port.machine = this.machine;
this.ports.set(rt.id, port);
return port;
@@ -1,4 +1,12 @@
import type { MemoryEntry } from "./port";
import type { MemoryCatalog, MemoryEntry } from "./port";
export function mockMemoryCatalog(memories: MemoryEntry[]): MemoryCatalog {
return {
memories: memories.map((m) => ({ ...m })),
recallQuery: "缓存命中率为什么会掉",
indexPath: "~/.reasonix/projects/reasonix/memory/MEMORY.md",
};
}
// The memory shelf the window opens onto with no kernel behind it. Data only:
// how a save or a restore moves it is the port's, and lives beside the calls
@@ -0,0 +1,19 @@
import { HttpError } from "./http_error";
import type { ToolOutputPage } from "./session";
import { MockGit } from "./mock_git";
// The windows onto a call's whole output that a page asks for when it kept only
// the ends of it.
export class MockToolOutput extends MockGit {
// The full output a call produced, by call id, for the windows toolOutput serves.
fullOutputs: Record<string, string> = {};
async toolOutput(callId: string, offset = 0, limit = 64 * 1024): Promise<ToolOutputPage> {
const text = this.fullOutputs[callId];
if (text === undefined) throw new HttpError(404, "no tool call with this id in this session", { code: "tool.output_unknown" });
const bytes = new TextEncoder().encode(text);
const from = Math.min(offset, bytes.length);
const to = Math.min(bytes.length, from + limit);
return { callId, text: new TextDecoder().decode(bytes.slice(from, to)), bytes: bytes.length, offset: from, truncated: to < bytes.length };
}
}
@@ -2,7 +2,7 @@ import { reason } from "../i18n/kernel";
import { HttpError } from "./http_error";
import type { KeptText } from "./attachment";
import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
import { MockGit } from "./mock_git";
import { MockToolOutput } from "./mock_tooloutput";
export function mockWorkspaces(root: string): import("./workspace").WorkspaceInfo {
return { current: root, canSwitch: true, canIsolate: true, recents: [
@@ -19,7 +19,7 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor
] };
}
export class MockWorkspace extends MockGit {
export class MockWorkspace extends MockToolOutput {
opensFiles() { return false; }
async openFile() {}
+15 -1
View File
@@ -1,4 +1,6 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
export type * from "./composition";
import { DeliveryError, HttpError } from "./http_error";
import type { DeliveryFault } from "./http_error";
@@ -24,7 +26,7 @@ export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
import type { SubagentRun, SubagentTranscript } from "./subagent";
@@ -344,6 +346,14 @@ export interface AgentPort {
// Read by the kernel's background sweep on each pass; no rebuild.
saveAutoArchive(s: Pick<AutoArchiveSettings, "enabled" | "days">): Promise<AutoArchiveSettings>;
persona(): Promise<PersonaSettings>;
agentPresets(): Promise<AgentPresetCatalog>;
saveAgentPreset(definition: AgentComposition, expectedRevision: string): Promise<AgentPresetVersion>;
deleteAgentPreset(id: string, revision: string): Promise<void>;
setDefaultAgentPreset(id: string, workspace: boolean): Promise<void>;
exportAgentPreset(id: string): Promise<AgentComposition>;
importAgentPreset(content: AgentComposition): Promise<{definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}>;
agentComposition(): Promise<CompositionSummary | null>;
selectAgentComposition(id: string): Promise<CompositionSummary | null>;
// The persona is part of the cache-stable prefix, composed once at boot:
// saving is written now and read by the next session, not this one's runtime.
savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings>;
@@ -704,6 +714,10 @@ export interface AgentPort {
// not take output from the model's own reader; a job that ended is refused
// (job.not_running).
jobOutput(jobId: string): Promise<JobOutput>;
// A window onto one tool call's output as the history carries it, for a page
// that kept only the ends. Refused with tool.output_unknown for a call this
// session has no record of and tool.output_range for a window it cannot cut.
toolOutput(callId: string, offset?: number, limit?: number): Promise<ToolOutputPage>;
// Re-freezes the files an entry references. A line that waited through the
// work that changed them would otherwise arrive quoting what is no longer.
refreshQueued(itemId: string): Promise<void>;
+13
View File
@@ -1,4 +1,5 @@
import type { ModelMode } from "./model";
import type { CompositionSummary } from "./composition";
import type { StoredAttachment, Via } from "./wire";
// A conversation and the states it passes through: what is running, what it
// cost, and the points it can be wound back to.
@@ -230,6 +231,8 @@ export interface Tariff {
}
export interface SessionStatus {
agentComposition?:CompositionSummary|null;
agentCompositionLocked?:boolean;
tariff?: Tariff;
// Whether the current model reads images at all, and whether that answer was
// ever given: a relay forwards models nothing here has a label for, and an
@@ -324,6 +327,16 @@ export interface JobOutput {
truncated: boolean;
}
// One window onto the output of one tool call as the history carries it: where
// Text starts, the whole length, and whether more follows.
export interface ToolOutputPage {
callId: string;
text: string;
bytes: number;
offset: number;
truncated: boolean;
}
// The UI depends on this and nothing else. SsePort talks to internal/frontend/serve;
// MockPort replays a fixture. Neither is allowed to leak transport details
// upward, which is what keeps the same UI usable in a browser and in a shell.
@@ -1,4 +1,5 @@
import { SseShell } from "./sse_shell";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
import type { PersonaDraft, PersonaSettings } from "./boundary";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
@@ -6,6 +7,14 @@ import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, Ses
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
export class SseBoundary extends SseShell {
agentPresets() { return this.get<AgentPresetCatalog>("/agent-presets"); }
saveAgentPreset(definition: AgentComposition, expectedRevision: string) { return this.post0<AgentPresetVersion>("/agent-presets", { definition, expectedRevision }); }
deleteAgentPreset(id: string, revision: string) { return this.del(`/agent-presets/${encodeURIComponent(id)}?revision=${encodeURIComponent(revision)}`); }
setDefaultAgentPreset(id: string, workspace: boolean) { return this.post0<void>("/agent-presets/default", { id, workspace }); }
exportAgentPreset(id: string) { return this.get<AgentComposition>(`/agent-presets/${encodeURIComponent(id)}/export`); }
importAgentPreset(content: AgentComposition) { return this.post0<{definition:AgentComposition;diagnostics:AgentPresetCatalog["diagnostics"]}>("/agent-presets/import", { content }); }
agentComposition() { return this.get<CompositionSummary | null>("/agent-composition"); }
selectAgentComposition(id: string) { return this.post0<CompositionSummary | null>("/agent-composition", { id }); }
adjudications() {
return this.get<Adjudications>("/adjudications");
}
@@ -1,6 +1,6 @@
import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, WorkspaceGit } from "./port";
import type { DiscardRequest, GitCommitFiles, GitHistory, GitRepos, PushPlan, PushRequest, PushResult } from "./workspace";
import type { JobOutput } from "./session";
import type { JobOutput, ToolOutputPage } from "./session";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import { SseFeedback } from "./sse_feedback";
import { host } from "./host";
@@ -95,4 +95,8 @@ export class SseWorkspace extends SseFeedback {
jobOutput(jobId: string) {
return this.get<JobOutput>("/jobs/" + encodeURIComponent(jobId) + "/output");
}
toolOutput(callId: string, offset = 0, limit?: number) {
const q = new URLSearchParams({ offset: String(offset), ...(limit === undefined ? {} : { limit: String(limit) }) });
return this.get<ToolOutputPage>("/history/tool/" + encodeURIComponent(callId) + "?" + q);
}
}
@@ -0,0 +1,27 @@
import { useSyncExternalStore } from "react";
const KEY = "rx-show-agent-composition";
const listeners = new Set<() => void>();
function read(): boolean {
try { return localStorage.getItem(KEY) === "on"; }
catch { return false; }
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
const refresh = (event: StorageEvent) => {
if (event.key === KEY || event.key === null) fn();
};
window.addEventListener("storage", refresh);
return () => { listeners.delete(fn); window.removeEventListener("storage", refresh); };
}
export function setShowsCompositionPicker(on: boolean): boolean {
try { localStorage.setItem(KEY, on ? "on" : "off"); }
catch { return false; }
listeners.forEach(fn => fn());
return true;
}
export const useShowsCompositionPicker = () => useSyncExternalStore(subscribe, read, read);
+12 -2
View File
@@ -76,20 +76,30 @@ export function foldTool(items: Item[], tool: Tool, running: boolean): Item[] {
const prev = items[at] as Extract<Item, { t: "tool" }>;
const next = items.slice();
next[at] = { ...prev, tool: merge(prev.tool, tool), running };
if (!running) delete (next[at] as { omitted?: number }).omitted;
return next;
}
}
return [...items, { t: "tool", id: nextId(), tool: { ...tool, partial: tool.partial ?? false }, running, children: [] }];
}
/** How much of a running tool's output the page keeps: the end of it. The result
* that closes the call replaces it with what the kernel bounded for the model. */
export const PROGRESS_TAIL = 64 * 1024;
// A progress frame is the call's id and its newest chunk of output; nothing else
// on it describes the call, so it lands on the card as more output.
// on it describes the call, so it lands on the card as more output, and only the
// last PROGRESS_TAIL of it stays.
export function foldProgress(items: Item[], tool: Tool): Item[] {
const at = tool.id ? items.findIndex((i) => i.t === "tool" && i.tool.id === tool.id) : -1;
if (at < 0) return foldTool(items, tool, true);
const prev = items[at] as Extract<Item, { t: "tool" }>;
const joined = (prev.tool.output ?? "") + (tool.output ?? "");
let cut = Math.max(0, joined.length - PROGRESS_TAIL);
// A cut between the halves of a surrogate pair would start the tail on half a character.
if (cut > 0 && cut < joined.length && joined.charCodeAt(cut) >= 0xdc00 && joined.charCodeAt(cut) <= 0xdfff) cut++;
const next = items.slice();
next[at] = { ...prev, tool: { ...prev.tool, output: (prev.tool.output ?? "") + (tool.output ?? "") }, running: true };
next[at] = { ...prev, tool: { ...prev.tool, output: cut > 0 ? joined.slice(cut) : joined }, running: true, ...(cut > 0 ? { omitted: (prev.omitted ?? 0) + cut } : {}) };
return next;
}
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, renderHook } from "@testing-library/react";
import { NEW_TASK_PRESET_KEY, newTaskComposition, newTaskPreset, setNewTaskPreset, useNewTaskPreset } from "./newTaskPreset";
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.removeItem(NEW_TASK_PRESET_KEY); });
it("leaves old installations unselected and ignores unsupported stored identities", () => {
expect(newTaskComposition()).toBeUndefined();
for (const raw of ["", "default", "user:review", "project:review", "builtin:retired", "{bad"]) {
localStorage.setItem(NEW_TASK_PRESET_KEY, raw);
expect(newTaskPreset()).toBeUndefined();
}
expect(setNewTaskPreset("project:review")).toBe(false);
});
it("round-trips explicit built-in choices while preserving request and history choices", () => {
for (const id of ["builtin:general", "builtin:minimal"]) {
expect(setNewTaskPreset(id)).toBe(true);
expect(newTaskComposition()).toBe(id);
expect(newTaskComposition("user:explicit")).toBe("user:explicit");
expect(newTaskComposition(undefined, "/history/task.jsonl")).toBeUndefined();
}
});
it("publishes local changes and cross-window storage changes to the card", () => {
const { result } = renderHook(useNewTaskPreset);
act(() => { setNewTaskPreset("builtin:minimal"); });
expect(result.current).toBe("builtin:minimal");
act(() => {
localStorage.setItem(NEW_TASK_PRESET_KEY, "builtin:general");
window.dispatchEvent(new StorageEvent("storage", { key: NEW_TASK_PRESET_KEY }));
});
expect(result.current).toBe("builtin:general");
});
it("refuses an unpersisted selection when local storage is unavailable", () => {
setNewTaskPreset("builtin:general");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage denied"); });
expect(setNewTaskPreset("builtin:minimal")).toBe(false);
expect(newTaskPreset()).toBe("builtin:general");
});
@@ -0,0 +1,37 @@
import { useSyncExternalStore } from "react";
export const NEW_TASK_PRESET_KEY = "rx-new-task-agent-preset";
const listeners = new Set<() => void>();
type BuiltinPreset = "builtin:general" | "builtin:minimal";
const valid = (value: unknown): value is BuiltinPreset =>
value === "builtin:general" || value === "builtin:minimal";
export function newTaskPreset(): BuiltinPreset | undefined {
try {
const value = localStorage.getItem(NEW_TASK_PRESET_KEY);
return valid(value) ? value : undefined;
} catch { return undefined; }
}
export function setNewTaskPreset(id: string): boolean {
if (!valid(id)) return false;
try { localStorage.setItem(NEW_TASK_PRESET_KEY, id); }
catch { return false; }
listeners.forEach(fn => fn());
return true;
}
function subscribe(fn: () => void) {
listeners.add(fn);
const refresh = (event: StorageEvent) => {
if (event.key === NEW_TASK_PRESET_KEY || event.key === null) fn();
};
window.addEventListener("storage", refresh);
return () => { listeners.delete(fn); window.removeEventListener("storage", refresh); };
}
export const useNewTaskPreset = () => useSyncExternalStore(subscribe, newTaskPreset, newTaskPreset);
export function newTaskComposition(explicit?: string, sessionPath?: string): string | undefined {
return explicit !== undefined || sessionPath ? explicit : newTaskPreset();
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { capToolOutput, OUTPUT_CAP, OUTPUT_EDGE } from "./outputcap";
import type { Tool } from "../port/wire";
const tool = (output: string, over: Partial<Tool> = {}): Tool => ({ id: "c1", name: "bash", args: "{}", output, readOnly: false, ...over });
describe("what the page keeps of a finished call's output", () => {
it("is all of it up to the cap", () => {
const t = tool("x".repeat(OUTPUT_CAP));
expect(capToolOutput(t)).toBe(t);
});
it("is the first and last 4 KB beyond it, counting the middle it let go of", () => {
const body = "a".repeat(OUTPUT_EDGE) + "m".repeat(20000) + "z".repeat(OUTPUT_EDGE);
const capped = capToolOutput(tool(body));
expect(capped.output).toBe("a".repeat(OUTPUT_EDGE) + "\n⋯\n" + "z".repeat(OUTPUT_EDGE));
expect(capped.hidden).toBe(20000);
expect(capped.output!.length + capped.hidden! - 3).toBe(body.length);
});
it("cuts no character in half at either end", () => {
const emoji = "😀";
const body = "a".repeat(OUTPUT_EDGE - 1) + emoji + "m".repeat(20000) + emoji + "b".repeat(OUTPUT_EDGE - 1);
const out = capToolOutput(tool(body)).output!;
expect(/[\ud800-\udbff]$/.test(out.split("\n⋯\n")[0])).toBe(false);
expect(/^[\udc00-\udfff]/.test(out.split("\n⋯\n")[1])).toBe(false);
});
it("leaves a diff, a plan and a remembered fact whole, because something else reads them", () => {
const big = "x".repeat(OUTPUT_CAP * 2);
expect(capToolOutput(tool(big, { outputDiff: true })).output).toBe(big);
expect(capToolOutput(tool(big, { name: "todo_write" })).output).toBe(big);
expect(capToolOutput(tool(big, { name: "remember" })).output).toBe(big);
});
it("leaves a lookup whole, because the manifest card counts its lines and hits from the text", () => {
const lines = Array.from({ length: 2000 }, (_, i) => `line ${i}`).join("\n");
for (const name of ["read_file", "grep", "glob", "ls"]) expect(capToolOutput(tool(lines, { name })).output).toBe(lines);
expect(capToolOutput(tool(lines, { name: "bash" })).hidden).toBeGreaterThan(0);
});
});
@@ -0,0 +1,29 @@
import type { Tool } from "../port/wire";
/** A finished call's output longer than this is kept as its two ends. The kernel
* already bounds a result for the model; this bounds what the page holds of it. */
export const OUTPUT_CAP = 16 * 1024;
export const OUTPUT_EDGE = 4 * 1024;
// The calls whose output something other than the output card reads: a plan, a
// remembered fact, a diff drawn as a diff, and the lookups whose line and hit
// counts the manifest card counts from the text.
const WHOLE = new Set(["todo_write", "remember", "update_goal", "read_file", "grep", "glob", "ls"]);
/** What the page keeps of a tool: the same call with `hidden` counting the
* characters of the middle of its output that it let go of. */
export type StateTool = Tool & { hidden?: number };
export function capToolOutput(tool: StateTool): StateTool {
const out = tool.output;
if (!out || out.length <= OUTPUT_CAP || tool.outputDiff || tool.diff || WHOLE.has(tool.name)) return tool;
let head = OUTPUT_EDGE;
let tail = out.length - OUTPUT_EDGE;
// A cut between the halves of a surrogate pair would end or start an end on half a character.
if (isHigh(out.charCodeAt(head - 1))) head--;
if (isLow(out.charCodeAt(tail))) tail++;
return { ...tool, output: out.slice(0, head) + "\n⋯\n" + out.slice(tail), hidden: tail - head };
}
const isHigh = (c: number) => c >= 0xd800 && c <= 0xdbff;
const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff;
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { foldProgress, foldTool, PROGRESS_TAIL } from "./fold";
import type { Item } from "./session";
const start = (): Item[] => foldTool([], { id: "t1", name: "bash", args: "{}", readOnly: false }, true);
const row = (items: Item[]) => items[0] as Extract<Item, { t: "tool" }>;
const feed = (chunks: string[]) => chunks.reduce((items, c) => foldProgress(items, { id: "t1", name: "bash", output: c, readOnly: false }), start());
describe("the output a running tool shows", () => {
it("is the whole of it while it is small, and says nothing was left out", () => {
const r = row(feed(["one\n", "two\n"]));
expect(r.tool.output).toBe("one\ntwo\n");
expect(r.omitted ?? 0).toBe(0);
});
it("is only its last 64 KB however much it has printed, and counts what came before", () => {
const line = "x".repeat(99) + "\n";
const chunks = Array.from({ length: 3000 }, () => line.repeat(10));
const r = row(feed(chunks));
expect(r.tool.output!.length).toBeLessThanOrEqual(PROGRESS_TAIL);
expect(r.tool.output!.endsWith(line)).toBe(true);
expect(r.omitted! + r.tool.output!.length).toBe(3000 * 1000);
});
it("never cuts a character in half when it trims", () => {
const emoji = "😀";
const r = row(feed([emoji.repeat(PROGRESS_TAIL), "a"]));
const first = r.tool.output!.charCodeAt(0);
expect(first >= 0xdc00 && first <= 0xdfff).toBe(false);
expect(r.tool.output!.endsWith("a")).toBe(true);
});
it("is replaced by the result when the call returns, with nothing marked as omitted", () => {
const grown = feed(["y".repeat(PROGRESS_TAIL + 5000)]);
expect(row(grown).omitted).toBeGreaterThan(0);
const done = foldTool(grown, { id: "t1", name: "bash", output: "final", readOnly: false }, false);
expect(row(done).tool.output).toBe("final");
expect(row(done).omitted).toBeUndefined();
});
});
+4 -3
View File
@@ -24,6 +24,7 @@ import { worked } from "./worked";
import { chipLabel, IDLE, RUNNING } from "./chip";
import { foldStall } from "./stall";
import { nameQueued, withoutPendingRow } from "./queued";
import { capToolOutput } from "./outputcap";
import { dropTool, foldLastRead, foldProgress, foldTool, isSubagentProgress, mergeReads, notePhase, noteRan } from "./fold";
import { rebuild } from "./rebuild";
export { chipLabel };
@@ -430,7 +431,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
executions,
doing: handedBack ? WAITING_MODEL : s.doing,
waiting: handedBack ? { ...s.waiting, ttftSince: Date.now() } : s.waiting,
items: mergeReads(foldTool(s.items, ev.tool, false)),
items: mergeReads(foldTool(s.items, capToolOutput(ev.tool), false)),
};
}
@@ -778,12 +779,12 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
}
out[at] = {
...prev,
tool: {
tool: capToolOutput({
...prev.tool,
output: m.content,
...(m.outputDiff ? { outputDiff: true } : {}),
...(m.toolFailed ? { err: m.content, refusalCode: m.toolRefusalCode } : {}),
},
}),
};
}
}
@@ -1,5 +1,6 @@
// What a session is made of, apart from the reducer that maintains it: the
// rows the transcript draws and the state one turn hands the next.
import type { StateTool } from "./outputcap";
import type { Ask, Approval, Compaction, CostCoverage, ExtensionSurface, Guardian, Receipt, StoredAttachment, Tool, Via, WorkspaceLease } from "../port/wire";
import type { Sample } from "../port/tokens";
import type { Executions } from "./executions";
@@ -34,7 +35,8 @@ export type Item =
// source names which model wrote it — a turn can carry two, and the card is
// where the answer has to survive.
| { t: "say"; id: string; text: string; reasoning?: string; done: boolean; thoughtMs?: number; source?: string; model?: string }
| { t: "tool"; id: string; tool: Tool; running: boolean; children: Tool[] }
// omitted counts the characters of a running call's output that the page let go of from the front.
| { t: "tool"; id: string; tool: StateTool; running: boolean; children: StateTool[]; omitted?: number }
| { t: "reads"; id: string; tools: Tool[] }
| { t: "guardian"; id: string; g: Guardian }
// by is who settled it when that was not this screen: the device named on
@@ -1105,6 +1105,14 @@ what exists.
- Sub-agents and background jobs are readings about this turn, so they sit with
the turn's other readings at the right end of the run rail rather than in a
panel of their own above it.
A sub-agent row, by the list's own width:
- Wide: one line holding the name, the task, the model chip and the status.
- Below 460px: the name and status stay on the first line, the task has the
second to itself, and the model chip the third.
- Why: the name, chip and status are content-sized and the task took what was
left, so it was the first thing to disappear at the widths where a person
most needs to tell two rows apart.
- The detail is inside the same anchor as the reading, which is what lets hover
and keyboard focus reach it. A click pins it: a panel that closes when the
pointer leaves is one nobody can scroll.
@@ -1532,6 +1540,15 @@ what exists.
- The row actions (`+`, `…`, refresh) are revealed on hover or focus, 22px, outlined, and share one hover, press, expanded and busy state, whichever row they sit on. Remote host actions are always drawn and outline only on those states.
- Every row's last action ends on the same edge: the machine row keeps a 15px right margin like a project row, so a machine's `+` sits over a project's `…`.
## Session rows
- The title takes every pixel the row's visible content leaves it. The age sits against the row's right padding and the earlier-versions badge beside it; nothing is held back on the right for a control that is not showing.
- The hover buttons (archive) take no width until the row is hovered or focused, then 24px plus 6px; on a device without hover they are always drawn.
- Taking the room only while shown means the title shortens when the buttons appear, never before. Pinning stays an overlay at the left, so the pin never moves the title.
- An unread dot is an overlay 11px from the right edge: an unread row keeps 22px after its age (or after its badge, 26px) for it, and the dot hides while the buttons are shown.
- A legacy `margin-right: 44px` on the age made room for a delete button the rail has long since hidden; the rail's rule sets it to zero.
- `perf/sessionrows.mjs` measures this in a real browser: with no button showing, a row with an age leaves at most 2px (8px with a badge) of its right side unused, and the buttons take no width at rest.
## Session rename dialog
- Double-clicking a sidebar session title or a tab starts inline editing.
@@ -1613,6 +1630,16 @@ what exists.
- The badge has a transparent fill, a 1px `--hair` border and `--muted` text, so it reads as a label and not as a notice; its outer size is unchanged.
- The badge is hidden at 760px and below. The wordmark box is cropped to its drawn width so the badge sits next to the letters, not next to the asset's padding.
## Composer context
- The Agent mode selector sits at the left of the top row; run status and token counts align at the right and wrap below on narrow panes. Controls remain outside the live status region; the input toolbar keeps task actions, model and execution settings.
- The row and composition controls wrap independently when space is constrained. Long composition names truncate inside the selector; diagnostics wrap without widening the composer.
- Preset editor and identifier controls share field tokens in `composition-presets.css`; the composer row remains in `app.css`. The row is mounted only after the person enables the mode picker; otherwise the original run status is rendered directly.
- Composition selection and management share one menu: choices show source and capability notes; the separated footer opens management. Started conversations keep the same trigger with lock details inside the menu, and choices explicitly create another conversation.
- The trigger shows only the mode name and arrow. Source and lock state remain in its accessible name, tooltip and menu; they do not widen the trigger.
- Preset management separates two built-in mode cards from custom presets. Card selection inspects a definition; setting a default affects new conversations and never switches the active binding.
- Open floating controls follow responsive fold changes and recheck their anchor after layout transitions finish; window resize alone precedes the body observer's fold publication.
## Side question
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
const NARROW = Object.values(
import.meta.glob("./narrow.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>,
)[0];
function declarations(block: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return block.match(new RegExp(String.raw`(?:^|\n)\s*${escaped}\s*\{([^}]*)\}`))?.[1] ?? "";
}
const NARROW_ROW = NARROW.match(/@container agents \(max-width:\s*460px\)\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
describe("a sub-agent row in a narrow list", () => {
it("is measured against the list's own width", () => {
expect(NARROW).toMatch(/\.agents\s*\{\s*container:\s*agents\s*\/\s*inline-size\s*\}/);
expect(NARROW_ROW, "no narrow rule for the row").not.toBe("");
});
it("gives the task a line of its own instead of what the other cells leave", () => {
const label = declarations(NARROW_ROW, ".ag .lbl");
expect(label).toMatch(/grid-row:\s*2/);
expect(label).toMatch(/grid-column:\s*2\s*\/\s*-1/);
});
it("keeps the name and the status on the first line and the model chip on the third", () => {
expect(declarations(NARROW_ROW, ".ag .pip, .ag .nm, .ag .rt, .ag .ag-open")).toMatch(/grid-row:\s*1/);
expect(declarations(NARROW_ROW, ".ag .ran")).toMatch(/grid-row:\s*3/);
});
});
+1 -1
View File
@@ -4371,7 +4371,7 @@
position: absolute; inset: 0; min-width: 0; min-height: 0;
display: flex; flex-direction: column;
}
.pane[data-off] { visibility: hidden; pointer-events: none }
.pane[data-off] { visibility: hidden; pointer-events: none; content-visibility: hidden }
.pane[data-off] .call[data-running] > .g .sym,
.pane[data-off] .glowring i,
.pane[data-off] :is(.await i, .caret) { animation-play-state: paused !important }
@@ -18,6 +18,10 @@
}
:root :is(.polmenu, .studio-model-menu).composer-menu { --choice-width: 340px; }
:root :is(.worktree-panel, .studio-effort-menu).composer-menu { --choice-width: 320px; }
:root .composer-menu.composition-menu { --choice-width: 320px; }
:root .composition-menu .studio-item-line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
:root .composition-menu .mi :is(.lb, .ds) { white-space: normal; overflow-wrap: anywhere; }
:root .composition-menu .composition-menu-note { margin: 0 8px 6px; color: var(--muted); font: 400 calc(11px * var(--fs-scale, 1))/1.45 var(--ui); }
:root .composer-menu :is(.studio-picker-title, .studio-policy-head, .worktree-heading), :root .composer-menu > .mi.head:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: auto; min-height: 30px; margin: 0 0 4px; padding: 2px 8px 8px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; color: var(--muted); font: 500 calc(12px * var(--fs-scale, 1))/1.4 var(--ui); }
:root .composer-menu :is(.studio-picker-title b, .studio-policy-head b), :root .composer-menu > .mi.head:first-child .lb { color: var(--muted); font: inherit; letter-spacing: 0; }
:root .composer-menu :is(.studio-picker-title small, .studio-policy-head small), :root .composer-menu > .mi.head:first-child .rt { margin-left: auto; color: var(--muted); font: 400 calc(11px * var(--fs-scale, 1))/1.4 var(--ui); text-align: right; }
@@ -0,0 +1,15 @@
.compose-context {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 16px;
min-height: 28px;
margin: -3px 0 4px;
}
.compose-context > .studio-runstate { margin-left: auto; flex-wrap: wrap; justify-content: flex-end; }
.composition-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; max-width: 100%; }
.composition-picker > .picker { min-width: 0; max-width: 100%; }
.composition-trigger { max-width: 100%; }
.composition-trigger .nm { max-width: 180px; }
.composition-trigger > .studio-icon { width: 12px; height: 12px; flex: none; }
.composition-picker [role="alert"] { overflow-wrap: anywhere; }
@@ -0,0 +1,110 @@
.prefs-main[data-sec="persona"]:has(.preset-workspace) .prefs-intro { display: none; }
.prefs-main[data-sec="persona"]:has(.preset-page) { padding: 0; overflow: hidden; }
.prefs-col:has(.preset-page) { height: 100%; max-width: none; animation: none; }
.preset-workspace[data-editor] { height: 100%; min-height: 0; }
.preset-list { padding-bottom: 16px; }
#set-composition-picker .grp-hd { align-items: center; }
#set-composition-picker .sw { margin-left: auto; flex-shrink: 0; }
.composition-list-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 24px; margin-bottom: 24px; }
.composition-list-heading h2 { margin: 0; font-size: var(--fs-display); }
.composition-list-heading p { grid-column: 1 / -1; margin: 0; color: var(--muted); line-height: 1.7; }
.composition-list-heading .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 36px; padding: 8px 16px; white-space: nowrap; }
.composition-list-heading svg { width: 16px; height: 16px; flex: none; }
.composition-default-note { color: var(--muted); margin: 0 0 28px; line-height: 1.7; }
.composition-list-group { margin-bottom: 28px; }
.composition-list-group h3 { color: var(--muted); font-size: var(--fs-ui); font-weight: var(--w-med); margin: 0 0 12px; }
.composition-builtin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 20px; }
.composition-builtin-card { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--border); border-radius: 22px; overflow: hidden; background: var(--surface); }
.composition-builtin-card[data-selected] { background: var(--overlay); border-color: var(--faint); }
.composition-builtin-card:focus-within { border-color: var(--focus); }
.composition-builtin-select { flex: 1; min-width: 0; padding: 24px; border: 0; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.composition-builtin-select:hover { background: var(--overlay); }
.composition-builtin-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.composition-builtin-heading strong { font-size: var(--fs-title); font-weight: var(--w-semi); }
.composition-builtin-heading code { margin-left: auto; color: var(--ghost); font: var(--w-reg) var(--fs-meta)/1.5 var(--mono); }
.composition-builtin-badge { padding: 2px 9px; border: 1px solid var(--border); border-radius: var(--r-pill); color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); white-space: nowrap; }
.composition-builtin-card[data-selected] .composition-builtin-badge { background: var(--text); border-color: var(--text); color: var(--page); }
.composition-builtin-description { display: block; margin-top: 20px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.8; overflow-wrap: anywhere; }
.composition-builtin-footer { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; padding: 16px 24px; border-top: 1px solid var(--hair); }
.composition-builtin-footer button { border: 0; padding: 4px 0; background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); cursor: pointer; }
.composition-builtin-footer button:hover { color: var(--text); }
.composition-builtin-footer .composition-builtin-copy { display: grid; place-items: center; margin-left: auto; width: 28px; min-height: 28px; padding: 4px; border-radius: var(--r-sm); }
.composition-builtin-copy svg { width: 18px; height: 18px; }
.composition-builtin-usage { margin: 0; padding: 16px 24px; border-top: 1px solid var(--hair); color: var(--muted); font-size: var(--fs-meta); line-height: 1.7; }
.composition-row { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); padding-right: 12px; }
.composition-row:first-of-type { border-top: 1px solid var(--hair); }
.composition-row-main { display: flex; align-items: center; gap: 16px; flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text); padding: 20px 12px; cursor: pointer; text-align: left; }
.composition-row:hover { background: var(--overlay); }
.composition-row-main > svg { width: 16px; height: 16px; flex-shrink: 0; color: var(--muted); }
.composition-row-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--r-md); background: var(--overlay); flex-shrink: 0; }
.composition-row-icon svg { width: 20px; height: 20px; }
.composition-row-copy { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
.composition-row-copy strong { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: var(--fs-ui); font-weight: var(--w-semi); }
.composition-row-copy small { font: var(--w-med) var(--fs-meta)/1.5 var(--ui); color: var(--muted); background: var(--overlay); padding: 1px 7px; border-radius: var(--r-sm); }
.composition-row-copy > span { color: var(--muted); font-size: var(--fs-meta); line-height: 1.7; }
.composition-how { margin: 20px 0; color: var(--muted); font-size: var(--fs-meta); line-height: 1.7; }
.composition-how summary { cursor: pointer; }
.composition-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-block: 16px; }
.preset-import { position: relative; overflow: hidden; }
.preset-import input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
.preset-import:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
.preset-page { display: flex; flex-direction: column; height: 100%; min-height: 0; color: var(--text); }
.preset-page-heading { padding: 24px 32px 20px; flex-shrink: 0; }
.preset-back { display: inline-flex; align-items: center; gap: 10px; border: 0; padding: 4px 0; background: transparent; color: var(--muted); font: inherit; cursor: pointer; }
.preset-back svg { width: 16px; height: 16px; transform: rotate(180deg); }
.preset-page-heading h2 { margin: 18px 0 6px; font-size: var(--fs-display); line-height: 1.3; }
.preset-page-heading p { margin: 0; color: var(--muted); line-height: 1.6; }
.preset-revision { display: block; margin-top: 6px; color: var(--muted); font-size: var(--fs-meta); }
.preset-page-content { flex: 1; min-height: 0; overflow: auto; padding: 0 32px 28px; }
.composition-editor { display: grid; gap: 24px; padding-top: 24px; border-top: 1px solid var(--hair); }
.composition-editor .pers-field { padding: 0; gap: 8px; }
.composition-editor .pers-field .lb { font-weight: var(--w-med); }
.composition-editor input, .composition-editor select { min-width: 0; max-width: 100%; width: 100%; min-height: 40px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); padding: 8px 12px; font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); }
.composition-editor textarea { width: 100%; min-height: 96px; }
.composition-editor > .pers-field > select { max-width: 280px; }
.composition-editor .ds { color: var(--muted); font-size: var(--fs-meta); line-height: 1.6; }
.composition-capabilities h3 { margin: 0 0 6px; font-size: var(--fs-ui); font-weight: var(--w-med); }
.composition-capabilities p { margin: 0 0 12px; }
.composition-capabilities > details { background: var(--overlay); padding-inline: 16px; }
.composition-capabilities > details:first-of-type { border-radius: var(--r-md) var(--r-md) 0 0; }
.composition-capabilities > details:last-of-type { border-radius: 0 0 var(--r-md) var(--r-md); }
.composition-capabilities summary { display: flex; align-items: center; gap: 12px; min-height: 52px; cursor: pointer; list-style: none; font-weight: var(--w-med); }
.composition-capabilities summary::-webkit-details-marker { display: none; }
.composition-capabilities summary > svg:last-child { margin-left: auto; color: var(--muted); }
.composition-capabilities details[open] > summary > svg:last-child { transform: rotate(90deg); }
.composition-capabilities summary svg { width: 18px; height: 18px; flex-shrink: 0; }
.composition-capabilities summary span { margin-left: auto; color: var(--muted); font-size: var(--fs-meta); font-weight: var(--w-reg); }
.composition-capabilities summary:has(span) > svg:last-child { margin-left: 4px; }
.composition-capabilities > details + details { border-top: 1px solid var(--hair); }
.composition-modules { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 12px; margin: 0 0 16px; min-width: 0; }
.composition-modules legend { font-weight: var(--w-med); padding-inline: 4px; }
.composition-module { margin-block: 8px; }
.composition-module .in { flex: 1; min-width: 100px; }
.composition-modules select + select { margin-top: 8px; }
.preset-page-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-shrink: 0; border-top: 1px solid var(--hair); padding: 16px 32px; background: var(--surface); }
.preset-page-footer p { margin: 0; color: var(--muted); font-size: var(--fs-meta); line-height: 1.6; }
.preset-page-footer > div { display: flex; gap: 10px; flex-shrink: 0; }
.preset-page-footer .btn { min-width: 84px; }
.composition-extra { border-top: 1px solid var(--hair); margin-top: 24px; padding-top: 16px; }
.composition-extra summary { cursor: pointer; color: var(--muted); }
.preset-confirm-veil { position: fixed; inset: 0; display: grid; place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); z-index: 1100; }
.preset-confirm { width: min(100%, 420px); padding: 24px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); box-shadow: var(--lit); }
.preset-confirm h3 { margin: 0 0 12px; font-size: var(--fs-title); }
.preset-confirm p { color: var(--muted); margin: 0 0 24px; line-height: 1.6; }
.preset-confirm-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 10px; }
.preset-workspace :is(button, summary, input, select, textarea):focus-visible, .preset-confirm button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.preset-workspace :is(input, select):disabled { opacity: .65; }
@media (max-width: 700px) {
.composition-builtin-grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.composition-builtin-select { padding: 20px; }
.composition-builtin-footer { padding: 14px 20px; }
.composition-list-heading { column-gap: 12px; }
.composition-row-main { gap: 10px; padding: 16px 4px; }
.composition-row-icon { width: 32px; height: 32px; }
.composition-row { padding-right: 4px; }
.preset-page-heading { padding: 20px 16px; }
.preset-page-content { padding: 0 16px 20px; }
.preset-page-footer { padding: 12px 16px; flex-wrap: wrap; }
.preset-page-footer > div { margin-left: auto; }
.composition-capabilities summary { flex-wrap: wrap; gap: 8px; }
}
@@ -24,3 +24,14 @@
.studio-overview-anchor { position: static; }
.studio-overview-anchor .studio-overview-popover { left: 0; right: 0; width: auto; }
}
.agents { container: agents / inline-size }
@container agents (max-width: 460px) {
.ag { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; column-gap: 9px; row-gap: 2px }
.ag .pip, .ag .nm, .ag .rt, .ag .ag-open { grid-row: 1 }
.ag .pip { grid-column: 1 }
.ag .nm { grid-column: 2 }
.ag .rt { grid-column: 3; margin-left: auto }
.ag .ag-open { grid-column: 4 }
.ag .lbl { grid-row: 2; grid-column: 2 / -1 }
.ag .ran { grid-row: 3; grid-column: 2 / -1; max-width: none }
}
@@ -6,6 +6,7 @@
.peerbody { margin: 0; padding: 6px 8px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text); max-height: 8.5em; }
.peerbody[data-clamped] { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }
.peerform { display: grid; gap: 6px; }
.peerreleased + .peerform, .peernote + .peerform { margin-top: 6px; padding: 10px 12px 4px; border-top: 1px solid var(--hair); }
.peerfield { box-sizing: border-box; width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--ui); }
textarea.peerfield { min-height: 4.5em; resize: vertical; }
.peerfield:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
@@ -1,8 +1,8 @@
.rail .sessquick {
flex: none; display: flex; align-items: center; width: 24px; margin-inline-end: 6px;
flex: none; display: flex; align-items: center; width: 0; margin-inline-end: 0; overflow: hidden;
opacity: 0; pointer-events: none; transition: opacity 100ms ease;
}
.rail .sessrow:is(:hover, :focus-within) > .sessquick,
.rail .sessrow:is(:hover, :focus-within) > .sessquick { width: 24px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
.rail .sessrow:is(:hover, :focus-within) > .sesspin-action { opacity: 1; pointer-events: auto; }
.rail .sessquick > button,
.rail .sesspin-action {
@@ -30,9 +30,9 @@
.rail .sessrow:has(> .sessedit) > :is(.sesspin-action, .sesspin-marker, .sessquick) { visibility: hidden; pointer-events: none; }
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within)::before,
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within) > .pip { visibility: hidden; }
.rail .sessrow:has(.sessquick) > .unread-dot { inset-inline-end: 36px; }
.rail .sessrow:has(.sessquick):is(:hover, :focus-within) > .unread-dot { visibility: hidden; }
@media (hover: none) {
.rail .sessquick,
.rail .sessquick { width: 24px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
.rail .sesspin-action { opacity: 1; pointer-events: auto; }
}
.rail .sesslegacy {
+6 -2
View File
@@ -3114,9 +3114,10 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
align-items: center;
gap: 7px;
min-height: 26px;
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
margin: -3px 0 4px;
margin: 0;
color: var(--muted);
font: 550 calc(10px * var(--fs-scale, 1))/1.2 var(--ui);
}
@@ -4217,3 +4218,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
.skupd-diff span[data-d="-"] { color: color-mix(in srgb, var(--del) 58%, var(--text)); background: color-mix(in srgb, var(--del) 8%, transparent); }
.skupd { margin: 6px 0 14px; }
.workbench-tab-in { min-width: 0; width: 100%; padding: 1px 4px; border: 1px solid var(--accent); border-radius: 4px; background: var(--page); color: var(--text); font: 500 calc(11px * var(--fs-scale, 1))/1.4 var(--ui); outline: none; }
.other-field { display: flex; align-items: center; gap: 8px; }
.other-field input { flex: 1; min-width: 0; }
.other-wrap .refine-card { margin: 8px 0 0; }
@@ -50,7 +50,9 @@
.rail .sessrow[data-run="running"] > .pip { display: block; }
.rail .rmtws-node .sessdel { opacity: 0; }
.rail .rmtws-node .sessrow:is(:hover, :focus-within) .sessdel { opacity: 1; }
.rail .sessage { margin-left: auto; font-size: var(--fs-micro); }
.rail .sessage { margin-left: auto; margin-right: 0; font-size: var(--fs-micro); }
.rail .sessage + .sesscopies { margin-inline-start: 6px; }
.rail .sessrow[data-unread]:not(:has(> .sesscopies)) > .sessage { margin-inline-end: 22px; }
.rail .sessmore { width: calc(100% - 12px); margin: 2px 0 5px 3px; padding-left: 9px; text-align: left; }
.rail .sessrow:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
.rail .sessrow[aria-selected="true"] { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); font-weight: var(--w-reg); box-shadow: none; }
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import {cleanup,render,screen,waitFor} from "@testing-library/react";
import "./testkit";
import userEvent from "@testing-library/user-event";
import {afterEach,beforeEach,expect,it,vi} from "vitest";
import {MockPort} from "../port/mock";
import {AgentCompositionPicker} from "./AgentCompositionPicker";
import {CompositionNewContext} from "./CompositionNewContext";
import {RunLine} from "./RunLine";
beforeEach(() => { localStorage.setItem("rx-show-agent-composition", "on"); });
afterEach(() => { cleanup(); localStorage.removeItem("rx-show-agent-composition"); });
it.each([null, "off", "unexpected"])("hides the picker and avoids catalog reads for visibility %s", async (stored) => {
if (stored === null) localStorage.removeItem("rx-show-agent-composition");
else localStorage.setItem("rx-show-agent-composition", stored);
const port = new MockPort();
const catalog = vi.spyOn(port, "agentPresets");
const select = vi.spyOn(port, "selectAgentComposition");
const drawPicker = () => <AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>;
const view = render(<RunLine label="空闲" running={false} blocked={false} sent={0} received={0} estimated={false}>
{drawPicker()}
</RunLine>);
expect(screen.getByRole("status")).toBeTruthy();
expect(document.querySelector(".compose-context")).toBeNull();
expect(screen.queryByRole("button", { name: /^Agent 组合/ })).toBeNull();
view.rerender(drawPicker());
expect(screen.queryByRole("button", { name: /^Agent 组合/ })).toBeNull();
expect(catalog).not.toHaveBeenCalled();
expect(select).not.toHaveBeenCalled();
});
it("shows the initial default only after opt-in without applying a server preference", async () => {
const port = new MockPort();
await port.setDefaultAgentPreset("builtin:minimal", false);
const select = vi.spyOn(port, "selectAgentComposition");
render(<RunLine label="空闲" running={false} blocked={false} sent={0} received={0} estimated={false}>
<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>
</RunLine>);
const picker = await screen.findByRole("button", { name: /^Agent 组合/ });
expect(picker.textContent).toBe("日常搭档");
expect(screen.getByRole("status").parentElement?.contains(picker)).toBe(true);
expect(await port.agentComposition()).toBeNull();
expect(select).not.toHaveBeenCalled();
});
it("shows an unavailable state with a management entry when the catalog fails", async () => {
const port = new MockPort();
vi.spyOn(port, "agentPresets").mockRejectedValue(new Error("catalog unavailable"));
const settings = vi.fn();
render(<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={settings} onError={()=>{}}/>);
const failed = await screen.findByRole("button", { name: "Agent 预设暂不可用" });
await userEvent.click(failed);
expect(settings).toHaveBeenCalledOnce();
});
it("groups composition controls with the run state without announcing controls as live status",async()=>{
const port=new MockPort();const changed=vi.fn();const settings=vi.fn();
const user=userEvent.setup();
render(<RunLine label="空闲" running={false} blocked={false} sent={0} received={0} estimated={false}>
<AgentCompositionPicker port={port} running={false} onChanged={changed} onSettings={settings} onError={()=>{}}/>
</RunLine>);
const picker=await screen.findByRole("button",{name:/^Agent 组合/});
const status=screen.getByRole("status");
expect(status.parentElement?.contains(picker)).toBe(true);
expect(status.contains(picker)).toBe(false);
expect(picker.compareDocumentPosition(status) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(picker.textContent).toBe("日常搭档");
expect(screen.queryByRole("button",{name:"管理 Agent 预设"})).toBeNull();
await user.click(picker);
const review=screen.getByRole("menuitem",{name:/轻装搭档/});
expect(review.getAttribute("data-action")).toBe("agent-composition.select");
await user.click(review);
expect(changed).toHaveBeenCalledOnce();
expect(picker.getAttribute("aria-label")).toContain("轻装搭档");
await user.click(picker);
const manage=screen.getByRole("menuitem",{name:"管理 Agent 预设…"});
expect(manage.getAttribute("data-action")).toBe("agent-composition.manage");
await user.click(manage);
expect(settings).toHaveBeenCalledOnce();
});
it("keeps started conversations locked and opens the chosen composition in a new conversation",async()=>{
const port=new MockPort();const create=vi.fn();const select=vi.spyOn(port,"selectAgentComposition");
const user=userEvent.setup();
render(<CompositionNewContext.Provider value={create}><AgentCompositionPicker port={port} locked running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/></CompositionNewContext.Provider>);
const picker=await screen.findByRole("button",{name:/^Agent 组合/});
expect(picker.textContent).toBe("日常搭档");
expect(picker.getAttribute("aria-label")).toContain("已锁定");
await user.click(picker);
expect(screen.getByText("该会话已开始,模式已锁定。请用其他模式新建会话。")).toBeTruthy();
const review=screen.getByRole("menuitem",{name:/轻装搭档/});
expect(review.getAttribute("data-action")).toBe("agent-composition.new");
await user.click(review);
expect(create).toHaveBeenCalledWith("builtin:minimal");
expect(select).not.toHaveBeenCalled();
});
it("supports keyboard selection and Escape with focus returning to the one trigger",async()=>{
const port=new MockPort();const select=vi.spyOn(port,"selectAgentComposition");
const user=userEvent.setup();
render(<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>);
const picker=await screen.findByRole("button",{name:/^Agent 组合/});
picker.focus();await user.keyboard("{Enter}{ArrowDown}{Enter}");
await waitFor(()=>expect(select).toHaveBeenCalledWith("builtin:minimal"));
expect(document.activeElement).toBe(picker);
await user.click(picker);await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).toBeNull();
expect(document.activeElement).toBe(picker);
});
it("keeps a failed selection unchanged and prevents duplicate submissions while pending",async()=>{
const port=new MockPort();const error=vi.fn();const changed=vi.fn();
let reject!:(error:Error)=>void;
const select=vi.spyOn(port,"selectAgentComposition").mockImplementation(()=>new Promise((_,fail)=>{reject=fail;}));
const user=userEvent.setup();
render(<AgentCompositionPicker port={port} running={false} onChanged={changed} onSettings={()=>{}} onError={error}/>);
const picker=await screen.findByRole("button",{name:/^Agent 组合/});
await user.click(picker);await user.click(screen.getByRole("menuitem",{name:/轻装搭档/}));
await user.click(picker);
expect((screen.getByRole("menuitem",{name:/日常搭档/}) as HTMLButtonElement).disabled).toBe(true);
expect(document.activeElement).toBe(screen.getByRole("menuitem",{name:"管理 Agent 预设…"}));
await user.click(screen.getByRole("menuitem",{name:/日常搭档/}));
expect(select).toHaveBeenCalledOnce();
reject(new Error("fixture failure"));
await waitFor(()=>expect(error).toHaveBeenCalledOnce());
expect(picker.textContent).toContain("日常搭档");
expect(changed).not.toHaveBeenCalled();
});
it("keeps management accessible when a locked host cannot create conversations",async()=>{
const port=new MockPort();const settings=vi.fn();const select=vi.spyOn(port,"selectAgentComposition");
const user=userEvent.setup();
render(<AgentCompositionPicker port={port} locked running={false} onChanged={()=>{}} onSettings={settings} onError={()=>{}}/>);
await user.click(await screen.findByRole("button",{name:/^Agent 组合.*已锁定/}));
expect(screen.getAllByRole("menuitem")).toHaveLength(1);
await user.click(screen.getByRole("menuitem",{name:"管理 Agent 预设…"}));
expect(settings).toHaveBeenCalledOnce();
expect(select).not.toHaveBeenCalled();
});
it("distinguishes equal names by source and retains a deleted binding's name",async()=>{
const port=new MockPort();const catalog=await port.agentPresets();
const original=catalog.presets[0];
vi.spyOn(port,"agentPresets").mockResolvedValue({...catalog,presets:[
{...original,definition:{...original.definition,id:"user:review",name:"同名审查"}},
{...original,definition:{...original.definition,id:"project:review",name:"同名审查"}},
]});
vi.spyOn(port,"agentComposition").mockResolvedValue({id:"user:deleted",name:"已删除但绑定的预设",digest:"frozen",revision:"1",started:false});
const user=userEvent.setup();
render(<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>);
const trigger=await screen.findByRole("button",{name:/^Agent 组合.*已删除但绑定的预设/});
await user.click(trigger);
const rows=screen.getAllByRole("menuitem",{name:/同名审查/});
expect(rows.map(row=>row.textContent)).toEqual([expect.stringContaining("个人"),expect.stringContaining("项目")]);
});
@@ -0,0 +1,87 @@
import { useContext, useEffect, useState } from "react";
import { CompositionNewContext } from "./CompositionNewContext";
import type { AgentPort } from "../port/port";
import type { AgentPresetCatalog, CompositionSummary } from "../port/composition";
import { Picker, type MenuItem } from "./Menu";
import { StudioIcon } from "./StudioIcon";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { useShowsCompositionPicker } from "../state/compositionPicker";
const MANAGE = "__manage";
function compositionName(id: string, name: string): string {
return id.startsWith("builtin:") ? t(name) : name;
}
function compositionSource(id: string): string {
return t(id.startsWith("builtin:") ? "内置" : id.startsWith("project:") ? "项目" : "个人");
}
export function AgentCompositionPicker({ port, binding, locked: statusLocked, running, onChanged, onSettings, onError }: {
port: AgentPort;
binding?: CompositionSummary | null;
locked?: boolean;
running: boolean;
onChanged: () => void;
onSettings: () => void;
onError: (e: unknown) => void;
}) {
const [catalog, setCatalog] = useState<AgentPresetCatalog | null>(null);
const [current, setCurrent] = useState<CompositionSummary | null>(null);
const [busy, setBusy] = useState(false);
const [unavailable, setUnavailable] = useState(false);
const enabled = useShowsCompositionPicker();
const createNew = useContext(CompositionNewContext);
useEffect(() => {
if (!enabled) return;
let active = true;
setUnavailable(false);
Promise.all([port.agentPresets(), port.agentComposition()]).then(([list, binding]) => {
if (active) { setCatalog(list); setCurrent(binding); }
}).catch(() => { if (active) setUnavailable(true); });
return () => { active = false; };
}, [enabled, port, running, binding?.digest, binding?.started]);
if (!enabled) return null;
if (unavailable) return <button className="mode composition-trigger" onClick={onSettings}
data-action="agent-composition.unavailable">{t("Agent 预设暂不可用")}</button>;
if (!catalog) return null;
const selected = catalog.presets.find((v) => v.definition.id === (current?.id ?? "builtin:general"))?.definition;
const locked = running || statusLocked || !!current?.started;
const label = current ? compositionName(current.id, current.name) : t(selected?.name ?? "日常搭档");
const items: MenuItem[] = locked && !createNew ? [] : catalog.presets.map(({ definition }) => ({
value: definition.id,
label: compositionName(definition.id, definition.name),
meta: compositionSource(definition.id),
desc: definition.id.startsWith("builtin:") ? t(definition.description) : definition.description,
right: locked ? t("新建会话") : undefined,
disabled: busy,
}));
items.push({ value: MANAGE, label: `${t("管理 Agent 预设")}…`, plain: true, divide: true });
const choose = (id: string) => {
if (id === MANAGE) { onSettings(); return; }
if (busy) return;
if (locked) { createNew?.(id); return; }
setBusy(true);
port.selectAgentComposition(id).then((binding) => { setCurrent(binding); onChanged(); })
.catch((e) => onError(reason(e))).finally(() => setBusy(false));
};
return <span className="composition-picker">
<Picker
className="mode composition-trigger"
menuClassName="composer-menu composition-menu"
ariaLabel={`${t("Agent 组合")}:${label}${current ? ` · ${compositionSource(current.id)}` : ""}${locked ? ` · ${t("已锁定")}` : ""}`}
data-action={(id) => id === MANAGE ? "agent-composition.manage" : locked ? "agent-composition.new" : "agent-composition.select"}
title={locked ? t("该会话已开始,模式已锁定。请用其他模式新建会话。") : selected?.description ? (selected.id.startsWith("builtin:") ? t(selected.description) : selected.description) : t("首次提交前可切换 Agent 组合")}
pending={busy}
label={<><span className="nm">{label}</span><StudioIcon name="down" /></>}
menuTitle={<><b>{t("Agent 预设")}</b><small>{t(locked ? "已锁定" : "首次提交前可切换")}</small></>}
head={locked && <p className="composition-menu-note">{t("该会话已开始,模式已锁定。请用其他模式新建会话。")}</p>}
items={items}
current={locked ? undefined : current?.id ?? "builtin:general"}
onPick={choose}
place="bottom"
/>
{current?.unavailable && <span role="alert" title={current.unavailable}>{t("组合资源不可用,当前会话仅可查看历史。")}</span>}
</span>;
}
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import "./testkit";
import { act, cleanup, render, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { AgentPresets } from "./AgentPresets";
import { MockPort } from "../port/mock";
afterEach(cleanup);
it("edits, exports and deletes a custom version without changing its conversation binding", async () => {
const user = userEvent.setup();
const port = new MockPort();
const base = (await port.agentPresets()).presets[0].definition;
const first = await port.saveAgentPreset({ ...base, id: "user:review", name: "Review" }, "");
await port.selectAgentComposition(first.definition.id);
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "编辑 Review" }));
expect(screen.getByRole<HTMLSelectElement>("combobox", { name: "存放范围" }).disabled).toBe(true);
await user.type(screen.getByLabelText("人设与要求"), "Literal requirements");
await user.click(screen.getByRole("button", { name: "保存预设" }));
await user.click(await screen.findByRole("button", { name: "编辑 Review" }));
await user.click(screen.getByText("更多操作"));
await user.click(screen.getByRole("button", { name: "导出文件" }));
const link = await screen.findByRole<HTMLAnchorElement>("link", { name: "下载预设 JSON" });
expect(JSON.parse(decodeURIComponent(link.href.split(",")[1])).persona.prefix).toBe("Literal requirements");
await user.click(screen.getByRole("button", { name: "删除预设" }));
await user.click(screen.getByRole("button", { name: "取消删除" }));
expect((await port.agentPresets()).presets.some(v => v.definition.id === first.definition.id)).toBe(true);
await user.click(screen.getByRole("button", { name: "删除预设" }));
await user.click(screen.getByRole("button", { name: "确认删除" }));
await screen.findByRole("button", { name: "新建预设" });
expect(screen.queryByRole("button", { name: "编辑 Review" })).toBeNull();
expect((await port.agentComposition())?.revision).toBe(first.revision);
});
it("does not let a late import replace a newly opened draft", async () => {
const user = userEvent.setup();
const port = new MockPort();
const definition = (await port.agentPresets()).presets[0].definition;
let finish!: (value: { definition: typeof definition; diagnostics: [] }) => void;
vi.spyOn(port, "importAgentPreset").mockImplementation(() => new Promise(resolve => { finish = resolve; }));
render(<AgentPresets port={port} />);
const input = await screen.findByLabelText("导入预设文件(先预览,再保存)");
const file = new File(["{}"], "preset.json", { type: "application/json" });
Object.defineProperty(file, "text", { value: async () => "{}" });
await user.upload(input, file);
await waitFor(() => expect(finish).toBeTypeOf("function"));
await user.click(screen.getByRole("button", { name: "新建预设" }));
await user.type(screen.getByLabelText("预设名称"), "保留我的草稿");
await act(async () => finish({ definition, diagnostics: [] }));
expect((screen.getByLabelText("预设名称") as HTMLInputElement).value).toBe("保留我的草稿");
});
it("starts with a catalog and opens creation in a separate editor", async () => {
const user = userEvent.setup();
render(<AgentPresets port={new MockPort()} />);
await screen.findByRole("button", { name: "新建预设" });
expect(screen.queryByRole("textbox", { name: "人设与要求" })).toBeNull();
await user.click(screen.getByRole("button", { name: "新建预设" }));
const editor = await screen.findByRole("form", { name: "新建预设" });
expect(within(editor).getByRole("textbox", { name: "预设名称" })).toBe(document.activeElement);
expect(within(editor).getByText("基于日常搭档创建")).toBeTruthy();
expect(within(editor).getByRole<HTMLButtonElement>("button", { name: "保存预设" }).disabled).toBe(true);
expect(within(editor).getByLabelText("预设 ID").closest("details")).toBeNull();
expect(within(editor).getByRole("textbox", { name: "补充要求" })).toBeTruthy();
expect(within(editor).queryByText("高级设置")).toBeNull();
});
it("saves a named preset, returns to its catalog and leaves the conversation unbound", async () => {
const user = userEvent.setup();
const port = new MockPort();
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "新建预设" }));
await user.type(screen.getByRole("textbox", { name: "预设名称" }), "代码审查助手");
await user.type(screen.getByRole("textbox", { name: "人设与要求" }), "保留证据,说明风险。");
await user.click(screen.getByRole("button", { name: "保存预设" }));
await waitFor(() => expect(screen.queryByRole("form")).toBeNull());
expect(screen.getByRole("button", { name: "编辑 代码审查助手" })).toBeTruthy();
const saved = (await port.agentPresets()).presets.find(v => v.definition.name === "代码审查助手")!;
expect(saved.definition.persona.prefix).toBe("保留证据,说明风险。");
expect(saved.definition.id.startsWith("user:")).toBe(true);
expect(await port.agentComposition()).toBeNull();
});
it("guards Escape and cancel, retains edits, and restores focus after confirmed discard", async () => {
const user = userEvent.setup();
render(<AgentPresets port={new MockPort()} />);
const create = await screen.findByRole("button", { name: "新建预设" });
await user.click(create);
await user.type(screen.getByRole("textbox", { name: "预设名称" }), "未保存的预设");
await user.keyboard("{Escape}");
expect(await screen.findByRole("alertdialog", { name: "放弃未保存的更改?" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "继续编辑" }));
expect((screen.getByRole("textbox", { name: "预设名称" }) as HTMLInputElement).value).toBe("未保存的预设");
await user.click(screen.getByRole("button", { name: "取消" }));
await user.click(screen.getByRole("button", { name: "放弃更改" }));
await waitFor(() => expect(screen.queryByRole("form")).toBeNull());
expect(document.activeElement).toBe(screen.getByRole("button", { name: "新建预设" }));
});
it("keeps a refused save editable with the same draft", async () => {
const user = userEvent.setup();
const port = new MockPort();
vi.spyOn(port, "saveAgentPreset").mockRejectedValue(new Error("revision conflict"));
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "新建预设" }));
await user.type(screen.getByRole("textbox", { name: "预设名称" }), "冲突草稿");
await user.click(screen.getByRole("button", { name: "保存预设" }));
expect((await screen.findByRole("alert")).textContent).toContain("revision conflict");
expect(screen.getByRole("form", { name: "新建预设" })).toBeTruthy();
expect((screen.getByRole("textbox", { name: "预设名称" }) as HTMLInputElement).value).toBe("冲突草稿");
});
@@ -0,0 +1,181 @@
// @vitest-environment jsdom
import "./testkit";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, it, expect, vi } from "vitest";
import { MockPort } from "../port/mock";
import { AgentPresets } from "./AgentPresets";
import { boot, STORAGE, t } from "../i18n";
import { AgentCompositionPicker } from "./AgentCompositionPicker";
import { RunLine } from "./RunLine";
import { NEW_TASK_PRESET_KEY } from "../state/newTaskPreset";
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.removeItem("rx-show-agent-composition"); localStorage.removeItem(NEW_TASK_PRESET_KEY); localStorage.setItem(STORAGE, "zh"); boot(); });
it("persists the visibility toggle without selecting or changing the current composition", async () => {
const port = new MockPort();
const select = vi.spyOn(port, "selectAgentComposition");
const user = userEvent.setup();
render(<><AgentPresets port={port}/>
<RunLine label="空闲" running={false} blocked={false} sent={0} received={0} estimated={false}>
<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>
</RunLine>
</>);
await screen.findByRole("button", { name: "查看 日常搭档" });
expect(screen.queryByRole("button", { name: /^Agent 组合/ })).toBeNull();
const toggle = screen.getByRole("switch", { name: "显示模式选择器" });
expect(toggle.getAttribute("aria-checked")).toBe("false");
await user.click(toggle);
const picker = await screen.findByRole("button", { name: /^Agent 组合/ });
expect(picker.textContent).toBe("日常搭档");
expect(localStorage.getItem("rx-show-agent-composition")).toBe("on");
expect(await port.agentComposition()).toBeNull();
expect(select).not.toHaveBeenCalled();
await user.click(picker);
await user.click(screen.getByRole("menuitem", { name: /轻装搭档/ }));
expect((await port.agentComposition())?.id).toBe("builtin:minimal");
expect(select).toHaveBeenCalledExactlyOnceWith("builtin:minimal");
await user.click(toggle);
expect(localStorage.getItem("rx-show-agent-composition")).toBe("off");
expect(screen.queryByRole("button", { name: /^Agent 组合/ })).toBeNull();
expect((await port.agentComposition())?.id).toBe("builtin:minimal");
await user.click(toggle);
expect((await screen.findByRole("button", { name: /^Agent 组合/ })).textContent).toBe("轻装搭档");
expect(select).toHaveBeenCalledTimes(1);
});
it("restores an enabled choice and follows storage changes from another window", async () => {
localStorage.setItem("rx-show-agent-composition", "on");
const port = new MockPort();
const select = vi.spyOn(port, "selectAgentComposition");
render(<><AgentPresets port={port} /><RunLine label="空闲" running={false} blocked={false} sent={0} received={0} estimated={false}>
<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}} />
</RunLine></>);
await screen.findByRole("button", { name: /^Agent 组合/ });
expect(screen.getByRole("switch", { name: "显示模式选择器" }).getAttribute("aria-checked")).toBe("true");
act(() => {
localStorage.removeItem("rx-show-agent-composition");
window.dispatchEvent(new StorageEvent("storage", { key: "rx-show-agent-composition" }));
});
expect(screen.getByRole("switch", { name: "显示模式选择器" }).getAttribute("aria-checked")).toBe("false");
expect(screen.queryByRole("button", { name: /^Agent 组合/ })).toBeNull();
expect(select).not.toHaveBeenCalled();
});
it("keeps visibility off and reports a refused persistence write", async () => {
render(<AgentPresets port={new MockPort()} />);
await screen.findByRole("button", { name: "查看 日常搭档" });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage denied"); });
await userEvent.click(screen.getByRole("switch", { name: "显示模式选择器" }));
expect(screen.getByRole("switch", { name: "显示模式选择器" }).getAttribute("aria-checked")).toBe("false");
expect(screen.getByRole("alert").textContent).toContain("无法保存模式选择器设置");
});
it("copies both built-in name and description in the window language", async () => {
localStorage.setItem(STORAGE, "en"); boot();
const port = new MockPort();
render(<AgentPresets port={port} />);
await userEvent.click(await screen.findByRole("button", { name: "Duplicate Everyday partner" }));
await userEvent.click(screen.getByRole("button", { name: "Save preset" }));
await screen.findByRole("status");
const catalog = await port.agentPresets();
const base = catalog.presets.find((v) => v.definition.id === "builtin:general")!.definition;
const copy = catalog.presets.find((v) => v.definition.id === "user:custom")!.definition;
expect(copy.name).toBe("Everyday partner Copy");
expect(copy.description).toBe(t(base.description));
});
describe("Agent preset management", () => {
it("edits the existing user persona in Standard without migration or binding", async () => {
const user = userEvent.setup();
const port = new MockPort();
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "查看 日常搭档" }));
await user.click(screen.getByText("更多操作"));
await user.click(screen.getByRole("button", { name: "编辑日常搭档人设" }));
await user.type(await screen.findByLabelText("系统提示词开头"), "User identity");
await user.click(screen.getByRole("button", { name: "保存" }));
await screen.findByRole("button", { name: "新建预设" });
expect((await port.persona()).prefix).toBe("User identity");
expect(await port.agentComposition()).toBeNull();
expect((await port.agentPresets()).presets).toHaveLength(2);
});
it("opens the user persona from settings search even with a different preferred preset", async () => {
const port = new MockPort();
await port.setDefaultAgentPreset("builtin:minimal", false);
render(<AgentPresets port={port} focusPersona />);
expect(await screen.findByLabelText("系统提示词开头")).toBeTruthy();
expect(screen.getByRole("button", { name: "严谨工程师" })).toBeTruthy();
expect(await port.agentComposition()).toBeNull();
});
it("shows only Standard and Minimal built-in cards and changes the default without changing this conversation", async () => {
const user = userEvent.setup();
const port = new MockPort();
render(<AgentPresets port={port} />);
expect(await screen.findByRole("button", { name: "查看 日常搭档" })).toBeTruthy();
const minimal = screen.getByRole("button", { name: "查看 轻装搭档" });
expect(within(screen.getByRole("region", { name: "内置" })).getAllByRole("article")).toHaveLength(2);
expect(screen.queryByText("代码审查")).toBeNull();
expect(screen.queryByText("资料研究")).toBeNull();
await user.click(minimal);
await user.click(screen.getByText("更多操作"));
await user.click(screen.getByText("显式默认配置"));
await user.click(screen.getByRole("button", { name: "设为个人首选" }));
await waitFor(async () => expect((await port.agentPresets()).default).toBe("builtin:minimal"));
expect(await port.agentComposition()).toBeNull();
await user.click(screen.getByRole("button", { name: "复制为自定义预设" }));
await user.click(screen.getByText("技能与扩展", { selector: "summary" }));
expect(screen.getByText("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "保存预设" }));
await screen.findByRole("status");
const copied = (await port.agentPresets()).presets.find((v) => v.definition.id === "user:custom")!;
expect(copied.definition.tools.members.map((m) => m.id)).toEqual(["bash", "view_image"]);
expect(copied.definition.mcp).toEqual({ mode: "select", members: [] });
});
it("copies a built-in, saves literal fields and keeps the current session revision", async () => {
const user = userEvent.setup();
const port = new MockPort();
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "复制 日常搭档" }));
await user.clear(screen.getByLabelText("预设名称"));
await user.type(screen.getByLabelText("预设名称"), "My agent");
await user.type(screen.getByLabelText("人设与要求"), "Literal prefix");
await user.type(screen.getByLabelText("补充要求"), "Literal suffix");
expect(screen.queryByRole("switch", { name: /complete/ })).toBeNull();
await user.click(screen.getByRole("switch", { name: "不注入可选运行时上下文" }));
await user.click(screen.getByRole("button", { name: "保存预设" }));
expect((await screen.findByRole("status")).textContent).toContain("新会话使用新版");
expect((await port.agentPresets()).presets.some((v) => v.definition.name === "My agent")).toBe(true);
expect(await port.agentComposition()).toBeNull();
expect((await port.agentPresets()).presets.find((v)=>v.definition.name==="My agent")?.definition.persona).toEqual({prefix:"Literal prefix",suffix:"Literal suffix",include_runtime_context:false});
});
it("keeps an unsaved draft after a conflict", async () => {
const user = userEvent.setup();
const port = new MockPort();
vi.spyOn(port, "saveAgentPreset").mockRejectedValue(new Error("conflict"));
render(<AgentPresets port={port} />);
await user.click(await screen.findByRole("button", { name: "复制 日常搭档" }));
await user.type(screen.getByLabelText("预设名称"), " draft");
await user.click(screen.getByRole("button", { name: "保存预设" }));
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
expect((screen.getByLabelText("预设名称") as HTMLInputElement).value).toContain("draft");
});
it("does not save an earlier parameter value when the visible JSON is invalid", async () => {
const user=userEvent.setup();const port=new MockPort();const save=vi.spyOn(port,"saveAgentPreset");
render(<AgentPresets port={port}/>);
await user.click(await screen.findByRole("button",{name:"复制 日常搭档"}));
await user.click(screen.getByText("技能与扩展", { selector: "summary" }));
await user.selectOptions(screen.getByLabelText("MCP 服务 选择方式"),"select");
const group=screen.getByRole("group",{name:"MCP 服务"});
await user.click(group.querySelector("button")!);
await user.type(screen.getByLabelText("MCP 服务 ID 1"),"docs");
await user.clear(screen.getByLabelText("MCP 服务 独立参数 1"));
await user.type(screen.getByLabelText("MCP 服务 独立参数 1"),"invalid");
await user.selectOptions(screen.getByLabelText("存放范围"), "project");
expect((screen.getByLabelText("MCP 服务 独立参数 1") as HTMLTextAreaElement).value).toBe("invalid");
expect(screen.getByRole<HTMLButtonElement>("button",{name:"保存预设"}).disabled).toBe(true);
expect(save).not.toHaveBeenCalled();
await user.click(group.querySelector(".composition-module button")!);
await waitFor(()=>expect(screen.getByRole<HTMLButtonElement>("button",{name:"保存预设"}).disabled).toBe(false));
});
});
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from "react";
import type { AgentPort } from "../port/port";
import type { AgentComposition, AgentPresetCatalog, AgentPresetVersion, CompositionSummary } from "../port/composition";
import { builtinCompositions } from "../port/composition";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { ApplyNote } from "./Group";
import { CompositionCatalog } from "./CompositionCatalog";
import { CompositionPickerSetting } from "./CompositionPickerSetting";
import { PresetPage, type PresetEntry } from "./PresetPage";
import { PresetPersonaPage } from "./PresetPersonaPage";
import { newPreset } from "./presetDraft";
type EditorTarget = { kind: "preset"; entry: PresetEntry } | { kind: "persona" };
type Editor = EditorTarget & { key: number };
export function AgentPresets({ port, focusPersona = false }: { port: AgentPort; focusPersona?: boolean }) {
const [catalog, setCatalog] = useState<AgentPresetCatalog | null>(null);
const [current, setCurrent] = useState<CompositionSummary | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const [error, setError] = useState("");
const [saved, setSaved] = useState("");
const [refresh, setRefresh] = useState(0);
const epoch = useRef(0);
const root = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const openerAction = useRef("");
const focusID = useRef("");
useEffect(() => {
let active = true;
setCatalog(null);
setError("");
Promise.all([port.agentPresets(), port.agentComposition()]).then(([next, binding]) => {
if (active) { setCatalog(next); setCurrent(binding); }
}).catch(e => { if (active) setError(reason(e)); });
return () => { active = false; ++epoch.current; };
}, [port, refresh]);
useEffect(() => {
if (focusPersona) setEditor({ kind: "persona", key: ++epoch.current });
}, [focusPersona]);
useEffect(() => {
if (editor) return;
const row = [...(root.current?.querySelectorAll<HTMLElement>("button[data-target]") ?? [])].find(el => el.dataset.target === focusID.current);
const trigger = root.current?.querySelector<HTMLElement>(`[data-action="${openerAction.current}"]`);
(row ?? (opener.current?.isConnected ? opener.current : trigger))?.focus();
focusID.current = "";
}, [editor]);
const open = (entry: EditorTarget) => {
if (!editor) {
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
openerAction.current = opener.current?.dataset.action ?? "";
focusID.current = opener.current?.dataset.target ?? "";
}
setEditor({ ...entry, key: ++epoch.current });
setError("");
setSaved("");
};
const copy = (base: AgentComposition, duplicate: boolean) => {
if (!catalog) return;
open({ kind: "preset", entry: { definition: newPreset(base, catalog, duplicate), version: null,
source: base.id.startsWith("builtin:") ? t(base.name) : base.name } });
};
const close = () => setEditor(null);
const savedPreset = (version: AgentPresetVersion) => {
setCatalog(prev => prev && ({ ...prev, presets: [...prev.presets.filter(v => v.definition.id !== version.definition.id), version] }));
focusID.current = version.definition.id;
setSaved(t("已保存,新会话使用新版"));
close();
};
const importFile = async (file: File) => {
if (!catalog) return;
const generation = epoch.current;
const active = () => root.current !== null && epoch.current === generation;
try {
if (file.size > 4 * 1024 * 1024) throw { code: "agent_composition.invalid" };
const imported = await port.importAgentPreset(JSON.parse(await file.text()) as AgentComposition);
if (!active()) return;
open({ kind: "preset", entry: { definition: newPreset(imported.definition, catalog, true), version: null,
source: t("导入的预设"), diagnostics: imported.diagnostics } });
} catch (e) { if (active()) setError(reason(e)); }
};
return <section ref={root} id="set-agent-presets" data-setting="agent-presets" className="preset-workspace" data-editor={editor ? "" : undefined}>
{editor?.kind === "persona" ? <PresetPersonaPage key={editor.key} port={port} onClose={close} />
: editor?.kind === "preset" && catalog ? <PresetPage key={editor.key} entry={editor.entry} catalog={catalog} port={port}
onClose={close} onSaved={savedPreset} onDeleted={() => {
setCatalog(prev => prev && ({ ...prev, presets: prev.presets.filter(v => v.definition.id !== editor.entry.definition.id) }));
setSaved(t("预设已删除,已有会话保持原版本。")); close();
}} onCopy={d => copy(d, true)} onPersona={() => open({ kind: "persona" })} />
: <div className="preset-list">
{catalog ? <>
<CompositionCatalog catalog={catalog} onCreate={() => copy(builtinCompositions()[0], false)}
onCopy={v => copy(v.definition, true)} onSelect={v => open({ kind: "preset", entry: { definition: v.definition, version: v,
source: v.definition.id.startsWith("builtin:") ? t(v.definition.name) : v.definition.name } })} />
<div className="composition-toolbar">
<label className="act preset-import">{t("导入预设文件(先预览,再保存)")}<input type="file" accept="application/json,.json"
aria-label={t("导入预设文件(先预览,再保存)")} data-action-change="agent-preset.import"
onChange={e => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void importFile(file); }} /></label>
<button className="act" data-action="agent-preset.reload" onClick={() => setRefresh(v => v + 1)}>{t("重新加载")}</button>
</div>
{current && <p className="ds">{t("当前会话")}: {current.id.startsWith("builtin:") ? t(current.name) : current.name} · {current.revision.slice(0, 12)} {t(current.started ? "已锁定" : "首次提交前可切换")}</p>}
{catalog.diagnostics.map((d, i) => <p className="ds" key={i}>{d.module}: {d.detail}</p>)}
<ApplyNote id="agent-presets" />
</> : <><CompositionPickerSetting /><div className="empty"><p>{error || t("读取预设中…")}</p><button className="act" data-action="agent-preset.reload" onClick={() => setRefresh(v => v + 1)}>{t("重新加载")}</button></div></>}
{saved && <p className="find" role="status">{saved}</p>}
{catalog && error && <p className="find" data-lvl="err" role="alert">{error}</p>}
</div>}
</section>;
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { useBranchSession } from "./useBranchSession";
import { CompositionNewContext } from "./CompositionNewContext";
import { keepSame } from "./stable";
import { usePaneCallbacks } from "./usePaneCallbacks";
import { watchTreeWake } from "./treewake";
@@ -106,7 +107,7 @@ export function App({ hub }: { hub: HubPort }) {
const [panesRead, setPanesRead] = useState(false);
const [active, setActive] = useState(savedActivePane);
const treeWindow = useTreeWindow();
const { tree, treeRead, readTree, reloadTree, retryTree } = useTreeRead(hub, treeWindow);
const { tree, treeRead, answered, readTree, reloadTree, retryTree } = useTreeRead(hub, treeWindow);
// Null until asked, and null again where this kernel refuses remote panes —
// which is what keeps the section out of a browser rather than drawing a
// heading over a feature that cannot work there.
@@ -542,6 +543,7 @@ export function App({ hub }: { hub: HubPort }) {
}
return (
<CompositionNewContext.Provider value={(id) => {const rt=runtimes.find((v)=>v.id===active);void (rt?.host?openRemotePane(rt.host,rt.root,undefined,id):openPane({root:newSessionRoot,agentComposition:id})).catch(fail);}}>
<PeerThreadsProvider port={activePort} hub={hub} onOpen={openPeerSession}>
<div
className="app"
@@ -598,6 +600,7 @@ export function App({ hub }: { hub: HubPort }) {
navShown={navRail.shown}
tree={viewed.tree}
treeRead={treeRead}
answered={answered}
runtimes={runtimes}
runs={runs}
active={active}
@@ -783,5 +786,6 @@ export function App({ hub }: { hub: HubPort }) {
<StopHint armed={stopArmed} />
</div>
</PeerThreadsProvider>
</CompositionNewContext.Provider>
);
}
@@ -0,0 +1,36 @@
import { useId, useState } from "react";
import type { AgentPresetVersion } from "../port/composition";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
export function BuiltinPresetCard({ version: { definition: d }, selected, onDefault, onSelect, onCopy }: {
version: AgentPresetVersion;
selected: boolean;
onDefault: () => void;
onSelect: () => void;
onCopy: () => void;
}) {
const [usage, setUsage] = useState(false);
const helpId = useId();
const name = t(d.name);
return <article className="composition-builtin-card" data-selected={selected ? "" : undefined}>
<button type="button" className="composition-builtin-select" data-action="agent-preset.new-task-default"
data-target={d.id} aria-pressed={selected} aria-label={t("将{name}设为新任务默认", { name })} onClick={onDefault}>
<span className="composition-builtin-heading"><strong>{name}</strong>
<span className="composition-builtin-badge">{t(selected ? "新任务默认" : "内置")}</span>
<code title={d.id}>{d.id.slice("builtin:".length)}</code></span>
<span className="composition-builtin-description">{t(d.description)}</span>
</button>
<footer className="composition-builtin-footer">
<button type="button" data-action="agent-preset.view" data-target={d.id}
aria-label={t("查看 {name}", { name })} onClick={onSelect}>{t("模式说明")}</button>
<button type="button" data-action="agent-preset.usage" data-target={d.id} aria-expanded={usage}
aria-controls={helpId} aria-label={t("如何使用 {name}", { name })} onClick={() => setUsage(v => !v)}>{t("如何使用")}</button>
<button type="button" className="composition-builtin-copy" data-action="agent-preset.copy" data-target={d.id}
aria-label={t("复制 {name}", { name })} title={t("复制为自定义预设")} onClick={onCopy}><StudioIcon name="copy" /></button>
</footer>
<p className="composition-builtin-usage" id={helpId} hidden={!usage}>{t(d.id === "builtin:general"
? "初次使用会选择日常搭档,直接说说你想做什么。你也可以点击卡片更改新任务默认,或复制一份自定义。"
: "在新任务输入框选择轻装搭档,再发送首条消息。需要支持读图的模型;终端操作继续遵守权限设置。")}</p>
</article>;
}
@@ -0,0 +1,56 @@
import { useState } from "react";
import type { AgentPresetCatalog, AgentPresetVersion } from "../port/composition";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import { BuiltinPresetCard } from "./BuiltinPresetCard";
import { CompositionPickerSetting } from "./CompositionPickerSetting";
import { setNewTaskPreset, useNewTaskPreset } from "../state/newTaskPreset";
import "../styles/composition-presets.css";
export function CompositionCatalog({ catalog, onSelect, onCreate, onCopy }: {
catalog: AgentPresetCatalog;
onSelect: (version: AgentPresetVersion) => void;
onCreate: () => void;
onCopy: (version: AgentPresetVersion) => void;
}) {
const preferred = useNewTaskPreset() ?? "builtin:general";
const [error, setError] = useState("");
return <div className="composition-catalog">
<header className="composition-list-heading">
<h2>{t("Agent 预设")}</h2>
<button className="btn" data-primary="" data-action="agent-preset.create" onClick={onCreate}><StudioIcon name="plus" />{t("新建预设")}</button>
<p>{t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。")}</p>
</header>
<CompositionPickerSetting />
<p className="composition-default-note">{t("点击卡片,设置本机新任务的默认模式;已有会话保持当前配置。")}</p>
{error && <p className="find" role="alert">{error}</p>}
{(["builtin", "user", "project"] as const).map((scope) => {
const items = catalog.presets.filter(v => v.definition.id.startsWith(scope + ":"));
if (!items.length) return null;
return <section className="composition-list-group" key={scope} aria-label={t(scope === "builtin" ? "内置" : scope === "user" ? "个人" : "项目共享")}>
<h3>{t(scope === "builtin" ? "内置" : scope === "user" ? "个人" : "项目共享")}</h3>
{scope === "builtin" ? <div className="composition-builtin-grid">{items.map(v =>
<BuiltinPresetCard key={v.definition.id} version={v} selected={preferred === v.definition.id}
onDefault={() => setError(setNewTaskPreset(v.definition.id) ? "" : t("无法保存新任务默认模式,请检查本地存储权限。"))}
onSelect={() => onSelect(v)} onCopy={() => onCopy(v)} />)}</div>
: items.map(v => <PresetRow key={v.definition.id} version={v} onSelect={() => onSelect(v)} onCopy={() => onCopy(v)} />)}
</section>;
})}
<details className="composition-how"><summary>{t("如何使用")}</summary><p>{t("在输入框顶部选择 Agent 模式,首次提交前可切换。开始对话后,请用其他模式新建会话。轻装搭档的终端仍可修改文件,并非只读模式。")}</p></details>
</div>;
}
function PresetRow({ version: { definition: d }, onSelect, onCopy }: { version: AgentPresetVersion; onSelect: () => void; onCopy: () => void }) {
const builtin = d.id.startsWith("builtin:");
const name = builtin ? t(d.name) : d.name;
return <article className="composition-row">
<button className="composition-row-main" data-action="agent-preset.view" data-target={d.id}
aria-label={t(builtin ? "查看 {name}" : "编辑 {name}", { name })} onClick={onSelect}>
<span className="composition-row-icon"><StudioIcon name={d.id === "builtin:minimal" ? "code" : "layers"} /></span>
<span className="composition-row-copy"><strong>{name}{d.id === "builtin:general" && <small>{t("默认")}</small>}</strong>
<span>{builtin ? t(d.description) : d.description}</span></span>
<StudioIcon name="chevron" />
</button>
<button className="act" data-action="agent-preset.copy" data-target={d.id} aria-label={t("复制 {name}", { name })} onClick={onCopy}>{t("复制")}</button>
</article>;
}
@@ -0,0 +1,291 @@
import { useEffect, useId, useState } from "react";
import { t } from "../i18n";
import type {
AgentComposition,
AgentPresetCatalog,
CompositionModule,
CompositionKind,
CompositionSelection,
} from "../port/composition";
import { COMPOSITION_KINDS } from "../port/composition";
import { Switch } from "./Switch";
import { StudioIcon } from "./StudioIcon";
const labels: Record<CompositionKind, string> = {
tools: "工具",
skills: "技能",
mcp: "MCP 服务",
extensions: "扩展",
hooks: "自动化 Hook",
commands: "斜杠命令",
};
export function CompositionEditor({
draft,
modules,
disabled,
creating = false,
onChange,
onValidityChange,
}: {
draft: AgentComposition;
modules: AgentPresetCatalog["modules"];
disabled: boolean;
creating?: boolean;
onChange: (d: AgentComposition) => void;
onValidityChange: (valid: boolean) => void;
}) {
const [valid, setValid] = useState<Record<string, boolean>>({});
const helpId = useId();
useEffect(() => onValidityChange(Object.values(valid).every(Boolean)), [valid, onValidityChange]);
const persona = draft.persona;
const updatePersona = (patch: Partial<AgentComposition["persona"]>) =>
onChange({ ...draft, persona: { ...persona, ...patch } });
return (
<div className="composition-editor">
<label className="pers-field">
<span className="lb">{t("预设名称")}</span>
<input
className="in"
aria-label={t("预设名称")}
data-action-change="agent-preset.name"
disabled={disabled}
value={draft.id.startsWith("builtin:") ? t(draft.name) : draft.name}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
/>
</label>
<label className="pers-field">
<span className="lb">{t("用途说明")}</span>
<input
className="in"
data-action-change="agent-preset.description"
disabled={disabled}
value={draft.id.startsWith("builtin:") ? t(draft.description) : draft.description}
onChange={(e) => onChange({ ...draft, description: e.target.value })}
/>
</label>
<label className="pers-field">
<span className="lb">{t("人设与要求")}</span>
<span className="ds" id={`${helpId}-persona`}>{t("描述预设的角色、工作方式与要求;内容写在系统提示词开头。")}</span>
<textarea
aria-label={t("人设与要求")}
aria-describedby={`${helpId}-persona`}
data-action-change="persona.prefix"
rows={5}
maxLength={32768}
disabled={disabled}
value={persona.prefix}
onChange={(e) => updatePersona({ prefix: e.target.value })}
/>
</label>
<label className="pers-field">
<span className="lb">{t("补充要求")}</span>
<span className="ds" id={`${helpId}-suffix`}>{t("放在内置系统指令之后,留空则不添加")}</span>
<textarea
aria-label={t("补充要求")}
aria-describedby={`${helpId}-suffix`}
data-action-change="persona.suffix"
rows={5}
maxLength={32768}
disabled={disabled}
value={persona.suffix}
onChange={(e) => updatePersona({ suffix: e.target.value })}
/>
</label>
<div className="lrow">
<span className="tx">
<span className="lb">{t("不注入可选运行时上下文")}</span>
<span className="ds">{t("开启后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。")}</span>
</span>
<Switch
data-action="persona.runtime-context"
label={t("不注入可选运行时上下文")}
on={!persona.include_runtime_context}
busy={disabled}
onClick={() => updatePersona({ include_runtime_context: !persona.include_runtime_context })}
/>
</div>
<div className="composition-capabilities">
<h3>{t("能力配置")}</h3>
<p className="ds">{t("继承当前的工具、技能与扩展配置,可按需单独调整。")}</p>
<details>
<summary><StudioIcon name="settings" />{t("工具")}<span>{t(draft.tools.mode === "inherit" ? "继承当前配置" : "仅使用所选项")}</span><StudioIcon name="chevron" /></summary>
<ModuleEditor kind="tools" choices={modules?.tools ?? []} value={draft.tools} disabled={disabled}
onValidityChange={(ok) => setValid(prev => ({ ...prev, tools: ok }))}
onChange={(value) => onChange({ ...draft, tools: value })} />
</details>
<details>
<summary><StudioIcon name="layers" />{t("技能与扩展")}<span>{t(COMPOSITION_KINDS.filter(k => k !== "tools").every(k => draft[k].mode === "inherit") ? "继承当前配置" : "自定义")}</span><StudioIcon name="chevron" /></summary>
{COMPOSITION_KINDS.filter(kind => kind !== "tools").map((kind) => (
<ModuleEditor key={kind} kind={kind} choices={modules?.[kind] ?? []} value={draft[kind]} disabled={disabled}
onValidityChange={(ok) => setValid(prev => ({ ...prev, [kind]: ok }))}
onChange={(value) => onChange({ ...draft, [kind]: value })} />
))}
</details>
</div>
<label className="pers-field">
<span className="lb">{t("存放范围")}</span>
<select aria-label={t("存放范围")} aria-describedby={`${helpId}-scope`} disabled={disabled || !creating} data-action-change="agent-preset.scope"
value={draft.id.split(":")[0]} onChange={(e) => onChange({ ...draft, id: `${e.target.value}:${draft.id.split(":")[1]}` })}>
{draft.id.startsWith("builtin:") && <option value="builtin">{t("内置")}</option>}
<option value="user">{t("个人")}</option><option value="project">{t("项目共享")}</option>
</select>
<span className="ds" id={`${helpId}-scope`}>{t("个人:保存在本机,可跨项目使用。项目共享:保存在当前项目,可随仓库共享。")}</span>
</label>
<label className="pers-field">
<span className="lb">{t("预设 ID")}</span>
<input className="in" aria-label={t("预设 ID")} aria-describedby={`${helpId}-id`} disabled={disabled || !creating} data-action-change="agent-preset.id"
value={draft.id.split(":")[1]} onChange={(e) => onChange({ ...draft, id: `${draft.id.split(":")[0]}:${e.target.value}` })} />
<span className="ds" id={`${helpId}-id`}>{t("自动生成,用于保存和引用预设;保存后不可修改。")}</span>
</label>
</div>
);
}
function ModuleEditor({
kind,
choices,
value,
disabled,
onChange,
onValidityChange,
}: {
kind: CompositionKind;
choices: CompositionModule[];
value: CompositionSelection;
disabled: boolean;
onChange: (value: CompositionSelection) => void;
onValidityChange: (valid: boolean) => void;
}) {
const [jsonErrors, setJsonErrors] = useState<Record<number, string>>({});
const [rawParameters, setRawParameters] = useState<Record<number, string>>({});
const validate = (errors: Record<number, string>) => {
setJsonErrors(errors);
onValidityChange(Object.values(errors).every((v) => !v));
};
const remove = (index: number) => {
const shift = <T,>(source: Record<number, T>): Record<number, T> =>
Object.fromEntries(
Object.entries(source)
.filter(([key]) => Number(key) !== index)
.map(([key, v]) => [Number(key) > index ? Number(key) - 1 : Number(key), v]),
);
validate(shift(jsonErrors));
setRawParameters(shift(rawParameters));
onChange({ ...value, members: value.members.filter((_, i) => i !== index) });
};
return (
<fieldset className="composition-modules" disabled={disabled}>
<legend>{t(labels[kind])}</legend>
{kind === "hooks" && (
<p className="ds">{t("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")}</p>
)}
<select
aria-label={`${t(labels[kind])} ${t("选择方式")}`}
data-action-change="agent-preset.module-mode"
data-target={kind}
value={value.mode}
onChange={(e) => {
validate({});
setRawParameters({});
onChange({ mode: e.target.value as "inherit" | "select", members: [] });
}}
>
<option value="inherit">{t("创建会话时继承当前配置")}</option>
<option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
</select>
{value.mode === "select" && (
<>
{choices.length > 0 && (
<select
aria-label={`${t(labels[kind])} ${t("添加模块")}`}
data-action-change="agent-preset.module-choose"
data-target={kind}
value=""
onChange={(e) => {
if (e.target.value)
onChange({ ...value, members: [...value.members, { id: e.target.value }] });
}}
>
<option value="">{t("添加模块")}</option>
{choices
.filter((m) => !value.members.some((s) => s.id === m.id))
.map((m) => (
<option key={m.id} value={m.id}>
{m.id}
</option>
))}
</select>
)}
{value.members.map((module, index) => (
<div className="composition-module" key={index}>
<div className="composition-toolbar">
<input
className="in"
aria-label={`${t(labels[kind])} ID ${index + 1}`}
data-action-change="agent-preset.module-id"
data-target={kind}
value={module.id}
onChange={(e) =>
onChange({
...value,
members: value.members.map((m, i) => (i === index ? { ...m, id: e.target.value } : m)),
})
}
/>
<button
type="button"
className="act"
data-action="agent-preset.module-remove"
data-target={kind}
onClick={() => remove(index)}
>
{t("移除")}
</button>
</div>
{(kind === "mcp" || kind === "extensions") && (
<label className="pers-field">
<span className="ds">{t("独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用")}</span>
<textarea
aria-label={`${t(labels[kind])} ${t("独立参数")} ${index + 1}`}
data-action-change="agent-preset.module-parameters"
data-target={kind}
rows={3}
value={rawParameters[index] ?? JSON.stringify(module.parameters ?? {}, null, 2)}
onChange={(e) => {
setRawParameters({ ...rawParameters, [index]: e.target.value });
try {
const parsed: unknown = JSON.parse(e.target.value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("object required");
validate({ ...jsonErrors, [index]: "" });
onChange({
...value,
members: value.members.map((m, i) =>
i === index ? { ...m, parameters: parsed as Record<string, unknown> } : m,
),
});
} catch {
validate({ ...jsonErrors, [index]: t("参数需要有效的 JSON 对象") });
}
}}
/>
{jsonErrors[index] && <span role="alert">{jsonErrors[index]}</span>}
</label>
)}
</div>
))}
<button
type="button"
className="act"
data-action="agent-preset.module-add"
data-target={kind}
onClick={() => onChange({ ...value, members: [...value.members, { id: "" }] })}
>
{t("添加模块")}
</button>
</>
)}
</fieldset>
);
}
@@ -0,0 +1,3 @@
import { createContext } from "react";
export const CompositionNewContext = createContext<((id: string) => void) | null>(null);
@@ -0,0 +1,16 @@
import { useState } from "react";
import { t } from "../i18n";
import { setShowsCompositionPicker, useShowsCompositionPicker } from "../state/compositionPicker";
import { Group } from "./Group";
import { Switch } from "./Switch";
export function CompositionPickerSetting() {
const enabled = useShowsCompositionPicker();
const [error, setError] = useState(false);
return <Group id="composition-picker" title={t("显示模式选择器")}
hint={t("默认关闭。开启后在聊天输入框顶部显示模式选择器;关闭只隐藏入口,不改变会话模式。")}
action={<Switch label={t("显示模式选择器")} data-action="agent-preset.show-picker" on={enabled}
onClick={() => setError(!setShowsCompositionPicker(!enabled))} />}>
{error && <p role="alert">{t("无法保存模式选择器设置,请检查本地存储权限。")}</p>}
</Group>;
}
@@ -66,4 +66,3 @@ describe("a price the pinned currency cannot be shown for", () => {
expect(screen.queryByText(/恢复官方价/)).toBeNull();
});
});
Loaded 100 of 505 files, more files were not shown because too many files have changed in this diff. Show more