Compare commits

..
7 Commits
Author SHA1 Message Date
esengine 4dc2b1938b feat(peers): with the dispatch switch on, a message from the user's other sessions reaches the model without a card
The dispatch switch already let a dispatched pair in unasked; any other session
of the user's still waited for an approval, so a model that sent a plain peer
message stalled until the person clicked. While the switch is on every message
from the user's sessions is released, audited as consent "switch" and framed
to the model as data the user did not read; a dispatch partner keeps its own
framing. With the switch off nothing changes. The tool descriptions, receipt,
slash notice and UI copy no longer promise an approval, and the tool-schema
golden follows.
2026-11-10 17:22:13 +00:00
esengine 6c9c6851af feat(studio): pick an element in the built-in browser and save the edit to the local page
The picker's shell half had no control. The browser toolbar now arms it, and a
picked element on a page opened from a workspace file can have its text, color,
background and size changed. The edit is patched into the file's source (the
element is re-found there, not trusted from the page) and written back through
saveWorkspaceFile with its revision check; anything that cannot be placed
reliably is refused with a typed reason and nothing is written.
2026-11-10 17:19:11 +00:00
yhh 7facc49510 test: every test directory is testenv.TempDir, and repolint refuses a bare one
On Windows a removed file's directory entry outlives it until the last handle
closes, so t.TempDir()'s cleanup fails the test as "directory is not empty"
with every file already gone. testenv.TempDir retries, but nothing required it,
and 1145 bare calls in 391 test files had come back; internal/state/sessionstore
failed a different test on most full runs.

Four of its tests ran 150 times each: 41 failures in 600 with t.TempDir(), 0 in
600 with testenv.TempDir(t), and the whole package then passed 6 of 6. A full
go test ./... after the sweep has no cleanup failure; its three failing packages
(jq missing, find.exe on PATH, WSL launcher detection) fail the same at HEAD.

The new temp-dir rule reports a TempDir() call in a test that is not os-qualified.
testenv's own test is exempt, since it cannot import itself.
2026-11-10 02:12:52 -08:00
yhh e19fc12a59 perf(sessionstore): a save digests the transcript and writes its index without per-message copies
Every save re-digests the whole transcript to check it still begins with what
is on disk, and rewrites the display index. The digest marshalled each message
into a fresh slice and the index was written indented, so a save allocated in
proportion to the session. The digest now encodes each message straight into
the hash through one encoder, byte-identical to before, and the index is
written compact.

Median of four runs of BenchmarkIncrementalSave: 1000 messages 33.7 to 24.6 ms
and 3.64 to 1.53 MB per save; 5000 messages 49.3 to 42.2 ms and 18.9 to 8.7 MB;
20000 messages 136.8 to 109.7 ms and 78.4 to 37.7 MB. The digest test pins the
bytes against the format's own definition, including characters JSON escapes.
2026-11-10 01:28:36 -08:00
yhh 2ff95dbc5e feat(studio): the workbench shows the file tree beside the open file
The tree is the left column and the file the right, in the dock as well as at
full width. Choosing a file leaves the tree open, so the next one is a click
away instead of a trip back to the Files tab; any other surface still takes the
whole column and folds the tree. The explorer title no longer wraps one
character per line in the narrow column.

Docked, the list used to hide the canvas, so the content views counted
themselves hidden while it was open and a closed last file brought the list
back. Neither holds side by side, so both rules are removed and the tests that
asserted them are rewritten to the new layout.
2026-11-10 00:59:01 -08:00
yhh f4a646549c fix(tools): the bash description steers searching to tool:grep and tool:glob
grep and glob are deferred tools reached through use_capability, so the model
had no direct name to prefer and searched through the shell: 33 of 37 searches
in a three-task sample. The description now names tool:grep and tool:glob and
leaves the shell its pipelines. Over the same three tasks the built-in share of
searches went from 4 of 37 to 16 of 31.

The tool schema grows by 62 tokens and the prefix changes once, so the golden
baseline and docs/TOOL_CONTRACT.md are regenerated with it.
2026-11-10 00:58:54 -08:00
yhh 302b6732a5 feat(tools): turnprofile splits a trajectory's turns into exclusive time buckets
Reads --trajectory JSONL and assigns every millisecond of a turn to one of
user wait, compaction, tool, model first token, model generation, discarded
attempt, or host, so where a turn spends its wall-clock is read off a recorded
run. Read-only; definitions and limits are in the README.
2026-11-09 23:40:13 -08:00
465 changed files with 3351 additions and 1244 deletions

No files matched your search

+13 -12
View File
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/safety/sandbox"
)
@@ -57,11 +58,11 @@ func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
if err := validateAnswerIsolation(tasks); err != nil {
t.Fatal(err)
}
work := t.TempDir()
work := testenv.TempDir(t)
if err := stageAnswerIsolation(tasks[0].answerRoot, work); err != nil {
t.Fatal(err)
}
home := t.TempDir()
home := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nbash = \"off\"\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -105,7 +106,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
if err := stageAnswerIsolation(answerRoot, filepath.Dir(answerRoot)); err == nil {
t.Fatal("workdir inside the checkout was accepted")
}
work := t.TempDir()
work := testenv.TempDir(t)
path := filepath.Join(work, "reasonix.toml")
if err := os.WriteFile(path, []byte("# task fixture\n"), 0o600); err != nil {
t.Fatal(err)
@@ -120,7 +121,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
requireShellStub(t)
suite := t.TempDir()
suite := testenv.TempDir(t)
taskDir := filepath.Join(suite, "tasks", "demo")
if err := os.MkdirAll(filepath.Join(taskDir, "memory", "project"), 0o755); err != nil {
t.Fatal(err)
@@ -129,7 +130,7 @@ func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "fake-agent")
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
set -e
test -f reasonix.toml
@@ -154,7 +155,7 @@ done
func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
requireShellStub(t)
suite := t.TempDir()
suite := testenv.TempDir(t)
taskDir := filepath.Join(suite, "tasks", "demo")
answer := filepath.Join(taskDir, "memory", "project", "fact.md")
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
@@ -166,7 +167,7 @@ func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "fake-agent")
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
set -e
printf 'ok\n' > answer.txt
@@ -194,7 +195,7 @@ done
func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
requireShellStub(t)
root := filepath.Join(t.TempDir(), "tasks")
root := filepath.Join(testenv.TempDir(t), "tasks")
var tasks []task
for _, id := range []string{"missing", "complete"} {
dir := filepath.Join(root, id)
@@ -206,7 +207,7 @@ func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
}
tasks = append(tasks, task{ID: id, Prompt: id, TimeoutSec: 30, dir: dir, answerRoot: root})
}
bin := filepath.Join(t.TempDir(), "fake-agent")
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
set -e
printf 'ok\n' > answer.txt
@@ -251,7 +252,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
{"glob lists name only", `{"event":{"kind":"tool_result","tool":{"name":"glob","args":"{\"pattern\":\"/repo/tasks/demo/memory/project/*.md\"}","output":"/repo/tasks/demo/memory/project/fact.md"}}}`, false},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "run.trajectory.jsonl")
path := filepath.Join(testenv.TempDir(t), "run.trajectory.jsonl")
if err := os.WriteFile(path, []byte(tc.data+"\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -264,7 +265,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
}
func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
root := filepath.Join(t.TempDir(), "tasks")
root := filepath.Join(testenv.TempDir(t), "tasks")
answer := filepath.Join(root, "demo", "memory", "project", "fact.md")
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
t.Fatal(err)
@@ -272,7 +273,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA fact body in the first trajectory leg.\n"), 0o600); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
dir := testenv.TempDir(t)
for index, data := range []string{
`{"event":{"kind":"tool_result","tool":{"name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}","output":"A fact body in the first trajectory leg."}}}`,
`{"event":{"kind":"text","text":"done"}}`,
+6 -4
View File
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
)
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
@@ -64,7 +66,7 @@ func timeAt(s string) time.Time {
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
t.Helper()
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
t.Fatalf("write batch: %v", err)
}
@@ -174,7 +176,7 @@ func TestBatchAuditRoundTrip(t *testing.T) {
// The axes carry what changes a figure's meaning, never the operator's machine:
// meter/tape/trajectory paths and steer text stay out of the manifest.
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
prov := fixtureProvenance(dir)
prov.axes = batchAxes(suiteConfig{
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
@@ -260,7 +262,7 @@ func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
// manifest, no commit to tie the figures to, and rows cut short.
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
t.Run("missing manifest", func(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
writeRows(t, dir, fixtureRows())
out, err := auditBatch(dir)
if err == nil {
@@ -387,7 +389,7 @@ const manifestWithoutOptionals = `{
`
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
writeRows(t, dir, fixtureRows())
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
if err != nil {
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/base/fileutil/encoding"
"reasonix/internal/base/testenv"
)
// benchmarksRoot holds every committed task corpus. Validation discovers the
@@ -363,7 +364,7 @@ func fullDecisions() map[authoringRule]ruleDecision {
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
t.Helper()
root := t.TempDir()
root := testenv.TempDir(t)
var policies []corpusPolicy
for _, name := range corpora {
dir := tempCorpus(t, root, name, "t-"+name, "")
@@ -437,7 +438,7 @@ func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
name: "a registry entry that shadows the root's corpus by name",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
elsewhere := tempCorpus(t, testenv.TempDir(t), "registered", "t-impostor", "")
policies[0].Dir = elsewhere
return root, policies
},
@@ -505,7 +506,7 @@ func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
}
}
root := t.TempDir()
root := testenv.TempDir(t)
for _, c := range []struct {
name string
corpus string
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestWilsonAndNewcombeMatchKnownValues(t *testing.T) {
@@ -42,7 +44,7 @@ func writeReport(t *testing.T, dir, name, capability string, pass int, n int) st
}
func TestReproductionSection(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
got := reproductionSection(a, same)
+7 -5
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
@@ -38,7 +40,7 @@ func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
// The real filesystem is the other witness: writing over a directory fails.
func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
if err := restoreSource(testenv.TempDir(t), []byte("original")); err == nil {
t.Fatal("restoring onto a directory reported success")
}
}
@@ -47,7 +49,7 @@ func TestTreeMatchesSeesADirtyTree(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
repo := t.TempDir()
repo := testenv.TempDir(t)
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
@@ -88,7 +90,7 @@ func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go unavailable")
}
repo := t.TempDir()
repo := testenv.TempDir(t)
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
@@ -150,7 +152,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
repo := t.TempDir()
repo := testenv.TempDir(t)
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
@@ -197,7 +199,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
// tree must not be left on base just because the failure happened early. The
// defer that restores is registered before the check for exactly this path.
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
repo := t.TempDir()
repo := testenv.TempDir(t)
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
+2 -2
View File
@@ -29,7 +29,7 @@ printf '%%s' "$metrics" > metrics-path.txt
printf '%%s' '%s' > "$metrics%s"
exit %d
`, body, suffix, code)
path := filepath.Join(t.TempDir(), name)
path := filepath.Join(testenv.TempDir(t), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
@@ -73,7 +73,7 @@ case "$metrics" in
esac
exit 0
`
path := filepath.Join(t.TempDir(), name)
path := filepath.Join(testenv.TempDir(t), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
+9 -7
View File
@@ -10,6 +10,8 @@ import (
"sync/atomic"
"testing"
"time"
"reasonix/internal/base/testenv"
)
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
@@ -44,7 +46,7 @@ func readAll(t *testing.T, resp *http.Response) string {
// A run replayed from its tape gets the recorded stream byte for byte, never
// reaches the provider, and is metered as the recorded run was.
func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
live := readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -71,7 +73,7 @@ func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
// A kernel change that alters what the model is asked shows as the first
// request whose body departs from the tape, named down to the message.
func TestTapeReplayNamesTheFirstRequestThatDiffers(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -123,7 +125,7 @@ func waitFor(t *testing.T, what string, cond func() bool) {
// A recording whose request file cannot be written is reported rather than
// treated as a tape: request.json is what marks the exchange complete.
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "request.json") {
@@ -161,7 +163,7 @@ func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
// A tape with no recorded request is incomplete, not a divergence: there is
// nothing to compare against, and blaming the harness would be wrong.
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -193,7 +195,7 @@ func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
// first: stopping is what waits for the handlers whose deferred writes carry the
// tape counters. Snapshotting before that misses the last exchange.
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
@@ -214,7 +216,7 @@ func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
// Re-recording over an existing tape must not let the previous run's
// completeness marker make a partial new exchange look complete.
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
@@ -263,7 +265,7 @@ func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
// incomplete exchange and prints no line about one. Only the failure paths were
// pinned before.
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
+6 -5
View File
@@ -7,11 +7,12 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
)
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
cases := []struct {
@@ -33,7 +34,7 @@ func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
}
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
mk := func(name string) string {
@@ -68,7 +69,7 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
}
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
home := filepath.Join(base, "state")
app := filepath.Join(base, "app")
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
@@ -96,7 +97,7 @@ func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
}
func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
state := filepath.Join(base, "state")
home := filepath.Join(base, "home")
app := filepath.Join(base, "app")
@@ -127,7 +128,7 @@ func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *test
}
func TestPickLaunchWorkspaceCreatesTheAutoWorkspaceLast(t *testing.T) {
base := t.TempDir()
base := testenv.TempDir(t)
app := filepath.Join(base, "app")
exe := filepath.Join(app, "Reasonix Studio")
auto := filepath.Join(base, "state", "workspaces", "auto")
+3 -3
View File
@@ -112,7 +112,7 @@ func (r recordingRunner) Run(_ context.Context, input string) error {
// hub, its config path and its capabilities are the ones the host builds.
func testHub(t *testing.T, cfg config.ServeConfig) (*serve.Hub, chan string) {
t.Helper()
root := t.TempDir()
root := testenv.TempDir(t)
got := make(chan string, 1)
bc := serve.NewBroadcaster()
ctrl := control.New(control.Options{
@@ -382,7 +382,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
t.Error("the null device was read as a parent holding this host open")
}
path := filepath.Join(t.TempDir(), "not-a-pipe")
path := filepath.Join(testenv.TempDir(t), "not-a-pipe")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
@@ -399,7 +399,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
// The whole run, driven the way Electron drives it: a pipe for the lease, one
// line of handshake, traffic, then the parent letting go.
func TestRunServesUntilTheParentLetsGo(t *testing.T) {
page := t.TempDir()
page := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(page, "index.html"), []byte("<title>studio</title>"), 0o600); err != nil {
t.Fatal(err)
}
@@ -7,11 +7,12 @@ import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/packagegrant"
)
func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
for name, app := range map[string]string{
"no application": "",
"a path that is nothing": filepath.Join(dir, "missing.exe"),
@@ -27,7 +28,7 @@ func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
// The shell reads this line as structure, so both lists are present even when
// empty: a null would be a third answer it has to guess about.
func TestStripPackageGrantsReportsOneLineOfJSON(t *testing.T) {
app := filepath.Join(t.TempDir(), "Reasonix Studio.exe")
app := filepath.Join(testenv.TempDir(t), "Reasonix Studio.exe")
if err := os.WriteFile(app, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
@@ -84,7 +86,7 @@ func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
{"pull_request", "desktop/electron/test/unit.mjs", true},
} {
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
// A shell function owns command lookup on every host, including Git
// Bash on Windows; a PATH shim can resolve the runner's real git.
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
+5 -4
View File
@@ -10,6 +10,7 @@ import (
"aead.dev/minisign"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/update"
)
@@ -28,7 +29,7 @@ func TestSignFiles(t *testing.T) {
t.Setenv("MINISIGN_PRIVATE_KEY", string(enc))
t.Setenv("MINISIGN_PASSWORD", "pw")
dir := t.TempDir()
dir := testenv.TempDir(t)
artifact := filepath.Join(dir, "Reasonix-linux-amd64.tar.gz")
payload := []byte("pretend this is a release tarball")
if err := os.WriteFile(artifact, payload, 0o644); err != nil {
@@ -51,7 +52,7 @@ func TestSignFiles(t *testing.T) {
// every platform is listed with a download URL, a parallel .minisig URL, and a
// non-empty digest. The .minisig and latest.json files must be ignored.
func TestGenManifest(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
names := []string{
"Reasonix-darwin-arm64.zip",
"Reasonix-darwin-amd64.zip",
@@ -158,7 +159,7 @@ func TestGenManifest(t *testing.T) {
}
func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(dir, "Reasonix-linux-amd64.tar.gz"), []byte("rc"), 0o644); err != nil {
t.Fatal(err)
}
@@ -181,7 +182,7 @@ func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
// TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known
// platform key is skipped rather than inventing a native_packages entry.
func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
for _, n := range []string{
"Reasonix-linux-amd64.tar.gz",
"Reasonix-mystery.deb",
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/update"
)
@@ -13,7 +14,7 @@ import (
// the shared chunk store named; an unsigned index fails the release instead of
// being offered.
func TestManifestListsEachSignedDeltaFromTheMirror(t *testing.T) {
dir, deltaDir := t.TempDir(), t.TempDir()
dir, deltaDir := testenv.TempDir(t), testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := os.WriteFile(filepath.Join(dir, "ReasonixStudio-windows-amd64-installer.exe"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
+9 -8
View File
@@ -9,11 +9,12 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/update"
)
func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
body := []byte("studio archive bytes")
for _, name := range []string{
@@ -71,7 +72,7 @@ func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
// on the Studio side does. Only what installs itself may resolve as a platform
// asset, or the version panel offers a move it cannot make.
func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-linux-amd64.tar.gz",
@@ -128,7 +129,7 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
func TestEmptyDirIsAFailedRelease(t *testing.T) {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
if err := run(testenv.TempDir(t), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
t.Fatal("a release with no artifacts must fail, not publish an empty manifest")
}
}
@@ -136,7 +137,7 @@ func TestEmptyDirIsAFailedRelease(t *testing.T) {
// A mirrored release is fetched from the mirror first, with GitHub kept as the
// address a client falls back to; every full artifact and its signature alike.
func TestMirroredManifestPutsTheMirrorFirst(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe",
@@ -193,7 +194,7 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
// Both Windows architectures ship an installer and a portable archive under
// the same naming rule; each installer answers only its own platform key.
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe",
@@ -258,7 +259,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
}
return m
}
debOnly, both := t.TempDir(), t.TempDir()
debOnly, both := testenv.TempDir(t), testenv.TempDir(t)
write(debOnly, "ReasonixStudio-linux-amd64.deb")
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
before, after := generate(debOnly), generate(both)
@@ -292,7 +293,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
// the second architecture must not move the first one's entries.
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
dir := t.TempDir()
dir := testenv.TempDir(t)
generate := func() update.Manifest {
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
t.Fatal(err)
@@ -345,7 +346,7 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
// replaces: it must not appear as a platform asset or a native package, and
// adding it must not move any entry a shipped client reads.
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
+1 -1
View File
@@ -396,7 +396,7 @@ export const CHAT: Messages = {
"chat.selected_text": { zh: "选中内容", en: "Selected text" },
"chat.selects_enter_references_tab_completes": { zh: "↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭", en: "↑↓ selects · Enter references · Tab completes or enters a folder · Esc closes" },
"chat.selects_tab_or_enter_completes": { zh: "↑↓ 选择 · Tab 或回车补全 · Esc 关闭", en: "↑↓ selects · Tab or Enter completes · Esc closes" },
"chat.send_text_to_another_reasonix": { zh: "向本机另一个 Reasonix 会话发送文字;对方用户批准后它的模型才会读到,发送本身不授予任何权限。", en: "Send text to another Reasonix session on this machine; its model reads it only after that session's user approves, and sending grants no permission." },
"chat.send_text_to_another_reasonix": { zh: "向本机另一个 Reasonix 会话发送文字;派发开关开着时对方的模型会直接读到,关着时要对方用户批准,发送本身不授予任何权限。", en: "Send text to another Reasonix session on this machine; with the dispatch switch on its model reads it directly, otherwise its user must approve first, and sending grants no permission." },
"chat.send_the_edit": { zh: "改完重发", en: "Send the edit" },
"chat.sending": { zh: "正在重发…", en: "Sending…" },
"chat.sent_to_background": { zh: "放到后台运行", en: "Sent to the background" },
+2 -2
View File
@@ -40,7 +40,7 @@ export const REMOTE: Messages = {
"remote.could_not_read_the_state": { zh: "无法读取会话间消息的状态。", en: "Could not read the state of messages between sessions." },
"remote.creating_qr_code": { zh: "正在生成二维码…", en: "Creating QR code…" },
"remote.default_connection_directory_path": { zh: "默认连接目录:{path}", en: "Default connection directory: {path}" },
"remote.delivered_to_to_it_is": { zh: "已送到「{to}」,对方的用户批准后才会读到。", en: "Delivered to “{to}”; it is read only after that session’s user approves it." },
"remote.delivered_to_to_it_is": { zh: "已送到「{to}」,对方的模型会直接读到(派发开关关着时需对方用户批准)。", en: "Delivered to “{to}”; its model reads it directly (it waits for its user only if the dispatch switch is off)." },
"remote.device_access": { zh: "设备访问", en: "Device access" },
"remote.device_access_n_online": { zh: "设备访问 · {n} 台在线", en: "Device access · {n} online" },
"remote.device_n": { zh: "设备 {n}", en: "Device {n}" },
@@ -234,7 +234,7 @@ export const REMOTE: Messages = {
"remote.when_something_on_your_computer": { zh: "电脑有事等你时,这台手机会收到一条通知。通知里只有“哪一类”和数量,不含会话名称、命令或对话内容。默认关闭,只提醒你勾选的几类。", en: "When something on your computer is waiting for you, this phone gets a notification. It carries only the kind of occasion and a count, never a session name, command or conversation text. Off by default, and only the occasions you tick are sent." },
"remote.which_machines_providers_and_keys": { zh: "远端会话使用哪台机器上配置的 Provider 与 Key", en: "Which machine's providers and keys a remote session uses" },
"remote.will_not_run": { zh: "无法运行", en: "will not run" },
"remote.write_what_to_tell_the": { zh: "写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。", en: "Write what to tell the other session. It reaches that session’s model only after its user approves." },
"remote.write_what_to_tell_the": { zh: "写下要告诉另一个会话的话。派发开关开着时会直接交给那边的模型,关着时要对方的用户批准。", en: "Write what to tell the other session. It goes straight to that session’s model while the dispatch switch is on; otherwise its user approves first." },
"remote.wsl_distribution": { zh: "WSL 发行版", en: "WSL distribution" },
"remote.youre_offline": { zh: "网络已断开", en: "You're offline" },
"remote.use_studio_on_your_phone": { zh: "在手机上用 Studio", en: "Use Studio on your phone" },
@@ -197,4 +197,30 @@ export const WORKBENCH: Messages = {
"workbench.copied_text": { zh: "已复制 {text}", en: "Copied {text}" },
"workbench.browser_tab_named": { zh: "浏览器标签 · {name}", en: "Browser tab · {name}" },
"workbench.lines_a_b": { zh: "第 {a}–{b} 行", en: "Lines {a}–{b}" },
"workbench.pick_element": { zh: "点选页面元素", en: "Pick an element on the page" },
"workbench.picking_element": { zh: "在页面上点一个元素,按 Esc 取消", en: "Click an element on the page; press Esc to cancel" },
"workbench.pick_ended_timeout": { zh: "等待太久,点选已取消", en: "Picking timed out and was cancelled" },
"workbench.pick_ended_hidden": { zh: "页面不在眼前,点选已取消", en: "The page is not on screen, so picking was cancelled" },
"workbench.pick_ended_navigation": { zh: "页面已跳转或重新加载,点选已取消", en: "The page navigated or reloaded, so picking was cancelled" },
"workbench.pick_ended_gone": { zh: "页面里已经找不到这个元素", en: "The element can no longer be found on the page" },
"workbench.edit_picked_element": { zh: "修改所选元素", en: "Edit the picked element" },
"workbench.edit_text": { zh: "文字", en: "Text" },
"workbench.edit_color": { zh: "文字颜色", en: "Text color" },
"workbench.edit_background": { zh: "背景色", en: "Background" },
"workbench.edit_font_size": { zh: "字号", en: "Font size" },
"workbench.edit_save": { zh: "保存到文件", en: "Save to file" },
"workbench.edit_saving": { zh: "正在保存…", en: "Saving…" },
"workbench.edit_close": { zh: "关闭", en: "Close" },
"workbench.edit_saved": { zh: "已保存到 {path}", en: "Saved to {path}" },
"workbench.edit_not_file": { zh: "只有本地网页文件可以在这里修改并保存", en: "Only a page opened from a local file can be edited and saved here" },
"workbench.edit_outside": { zh: "这个文件不在当前工作区里,不能保存", en: "This file is outside the current workspace and cannot be saved" },
"workbench.edit_conflict": { zh: "文件在你修改期间已变化,请重新点选后再改", en: "The file changed in the meantime; pick the element again and retry" },
"workbench.edit_failed": { zh: "保存失败,文件没有被改动", en: "Saving failed; the file was not changed" },
"workbench.edit_not_found": { zh: "在文件源码里找不到这个元素,它可能是脚本生成的", en: "The element is not in the file's source; a script may have made it" },
"workbench.edit_ambiguous": { zh: "文件里有多个元素匹配,无法确定改哪一个", en: "More than one element in the file matches, so it is unclear which to change" },
"workbench.edit_unmappable": { zh: "这个文件的结构无法可靠对应到页面,未做修改", en: "The file's structure cannot be matched to the page reliably; nothing was changed" },
"workbench.edit_mismatch": { zh: "文件里的文字和页面上显示的不一致,未做修改", en: "The file's text differs from what the page shows; nothing was changed" },
"workbench.edit_not_plain_text": { zh: "这个元素里还有别的元素,只能改颜色和字号,不能改文字", en: "This element contains other elements; only its colors and size can be changed, not its text" },
"workbench.edit_bad_value": { zh: "这个值不能写进样式", en: "That value cannot be written into a style" },
"workbench.edit_nothing": { zh: "没有改动", en: "Nothing changed" },
};
+40
View File
@@ -123,6 +123,11 @@ export interface HostPort {
answerBrowserLogin(id: string, username: string, password: string): void;
/** Proceed past the certificate this page was refused for, for this run. */
trustBrowserCertificate(target: string): Promise<boolean>;
/** Whether this shell can let a person point at an element of a page. */
picksBrowserElements(): boolean;
/** Arm the page's picker and wait for the element pointed at, or for why none was. */
pickBrowserElement(target: string, labels: BrowserPickLabels): Promise<BrowserPick>;
cancelBrowserPick(target: string): void;
}
/** Why the shell will not load a typed address: a scheme it never opens, or a
@@ -178,6 +183,22 @@ export interface ViewRect {
height: number;
}
/** What the shell reports of an element the person pointed at. */
export interface PickedElement {
selector: string;
tagName: string;
text?: string;
pageUrl?: string;
style?: Partial<Record<"color" | "backgroundColor" | "fontSize" | "fontWeight", string>>;
}
/** The words the page-side hover card draws; the window owns them. */
export type BrowserPickLabels = Record<"color" | "background" | "font" | "frame" | "shadow", string>;
export type BrowserPickEnd = "escape" | "dismissed" | "timeout" | "hidden" | "navigation" | "gone";
export type BrowserPick = { cancelled: false; element: PickedElement } | { cancelled: true; reason: BrowserPickEnd };
export type BrowserControl = "back" | "forward" | "reload" | "stop";
// The preload bridge. Verbs only: the origin the page was loaded from and the
@@ -217,6 +238,8 @@ interface ElectronBridge {
onBrowserLogin?(listener: (ask: BrowserLogin) => void): () => void;
answerBrowserLogin?(id: string, username: string, password: string): Promise<void>;
trustBrowserCertificate?(target: string): Promise<boolean>;
pickBrowserElement?(target: string, labels: BrowserPickLabels): Promise<BrowserPick>;
cancelBrowserPick?(target: string): Promise<boolean>;
}
const bridge = () => (window as unknown as { reasonixHost?: ElectronBridge }).reasonixHost;
@@ -354,6 +377,16 @@ class ElectronHost implements HostPort {
trustBrowserCertificate(target: string) {
return this.api.trustBrowserCertificate?.(target) ?? Promise.resolve(false);
}
picksBrowserElements() {
return typeof this.api.pickBrowserElement === "function";
}
pickBrowserElement(target: string, labels: BrowserPickLabels): Promise<BrowserPick> {
const gone: BrowserPick = { cancelled: true, reason: "gone" };
return this.api.pickBrowserElement?.(target, labels).catch(() => gone) ?? Promise.resolve(gone);
}
cancelBrowserPick(target: string) {
void this.api.cancelBrowserPick?.(target);
}
}
class BrowserHost implements HostPort {
@@ -454,6 +487,13 @@ class BrowserHost implements HostPort {
trustBrowserCertificate() {
return Promise.resolve(false);
}
picksBrowserElements() {
return false;
}
pickBrowserElement(): Promise<BrowserPick> {
return Promise.resolve({ cancelled: true, reason: "gone" });
}
cancelBrowserPick() {}
}
function pick(): HostPort {
@@ -324,6 +324,9 @@ the picker and keyboard navigation.
1 CSS px in both themes at 100% and 125% device scale.
- The compact account button has no row divider; the footer owns the divider
above all three controls.
- Inside the workbench the file tree is the left column and the open file the right, in the dock as
well as full width. Choosing a file leaves the tree open; choosing any other surface takes the
whole column and folds it. Below 760px the same rule holds with the tree folded by default.
- The docked workbench uses the divider's remembered width. Pointer dragging and
keyboard resizing share that width; there is no intermediate width preset.
- Full-workbench view preserves the divider width and keeps the conversation and
+1 -2
View File
@@ -649,8 +649,7 @@
.app[data-browser="off"] .pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { visibility: hidden; transition-property: visibility; transition-duration: 0s; transition-delay: var(--t-panel) }
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr) }
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-explorer { display: none }
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] { grid-template-columns: minmax(0, 1fr) }
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] .workbench-canvas { display: none }
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-canvas { grid-column: 1 }
.bpanel { flex: 1; display: flex; flex-direction: column; min-height: 0 }
.bbar { flex: none; display: flex; flex-direction: column; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--hair) }
.bbar .vtabs { overflow-x: auto }
+17 -5
View File
@@ -1142,6 +1142,17 @@ body { background: var(--page) !important; }
.studio-browser-note > button { flex: none; margin-left: auto; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer; font: var(--w-med) var(--fs-micro)/1.45 var(--ui); }
.studio-browser-note > button:hover { color: var(--text); background: var(--overlay); }
.studio-browser-error { border-top: 0; border-bottom: 1px solid var(--border); color: #c78a82; }
.belement { flex: none; display: grid; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--hair); background: var(--surface); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); }
.belement-head { display: flex; align-items: center; gap: 10px; }
.belement-head code { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.belement-head button, .belement-foot button { flex: none; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer; font: var(--w-med) var(--fs-micro)/1.45 var(--ui); }
.belement-head button:hover, .belement-foot button:hover:not(:disabled) { color: var(--text); background: var(--overlay); }
.belement-foot button:disabled { cursor: default; opacity: .5; }
.belement-field { display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; gap: 8px; }
.belement-field input { min-width: 0; padding: 3px 6px; border: 1px solid var(--border); border-radius: var(--r-xs); background: var(--page); color: var(--text); font: inherit; }
.belement-field input:disabled { opacity: .6; }
.belement-foot { display: flex; align-items: center; gap: 10px; }
.belement-foot > p { padding: 0; border: 0; background: transparent; }
.workbench { width: 100%; height: 100%; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface); color: var(--text); }
.pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { padding: 8px 8px 8px 2px; border-left: 0; background: transparent; }
.pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] .workbench-tabs { height: calc(var(--pane-bar-h) - 9px); }
@@ -1177,12 +1188,13 @@ body { background: var(--page) !important; }
.workbench-tab:hover .workbench-tab-close, .workbench-tab[aria-selected="true"] .workbench-tab-close { opacity: 1; }
.workbench-tab-close:hover { color: var(--text); }
.workbench-tabs svg { width: 13px; height: 13px; flex: none; }
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) clamp(190px, 25%, 300px); grid-template-rows: minmax(0, 1fr); }
.workbench-canvas { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: clamp(190px, 36%, 260px) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.workbench-canvas { grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.workbench-content > .studio-browser, .workbench-content > .bpanel { width: 100%; height: 100%; min-width: 0; }
.workbench-canvas .bbar > .vtabs { display: none; }
.workbench-explorer { min-width: 0; display: flex; flex-direction: column; border-left: 1px solid var(--hair); background: var(--page); }
.workbench-explorer { grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--hair); background: var(--page); }
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); text-transform: uppercase; }
.workbench-explorer-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workbench-explorer-head small { color: var(--faint); font-size: var(--fs-micro); }
.workbench-explorer-head small { margin-left: auto; }
.workbench-open-editor {
@@ -1251,9 +1263,9 @@ body { background: var(--page) !important; }
.workbench-error.workbench-close-error { flex: none; font-size: var(--fs-meta); white-space: pre-line; overflow-wrap: anywhere; }
.workbench-error { padding: 8px 12px; border-bottom: 1px solid var(--err); color: var(--err-ink); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
@media (max-width: 760px) {
.workbench-body { grid-template-columns: minmax(0, 1fr); }
.workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr); }
.workbench-body:not([data-files]) .workbench-explorer { display: none; }
.workbench-body[data-files] .workbench-canvas { display: none; }
.workbench-body:not([data-files]) .workbench-canvas { grid-column: 1; }
}
/* Settings is one stable layer in the current window. */
@@ -0,0 +1,97 @@
import { useState } from "react";
import { t } from "../i18n";
import type { PickedElement } from "../port/host";
import type { SourceEdit, StyleProperty } from "./htmlsource";
import type { PageEditFailure, PageEditor } from "./pageedit";
const FAILED: Record<PageEditFailure, () => string> = {
not_found: () => t("workbench.edit_not_found"),
ambiguous: () => t("workbench.edit_ambiguous"),
unmappable: () => t("workbench.edit_unmappable"),
mismatch: () => t("workbench.edit_mismatch"),
not_plain_text: () => t("workbench.edit_not_plain_text"),
bad_value: () => t("workbench.edit_bad_value"),
outside: () => t("workbench.edit_outside"),
conflict: () => t("workbench.edit_conflict"),
failed: () => t("workbench.edit_failed"),
};
const STYLES: { property: StyleProperty; label: () => string }[] = [
{ property: "color", label: () => t("workbench.edit_color") },
{ property: "backgroundColor", label: () => t("workbench.edit_background") },
{ property: "fontSize", label: () => t("workbench.edit_font_size") },
];
interface Props {
element: PickedElement;
/** The disk path of the file the page was opened from, or "" for any other page. */
filePath: string;
editor?: PageEditor;
onSaved: () => void;
onClose: () => void;
}
export function BrowserElementEdit({ element: picked, filePath, editor, onSaved, onClose }: Props) {
// What the file holds now: a save moves it, so the next one is measured from there.
const [element, setElement] = useState(picked);
const [text, setText] = useState(picked.text ?? "");
const [styles, setStyles] = useState<Partial<Record<StyleProperty, string>>>(picked.style ?? {});
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const editable = !!filePath && !!editor;
const changes = (): SourceEdit[] => {
const out: SourceEdit[] = [];
if (element.text && text !== element.text) out.push({ kind: "text", before: element.text, after: text });
for (const { property } of STYLES) {
const value = (styles[property] ?? "").trim();
if (value && value !== element.style?.[property]) out.push({ kind: "style", property, value });
}
return out;
};
const save = async () => {
const edits = changes();
if (!editor || !edits.length) {
setNote({ kind: "error", text: t("workbench.edit_nothing") });
return;
}
setBusy(true);
setNote(null);
const out = await editor(filePath, { selector: element.selector, tagName: element.tagName }, edits);
setBusy(false);
if (out.saved) {
setElement({ ...element, text: element.text ? text : element.text, style: { ...element.style, ...Object.fromEntries(edits.flatMap((e) => (e.kind === "style" ? [[e.property, e.value]] : []))) } });
setNote({ kind: "ok", text: t("workbench.edit_saved", { path: out.path }) });
onSaved();
} else {
setNote({ kind: "error", text: FAILED[out.code]() });
}
};
return (
<form className="belement" aria-label={t("workbench.edit_picked_element")} data-action-submit="browser.edit-save" onSubmit={(event) => { event.preventDefault(); void save(); }}>
<header className="belement-head">
<code title={element.selector}>{element.selector}</code>
<button type="button" data-action="browser.edit-close" onClick={onClose}>{t("workbench.edit_close")}</button>
</header>
{!editable && <p className="studio-browser-note">{t("workbench.edit_not_file")}</p>}
{element.text !== undefined && element.text !== "" && (
<label className="belement-field">
<span>{t("workbench.edit_text")}</span>
<input value={text} disabled={!editable || busy} onChange={(event) => setText(event.target.value)} spellCheck={false} />
</label>
)}
{STYLES.map(({ property, label }) => (
<label className="belement-field" key={property}>
<span>{label()}</span>
<input value={styles[property] ?? ""} disabled={!editable || busy} onChange={(event) => setStyles((v) => ({ ...v, [property]: event.target.value }))} spellCheck={false} />
</label>
))}
<div className="belement-foot">
<button type="submit" data-action="browser.edit-save" disabled={!editable || busy}>{busy ? t("workbench.edit_saving") : t("workbench.edit_save")}</button>
{note && <p role={note.kind === "error" ? "alert" : "status"} className={note.kind === "error" ? "studio-browser-error" : "studio-browser-note"}>{note.text}</p>}
</div>
</form>
);
}
+67 -5
View File
@@ -1,14 +1,16 @@
import { useEffect, useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react";
import { ArrowLeft, ArrowRight, ExternalLink, Globe, MousePointerClick, RotateCw } from "lucide-react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, BrowserTab } from "../port/port";
import { host, type BrowserControl, type BrowserLoadFailure, type BrowserRefusal } from "../port/host";
import { host, type BrowserControl, type BrowserLoadFailure, type BrowserPickEnd, type BrowserRefusal, type PickedElement } from "../port/host";
import { SWAP_MARK } from "./swap";
import { occluded, visibleBox } from "./occlusion";
import { BrowserFailure } from "./BrowserFailure";
import { AgentPageNote } from "./AgentPageNote";
import { readPageTabs } from "./browser_open";
import { BrowserElementEdit } from "./BrowserElementEdit";
import type { PageEditor } from "./pageedit";
const START = "reasonix://start";
@@ -181,13 +183,20 @@ export function localFilePath(url: string): string {
type OpenDocument = (path: string) => Promise<void>;
export function AgentBrowserPanel(props: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument }) {
export function AgentBrowserPanel(props: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
if (host().drawsBrowserViews()) return <AgentBrowserView {...props} />;
const current = props.tabs.find((tab) => tab.active) ?? props.tabs[0];
return <div className="bpanel">{current && <AgentPageNote tab={current} />}</div>;
}
function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument }) {
const PICK_ENDED: Partial<Record<BrowserPickEnd, () => string>> = {
timeout: () => t("workbench.pick_ended_timeout"),
hidden: () => t("workbench.pick_ended_hidden"),
navigation: () => t("workbench.pick_ended_navigation"),
gone: () => t("workbench.pick_ended_gone"),
};
function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument, editPage }: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
const slot = useRef<HTMLDivElement>(null);
const [picked, setPicked] = useState("");
const current = tabs.find((tab) => tab.target === picked) ?? tabs.find((tab) => tab.active) ?? tabs[0];
@@ -196,6 +205,9 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
const [refused, setRefused] = useState<BrowserRefusal>("");
const [docFailed, setDocFailed] = useState("");
const [frame, setFrame] = useState("");
const [picking, setPicking] = useState(false);
const [element, setElement] = useState<PickedElement | null>(null);
const [pickEnd, setPickEnd] = useState("");
const target = current?.target ?? "";
const failure = failures[target];
@@ -276,6 +288,41 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
};
}, [shown, target, failure]);
// A pick belongs to the page it was armed on: another page, or another
// address on the same one, leaves it nothing to point at.
useEffect(() => {
setElement(null);
setPickEnd("");
if (!picking) return;
setPicking(false);
if (target) host().cancelBrowserPick(target);
}, [target, current?.url]);
const pick = () => {
if (!target) return;
if (picking) {
host().cancelBrowserPick(target);
return;
}
setPicking(true);
setPickEnd("");
const armed = target;
void host()
.pickBrowserElement(armed, {
color: t("workbench.edit_color"),
background: t("workbench.edit_background"),
font: t("workbench.edit_font_size"),
frame: t("workbench.pick_ended_gone"),
shadow: t("workbench.pick_ended_gone"),
})
.then((answer) => {
setPicking(false);
if (answer.cancelled) {
setPickEnd(PICK_ENDED[answer.reason]?.() ?? "");
return;
}
setElement(answer.element);
});
};
const control = (action: BrowserControl) => target && host().controlBrowserView(target, action);
const open = (to: string) => {
if (!target) return;
@@ -308,6 +355,9 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="back" aria-label={t("workbench.back")} title={t("workbench.back")} onClick={() => control("back")}><ArrowLeft size={16} /></button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="forward" aria-label={t("workbench.forward")} title={t("workbench.forward")} onClick={() => control("forward")}><ArrowRight size={16} /></button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="reload" aria-label={t("common.reload")} title={t("common.reload")} onClick={() => control("reload")}><RotateCw size={16} /></button>
{host().picksBrowserElements() && (
<button className="bbtn" data-action="browser.pick" aria-pressed={picking} disabled={!target || !!failure} aria-label={t("workbench.pick_element")} title={t("workbench.pick_element")} onClick={pick}><MousePointerClick size={16} /></button>
)}
<button className="bbtn" data-action="browser.external" disabled={!documentPath && !/^https?:\/\//i.test(current?.url ?? "")} aria-label={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} title={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} onClick={() => (documentPath ? openDocument() : current && void host().openExternal(current.url))}><ExternalLink size={16} /></button>
<form className="baddr" data-action="browser.navigate" data-target={current?.id} onSubmit={go}>
<input data-action="browser.navigate" data-target={current?.id} value={address} spellCheck={false} aria-label={t("workbench.address")} onChange={(event) => { setAddress(event.target.value); setRefused(""); }} />
@@ -316,6 +366,18 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
</div>
{documentPath && <p className="studio-browser-note" data-action="browser.document-hint">{t("workbench.opens_this_file_with_the")}</p>}
{docFailed && <p className="studio-browser-error" role="alert">{docFailed}</p>}
{picking && <p className="studio-browser-note" role="status">{t("workbench.picking_element")}</p>}
{pickEnd && <p className="studio-browser-note" role="status">{pickEnd}</p>}
{element && (
<BrowserElementEdit
key={element.selector}
element={element}
filePath={localFilePath(current?.url ?? "")}
editor={editPage}
onSaved={() => control("reload")}
onClose={() => setElement(null)}
/>
)}
{refused && (
<p className="studio-browser-error" role="alert">
{refused === "network_file" ? t("workbench.a_network_share_cannot_be") : t("workbench.enter_a_valid_http_or")}
@@ -337,6 +399,6 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
);
}
export function BrowserPanel(props: ManualProps | { tabs: BrowserTab[]; shown: boolean; onOpenDocument?: OpenDocument }) {
export function BrowserPanel(props: ManualProps | { tabs: BrowserTab[]; shown: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
return "tabs" in props ? <AgentBrowserPanel {...props} /> : <ManualBrowserPanel {...props} />;
}
@@ -43,7 +43,7 @@ describe("messages between sessions", () => {
await userEvent.type(box, "迁移完成");
await userEvent.click(button);
expect(send).toHaveBeenCalledWith("s-2", "迁移完成");
expect(await screen.findByText(/对方的用户批准后才会读到/)).toBeTruthy();
expect(await screen.findByText(/对方的模型会直接读到/)).toBeTruthy();
expect((box as HTMLTextAreaElement).value).toBe("");
});
@@ -405,12 +405,9 @@ describe("WorkbenchPanel", () => {
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
// A file opened from the explorer steps the list aside rather than closing it,
// so closing that file comes back to the list. Folding the column instead
// leaves someone who opened one file with no workbench at all. With no tabs
// the launcher draws a "文件" card as well as the toolbar toggle, so the
// The launcher draws a "文件" card as well as the toolbar toggle, so the
// toolbar button is named by its action rather than its label.
it("returns to the explorer when a file opened from it is closed", async () => {
it("keeps the explorer beside a file opened from it, and after that file is closed", async () => {
const user = userEvent.setup();
const onCloseManual = vi.fn();
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={onCloseManual} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
@@ -420,18 +417,14 @@ describe("WorkbenchPanel", () => {
await user.click(await screen.findByRole("button", { name: "README.md" }));
expect(screen.getByRole("tab", { name: "README.md" })).toBeTruthy();
// The list stepped aside for the file.
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await user.click(screen.getByRole("button", { name: "关闭 README.md" }));
expect(onCloseManual).not.toHaveBeenCalled();
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
// The list was stepped aside for that file, not closed on behalf of the one
// behind it: closing the file leaves the other tab selected and alone, so the
// list must stay out of its way and the column must not fold.
it("does not bring the explorer back over a tab that is still open", async () => {
it("leaves the explorer as it was when the file picked from it is closed over another tab", async () => {
const user = userEvent.setup();
const onCloseManual = vi.fn();
const page = { id: "b1", target: "t1", url: "https://example.com", title: "My page", active: false };
@@ -439,17 +432,14 @@ describe("WorkbenchPanel", () => {
await user.click(container.querySelector<HTMLElement>('[data-action="workbench.files"]')!);
await user.click(await screen.findByRole("button", { name: "README.md" }));
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await user.click(screen.getByRole("button", { name: "关闭 README.md" }));
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
expect(onCloseManual).not.toHaveBeenCalled();
expect(screen.getByRole("tab", { name: "My page" }).getAttribute("aria-selected")).toBe("true");
});
// Closing every browser tab at once while a file from the explorer is still
// open leaves that file behind, so the column stays and the list stays away.
// The batch goes through the same rule as a single close, which is the point.
it("keeps the column when every browser tab is closed at once beside an explorer file", async () => {
const user = userEvent.setup();
const onCloseManual = vi.fn();
@@ -470,7 +460,7 @@ describe("WorkbenchPanel", () => {
expect(screen.queryByRole("tab", { name: "Agent page" })).toBeNull();
expect(screen.getByRole("tab", { name: "README.md" })).toBeTruthy();
expect(onCloseManual).not.toHaveBeenCalled();
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
it("shows a page the person opened beside the agent's, while the agent's tab stays active", () => {
@@ -518,13 +508,13 @@ describe("WorkbenchPanel", () => {
localStorage.removeItem("rx-show-hidden-files");
});
it("shows the file that was picked from the list rather than leaving the list over it", async () => {
it("shows the file picked from the list and keeps the list beside it", async () => {
const user = userEvent.setup();
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
await user.click(screen.getAllByRole("button", { name: "文件" })[0]);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await user.click(await screen.findByRole("button", { name: "README.md" }));
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe("true");
});
+18 -25
View File
@@ -137,9 +137,6 @@ export function WorkbenchPanel({
[directories, setDirectories] = useState<string[]>([]),
[openFiles, setOpenFiles] = useState<string[]>([]);
const [showFiles, setShowFiles] = useState(false);
// The file picked out of the explorer, if that is how it was opened: closing
// it comes back to the list it was stepped aside from.
const fromExplorer = useRef("");
const [launcher, setLauncher] = useState(false);
const newTab = useRef<HTMLButtonElement>(null);
const toggleLauncher = () => { supersede(); setLauncher((open) => !open); };
@@ -160,10 +157,10 @@ export function WorkbenchPanel({
useOnBump(toggleFiles, toggleExplorer);
const [query, setQuery] = useState(""),
[selected, setSelected] = useState("");
// Docked, the file list hides the canvas a page is drawn in, so choosing any
// surface steps the list aside.
// The list and the canvas sit side by side, so a file stays beside its tree;
// any other surface takes the whole column and steps the list aside.
const select = useCallback((key: string) => {
setShowFiles(false);
if (!key.startsWith("file:")) setShowFiles(false);
setLauncher(false);
setFailed("");
setSelected(key);
@@ -333,11 +330,11 @@ export function WorkbenchPanel({
// source view is one click away.
const filePath = active?.kind === "file" ? active.path : "";
useEffect(() => {
onActiveFile?.(shown && !launcher && !showFiles && filePath ? filePath : undefined);
onActiveFile?.(shown && !launcher && filePath ? filePath : undefined);
return () => onActiveFile?.(undefined);
}, [filePath, shown, launcher, showFiles, onActiveFile]);
}, [filePath, shown, launcher, onActiveFile]);
const textMenu = useWorkbenchTextMenu({
path: filePath, mode, scope, shown: shown && !launcher && !showFiles,
path: filePath, mode, scope, shown: shown && !launcher,
onQuote: onQuoteSelection, onError: setFailed,
});
const grantedFile = parseGrantedFile(filePath);
@@ -359,7 +356,7 @@ export function WorkbenchPanel({
const unpreviewableText = !hasDocument && errorCode === "workspace.file_unreadable";
const revision = file?.revision;
useEffect(() => {
if (mode !== "preview" || !previewable || !shown || showFiles || launcher || previewTab || (!revision && !fileFailed && !failed)) return;
if (mode !== "preview" || !previewable || !shown || launcher || previewTab || (!revision && !fileFailed && !failed)) return;
if (missing) {
setMode("file");
return;
@@ -368,7 +365,7 @@ export function WorkbenchPanel({
setFailed(reason(e));
setMode("file");
});
}, [mode, previewable, shown, showFiles, launcher, previewTab, revision, fileFailed, missing, failed, filePath, openPreview, tabs]);
}, [mode, previewable, shown, launcher, previewTab, revision, fileFailed, missing, failed, filePath, openPreview, tabs]);
useEffect(() => {
if (mode === "preview" && revision) syncPreview(filePath, revision);
}, [mode, filePath, revision, syncPreview]);
@@ -391,8 +388,7 @@ export function WorkbenchPanel({
useEffect(() => {
strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}, [activeKey, surfaces.length]);
// Picking a file is asking to read it. Docked, the list and the file share one
// column, so the list steps aside; side by side it stays where it is.
// Picking a file is asking to read it; the list stays where it is.
const [recentFiles, setRecentFiles] = useState(() => readRecentFiles(workspaceRoot ?? ""));
const openFile = useCallback((path: string) => {
setRecentFiles(rememberFile(workspaceRoot ?? "", path));
@@ -402,11 +398,8 @@ export function WorkbenchPanel({
n.delete(`file:${path}`);
return n;
});
// A file picked while the explorer is shown leaves that list stepped aside
// rather than closed, so closing this file comes back to the list.
fromExplorer.current = showFiles ? path : "";
pick(`file:${path}`);
}, [pick, showFiles, workspaceRoot]);
}, [pick, workspaceRoot]);
useEffect(() => { if (fileRequest) { openFile(fileRequest.path); fileRequest.consumed?.(); } }, [fileRequest, openFile]);
// A rendered document's own links open beside it and its images load from the
// tree. Held stable per document, so typing re-renders the text alone.
@@ -459,7 +452,7 @@ export function WorkbenchPanel({
if (openingBrowser.current === page) openingBrowser.current = null;
}
};
const { close: closeTabs, closing, closeFailure } = useTabClose({ port, surfaces, selected, showFiles: showFiles || launcher, fromExplorer, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, setShowFiles, setLauncher, onCloseManual, onCloseSelected: () => setFailed("") });
const { close: closeTabs, closing, closeFailure } = useTabClose({ port, surfaces, selected, showFiles: showFiles || launcher, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, setLauncher, onCloseManual, onCloseSelected: () => setFailed("") });
const close = (targets: Surface[]) => { supersede(); return terminals.guard(targets, closeTabs); };
const dismissTabMenu = useCallback(() => setTabMenu(null), []);
const [tabMenu, setTabMenu] = useState<{ surface: Surface; x: number; y: number; anchor: HTMLElement } | null>(null);
@@ -628,22 +621,22 @@ export function WorkbenchPanel({
)}
<div className="workbench-content" ref={canvas} hidden={launcher || !active} data-action-contextmenu="workbench.quote-selection" {...textMenu.readerProps}>
<ManualBrowsers ids={browsers} active={active?.kind === "manual" ? active.id : ""} onAddress={noteHost} onExternal={onExternal} scheme={scheme} />
<WorkbenchTerminals port={port} hook={terminals} active={active?.kind === "terminal" ? active.id : ""} shown={shown && !launcher && !showFiles}
<WorkbenchTerminals port={port} hook={terminals} active={active?.kind === "terminal" ? active.id : ""} shown={shown && !launcher}
scheme={scheme} onQuote={onQuoteSelection} onClose={(targets) => void close(targets)} />
{active?.kind === "browser" && (
<AgentBrowserPanel
tabs={[active.tab]}
shown={shown && !launcher}
showTabs={false}
showTabs={false} editPage={preview.edit}
onOpenDocument={(path) => port.openDocument(path)}
/>
)}
{active?.kind === "changes" && (
<WorkbenchChangesPage port={port} changes={changes} branch={gitRepos.repos.find((r) => r.dir === "")?.branch} shown={shown && !launcher && !showFiles}
<WorkbenchChangesPage port={port} changes={changes} branch={gitRepos.repos.find((r) => r.dir === "")?.branch} shown={shown && !launcher}
remote={remote} onOpen={(path) => { openFile(path); setMode("diff"); }} onChanged={onTreeChanged} />
)}
{active?.kind === "job" && <JobRunView key={active.id} port={port} id={active.id} name={active.name} shown={shown && !launcher && !showFiles} />}
{active?.kind === "agent" && <AgentRunView key={active.ref} port={port} run={active} shown={shown && !launcher && !showFiles} onShowCall={onShowCall} />}
{active?.kind === "job" && <JobRunView key={active.id} port={port} id={active.id} name={active.name} shown={shown && !launcher} />}
{active?.kind === "agent" && <AgentRunView key={active.ref} port={port} run={active} shown={shown && !launcher} onShowCall={onShowCall} />}
{active?.kind === "file" && (
<>
<WorkbenchFileBar
@@ -673,7 +666,7 @@ export function WorkbenchPanel({
)}
{mode === "document" || unpreviewableText ? (
<Suspense fallback={<div className="workbench-empty">{t("common.loading")}</div>}>
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher}
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !launcher}
view={documentState(documentStates, filePath)}
refresh={JSON.stringify([changeKey, glance, running, wrote])}
onOpenExternal={!remote && port.opensFiles() && /\.(pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(filePath) ? () => {
@@ -684,7 +677,7 @@ export function WorkbenchPanel({
</Suspense>
) : mode === "preview" ? (
previewTab ? (
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} />
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} editPage={preview.edit} />
) : (
<div className="workbench-empty">{t("common.loading")}</div>
)
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import "./testkit";
import { host, type BrowserControl, type BrowserPick, type BrowserPickLabels } from "../port/host";
import type { BrowserTab } from "../port/port";
import { AgentBrowserPanel } from "./BrowserPanel";
import type { PageEditor } from "./pageedit";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const file: BrowserTab = { id: "b1", target: "v1", title: "Demo", url: "file:///work/site/a.html", active: true };
const web: BrowserTab = { id: "b2", target: "v2", title: "Web", url: "https://example.com/", active: false };
const picked: BrowserPick = {
cancelled: false,
element: { selector: "#note", tagName: "p", text: "hello", style: { color: "#111111", backgroundColor: "#FFFFFF", fontSize: "16px" } },
};
let pick: Mock<(target: string, labels: BrowserPickLabels) => Promise<BrowserPick>>;
let cancel: Mock<(target: string) => void>;
let control: Mock<(target: string, action: BrowserControl) => void>;
beforeEach(() => {
const shell = host();
vi.spyOn(shell, "drawsBrowserViews").mockReturnValue(true);
vi.spyOn(shell, "picksBrowserElements").mockReturnValue(true);
vi.spyOn(shell, "showBrowserView").mockImplementation(() => {});
vi.spyOn(shell, "hideBrowserView").mockImplementation(() => {});
pick = vi.fn(async () => picked);
cancel = vi.fn();
control = vi.fn();
vi.spyOn(shell, "pickBrowserElement").mockImplementation(pick);
vi.spyOn(shell, "cancelBrowserPick").mockImplementation(cancel);
vi.spyOn(shell, "controlBrowserView").mockImplementation(control);
});
const pickButton = () => screen.getByRole("button", { name: "点选页面元素" });
describe("editing a picked element of a local page", () => {
it("offers the picker only where the shell can pick", () => {
vi.spyOn(host(), "picksBrowserElements").mockReturnValue(false);
render(<AgentBrowserPanel tabs={[file]} shown />);
expect(screen.queryByRole("button", { name: "点选页面元素" })).toBeNull();
});
it("saves the changed fields only, and reloads the page that was edited", async () => {
const editor: PageEditor = vi.fn(async () => ({ saved: true as const, path: "a.html" }));
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
expect(pick).toHaveBeenCalledWith("v1", expect.objectContaining({ color: expect.any(String) }));
const form = await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
fireEvent.change(screen.getByLabelText("文字颜色"), { target: { value: "#ff0000" } });
fireEvent.submit(form);
await waitFor(() => expect(editor).toHaveBeenCalledTimes(1));
expect(editor).toHaveBeenCalledWith("/work/site/a.html", { selector: "#note", tagName: "p" }, [
{ kind: "text", before: "hello", after: "bye" },
{ kind: "style", property: "color", value: "#ff0000" },
]);
expect(await screen.findByText("已保存到 a.html")).toBeTruthy();
expect(control).toHaveBeenCalledWith("v1", "reload");
});
it("measures a second save from what the first one wrote", async () => {
const editor: PageEditor = vi.fn(async () => ({ saved: true as const, path: "a.html" }));
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
const form = await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
fireEvent.submit(form);
await screen.findByText("已保存到 a.html");
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "again" } });
fireEvent.submit(form);
await waitFor(() => expect(editor).toHaveBeenCalledTimes(2));
expect((editor as ReturnType<typeof vi.fn>).mock.calls[1][2]).toEqual([{ kind: "text", before: "bye", after: "again" }]);
});
it("says why a save was refused and leaves the page alone", async () => {
const editor: PageEditor = vi.fn(async () => ({ saved: false as const, code: "mismatch" as const }));
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
const form = await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
fireEvent.submit(form);
expect((await screen.findByRole("alert")).textContent).toBe("文件里的文字和页面上显示的不一致,未做修改");
expect(control).not.toHaveBeenCalled();
});
it("does not call the editor when nothing was changed", async () => {
const editor: PageEditor = vi.fn();
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
fireEvent.click(pickButton());
fireEvent.submit(await screen.findByRole("form", { name: "修改所选元素" }));
expect((await screen.findByRole("alert")).textContent).toBe("没有改动");
expect(editor).not.toHaveBeenCalled();
});
it("shows a page that is not a local file but will not save to it", async () => {
const editor: PageEditor = vi.fn();
render(<AgentBrowserPanel tabs={[web]} shown editPage={editor} />);
fireEvent.click(pickButton());
await screen.findByRole("form", { name: "修改所选元素" });
expect(screen.getByText("只有本地网页文件可以在这里修改并保存")).toBeTruthy();
expect((screen.getByRole("button", { name: "保存到文件" }) as HTMLButtonElement).disabled).toBe(true);
});
it("reports why a pick ended without an element, and nothing for Escape", async () => {
pick.mockResolvedValueOnce({ cancelled: true, reason: "navigation" });
render(<AgentBrowserPanel tabs={[file]} shown />);
fireEvent.click(pickButton());
expect(await screen.findByText("页面已跳转或重新加载,点选已取消")).toBeTruthy();
pick.mockResolvedValueOnce({ cancelled: true, reason: "escape" });
fireEvent.click(pickButton());
await waitFor(() => expect(screen.queryByText("页面已跳转或重新加载,点选已取消")).toBeNull());
expect(screen.queryByRole("status")).toBeNull();
});
it("puts the picker away when the button is pressed again", async () => {
let end: (answer: BrowserPick) => void = () => {};
pick.mockImplementationOnce(() => new Promise<BrowserPick>((done) => { end = done; }));
render(<AgentBrowserPanel tabs={[file]} shown />);
fireEvent.click(pickButton());
expect(await screen.findByText("在页面上点一个元素,按 Esc 取消")).toBeTruthy();
fireEvent.click(pickButton());
expect(cancel).toHaveBeenCalledWith("v1");
await act(async () => end({ cancelled: true, reason: "dismissed" }));
expect(screen.queryByText("在页面上点一个元素,按 Esc 取消")).toBeNull();
});
it("drops the picked element when another page is shown", async () => {
render(<AgentBrowserPanel tabs={[file, web]} shown />);
fireEvent.click(pickButton());
await screen.findByRole("form", { name: "修改所选元素" });
fireEvent.click(screen.getByRole("tab", { name: "Web" }));
await waitFor(() => expect(screen.queryByRole("form", { name: "修改所选元素" })).toBeNull());
});
});
@@ -19,8 +19,10 @@ it("reports actual tab selection and clears it when the canvas is hidden or its
fireEvent.click(screen.getAllByRole("button", { name: "one.md" })[0]);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getByRole("button", { name: "文件" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getByRole("button", { name: "新建标签" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith(undefined));
fireEvent.click(screen.getByRole("button", { name: "文件" }));
fireEvent.click(screen.getByRole("button", { name: "新建标签" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getAllByRole("button", { name: "关闭 one.md" })[0]);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("other/one.md"));
@@ -68,6 +68,8 @@ it.each(["rail", "sidebar", "palette"])("opens only the focused pane's files fro
expect(filesShown(background)).toBe(false);
expect(focused().querySelectorAll(".workbench-tab")).toHaveLength(0);
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
expect(filesShown()).toBe(true);
await openFiles();
expect(filesShown()).toBe(false);
await openFiles();
expect(filesShown()).toBe(true);
@@ -111,14 +113,15 @@ it("does not replay a Files request into a newly focused session", async () => {
await palette("文件");
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
const previous = focused();
const kept = filesShown(previous);
fireEvent.click(document.querySelector('.ptab[data-pane="r1"]')!);
expect(focused()).not.toBe(previous);
expect(filesShown()).toBe(false);
await palette("文件");
expect(filesShown()).toBe(true);
expect(filesShown(previous)).toBe(false);
expect(filesShown(previous)).toBe(kept);
fireEvent.click(document.querySelector('.ptab[data-pane="r2"]')!);
expect(filesShown()).toBe(false);
expect(filesShown()).toBe(kept);
});
const railFiles = () => within(document.querySelector<HTMLElement>(".nav")!).getByRole("button", { name: "文件" });
@@ -101,7 +101,7 @@ it("keeps a superseded successful user tab owned while a separate preview claim
});
describe("opening an HTML file from the explorer", () => {
it.each(["Files", "launcher"])("does not start a hidden preview after a late file read and newer %s choice", async (choice) => {
it.each(["Files", "launcher"])("starts a preview only while its file is in view after a late read and newer %s choice", async (choice) => {
const user = userEvent.setup();
const { port, open } = setup();
let read!: (file: Awaited<ReturnType<MockPort["workspaceFile"]>>) => void;
@@ -113,11 +113,14 @@ describe("opening an HTML file from the explorer", () => {
if (choice === "Files") await user.click(container.querySelector('[data-action="workbench.files"]')!);
else await user.click(screen.getByRole("button", { name: "新建标签" }));
await act(async () => read({ path: "index.html", content: "<h1>Late</h1>", revision: "r1" }));
if (choice === "Files") {
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
return;
}
expect(open).not.toHaveBeenCalled();
if (choice === "Files") expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
else expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
if (choice === "Files") await user.click(screen.getByRole("tab", { name: "index.html" }).querySelector(".workbench-tab-pick")!);
else await user.keyboard("{Escape}");
expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
await user.keyboard("{Escape}");
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
});
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { patchSource } from "./htmlsource";
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Demo</title>
<style>h1 > b { color: red }</style>
</head>
<body>
<!-- <h1 id="ghost">not an element</h1> -->
<h1 id="title" class="big">Hello <b>world</b></h1>
<p id="note">Plain &amp; simple</p>
<p data-testid="styled" style="color: red; margin: 0">Styled</p>
<ul><li>one<li>two</ul>
<script>if (1 < 2) document.title = "<h1 id='x'>"</script>
</body>
</html>
`;
describe("patchSource", () => {
it("replaces the text of an element and leaves every other byte alone", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "text", before: "Plain & simple", after: "Fish & <chips>" });
expect(out).toEqual({ ok: true, content: PAGE.replace("Plain &amp; simple", "Fish &amp; &lt;chips&gt;") });
});
it("refuses text whose current words are not the ones the person saw", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "text", before: "something else", after: "x" });
expect(out).toEqual({ ok: false, code: "mismatch" });
});
it("refuses text edits on an element that has children of its own", () => {
const out = patchSource(PAGE, { selector: "#title", tagName: "h1" }, { kind: "text", before: "Hello world", after: "Bye" });
expect(out).toEqual({ ok: false, code: "not_plain_text" });
});
it("refuses an element whose end tag is implied", () => {
const out = patchSource(PAGE, { selector: "li:first-child", tagName: "li" }, { kind: "text", before: "one", after: "1" });
expect(out).toEqual({ ok: false, code: "unmappable" });
});
it("adds an inline style where the element has none", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value: "#336699" });
expect(out).toEqual({ ok: true, content: PAGE.replace('<p id="note">', '<p style="color: #336699" id="note">') });
});
it("replaces one declaration of an existing style and keeps the rest", () => {
const out = patchSource(PAGE, { selector: '[data-testid="styled"]', tagName: "p" }, { kind: "style", property: "color", value: "blue" });
expect(out).toEqual({ ok: true, content: PAGE.replace('style="color: red; margin: 0"', 'style="color: blue; margin: 0"') });
});
it("appends a declaration the style did not have, writing camelCase as CSS", () => {
const out = patchSource(PAGE, { selector: '[data-testid="styled"]', tagName: "p" }, { kind: "style", property: "backgroundColor", value: "#fff" });
expect(out).toEqual({ ok: true, content: PAGE.replace('style="color: red; margin: 0"', 'style="color: red; margin: 0; background-color: #fff"') });
});
it("refuses a value that could leave the declaration", () => {
for (const value of ['red"; x="', "red; display: none", "a{b}", "<b>", "x\ny"]) {
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value });
expect(out).toEqual({ ok: false, code: "bad_value" });
}
});
it("does not read markup inside comments or scripts as elements", () => {
const out = patchSource(PAGE, { selector: "#ghost", tagName: "h1" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "not_found" });
const next = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(next.ok).toBe(true);
});
it("refuses a selector naming a different tag than the pick reported", () => {
const out = patchSource(PAGE, { selector: "#note", tagName: "div" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "not_found" });
});
it("refuses a selector that names more than one element", () => {
const out = patchSource(PAGE, { selector: "p", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "ambiguous" });
});
it("refuses a page the parser rebuilt beyond what the source says", () => {
const table = "<table><tr><td id=c>x</td></tr></table>";
const ok = patchSource(table, { selector: "#c", tagName: "td" }, { kind: "style", property: "color", value: "red" });
expect(ok).toEqual({ ok: true, content: '<table><tr><td style="color: red" id=c>x</td></tr></table>' });
const broken = patchSource("<b><p id=q>y</b>", { selector: "#q", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(broken).toEqual({ ok: false, code: "unmappable" });
});
it("refuses an invalid selector instead of throwing", () => {
const out = patchSource(PAGE, { selector: "###", tagName: "p" }, { kind: "style", property: "color", value: "red" });
expect(out).toEqual({ ok: false, code: "not_found" });
});
});
+206
View File
@@ -0,0 +1,206 @@
/** What a person can change on a picked element of a page that is a file. */
export type SourceEdit =
| { kind: "text"; before: string; after: string }
| { kind: "style"; property: StyleProperty; value: string };
export type StyleProperty = "color" | "backgroundColor" | "fontSize" | "fontWeight";
/** Why a source could not take the edit. Each answer is the file's, not the page's:
* the page may look fine while the file cannot say where an element lives. */
export type PatchFailure = "not_found" | "ambiguous" | "unmappable" | "mismatch" | "not_plain_text" | "bad_value";
export type Patched = { ok: true; content: string } | { ok: false; code: PatchFailure };
export interface Target {
selector: string;
tagName: string;
}
interface Tag {
name: string;
start: number;
end: number;
/** The first end tag or start tag after this one, which closes its text. */
next?: Token;
}
interface Token {
closing: boolean;
name: string;
start: number;
}
const RAW_TEXT = new Set(["script", "style", "textarea", "title"]);
// Elements the parser makes without a tag in the source.
const IMPLIED = new Set(["html", "head", "body", "tbody"]);
const CSS_NAME: Record<StyleProperty, string> = {
color: "color",
backgroundColor: "background-color",
fontSize: "font-size",
fontWeight: "font-weight",
};
// The source's start tags in document order, found without building a tree: a
// comment, a doctype and the body of a raw-text element hold no tags of the
// page's. A scan that cannot tell is reported as null, never guessed at.
function scan(source: string): { starts: Tag[]; tokens: Token[] } | null {
const starts: Tag[] = [];
const tokens: Token[] = [];
let at = 0;
while (at < source.length) {
const open = source.indexOf("<", at);
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0) return null;
at = close + 3;
continue;
}
if (source[open + 1] === "!" || source[open + 1] === "?") {
const close = source.indexOf(">", open);
if (close < 0) return null;
at = close + 1;
continue;
}
const match = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(source.slice(open, open + 64));
if (!match) {
at = open + 1;
continue;
}
const end = tagEnd(source, open + match[0].length);
if (end < 0) return null;
const name = match[2].toLowerCase();
const token: Token = { closing: match[1] === "/", name, start: open };
tokens.push(token);
if (!token.closing) starts.push({ name, start: open, end });
at = end;
if (!token.closing && RAW_TEXT.has(name)) {
const close = new RegExp(`</${name}[\\s/>]`, "i").exec(source.slice(at));
if (!close) return null;
at += close.index;
}
}
for (const tag of starts) {
const index = tokens.findIndex((token) => token.start === tag.start);
tag.next = tokens[index + 1];
}
return { starts, tokens };
}
// The offset just past the ">" that ends a tag, honouring quoted values.
function tagEnd(source: string, from: number): number {
let quote = "";
for (let i = from; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === quote) quote = "";
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === ">") {
return i + 1;
}
}
return -1;
}
// Which source tag made each element of the parsed page. The parser may add
// elements the source never wrote (html, head, body, tbody); anything else it
// added, or dropped, means the source cannot be read off the page by position.
function align(elements: Element[], starts: Tag[]): Map<Element, Tag> | null {
const out = new Map<Element, Tag>();
let i = 0;
for (const el of elements) {
const name = el.localName.toLowerCase();
if (starts[i] && starts[i].name === name) {
out.set(el, starts[i++]);
} else if (!IMPLIED.has(name)) {
return null;
}
}
return i === starts.length ? out : null;
}
const collapse = (text: string) => text.replace(/\s+/g, " ").trim();
const escapeText = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escapeAttr = (text: string) => text.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
// A value that stays inside one declaration. A rejected one is refused rather
// than cleaned: what was typed is not what would be saved.
const SAFE_VALUE = /^[^;{}<>"\r\n\\]{1,80}$/;
function declarations(style: string): [string, string][] {
const out: [string, string][] = [];
let depth = 0;
let quote = "";
let from = 0;
const flush = (to: number) => {
const part = style.slice(from, to);
const colon = part.indexOf(":");
if (colon > 0) out.push([part.slice(0, colon).trim(), part.slice(colon + 1).trim()]);
};
for (let i = 0; i < style.length; i++) {
const c = style[i];
if (quote) {
if (c === quote) quote = "";
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === "(") {
depth++;
} else if (c === ")") {
depth = Math.max(0, depth - 1);
} else if (c === ";" && depth === 0) {
flush(i);
from = i + 1;
}
}
flush(style.length);
return out;
}
function withStyle(el: Element, tag: Tag, source: string, property: StyleProperty, value: string): Patched {
const name = CSS_NAME[property];
const had = declarations(el.getAttribute("style") ?? "");
const index = had.findIndex(([key]) => key.toLowerCase() === name);
const next: [string, string][] = index >= 0 ? had.map((d, i) => (i === index ? [d[0], value] : d)) : [...had, [name, value]];
const written = ` style="${escapeAttr(next.map(([k, v]) => `${k}: ${v}`).join("; "))}"`;
const body = source.slice(tag.start, tag.end);
const attr = /\sstyle\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i.exec(body);
if (attr) {
const from = tag.start + attr.index;
return { ok: true, content: source.slice(0, from) + written + source.slice(from + attr[0].length) };
}
const at = tag.start + 1 + tag.name.length;
return { ok: true, content: source.slice(0, at) + written + source.slice(at) };
}
function withText(el: Element, tag: Tag, source: string, edit: { before: string; after: string }): Patched {
if (el.children.length > 0 || RAW_TEXT.has(tag.name)) return { ok: false, code: "not_plain_text" };
if (collapse(el.textContent ?? "") !== collapse(edit.before)) return { ok: false, code: "mismatch" };
const close = tag.next;
if (!close || !close.closing || close.name !== tag.name) return { ok: false, code: "unmappable" };
return { ok: true, content: source.slice(0, tag.end) + escapeText(edit.after) + source.slice(close.start) };
}
/** Applies one edit to the source of a page. The element the person picked is
* found again in the file, not trusted from the page: the page may have been
* changed by its own scripts since the file was written. */
export function patchSource(source: string, target: Target, edit: SourceEdit): Patched {
if (edit.kind === "style" && !SAFE_VALUE.test(edit.value)) return { ok: false, code: "bad_value" };
const scanned = scan(source);
if (!scanned) return { ok: false, code: "unmappable" };
const doc = new DOMParser().parseFromString(source, "text/html");
let found: Element[];
try {
found = [...doc.querySelectorAll(target.selector)];
} catch {
return { ok: false, code: "not_found" };
}
const el = found[0];
if (!el || el.localName.toLowerCase() !== target.tagName.toLowerCase()) return { ok: false, code: "not_found" };
if (found.length > 1) return { ok: false, code: "ambiguous" };
const tags = align([...doc.querySelectorAll("*")], scanned.starts);
const tag = tags?.get(el);
if (!tag) return { ok: false, code: "unmappable" };
return edit.kind === "text" ? withText(el, tag, source, edit) : withStyle(el, tag, source, edit.property, edit.value);
}
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { HttpError } from "../port/http_error";
import type { AgentPort } from "../port/port";
import { insideWorkspace, pageEditor } from "./pageedit";
const PAGE = '<!doctype html><html><body><p id="a">hello</p></body></html>';
const target = { selector: "#a", tagName: "p" };
function portWith(over: Partial<Record<"workspaceFile" | "saveWorkspaceFile", unknown>> = {}) {
const save = vi.fn(async (file: { content: string }) => file);
const port = {
workspaces: async () => ({ current: "/work/site" }),
workspaceFile: vi.fn(async (path: string) => ({ path, content: PAGE, revision: "r1" })),
saveWorkspaceFile: save,
...over,
} as unknown as AgentPort;
return { port, save };
}
describe("insideWorkspace", () => {
it("names a file under the workspace by its relative path", () => {
expect(insideWorkspace("/work/site", "/work/site/pages/a.html")).toBe("pages/a.html");
expect(insideWorkspace("C:\\work\\site\\", "c:/Work/Site/a.html")).toBe("a.html");
});
it("answers nothing for a path outside, beside, or escaping the workspace", () => {
expect(insideWorkspace("/work/site", "/work/site2/a.html")).toBe("");
expect(insideWorkspace("/work/site", "/elsewhere/a.html")).toBe("");
expect(insideWorkspace("/work/site", "/work/site/../x/a.html")).toBe("");
expect(insideWorkspace("/work/site", "/work/site")).toBe("");
});
});
describe("pageEditor", () => {
it("writes every edit in one save, against the revision it read", async () => {
const { port, save } = portWith();
const out = await pageEditor(port)("/work/site/a.html", target, [
{ kind: "text", before: "hello", after: "bye" },
{ kind: "style", property: "color", value: "red" },
]);
expect(out).toEqual({ saved: true, path: "a.html" });
expect(save).toHaveBeenCalledTimes(1);
expect(save.mock.calls[0][0]).toEqual({
path: "a.html",
revision: "r1",
content: '<!doctype html><html><body><p style="color: red" id="a">bye</p></body></html>',
});
});
it("saves nothing when any one edit cannot be placed", async () => {
const { port, save } = portWith();
const out = await pageEditor(port)("/work/site/a.html", target, [
{ kind: "style", property: "color", value: "red" },
{ kind: "text", before: "not what is there", after: "x" },
]);
expect(out).toEqual({ saved: false, code: "mismatch" });
expect(save).not.toHaveBeenCalled();
});
it("does not touch a file outside the workspace", async () => {
const { port, save } = portWith();
const out = await pageEditor(port)("/tmp/a.html", target, [{ kind: "style", property: "color", value: "red" }]);
expect(out).toEqual({ saved: false, code: "outside" });
expect(port.workspaceFile).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it("tells a file that changed under the page apart from a write that failed", async () => {
const changed = new HttpError(409, "changed", { code: "workspace.file_changed" }, true);
const a = portWith({ saveWorkspaceFile: vi.fn().mockRejectedValue(changed) });
expect(await pageEditor(a.port)("/work/site/a.html", target, [{ kind: "style", property: "color", value: "red" }])).toEqual({ saved: false, code: "conflict" });
const b = portWith({ saveWorkspaceFile: vi.fn().mockRejectedValue(new Error("disk")) });
expect(await pageEditor(b.port)("/work/site/a.html", target, [{ kind: "style", property: "color", value: "red" }])).toEqual({ saved: false, code: "failed" });
});
});
+47
View File
@@ -0,0 +1,47 @@
import { HttpError } from "../port/http_error";
import type { AgentPort } from "../port/port";
import { patchSource, type PatchFailure, type SourceEdit, type Target } from "./htmlsource";
export type PageEditFailure = PatchFailure | "outside" | "conflict" | "failed";
export type PageEditOutcome = { saved: true; path: string } | { saved: false; code: PageEditFailure };
/** Writes edits made on a page back to the file the page was opened from. */
export type PageEditor = (diskPath: string, target: Target, edits: SourceEdit[]) => Promise<PageEditOutcome>;
const flat = (path: string) => path.replaceAll("\\", "/");
/** The workspace-relative path of a disk path inside the workspace, or "". */
export function insideWorkspace(root: string, disk: string): string {
const base = flat(root).replace(/\/+$/, "");
const path = flat(disk);
const windows = /^[A-Za-z]:/.test(base);
const head = `${base}/`;
const inside = windows ? path.toLowerCase().startsWith(head.toLowerCase()) : path.startsWith(head);
if (!inside) return "";
const rest = path.slice(head.length);
return rest && !rest.split("/").some((part) => part === ".." || part === "") ? rest : "";
}
export function pageEditor(port: AgentPort): PageEditor {
return async (disk, target, edits) => {
try {
const root = (await port.workspaces()).current;
const path = root ? insideWorkspace(root, disk) : "";
if (!path) return { saved: false, code: "outside" };
const file = await port.workspaceFile(path);
let content = file.content;
for (const edit of edits) {
const next = patchSource(content, target, edit);
if (!next.ok) return { saved: false, code: next.code };
content = next.content;
}
if (content === file.content) return { saved: true, path };
await port.saveWorkspaceFile({ ...file, content });
return { saved: true, path };
} catch (e) {
const code = e instanceof HttpError ? e.reason?.code : "";
return { saved: false, code: code === "workspace.file_changed" ? "conflict" : "failed" };
}
};
}
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
import { host } from "../port/host";
import type { AgentPort, BrowserTab } from "../port/port";
import { preparePage } from "./browser_open";
import { pageEditor, type PageEditor } from "./pageedit";
import { UNREAD_TABS } from "./BrowserPanel";
const HTML = /\.html?$/i;
@@ -40,6 +41,8 @@ export interface HtmlPreview {
tabFor(path: string): BrowserTab | undefined;
/** Puts the file in a page beside the agent's, or finds the one already there. */
open(path: string, revision: string | undefined): Promise<void>;
/** Writes an edit made on a previewed page back to its file; none for a remote workspace. */
edit: PageEditor | undefined;
/** Reloads the page when the file on disk is no longer the one it loaded. */
sync(path: string, revision: string): void;
}
@@ -112,5 +115,7 @@ export function useHtmlPreview(port: AgentPort, all: BrowserTab[], remote: boole
[owned],
);
return { tabs, pending: claim !== null, can, tabFor, open, sync };
const edit = useMemo(() => (remote ? undefined : pageEditor(port)), [port, remote]);
return { tabs, pending: claim !== null, can, tabFor, open, sync, edit };
}
+5 -14
View File
@@ -10,10 +10,10 @@ interface Props {
port: AgentPort; surfaces: Surface[]; selected: string; showFiles: boolean;
setBrowsers: Setter<string[]>; setOpenFiles: Setter<string[]>; setAgents: Setter<RunTab[]>; setDismissed: Setter<Set<string>>;
setSelected: Setter<string>; onCloseManual: () => void; onCloseSelected: () => void;
fromExplorer?: { current: string }; setShowFiles?: Setter<boolean>; setLauncher?: Setter<boolean>;
setLauncher?: Setter<boolean>;
}
export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, onCloseManual, onCloseSelected, fromExplorer, setShowFiles, setLauncher }: Props) {
export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, onCloseManual, onCloseSelected, setLauncher }: Props) {
const current = useRef({ surfaces, selected, showFiles });
current.current = { surfaces, selected, showFiles };
const pending = useRef(false);
@@ -42,19 +42,10 @@ export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers,
if (closed.has(current.current.selected)) onCloseSelected();
setSelected((v) => closed.has(v) ? "" : v);
setCloseFailure(failures.join("\n"));
// A file picked out of the explorer leaves that list stepped aside, not
// closed. When that close is the one that would fold the column — nothing
// left behind, the list not open — the list comes back instead. Any other
// close keeps the previous rule, so the list never lands over a tab that
// is still open.
// Closing the last page with no list open leaves the column with something
// to offer rather than folding it away.
if (!current.current.showFiles && current.current.surfaces.every((s) => closed.has(keyOf(s)))) {
if (fromExplorer?.current && closed.has(`file:${fromExplorer.current}`)) {
fromExplorer.current = "";
setLauncher?.(false);
setShowFiles?.(true);
} else if (targets.some((s) => s.kind !== "file")) {
// Closing the last page leaves the column with something to offer
// rather than folding it away: the same place a new tab starts from.
if (targets.some((s) => s.kind !== "file")) {
setLauncher?.(true);
} else {
onCloseManual();
@@ -49,7 +49,7 @@ it('retires an opening whose tab was observed then closed before the response',
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it('does not start a preview after its file read finishes behind a newer Files selection', async () => {
it('starts a preview once its file read finishes with the file still in view beside the tree', async () => {
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
const port = new MockPort();
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
@@ -62,9 +62,7 @@ it('does not start a preview after its file read finishes behind a newer Files s
await waitFor(()=>expect(reading).toHaveBeenCalled());
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
await act(async()=>read({path:'index.html',content:'hello',revision:'r1'}));
expect(open).not.toHaveBeenCalled();
expect(filesShown()).toBe(true);
fireEvent.click(document.querySelector('[data-action-click="workbench.tab"][data-target="file:index.html"]')!);
await waitFor(()=>expect(open).toHaveBeenCalledOnce());
});
@@ -36,12 +36,19 @@ it("offers a terminal in a new tab and opens it as a tab in front", async () =>
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("yes");
});
it("keeps a terminal's screen mounted but not in front when another tab is chosen", async () => {
it("keeps a terminal's screen mounted but not in front while the new-tab launcher is up", async () => {
const port = new MockPort();
await openTerminalFromLauncher(port);
fireEvent.click(document.querySelector('[data-action="workbench.new"]')!);
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("no");
});
it("keeps a terminal's screen in front beside the file list", async () => {
const port = new MockPort();
await openTerminalFromLauncher(port);
fireEvent.click(document.querySelector('[data-action="workbench.new"]')!);
fireEvent.click(document.querySelector('[data-action="workbench.launch-files"]')!);
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("no");
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("yes");
});
it("numbers terminals once there are two, and names a busy one for the command it runs with a dot", async () => {
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import "./testkit";
import { MockPort } from "../port/mock";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { boot, STORAGE } from "../i18n";
beforeEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); });
const mount = (port = new MockPort()) =>
render(<WorkbenchPanel port={port} tabs={[]} manual shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
const filesShown = (c: HTMLElement) => c.querySelector(".workbench-body")!.hasAttribute("data-files");
async function openTree(c: HTMLElement, user: ReturnType<typeof userEvent.setup>) {
await user.click(c.querySelector('[data-action="workbench.files"]')!);
await waitFor(() => expect(c.querySelector('[data-action-click="workbench.file"]')).toBeTruthy());
}
it("keeps the file tree in view when a file is picked from it", async () => {
const user = userEvent.setup();
const { container } = mount();
await openTree(container, user);
const row = container.querySelector<HTMLElement>('[data-action-click="workbench.file"]')!;
const path = row.dataset.target!;
await user.click(row);
expect(screen.getAllByRole("tab").length).toBeGreaterThan(0);
expect(filesShown(container)).toBe(true);
expect(container.querySelector<HTMLElement>(`[data-action-click="workbench.file"][data-target="${path}"]`)).toBeTruthy();
});
it("lets the next file be picked from the same tree without reopening it", async () => {
const user = userEvent.setup();
const port = new MockPort();
vi.spyOn(port, "workspaceFiles").mockResolvedValue({ files: ["a.md", "b.md"], directories: [] });
const { container } = mount(port);
await openTree(container, user);
await user.click(container.querySelector<HTMLElement>('[data-target="a.md"]')!);
await user.click(container.querySelector<HTMLElement>('[data-target="b.md"]')!);
expect(filesShown(container)).toBe(true);
expect(screen.getAllByRole("tab")).toHaveLength(2);
});
it("leaves the tree where the user put it when its file is closed", async () => {
const user = userEvent.setup();
const { container } = mount();
await openTree(container, user);
await user.click(container.querySelector<HTMLElement>('[data-action-click="workbench.file"]')!);
await user.click(screen.getAllByRole("tab")[0].querySelector(".workbench-tab-close")!);
expect(filesShown(container)).toBe(true);
});
it("does not bring the tree back after the user folded it", async () => {
const user = userEvent.setup();
const { container } = mount();
await openTree(container, user);
await user.click(container.querySelector<HTMLElement>('[data-action-click="workbench.file"]')!);
await user.click(container.querySelector('[data-action="workbench.files"]')!);
expect(filesShown(container)).toBe(false);
await user.click(screen.getAllByRole("tab")[0].querySelector(".workbench-tab-close")!);
expect(filesShown(container)).toBe(false);
});
+1 -1
View File
@@ -38,7 +38,7 @@ Boot 效果测试覆盖关闭时缺席、启用时精确的 schema、读取拒
| Tool | Read-only | Description |
| --- | --- | --- |
| `bash` | false | Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. |
| `bash` | false | Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. To search, call tool:grep (contents) or tool:glob (names) through use_capability — not grep, rg or find in the shell; the built-ins honor .gitignore and cap their output. A pipeline over another command's output (git log \| grep) is the exception. |
| `bash_output` | true | Read new output from a background job started with bash(run_in_background=true) or task(run_in_background=true). Returns the output produced since the last bash_output call for that job, plus its status (running/done/failed/killed). Does not block. Omit job_id to list the jobs still running, with their kind, label and elapsed time. |
| `browser_act` | false | Operate a page with real input. Ordered steps stop at the first failure; target by ref, or screenshot x/y. Returns results, page errors and changes. fill replaces, type inserts; press takes keys like Enter or Control+a; select sets a <select>; scroll takes delta_y or delta_x; drag ends at to_ref or to_x/to_y; upload gives a file input workspace files; dialog answers prompts; eval executes arbitrary page JavaScript from script and awaits promises; resize sets the viewport to width×height; back, forward and reload move this tab. |
| `browser_open` | false | Load a page and return its snapshot, one element per line with [eN] refs. url: http(s), about:blank or a workspace file; no url switches to tab; close closes. The user sees tabs: web_fetch reads static text, close tabs when done. |
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/safety/sandbox"
)
@@ -60,7 +61,7 @@ func TestProjectEnvCannotRedirectAuthorityEndpoints(t *testing.T) {
host := shortSocket(t, "HOST-DAEMON")
attacker := shortSocket(t, "ATTACKER")
root := t.TempDir()
root := testenv.TempDir(t)
t.Setenv("HOME", filepath.Join(root, "home"))
t.Setenv("USERPROFILE", filepath.Join(root, "home"))
t.Setenv("DOCKER_HOST", "unix://"+host)
@@ -152,7 +153,7 @@ while time.time() < deadline:
t.Fatalf("the descriptor scan cannot detect an inherited socket, so the check below is vacuous: %q", out)
}
spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{t.TempDir()}, Network: true}
spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{testenv.TempDir(t)}, Network: true}
argv, wrapped := sandbox.CommandArgs(spec, []string{"python3", "-c", scan})
if !wrapped {
t.Fatal("spec did not wrap")
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/state/agentpreset"
@@ -15,7 +16,7 @@ func TestCompositionRetainsLinkedSkillAcrossRestart(t *testing.T) {
for _, layout := range []string{"directory", "file"} {
t.Run(layout, func(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
source := t.TempDir()
source := testenv.TempDir(t)
writeFile(t, source, "SKILL.md", "---\nname: linked\ndescription: linked skill\n---\nORIGINAL SKILL")
writeFile(t, source, "references/guide.md", "ORIGINAL REFERENCE")
target, link := source, filepath.Join(home, "skills", "linked")
@@ -83,7 +84,7 @@ func TestCompositionRetainsLinkedSkillAcrossRestart(t *testing.T) {
func TestCompositionLinkedSkillRejectsLinkedResources(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
source, outside := t.TempDir(), t.TempDir()
source, outside := testenv.TempDir(t), testenv.TempDir(t)
writeFile(t, source, "SKILL.md", "---\nname: linked\ndescription: linked skill\n---\nSKILL")
writeFile(t, outside, "private.txt", "must not be retained")
if err := os.Symlink(filepath.Join(outside, "private.txt"), filepath.Join(source, "reference.md")); err != nil {
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/plugin"
@@ -21,7 +22,7 @@ func TestLiveCompositionDoesNotCaptureUnusedSkillAssets(t *testing.T) {
home, root := compositionTestWorkspace(t, "openai")
writeFile(t, home, "skills/templates/SKILL.md", "---\nname: templates\ndescription: template library\n---\nRead templates when needed.")
// An unused link cannot participate in assembling a capability grant.
if err := os.Symlink(t.TempDir(), filepath.Join(home, "skills/templates/unused")); err != nil {
if err := os.Symlink(testenv.TempDir(t), filepath.Join(home, "skills/templates/unused")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
d := agentcomposition.Builtins()[0]
@@ -13,6 +13,7 @@ import (
"testing"
"reasonix/internal/base/secrets"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/safety/sandbox"
)
@@ -26,11 +27,11 @@ const probeSecret = "REASONIX-PROBE-PRIVATE-KEY-a7f3c1"
// fakeHome plants files under a temporary HOME, returning the write root that
// must be bound for them to be visible inside the sandbox, a workspace under
// it, and the forbid-read roots. The HOME has to sit under the bound root:
// bubblewrap swaps /tmp for a fresh tmpfs, so a t.TempDir() home is absent
// bubblewrap swaps /tmp for a fresh tmpfs, so a testenv.TempDir(t) home is absent
// inside the sandbox and every deny assertion would pass without denying.
func fakeHome(t *testing.T, files map[string]string) (bound, ws string, forbid []string) {
t.Helper()
bound = t.TempDir()
bound = testenv.TempDir(t)
home := filepath.Join(bound, "home")
ws = filepath.Join(bound, "ws")
if err := os.MkdirAll(ws, 0o700); err != nil {
@@ -222,7 +223,7 @@ func TestOrdinaryDevWorkIsUnaffected(t *testing.T) {
// invocation fails at startup, and npm silently relocates its global prefix —
// so they are the broker's problem, not the denylist's.
func TestMixedConfigCredentialFilesStayReadable(t *testing.T) {
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
for _, rel := range []string{".config/gh/hosts.yml", ".npmrc", ".docker/config.json"} {
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/pricing"
"reasonix/internal/contract/provider"
@@ -20,7 +21,7 @@ import (
func TestEffectAnthropicPartialUsageRecovery(t *testing.T) {
for _, mode := range []string{"upstream_error", "premature_eof", "complete_control"} {
t.Run(mode, func(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
home, root := statedBootHome(t), testenv.TempDir(t)
writeFile(t, root, "fixture.txt", "partial usage fixture")
partial := mode != "complete_control"
var calls, reads atomic.Int32
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/model/billing"
@@ -26,7 +27,7 @@ func buildWithWallet(t *testing.T, balanceFormat string) (*atomic.Int64, billing
t.Cleanup(wallet.Close)
home := statedBootHome(t)
dir := t.TempDir()
dir := testenv.TempDir(t)
kind := "boot-wallet-" + strings.ReplaceAll(t.Name(), "/", "-") + "-" + balanceFormat + "-fmt"
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return &peerRecorder{}, nil })
format := ""
@@ -10,6 +10,7 @@ import (
"sync/atomic"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/session/control"
)
@@ -29,7 +30,7 @@ func (s *budgetCompletenessSink) RecordRunBudget(sample event.RunBudgetSample) {
func TestEffectBudgetCompletenessFromOpenAIStream(t *testing.T) {
for _, missing := range []int32{0, 1, 2} {
t.Run(fmt.Sprintf("missing_%d", missing), func(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
home, root := statedBootHome(t), testenv.TempDir(t)
writeFile(t, root, "fixture.txt", "fixture content")
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/hook"
"reasonix/internal/session/control"
@@ -40,7 +41,7 @@ func TestEffectCancelledCheckpointAdmissionStopsUserCode(t *testing.T) {
scenario = name + "/capability"
}
t.Run(scenario, func(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
home, root := statedBootHome(t), testenv.TempDir(t)
writeFile(t, root, "note.txt", "user bytes")
marker := filepath.Join(root, "hook.txt")
args := `{"path":"note.txt"}`
@@ -3,6 +3,7 @@ package boot
import (
"os"
"path/filepath"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/provider"
"reasonix/internal/platform/computer"
"strings"
@@ -16,7 +17,7 @@ func TestEffectComputerLaunchKeepsItsApprovalSeparateFromCanonicalRead(t *testin
}
t.Setenv(fakeComputerHelperEnv, "1")
t.Setenv("REASONIX_BOOT_CUA_INSTALLED_APP", exe)
log := filepath.Join(t.TempDir(), "requests.jsonl")
log := filepath.Join(testenv.TempDir(t), "requests.jsonl")
t.Setenv("REASONIX_BOOT_CUA_REQUEST_LOG", log)
driver := computer.NewDriver(exe, os.Environ())
t.Cleanup(driver.Close)
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/provider"
"reasonix/internal/platform/computer"
"reasonix/internal/safety/evidence"
@@ -93,7 +94,7 @@ func TestEffectComputerUseThroughTheRealAssembly(t *testing.T) {
t.Fatal(err)
}
t.Setenv(fakeComputerHelperEnv, "1")
log := filepath.Join(t.TempDir(), "native-requests.jsonl")
log := filepath.Join(testenv.TempDir(t), "native-requests.jsonl")
t.Setenv("REASONIX_BOOT_CUA_REQUEST_LOG", log)
driver := computer.NewDriver(exe, os.Environ())
t.Cleanup(driver.Close)
@@ -22,61 +22,60 @@ func (p *peerToolProvider) sawInMessages(needle string) bool {
return false
}
// While the user's dispatch switch is on, a task from the session that opened
// this one reaches the model with no card, framed as data from that session and
// not as something the user read; the same words from a session the host never
// paired it with, or signed with another secret, wait for the user as before.
func TestEffectADispatchedTaskReachesTheModelWithoutACardAndAStrangersDoesNot(t *testing.T) {
// With the dispatch switch on, a task from the session that opened this one
// reaches the model with no card, framed as data the user did not read. The same
// words from an unpaired or wrongly signed sender arrive the same way but are not
// called paired. Each case is its own session: the stub provider's turn waits on a
// card of its own.
func TestEffectADispatchedTaskReachesTheModelWithoutACardAndAStrangerIsNotCalledPaired(t *testing.T) {
const parent = "s-00000000000000bb"
secret := []byte("0123456789abcdef0123456789abcdef")
rec, ctrl, peersDir, seen := buildPeerToolSession(t, true, "nobody", "hello", "dispatch = true\n")
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
receiver := ctrl.PeerKey()
if receiver == "" {
t.Fatal("the receiving session did not publish itself")
}
ctrl.MarkOpenedByDispatch(parent, secret, 1)
d, err := peermail.Open(peersDir)
if err != nil {
t.Fatal(err)
}
var seq uint64
send := func(id, body string, signWith []byte) {
t.Helper()
seq++
m := peermail.Message{ID: id, SentAt: time.Now().UTC(), Body: body, Seq: seq,
From: peermail.Party{SessionID: parent, Title: "planner", Project: "alpha"}, To: peermail.Party{SessionID: receiver}}
m.Proof = peermail.Proof(signWith, m)
if err := d.Send(m); err != nil {
t.Fatal(err)
}
}
send("m-forged", "FORGED-TASK-WITH-ANOTHER-SECRET", []byte("another secret another secret!!!"))
ctrl.RefreshPeers()
if held := ctrl.HeldPeerMessages(); len(held) != 1 || held[0].Body != "FORGED-TASK-WITH-ANOTHER-SECRET" {
t.Fatalf("held = %+v, want the badly signed task waiting for a card", held)
}
send("m-real", "ROTATE-THE-KEY-TASK", secret)
ctrl.RefreshPeers()
waitFor(t, "the dispatched task to reach the provider", func() bool { return rec.sawInMessages("ROTATE-THE-KEY-TASK") })
if rec.sawInMessages("FORGED-TASK-WITH-ANOTHER-SECRET") {
t.Fatal("a task signed with another secret reached the model")
}
if !rec.sawInMessages("has not read this message") || !rec.sawInMessages("not an instruction from the user") {
t.Fatal("the dispatched task reached the model without saying the user did not read it and that it is data")
}
// The stub provider's own send_peer_message call is the only card there may be:
// nothing asked the user to let the dispatched task in.
seen.mu.Lock()
for _, card := range seen.approvals {
if card.Tool != "send_peer_message" {
t.Errorf("unexpected approval card: %+v", card)
}
}
seen.mu.Unlock()
if got := ctrl.HeldPeerMessages(); len(got) != 1 {
t.Fatalf("held = %+v, want only the badly signed one still waiting", got)
for _, tc := range []struct {
name, body string
signWith []byte
paired bool
}{
{"forged", "FORGED-TASK-WITH-ANOTHER-SECRET", []byte("another secret another secret!!!"), false},
{"paired", "ROTATE-THE-KEY-TASK", secret, true},
} {
t.Run(tc.name, func(t *testing.T) {
rec, ctrl, peersDir, seen := buildPeerToolSession(t, true, "nobody", "hello", "dispatch = true\n")
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
receiver := ctrl.PeerKey()
if receiver == "" {
t.Fatal("the receiving session did not publish itself")
}
ctrl.MarkOpenedByDispatch(parent, secret, 1)
d, err := peermail.Open(peersDir)
if err != nil {
t.Fatal(err)
}
m := peermail.Message{ID: "m-1", SentAt: time.Now().UTC(), Body: tc.body, Seq: 1,
From: peermail.Party{SessionID: parent, Title: "planner", Project: "alpha"}, To: peermail.Party{SessionID: receiver}}
m.Proof = peermail.Proof(tc.signWith, m)
if err := d.Send(m); err != nil {
t.Fatal(err)
}
ctrl.RefreshPeers()
waitFor(t, "the task to reach the provider", func() bool { return rec.sawInMessages(tc.body) })
if got := rec.sawInMessages("paired with by a dispatch"); got != tc.paired {
t.Fatalf("described as paired = %v, want %v", got, tc.paired)
}
if !rec.sawInMessages("has not read this message") || !rec.sawInMessages("not an instruction from the user") {
t.Fatal("the task reached the model without saying the user did not read it and that it is data")
}
// The stub provider's own send_peer_message call is the only card there
// may be: nothing asked the user to let a task in.
seen.mu.Lock()
for _, card := range seen.approvals {
if card.Tool != "send_peer_message" {
t.Errorf("unexpected approval card: %+v", card)
}
}
seen.mu.Unlock()
if got := ctrl.HeldPeerMessages(); len(got) != 0 {
t.Fatalf("held = %+v, want nothing waiting for a card", got)
}
})
}
}
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/pricing"
"reasonix/internal/session/control"
@@ -17,7 +18,7 @@ import (
func TestEffectEstimatedUsageQuoteAfterStreamRecovery(t *testing.T) {
for _, interrupted := range []bool{false, true} {
t.Run(map[bool]string{false: "reported_control", true: "interrupted"}[interrupted], func(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
home, root := statedBootHome(t), testenv.TempDir(t)
writeFile(t, root, "fixture.txt", "fixture content")
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -10,12 +10,13 @@ import (
"sync/atomic"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/session/control"
)
func TestEffectGoalResumeRenewsPlannerBudget(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
home, root := statedBootHome(t), testenv.TempDir(t)
for n := 1; n <= 4; n++ {
writeFile(t, root, fmt.Sprintf("fixture-%d.txt", n), fmt.Sprintf("planner resume fixture %d", n))
}
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/hook"
@@ -26,7 +27,7 @@ func TestEffectReadHooksLeaveCheckpointCoverage(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
home := statedBootHome(t)
root := t.TempDir()
root := testenv.TempDir(t)
marker := filepath.Join(root, "hook.txt")
writeFile(t, root, "note.txt", "original")
kind := "hook-checkpoint-" + strings.ReplaceAll(t.Name(), "/", "-")
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/hook"
@@ -73,7 +74,7 @@ func TestEffectHookWritesInvalidateWorkspaceWithoutExecutingDeniedTool(t *testin
}
t.Run(name, func(t *testing.T) {
home := statedBootHome(t)
root := t.TempDir()
root := testenv.TempDir(t)
marker := filepath.Join(root, "hook-side.txt")
writeFile(t, root, "note.txt", "original")
kind := "hook-workspace-" + strings.ReplaceAll(t.Name(), "/", "-")
@@ -6,6 +6,7 @@ import (
"sync/atomic"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
@@ -33,7 +34,7 @@ func TestEffectBackgroundJobNoLaunchFinalizesResources(t *testing.T) {
mode = "capability"
}
t.Run(name+"/"+mode, func(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
home, root := statedBootHome(t), testenv.TempDir(t)
child := &noLaunchProvider{}
var ctrl *control.Controller
parent := &browserScriptProvider{rounds: []func(string) *provider.ToolCall{
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
@@ -107,7 +108,7 @@ func TestEffectAModQueuesAPromptThatReachesTheModelLabelledAsTheMods(t *testing.
done <- struct{}{}
}
})
res, err := BuildRuntime(t.Context(), Options{Sink: sink, SessionDir: t.TempDir()})
res, err := BuildRuntime(t.Context(), Options{Sink: sink, SessionDir: testenv.TempDir(t)})
if err != nil {
t.Fatal(err)
}
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/state/peermail"
)
@@ -74,7 +75,7 @@ func TestEffectAReleasedPeerMessageStartsATurnOnAnIdleSession(t *testing.T) {
// nothing into the prompt and does not hold the queue.
func TestEffectPeerMessageTextIsNeverReadForFileReferences(t *testing.T) {
rec, peersDir, ctrl, held := buildPeerSession(t, true)
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
@@ -37,7 +37,7 @@ func TestEffectPeerMessageAgainstAPausedQueue(t *testing.T) {
rec, peersDir, ctrl, held := buildPeerSession(t, true)
home := filepath.Dir(peersDir)
t.Setenv("REASONIX_HOME", home)
config := "[peers]\nenabled = true\n"
config := "[peers]\nenabled = true\ndispatch = false\n"
if tc.consent == "release" {
config += fmt.Sprintf("release_projects = [%q]\n", ctrl.WorkspaceRoot())
}
+2 -2
View File
@@ -70,7 +70,7 @@ name = "test-model"
kind = "`+kind+`"
model = "x"
`)
writeFile(t, home, "config.toml", fmt.Sprintf("[peers]\nenabled = %t\n", enabled))
writeFile(t, home, "config.toml", fmt.Sprintf("[peers]\nenabled = %t\ndispatch = false\n", enabled))
approveWorkspaceAt(t, home, dir)
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
if err != nil {
@@ -111,8 +111,8 @@ func TestEffectExplicitPeerOptOutStaysUnpublished(t *testing.T) {
// then as one host-labelled block in the turn's tail, and the cache-stable
// prefix is the one every other turn of the session sends.
func TestEffectAHeldPeerMessageReachesTheProviderOnlyAfterApproval(t *testing.T) {
t.Parallel()
rec, peersDir, ctrl, held := buildPeerSession(t, true)
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
if err := ctrl.Run(context.Background(), "an ordinary turn"); err != nil {
t.Fatal(err)
}
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/runtime/agent/testutil"
@@ -37,7 +38,7 @@ func slowShellBuild(t *testing.T, kind string, tune func(*sandbox.ShellDiscovery
func fakeBashOnPath(t *testing.T) string {
t.Helper()
bin := t.TempDir()
bin := testenv.TempDir(t)
name := "bash"
if runtime.GOOS == "windows" {
name = "bash.exe"
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"sync/atomic"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
@@ -16,7 +17,7 @@ var tariffKinds atomic.Int64
func tariffOf(t *testing.T, baseURL string, price string) bool {
t.Helper()
home := statedBootHome(t)
dir := t.TempDir()
dir := testenv.TempDir(t)
kind := fmt.Sprintf("boot-tariff-%s-%d", strings.ReplaceAll(t.Name(), "/", "-"), tariffKinds.Add(1))
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return &peerRecorder{}, nil })
body := `
@@ -4,6 +4,7 @@ import (
"context"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/ext/extension"
@@ -16,7 +17,7 @@ import (
// notice naming the extension, the point and the reason — and it does so once.
func TestMissingSidecarReachesTheSinkAsATypedNotice(t *testing.T) {
sink := &noticeSink{}
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(t.TempDir()), t.TempDir(), nil, sink)
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(testenv.TempDir(t)), testenv.TempDir(t), nil, sink)
if err != nil {
t.Fatal(err)
}
@@ -48,7 +49,7 @@ func TestMissingSidecarReachesTheSinkAsATypedNotice(t *testing.T) {
// sidecar missing right after an extension was installed still reaches the sink.
func TestRebuiltDispatcherKeepsTheMissingSidecarNotice(t *testing.T) {
sink := &noticeSink{}
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(t.TempDir()), t.TempDir(), nil, sink)
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(testenv.TempDir(t)), testenv.TempDir(t), nil, sink)
if err != nil {
t.Fatal(err)
}
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
@@ -26,7 +27,7 @@ func TestRejectedMCPCacheDiscoversWithoutPinning(t *testing.T) {
{"always_after_handshake", true, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
listed, release := make(chan struct{}), make(chan struct{})
@@ -10,14 +10,15 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/ext/skill"
)
func TestSkillDiagnosticsReachRendererAcrossRediscovery(t *testing.T) {
root := t.TempDir()
home := t.TempDir()
root := testenv.TempDir(t)
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
var raw bytes.Buffer
var notices []event.Event
@@ -72,8 +73,8 @@ func TestSkillDiagnosticDestinationsAtStartup(t *testing.T) {
for _, interactive := range []bool{false, true} {
for _, suppressed := range []bool{false, true} {
t.Run(fmt.Sprintf("renderer=%t/suppressed=%t", interactive, suppressed), func(t *testing.T) {
root := t.TempDir()
t.Setenv("REASONIX_HOME", t.TempDir())
root := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
dir := filepath.Join(root, ".reasonix", "skills", "startup-warning")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
@@ -49,7 +50,7 @@ model = "x"
// a read that succeeded counts; a read that failed, and a file outside the
// workspace, do not.
func TestEffectCompletedReadsFeedTheSkillPathSet(t *testing.T) {
outside := filepath.Join(t.TempDir(), "elsewhere.go")
outside := filepath.Join(testenv.TempDir(t), "elsewhere.go")
if err := os.WriteFile(outside, []byte("package x\n"), 0o644); err != nil {
t.Fatal(err)
}
@@ -161,7 +162,7 @@ func TestEffectGrepRecordsOnlyAFileItWasPointedAt(t *testing.T) {
t.Fatalf("Build: %v", err)
}
defer fresh.Close()
if err := fresh.Resume(saved, filepath.Join(t.TempDir(), "resumed.jsonl")); err != nil {
if err := fresh.Resume(saved, filepath.Join(testenv.TempDir(t), "resumed.jsonl")); err != nil {
t.Fatalf("Resume: %v", err)
}
if got := fresh.TouchedPaths(); !reflect.DeepEqual(got, want) {
@@ -191,7 +192,7 @@ func TestEffectResumeResolvesSymlinksAgainstTheCurrentDisk(t *testing.T) {
for _, m := range ctrl.History() {
saved.Add(m)
}
elsewhere := t.TempDir()
elsewhere := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(elsewhere, "a.go"), []byte("package x\n"), 0o644); err != nil {
t.Fatal(err)
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"SystemHash": "bc08dbc97c837bba",
"ToolsHash": "8e997d80438fe9b9",
"PrefixHash": "1c79eb3c43c306ba",
"ToolsHash": "6a63bb25e421685d",
"PrefixHash": "4c8bc5e578932c06",
"LogRewriteVersion": 0,
"ToolSchemaTokens": 4997
"ToolSchemaTokens": 5076
}
@@ -121,7 +121,7 @@
},
{
"name": "bash",
"description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors.",
"description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. To search, call tool:grep (contents) or tool:glob (names) through use_capability — not grep, rg or find in the shell; the built-ins honor .gitignore and cap their output. A pipeline over another command's output (git log | grep) is the exception.",
"parameters": {
"properties": {
"command": {
@@ -362,7 +362,7 @@
},
{
"name": "send_peer_message",
"description": "Send text to another Reasonix session on this machine, named by list_peers. The message waits in that session until its own user approves it; only then does its model read it, labelled as coming from you. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
"description": "Send text to another Reasonix session on this machine, named by list_peers. While the user's dispatch switch is on (the default) its model reads it as soon as that session is free, labelled as coming from you; with the switch off it waits there for its user to approve it. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
"parameters": {
"additionalProperties": false,
"properties": {
+2 -2
View File
@@ -93,7 +93,7 @@
},
{
"Name": "bash",
"Description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors.",
"Description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. To search, call tool:grep (contents) or tool:glob (names) through use_capability — not grep, rg or find in the shell; the built-ins honor .gitignore and cap their output. A pipeline over another command's output (git log | grep) is the exception.",
"ReadOnly": false,
"Schema": {
"properties": {
@@ -344,7 +344,7 @@
},
{
"Name": "send_peer_message",
"Description": "Send text to another Reasonix session on this machine, named by list_peers. The message waits in that session until its own user approves it; only then does its model read it, labelled as coming from you. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
"Description": "Send text to another Reasonix session on this machine, named by list_peers. While the user's dispatch switch is on (the default) its model reads it as soon as that session is free, labelled as coming from you; with the switch off it waits there for its user to approve it. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
"ReadOnly": false,
"Schema": {
"additionalProperties": false,
@@ -8,6 +8,7 @@ import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/ext/hook"
"reasonix/internal/safety/sandbox"
@@ -18,7 +19,7 @@ import (
// checkout's [tools.shell] must never reach the resolver or the runtime.
func TestUserHookRunnerIgnoresCheckoutShell(t *testing.T) {
isolateConfigHome(t)
checkout := t.TempDir()
checkout := testenv.TempDir(t)
// An installed-looking path: one where sandboxed commands write is refused
// before it could reach the merged config this test starts from.
evil := filepath.Join(filepath.VolumeName(os.TempDir())+string(filepath.Separator), "opt", "reasonix-test", "evil-bash")
@@ -65,7 +66,7 @@ func TestUserHookRunnerIgnoresCheckoutShell(t *testing.T) {
})
t.Run("user shell is honoured", func(t *testing.T) {
userShell := filepath.Join(t.TempDir(), "user-bash")
userShell := filepath.Join(testenv.TempDir(t), "user-bash")
userConfig := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
t.Fatal(err)
@@ -85,7 +86,7 @@ func TestUserHookRunnerIgnoresCheckoutShell(t *testing.T) {
// python.exe the branch ships before the user's own interpreter.
func TestUserHookRunnerDisablesCwdExeSearch(t *testing.T) {
isolateConfigHome(t)
checkout := t.TempDir()
checkout := testenv.TempDir(t)
if err := hook.Save(hook.ScopeGlobal, "", hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{
hook.PreToolUse: {{Match: "read_file", Command: `python C:\hooks\guard.py`}},
}}); err != nil {
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
)
@@ -18,7 +19,7 @@ func TestWorkspaceBranchSummaryReadsOverlapThroughBuild(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("git wrapper uses POSIX shell")
}
dir := t.TempDir()
dir := testenv.TempDir(t)
realGit, err := exec.LookPath("git")
if err != nil {
t.Skip("git unavailable")
@@ -33,7 +34,7 @@ func TestWorkspaceBranchSummaryReadsOverlapThroughBuild(t *testing.T) {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", home)
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard, Stderr: io.Discard})
@@ -41,7 +42,7 @@ func TestWorkspaceBranchSummaryReadsOverlapThroughBuild(t *testing.T) {
t.Fatal(err)
}
defer ctrl.Close()
bin, signals := t.TempDir(), t.TempDir()
bin, signals := testenv.TempDir(t), testenv.TempDir(t)
wrapper := `#!/bin/sh
probe=
for arg in "$@"; do
@@ -6,13 +6,14 @@ import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/gitcmd"
)
// A folder that carries a .git git will not open keeps an empty identity, and
// says that git refused it rather than that there is no repository.
func TestWorkspaceRepoSaysWhyAMarkedFolderHasNoIdentity(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: ./nowhere\n"), 0o644); err != nil {
t.Fatal(err)
}
@@ -20,7 +21,7 @@ func TestWorkspaceRepoSaysWhyAMarkedFolderHasNoIdentity(t *testing.T) {
if repo.Valid() || repo.Absent != gitcmd.AbsenceRefused || repo.AbsentDetail == "" {
t.Fatalf("repo = %+v, want an empty identity that says git refused it", repo)
}
plain := workspaceRepo(context.Background(), gitcmd.Repo{}, t.TempDir())
plain := workspaceRepo(context.Background(), gitcmd.Repo{}, testenv.TempDir(t))
if plain.Valid() || plain.Absent != "" {
t.Fatalf("a folder with no marker = %+v, want an empty identity with no absence", plain)
}
+7 -5
View File
@@ -3,10 +3,12 @@ package fileutil
import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func TestUnderExcludesTheRootItself(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
if Under(root, root) {
t.Error("Under(root, root) = true; the root is not below itself")
}
@@ -16,7 +18,7 @@ func TestUnderExcludesTheRootItself(t *testing.T) {
}
func TestAtOrUnderIncludesTheRootItself(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
for _, path := range []string{root, filepath.Join(root, "child"), filepath.Join(root, "a", "b")} {
if !AtOrUnder(path, root) {
t.Errorf("AtOrUnder(%q, root) = false", path)
@@ -25,7 +27,7 @@ func TestAtOrUnderIncludesTheRootItself(t *testing.T) {
}
func TestContainmentRejectsEscapes(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
outside := filepath.Join(filepath.Dir(root), "sibling")
for _, path := range []string{outside, filepath.Join(root, "..", "sibling"), filepath.Dir(root)} {
if AtOrUnder(path, root) {
@@ -40,7 +42,7 @@ func TestContainmentRejectsEscapes(t *testing.T) {
// An interior ".." must be resolved before the comparison, or a path spells
// its way inside a root it never reaches.
func TestContainmentResolvesInteriorDotDot(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
if !AtOrUnder(filepath.Join(root, "a", "..", "b"), root) {
t.Error("a path that cleans to root/b is inside root")
}
@@ -50,7 +52,7 @@ func TestContainmentResolvesInteriorDotDot(t *testing.T) {
}
func TestContainmentRejectsEmptySides(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
if AtOrUnder("", root) || AtOrUnder(root, "") || Under("", root) || Under(root, "") {
t.Error("an empty side answers no question and is not containment")
}
@@ -6,10 +6,12 @@ import (
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func TestContentVersionTracksBytesKindAndAbsence(t *testing.T) {
path := filepath.Join(t.TempDir(), "file")
path := filepath.Join(testenv.TempDir(t), "file")
absent, err := ReadContentVersion(t.Context(), path)
if err != nil || absent.Kind != "absent" {
t.Fatalf("absent: %+v %v", absent, err)
@@ -43,13 +45,13 @@ func TestContentVersionTracksBytesKindAndAbsence(t *testing.T) {
func TestContentVersionCancellationPrecedesFilesystemAccess(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := ReadContentVersion(ctx, filepath.Join(t.TempDir(), "missing")); !errors.Is(err, context.Canceled) {
if _, err := ReadContentVersion(ctx, filepath.Join(testenv.TempDir(t), "missing")); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled read: %v", err)
}
}
func TestContentVersionHashesSymlinkTextWithoutReadingTarget(t *testing.T) {
path := filepath.Join(t.TempDir(), "link")
path := filepath.Join(testenv.TempDir(t), "link")
if err := os.Symlink("missing-target", path); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
+3 -1
View File
@@ -4,6 +4,8 @@ import (
"path/filepath"
"runtime"
"testing"
"reasonix/internal/base/testenv"
)
// A workspace opened at a drive root was listed as "\": filepath.Base answers
@@ -25,7 +27,7 @@ func TestRootNameOfAVolumeIsTheVolume(t *testing.T) {
// An ordinary directory keeps the name it already had.
func TestRootNameOfADirectoryIsItsBase(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
if got, want := RootName(dir), filepath.Base(dir); got != want {
t.Fatalf("RootName(%q) = %q, want %q", dir, got, want)
}
+6 -4
View File
@@ -5,10 +5,12 @@ import (
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func TestMarkUntrackedKeepsAnExistingMarker(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
marker := filepath.Join(dir, ".gitignore")
if err := os.WriteFile(marker, []byte("keep.me\n"), 0o644); err != nil {
t.Fatal(err)
@@ -22,7 +24,7 @@ func TestMarkUntrackedKeepsAnExistingMarker(t *testing.T) {
}
func TestMarkUntrackedIsIdempotent(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
for range 2 {
if err := MarkUntracked(dir); err != nil {
t.Fatal(err)
@@ -34,7 +36,7 @@ func TestMarkUntrackedIsIdempotent(t *testing.T) {
}
func TestMarkUntrackedLeavesNothingWhenTheWriteFails(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
orig := writeMarker
writeMarker = func(*os.File) error { return errors.New("disk full") }
err := MarkUntracked(dir)
@@ -54,7 +56,7 @@ func TestMarkUntrackedLeavesNothingWhenTheWriteFails(t *testing.T) {
}
func TestMarkUntrackedDoesNotFollowASymlinkedMarker(t *testing.T) {
dir, outside := t.TempDir(), t.TempDir()
dir, outside := testenv.TempDir(t), testenv.TempDir(t)
target := filepath.Join(outside, "victim")
if err := os.Symlink(target, filepath.Join(dir, ".gitignore")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
+3 -1
View File
@@ -11,6 +11,8 @@ import (
"path/filepath"
"testing"
"time"
"reasonix/internal/base/testenv"
)
// A launcher (cmd.exe) that spawns a long-lived grandchild (ping) which inherits
@@ -99,7 +101,7 @@ func TestStartTrackedResumesSuspendedChild(t *testing.T) {
// wedged system tool (a busy WMI, security software holding the call) behaves.
func slowTaskkill(t *testing.T) {
t.Helper()
dir := t.TempDir()
dir := testenv.TempDir(t)
src := filepath.Join(dir, "main.go")
if err := os.WriteFile(src, []byte("package main\nimport \"time\"\nfunc main() { time.Sleep(10 * time.Minute) }\n"), 0o644); err != nil {
t.Fatal(err)
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"slices"
"testing"
"reasonix/internal/base/testenv"
)
func TestCommandNamesFollowsPATHEXTOnlyOnWindows(t *testing.T) {
@@ -23,7 +25,7 @@ func TestCommandNamesFollowsPATHEXTOnlyOnWindows(t *testing.T) {
}
func TestLookPathInSkipsRelativeEntriesAndFindsTheSibling(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(dir, "mcp.cmd"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
@@ -12,6 +12,8 @@ import (
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
)
const publishedJobMarker = "REASONIX_PUBLISHED_JOB_TASKKILL_MARKER"
@@ -51,7 +53,7 @@ func TestPublishedJobRecorderAlias(t *testing.T) {
func publishedJobStub(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dir := testenv.TempDir(t)
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
+3 -1
View File
@@ -9,6 +9,8 @@ import (
"testing"
"testing/synctest"
"time"
"reasonix/internal/base/testenv"
)
func assertRootWaitClosed(t *testing.T, done <-chan struct{}) {
@@ -177,7 +179,7 @@ func TestRootWaitDoesNotRegisterBeforeStartOrForUntrackedCommands(t *testing.T)
cancel()
}
if scenario == "missing_executable" {
cmd.Path = t.TempDir() + string(os.PathSeparator) + "missing"
cmd.Path = testenv.TempDir(t) + string(os.PathSeparator) + "missing"
}
tracked, err := RunCommand(ctx, cmd, RunOptions{Track: scenario != "untracked"})
if scenario != "untracked" && err == nil {
+3 -1
View File
@@ -9,6 +9,8 @@ import (
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
)
const holderEnv = "REASONIX_SCRATCH_HOLDER"
@@ -33,7 +35,7 @@ func TestMain(m *testing.M) {
func isolatedTemp(t *testing.T) string {
t.Helper()
root := t.TempDir()
root := testenv.TempDir(t)
t.Setenv("TMPDIR", root)
t.Setenv("TMP", root)
t.Setenv("TEMP", root)
+3 -1
View File
@@ -5,11 +5,13 @@ import (
"path/filepath"
"slices"
"testing"
"reasonix/internal/base/testenv"
)
func credHome(t *testing.T, files ...string) string {
t.Helper()
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
for _, rel := range files {
+6 -4
View File
@@ -7,6 +7,8 @@ import (
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
)
const sentinel = "late-write.txt"
@@ -17,7 +19,7 @@ const sentinel = "late-write.txt"
// raced, because a producer driven by a sleep would sometimes lose and make
// this test the flaky one.
func TestContentThatOutlivesTheDeadlineIsReported(t *testing.T) {
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(dir, sentinel), []byte("still running"), 0o600); err != nil {
t.Fatal(err)
}
@@ -34,7 +36,7 @@ func TestContentThatOutlivesTheDeadlineIsReported(t *testing.T) {
// And the behaviour that motivated the wait: a refusal that stops is not a
// leak. Reporting the first frame of one is what made the gate flap.
func TestARefusalThatStopsLetsTheDirectoryQuiesce(t *testing.T) {
dir := filepath.Join(t.TempDir(), "settling")
dir := filepath.Join(testenv.TempDir(t), "settling")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
@@ -58,7 +60,7 @@ func TestARefusalThatStopsLetsTheDirectoryQuiesce(t *testing.T) {
}
func TestAnOrdinaryTreeIsRemovedWithoutWaiting(t *testing.T) {
root := filepath.Join(t.TempDir(), "root")
root := filepath.Join(testenv.TempDir(t), "root")
if err := os.MkdirAll(filepath.Join(root, "nested"), 0o755); err != nil {
t.Fatal(err)
}
@@ -75,7 +77,7 @@ func TestAnOrdinaryTreeIsRemovedWithoutWaiting(t *testing.T) {
// A directory already gone is not a failure: the test may have removed it.
func TestARemovalOfSomethingAlreadyGoneIsNotReported(t *testing.T) {
if err := removeDir(filepath.Join(t.TempDir(), "never-existed")); err != nil {
if err := removeDir(filepath.Join(testenv.TempDir(t), "never-existed")); err != nil {
t.Fatalf("removeDir: %v", err)
}
}
+9 -7
View File
@@ -6,10 +6,12 @@ import (
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
data := []byte("%PDF-1.7\n\x00binary")
if err := os.WriteFile(filepath.Join(root, "报告.pdf"), data, 0o600); err != nil {
t.Fatal(err)
@@ -23,12 +25,12 @@ func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
if err != nil || string(got) != string(data) {
t.Fatalf("read = %q, %v", got, err)
}
for _, ref := range []Ref{{Path: "../secret"}, {Root: t.TempDir(), Path: "x"}} {
for _, ref := range []Ref{{Path: "../secret"}, {Root: testenv.TempDir(t), Path: "x"}} {
if _, err := s.Info(context.Background(), ref); !errors.Is(err, ErrDenied) {
t.Fatalf("escape = %v", err)
}
}
outside := t.TempDir()
outside := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
@@ -40,7 +42,7 @@ func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
}
func TestPreviewLimitsAndUnknownBinary(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
s := Service{Primary: root}
for _, tc := range []struct {
name string
@@ -64,11 +66,11 @@ func TestPreviewLimitsAndUnknownBinary(t *testing.T) {
}
func TestGrantedRootAndCancellation(t *testing.T) {
extra := t.TempDir()
extra := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(extra, "notes.md"), []byte("# Notes"), 0o600); err != nil {
t.Fatal(err)
}
s := Service{Primary: t.TempDir(), Extra: []string{extra}}
s := Service{Primary: testenv.TempDir(t), Extra: []string{extra}}
ref, err := s.Resolve(filepath.Join(extra, "notes.md"))
if err != nil || ref.Root != extra || ref.Path != "notes.md" {
t.Fatalf("resolve = %+v, %v", ref, err)
@@ -89,7 +91,7 @@ func TestGrantedRootAndCancellation(t *testing.T) {
}
func TestOfficeDocumentsPreviewAsBinaryWithinTheirLimit(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
zip := []byte("PK\x03\x04\x00binary office package")
s := Service{Primary: root}
for _, name := range []string{"报告.docx", "budget.XLSX"} {
@@ -6,18 +6,20 @@ import (
"path/filepath"
"runtime"
"testing"
"reasonix/internal/base/testenv"
)
func bindingHome(t *testing.T) (*ActivationStore, string) {
t.Helper()
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("BINDING_FLAG", "")
_ = os.Unsetenv("BINDING_FLAG")
return DefaultActivationStore(), t.TempDir()
return DefaultActivationStore(), testenv.TempDir(t)
}
func writeProjectFiles(t *testing.T, root string, files map[string]string) {
@@ -4,6 +4,8 @@ import (
"errors"
"maps"
"testing"
"reasonix/internal/base/testenv"
)
// An enable on a repository-declared server is the user's answer for the
@@ -76,7 +78,7 @@ func TestEnableHoldsOnlyForTheDeclarationItWasGivenFor(t *testing.T) {
// upgrade must not make every enabled project server ask again.
func TestEnableSurvivesChangesTheRepositoryDoesNotControl(t *testing.T) {
store, root := bindingHome(t)
userBin := t.TempDir()
userBin := testenv.TempDir(t)
writeProjectFiles(t, userBin, map[string]string{"rx-fake-node": "v1"})
t.Setenv("PATH", userBin)
writeProjectFiles(t, root, map[string]string{
@@ -136,7 +138,7 @@ func TestEnableRecordedWithoutADeclarationAsksAgain(t *testing.T) {
// depend on where the checkout sits.
func TestDeclarationDigestDoesNotDependOnTheCheckoutPath(t *testing.T) {
_, first := bindingHome(t)
second := t.TempDir()
second := testenv.TempDir(t)
files := map[string]string{
".mcp.json": mcpJSON(t, map[string]any{"command": "./bin/server.sh"}),
"bin/server.sh": "#!/bin/sh\n",
@@ -3,13 +3,15 @@ package config
import (
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func trustStore(t *testing.T) (*ActivationStore, string) {
t.Helper()
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
return DefaultActivationStore(), t.TempDir()
return DefaultActivationStore(), testenv.TempDir(t)
}
// A .mcp.json is checked in, so whoever wrote the repo chose the command. It
@@ -88,7 +90,7 @@ func TestApprovalDoesNotTravelToAnotherProject(t *testing.T) {
if err := store.SetServerEnabled(entry, root, ActivationProject, true); err != nil {
t.Fatal(err)
}
elsewhere := filepath.Join(t.TempDir(), "other")
elsewhere := filepath.Join(testenv.TempDir(t), "other")
if enabled, _ := store.IsEnabled(entry, elsewhere); enabled {
t.Fatal("an approval in one project started a same-named server in another")
}
@@ -8,6 +8,8 @@ import (
"testing"
"github.com/BurntSushi/toml"
"reasonix/internal/base/testenv"
)
func TestAttachmentLimitIsHeldAtUserScope(t *testing.T) {
@@ -48,7 +50,7 @@ func TestAttachmentLimitUserRenderAndSaveRoundTrip(t *testing.T) {
if strings.Contains(RenderTOMLForScope(cfg, RenderScopeProject), "max_stream_bytes") || strings.Contains(RenderTOMLProjectDelta(cfg), "max_stream_bytes") {
t.Fatal("user-only limit leaked into project config")
}
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
path := filepath.Join(home, "config.toml")
if err := os.WriteFile(path, []byte("# user's comment\n[future_feature]\nkeep=42\n"), 0o600); err != nil {
@@ -69,7 +71,7 @@ func TestAttachmentLimitUserRenderAndSaveRoundTrip(t *testing.T) {
}
func TestAttachmentLimitStrictReadRejectsMalformedUserFile(t *testing.T) {
home := t.TempDir()
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
if got, err := LoadAttachmentStreamLimit(); err != nil || got.Bytes != DefaultAttachmentStreamBytes || got.Source != "default" {
t.Fatalf("missing user config: %+v %v", got, err)
+9 -7
View File
@@ -4,11 +4,13 @@ import (
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func writeClaudeJSON(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
dir := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(dir, claudeJSONFile), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
@@ -20,7 +22,7 @@ func writeClaudeJSON(t *testing.T, body string) string {
// reading only .mcp.json found none of what a machine had configured: three
// servers on the reporting user's machine, zero of them in a file Reasonix read.
func TestClaudeConfigSuppliesTheServersItsOwnDefaultScopeHolds(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
path := writeClaudeJSON(t, `{
"mcpServers": {"shared": {"type": "stdio", "command": "user-node"}},
"projects": {
@@ -79,13 +81,13 @@ func TestReasonixConfigurationOutranksTheCompatibilityRead(t *testing.T) {
// The file belongs to another tool. Reasonix refusing to start over a typo in it
// would be a worse answer than starting without it.
func TestAnUnreadableClaudeConfigIsNotAnError(t *testing.T) {
if got := loadClaudeMCP(filepath.Join(t.TempDir(), "absent.json"), t.TempDir()); got != nil {
if got := loadClaudeMCP(filepath.Join(testenv.TempDir(t), "absent.json"), testenv.TempDir(t)); got != nil {
t.Fatalf("absent file returned %+v", got)
}
if got := loadClaudeMCP(writeClaudeJSON(t, `{ not json`), t.TempDir()); got != nil {
if got := loadClaudeMCP(writeClaudeJSON(t, `{ not json`), testenv.TempDir(t)); got != nil {
t.Fatalf("malformed file returned %+v", got)
}
if got := loadClaudeMCP("", t.TempDir()); got != nil {
if got := loadClaudeMCP("", testenv.TempDir(t)); got != nil {
t.Fatalf("empty path returned %+v", got)
}
}
@@ -93,7 +95,7 @@ func TestAnUnreadableClaudeConfigIsNotAnError(t *testing.T) {
// Claude Code writes those keys with forward slashes on Windows, where the same
// directory is also spelled with backslashes.
func TestTheProjectKeyIsComparedAsAPathNotAString(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
path := writeClaudeJSON(t, `{"projects": {"`+filepath.ToSlash(root)+`": {
"mcpServers": {"here": {"type": "stdio", "command": "node"}}}}}`)
if got := loadClaudeMCP(path, filepath.FromSlash(root)); len(got) != 1 {
@@ -107,7 +109,7 @@ func TestTheProjectKeyIsComparedAsAPathNotAString(t *testing.T) {
// The effect at the boundary a session actually goes through: a workspace whose
// only MCP configuration is Claude's own default scope still comes up with it.
func TestLoadingAWorkspaceFindsWhatClaudeConfiguredForIt(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
writeClaudeJSON(t, `{"projects": {"`+filepath.ToSlash(root)+`": {
"mcpServers": {"unity-editor-mcp": {"type": "stdio", "command": "node", "args": ["server.js"]}}}}}`)
@@ -9,6 +9,8 @@ import (
"testing"
"github.com/BurntSushi/toml"
"reasonix/internal/base/testenv"
)
// The [desktop] table is written by hand, one line per field, so a field added
@@ -114,7 +116,7 @@ func TestEveryDesktopSettingSurvivesASave(t *testing.T) {
}
want := set.Interface()
path := filepath.Join(t.TempDir(), "config.toml")
path := filepath.Join(testenv.TempDir(t), "config.toml")
if err := os.WriteFile(path, []byte(RenderTOMLForScope(cfg, RenderScopeUser)), 0o600); err != nil {
t.Fatal(err)
}
@@ -7,6 +7,8 @@ import (
"testing"
"github.com/BurntSushi/toml"
"reasonix/internal/base/testenv"
)
func TestDisabledToolsSurviveEveryTOMLRenderScope(t *testing.T) {
@@ -38,7 +40,7 @@ func TestDisabledToolsSurviveEveryTOMLRenderScope(t *testing.T) {
}
func TestDisabledToolsSurviveMCPJSONEdits(t *testing.T) {
path := filepath.Join(t.TempDir(), ".mcp.json")
path := filepath.Join(testenv.TempDir(t), ".mcp.json")
entry := PluginEntry{Name: "server", Command: "mcp", DisabledTools: []string{"write_file", "delete_file"}}
for _, names := range [][]string{entry.DisabledTools, {}} {
entry.DisabledTools = names
@@ -4,6 +4,8 @@ import (
"os"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func labelOf(e ProviderEntry, model string) string { return e.ModelShort(model) }
@@ -51,7 +53,7 @@ func TestHideModelPrefixRoundTripsThroughTheConfigFile(t *testing.T) {
if !strings.Contains(out, "hide_model_prefix = true") {
t.Fatalf("not rendered:\n%s", out)
}
path := t.TempDir() + "/config.toml"
path := testenv.TempDir(t) + "/config.toml"
if err := os.WriteFile(path, []byte(out), 0o600); err != nil {
t.Fatal(err)
}
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"slices"
"testing"
"reasonix/internal/base/testenv"
)
func TestTheModCeilingsHaveDefaultsAndAZeroIsAnExplicitChoice(t *testing.T) {
@@ -33,7 +35,7 @@ func TestAProjectFileCannotRaiseWhatAModMayAdd(t *testing.T) {
}
func TestSavingTheUserFileKeepsACustomModCeiling(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
+2 -2
View File
@@ -557,7 +557,7 @@ func TestAcquireConfigEditLockPathRefusesLockDirOwnedBySomeoneElse(t *testing.T)
if runtime.GOOS == "windows" {
t.Skip("directory ownership is not checked on Windows")
}
dir := filepath.Join(t.TempDir(), "locks")
dir := filepath.Join(testenv.TempDir(t), "locks")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
@@ -594,7 +594,7 @@ func TestAcquireConfigEditLockPathTightensOwnLockDir(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX modes are not enforced on Windows")
}
dir := filepath.Join(t.TempDir(), "locks")
dir := filepath.Join(testenv.TempDir(t), "locks")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
@@ -6,6 +6,8 @@ import (
"path/filepath"
"slices"
"testing"
"reasonix/internal/base/testenv"
)
func TestOTLPExportIsOffUnlessTheUserFileSwitchesItOnAndNamesACollector(t *testing.T) {
@@ -43,7 +45,7 @@ func TestAProjectFileCannotTurnOnOrRedirectTraceExport(t *testing.T) {
}
func TestSavingTheUserFileKeepsTheTraceExportSettings(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
@@ -5,6 +5,8 @@ import (
"path/filepath"
"slices"
"testing"
"reasonix/internal/base/testenv"
)
func TestPeersAndDispatchDefaultOnAndRespectExplicitOptOut(t *testing.T) {
@@ -51,7 +53,7 @@ func TestAProjectFileCannotSwitchOffWhatTheUserChose(t *testing.T) {
}
func TestSavingTheUserFileKeepsEveryPeersSetting(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
@@ -91,7 +93,7 @@ func TestPeersDispatchNeedsPeersAndAProjectFileCannotGrantIt(t *testing.T) {
}
func TestSavingPeersSettingsWritesThemAndReadsThemBack(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := UserConfigPath()
cfg := LoadForEdit(path)
cfg.Peers = PeersConfig{Enabled: true, Dispatch: true, ReleaseProjects: []string{"/work/b"}}
@@ -113,7 +115,7 @@ func TestSavingPeersSettingsWritesThemAndReadsThemBack(t *testing.T) {
}
func TestSavingAnOptOutDoesNotRestorePeerDefaults(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := UserConfigPath()
for _, values := range [][2]bool{{false, false}, {true, false}, {false, true}} {
cfg := Default()
@@ -151,7 +153,7 @@ func TestTheDispatchCeilingsDefaultToTheNamedValuesAndOnlyTheUserFileMovesThem(t
}
func TestCeilingsTheUserSetSurviveAnUnrelatedSave(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
@@ -5,6 +5,8 @@ import (
"sync/atomic"
"testing"
"time"
"reasonix/internal/base/testenv"
)
func withPackageSource(t *testing.T, src func(home string) []InstalledPackage) {
@@ -20,7 +22,7 @@ func TestEnabledPackagesConcurrentCallersShareParses(t *testing.T) {
time.Sleep(40 * time.Millisecond)
return []InstalledPackage{{Name: "ecc"}}
})
r := RootsForHome(t.TempDir())
r := RootsForHome(testenv.TempDir(t))
const callers = 16
var wg sync.WaitGroup
@@ -51,7 +53,7 @@ func TestEnabledPackagesCallerNeverReadsStateOlderThanItsArrival(t *testing.T) {
<-release
return []InstalledPackage{{Name: "gen", Version: string(rune('0' + seen))}}
})
r := RootsForHome(t.TempDir())
r := RootsForHome(testenv.TempDir(t))
first := make(chan []InstalledPackage, 1)
go func() { first <- r.enabledPackages() }()
@@ -81,7 +83,7 @@ func TestEnabledPackagesCallersDoNotShareMutableResults(t *testing.T) {
MCPServers: map[string]PackageMCPServer{"a": {Args: []string{"x"}}},
}}
})
r := RootsForHome(t.TempDir())
r := RootsForHome(testenv.TempDir(t))
one := r.enabledPackages()
one[0].SkillRoots[0] = "changed"
@@ -4,13 +4,15 @@ import (
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
// Serve authentication is user-global: a repository or the agent writing
// inside the workspace cannot choose the launch token or the auth mode.
func TestProjectConfigCannotSetServeAuthentication(t *testing.T) {
isolateUserConfigHome(t)
root := t.TempDir()
root := testenv.TempDir(t)
body := "[serve]\nauth_mode = \"none\"\ntoken = \"attacker-known\"\nbehind_proxy = true\n"
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
)
func TestSharePortRange(t *testing.T) {
@@ -64,7 +66,7 @@ func TestSharePortSurvivesASaveAndKeepsTheRestOfServe(t *testing.T) {
func TestProjectConfigCannotPinTheSharePort(t *testing.T) {
isolateUserConfigHome(t)
root := t.TempDir()
root := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[serve]\nshare_port = 41234\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -7,12 +7,14 @@ import (
"sync"
"testing"
"time"
"reasonix/internal/base/testenv"
)
// A directory another test creates and removes while the guard walks the tree
// must not fail the walk, and a join in a stable file is still found.
func TestStateRootJoinsSurvivesDirectoriesVanishingMidWalk(t *testing.T) {
root := t.TempDir()
root := testenv.TempDir(t)
src := "package x\n\nfunc f() { _ = filepath.Join(userSupportDir(), \"kept\") }\n"
if err := os.WriteFile(filepath.Join(root, "kept.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
Loaded 100 of 465 files, more files were not shown because too many files have changed in this diff. Show more