refactor(desktop): Studio is the only shell the module builds

The Wails-bound app and its React frontend are gone. Studio reaches the
kernel through internal/serve's HTTP routes instead of bound Go methods,
so the shell that replaces ~320k lines is next/ plus the SPA it serves.

Three things the deleted tree owned move rather than disappear: the
WebView2 fork's patch guard, the macOS dock-icon safe-area assertion, and
the Windows ICO the resource stamper needs. The fork itself stays — its
mixed-DPI and proxy-isolation patches are Studio's too.

CI keeps one desktop Go gate per platform, now built against WebKitGTK
4.1 the way studio-build.sh links; the frontend legs, the Wails CLI pin,
and the 4.0 leg went with the app.

The desktop release line (release-desktop.yml and the desktop channel in
release-stable.yml) is untouched here: it still serves installs that are
already out there, and unwiring it is release-pipeline surgery of its own.
This commit is contained in:
esengine committed 2026-08-17 08:13:37 -07:00
1 parent 9747503b10
commit 908bc3ec0c
937 files changed
+271 -320354

No files matched your search

+21 -172
View File
@@ -316,22 +316,6 @@ jobs:
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
- name: gofmt
run: |
unformatted=$(gofmt -l .)
@@ -350,34 +334,15 @@ jobs:
exit 1
fi
# WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag).
# WebKitGTK 4.1 is what studio-build.sh links against: 4.0
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
- name: Install Linux build deps
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev xvfb
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install --with-deps chromium
pnpm --dir frontend build
- name: Test desktop frontend
run: |
pnpm --dir frontend test:terminal
pnpm --dir frontend test:task-monitor
pnpm --dir frontend test:workspace
pnpm --dir frontend test:motion
pnpm --dir frontend test:motion-browser
pnpm --dir frontend test:transcript
pnpm --dir frontend test:transcript-browser
- name: Test usage statistics frontend
run: pnpm --dir frontend test:usage-stats
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
- name: vet
run: go vet ./...
run: go vet -tags webkit2_41 ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
@@ -387,51 +352,19 @@ jobs:
args: --timeout=5m
- name: build
run: go build ./...
run: go build -tags webkit2_41 ./...
- name: test
run: go test ./...
- name: WebKitGTK 4.0 native recovery smoke
run: xvfb-run -a env WEBKIT_DISABLE_COMPOSITING_MODE=1 go test -tags reasonix_webkit_smoke . -run '^TestWebKitNativeRecoverySmoke$'
# The extension work added new shared-state paths to the desktop
# runtime (tab/rebuild fences, extension UI). Race-sweep the module so
# regressions are CI-blocked, not just author-verified locally.
- name: test -race
run: go test -race ./...
# Ubuntu 24.04 ships WebKitGTK 4.1 only. Keep this capability build separate
# from the 22.04/4.0 leg so neither runtime becomes a distro whitelist.
desktop-linux-webkit41:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: ubuntu-24.04
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- name: Install WebKitGTK 4.1 build dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
- name: Build and test WebKitGTK 4.1 observer
run: go test -tags webkit2_41 ./...
- name: WebKitGTK 4.1 native recovery smoke
run: xvfb-run -a env WEBKIT_DISABLE_COMPOSITING_MODE=1 go test -tags 'webkit2_41 reasonix_webkit_smoke' . -run '^TestWebKitNativeRecoverySmoke$'
# The shell's tab/rebuild fences and extension UI are shared state.
# Race-sweep the module so regressions are CI-blocked, not just
# author-verified locally.
- name: test -race
run: go test -race -tags webkit2_41 ./...
# desktop/ is a separate module, so the root macOS matrix above does not
# compile or exercise the native PTY implementation.
# compile it against a real macOS toolchain.
desktop-macos:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
@@ -448,11 +381,11 @@ jobs:
cache: true
cache-dependency-path: desktop/go.sum
- name: Test integrated terminal and PTY lifecycle
run: go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' .
- name: test
run: go test -race ./...
# desktop/*_darwin.go is the one build-tag set the lint job cannot reach:
# the main-thread watchdog is cgo, so it only type-checks with a real
# desktop's *_darwin.go is the one build-tag set the lint job cannot
# reach: the dock-icon glue is cgo, so it only type-checks with a real
# macOS toolchain.
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
@@ -461,10 +394,8 @@ jobs:
working-directory: desktop
args: --timeout=5m
# Desktop project-root matching is case-insensitive only on Windows
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
# regression tests for that contract are named *OnWindows and can never
# run on the ubuntu leg above.
# The update helper's versioned-install and uninstall paths are Windows-only
# (//go:build windows), so the ubuntu leg above never compiles them.
desktop-windows:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
@@ -482,72 +413,14 @@ jobs:
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
# go:embed of frontend/dist needs a built frontend before the package
# compiles, same as the ubuntu desktop leg.
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: Test native motion contracts
run: pnpm --dir frontend test:motion
- name: Test WebView2 native smoke state machine
shell: pwsh
run: ../scripts/test-webview2-native-smoke.ps1 -SelfTest
# Build the real Wails executable on every desktop PR and exercise the
# exact production startup path in Microsoft's installed WebView2 runtime.
# The frontend and bindings were already built above, so this skips only
# duplicate build work, not the native shell.
- name: Build Wails executable for native startup smoke
timeout-minutes: 10
run: wails build -clean -s -skipbindings -nopackage -platform windows/amd64 -webview2 embed
- name: Smoke-test Wails/WebView2 native startup
shell: pwsh
timeout-minutes: 2
run: |
../scripts/test-webview2-native-smoke.ps1 `
-ExecutablePath "build/bin/reasonix-desktop.exe" `
-TimeoutSeconds 60 `
-HealthySeconds 5
- name: test (Windows desktop and update helper)
# The vendored WebView2 fork carries the mixed-DPI and proxy-isolation
# patches, and its own tests only compile on Windows.
- name: test (Windows shell, update helper, and the WebView2 fork)
timeout-minutes: 15
run: |
go test ./...
(cd third_party/go-webview2 && go test ./pkg/edge)
# Installer packaging is packaging validation, not a code gate: run it
# on pushes to main-v2 (the release pipeline builds installers again
# anyway), but let pull requests stop after the test step.
- name: Install NSIS
if: github.event_name != 'pull_request'
run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
- name: Build Windows installer and portable archive
if: github.event_name != 'pull_request'
timeout-minutes: 20
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
# repolint scans desktop/ and sdk/ too, so this job also runs for diffs the
# `code` filter would otherwise skip.
lint:
@@ -562,33 +435,9 @@ jobs:
go-version-file: go.mod
cache: true
- if: github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false'
uses: pnpm/[email protected]
with:
version: 10
run_install: false
- if: github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false'
uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: required native motion contracts
if: github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false'
run: |
pnpm --dir desktop/frontend install --frozen-lockfile
pnpm --dir desktop/frontend test:motion
- name: repo standards
run: go run ./tools/repolint
- name: wails version pin
run: |
bash scripts/check-wails-pin.sh
bash scripts/check-wails-pin.test.sh
# `make lint-install` reads the same file, so a local run and this job
# cannot drift onto different linter versions.
- id: golangci
@@ -624,7 +473,7 @@ jobs:
.github/workflows/release-stable-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml \
.github/workflows/release-studio.yml \
.github/workflows/release-verify-issues.yml \
.github/workflows/docs-impact.yml \
.github/workflows/ci.yml
+4 -16
View File
@@ -8,9 +8,8 @@ LDFLAGS := -s -w \
GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
WAILS_VERSION := $(shell tr -d '[:space:]' < .wails-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update check wails-install test desktop-test desktop-test-short desktop-test-times sdk-test sdk-test-race hooks cross clean studio
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update check test studio-test sdk-test sdk-test-race hooks cross clean studio
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -35,8 +34,6 @@ fmt:
# particular never surface in `go vet`.
lint: lint-go
go run ./tools/repolint
bash scripts/check-wails-pin.sh
bash scripts/check-wails-pin.test.sh
lint-go:
@command -v golangci-lint >/dev/null || { echo "golangci-lint not installed; run: make lint-install"; exit 1; }
@@ -60,10 +57,6 @@ check:
lint-update:
go run ./tools/repolint -update
wails-install:
bash scripts/check-wails-pin.sh
go install "github.com/wailsapp/wails/v2/cmd/wails@$(WAILS_VERSION)"
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
lint-cross:
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
@@ -75,14 +68,9 @@ lint-cross:
test:
go test ./...
desktop-test:
cd desktop && go test .
desktop-test-short:
cd desktop && go test -short .
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
# The shell is a nested module, so the root `test` target never reaches it.
studio-test:
cd desktop && go test ./...
sdk-test:
cd sdk/go && go test ./...
+68 -278
View File
@@ -1,308 +1,98 @@
# Reasonix Desktop (Wails shell)
# Reasonix Studio (Wails shell)
A native desktop window around the Reasonix Go kernel. The same
transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE
server is bound **directly** to a React webview — Go methods in, typed events
out, no HTTP hop.
A native desktop window around the Reasonix Go kernel. The window hosts a React
SPA; every request the SPA makes is routed in-process to the kernel's own HTTP
server (`internal/serve`) instead of to a set of bound Go methods, so the
desktop, `reasonix serve`, and the remote UI all answer the same routes.
```
┌─────────────────────────────────────────────────────────────┐
│ webview (React + TS, Vite) │
│ bridge.ts ──calls──▶ window.go.main.App.{Submit,Cancel,…} │
│ bridge.ts ◀─events── window.runtime.EventsOn("agent:event")│
└───────────────▲───────────────────────────┬─────────────────┘
bound methods runtime.EventsEmit
┌───────────────┴───────────────────────────▼─────────────────┐
│ desktop/app.go App (bound) + eventSink (event.Sink) │
│ desktop/main.go Wails options, window, embed frontend/dist │
└───────────────▲───────────────────────────┬─────────────────┘
commands │ │ typed event stream
┌───────────────┴────────────────────────────▼────────────────┐
│ internal/boot.Build → internal/control.Controller (kernel) │
│ (same assembly the CLI uses: providers, tools, gate, …) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ webview (React + TS, Vite) — desktop/frontend-next │
│ fetch("/api/…") · EventSource("/api/events") │
└───────────────▲────────────────────────────┬─────────────────┘
│ │
┌───────────────┴────────────────────────────▼─────────────────┐
│ desktop/next main.go │
│ isAPIPath / isHubPath ──▶ serve.Server (kernel HTTP mux) │
│ everything else ──▶ frontendAssets() (the SPA) │
└───────────────▲────────────────────────────┬─────────────────┘
│ │
┌───────────────┴────────────────────────────▼─────────────────┐
│ internal/boot.Build → internal/control.Controller (kernel) │
│ (same assembly the CLI uses: providers, tools, gate, …) │
└───────────────────────────────────────────────────────────────┘
```
Because the SPA speaks HTTP rather than `window.go.*`, `desktop/next` stays
thin: it owns the window, the platform glue (dock icon, file drop, window fit),
and updates. Anything a frontend needs is a kernel route, and
`next/route_parity_test.go` fails if the shell routes one of them to the assets.
## Layout
| Path | What it is |
| --- | --- |
| `next/` | the Wails shell: window, routing, updates, platform glue |
| `frontend-next/` | the SPA it serves |
| `internal/update/` | manifest, download, verify, apply — shared by the shell and the helper |
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
| `cmd/studio-manifest/`, `cmd/sign/`, `cmd/windows-resource/` | release-side tools |
| `third_party/go-webview2/` | vendored fork; see below |
## Why a nested module
`desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix =>
../`). That keeps the CGO + WebKit desktop build entirely separate from the CLI's
../`). That keeps the CGO + WebKit build entirely separate from the CLI's
`CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build /
vet / test ./...` skip this directory, while the import path stays under
`reasonix/` so it can still import the `reasonix/internal/*` kernel.
## The vendored WebView2 fork
`replace github.com/wailsapp/go-webview2 => ./third_party/go-webview2` carries
patches upstream does not have: mixed-DPI monitor-scale detection (#5862),
`--no-proxy-server` so the loopback UI ignores stale system proxies, and native
renderer-failure recovery. `next/webview2_patch_test.go` reads the fork's AST
and fails if a Wails bump silently drops any of them.
## Prerequisites
- Go (matches the parent module).
- Node 24+ and **pnpm 10** (`npm install -g pnpm@10`).
- Wails CLI matching the library: run `make wails-install` from the repository
root. The target reads the shared `.wails-version` pin.
- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2**
runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links
against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu
24.04+, Arch) build with `-tags webkit2_41` — see [Build](#build). Run
`wails doctor` to verify.
runtime; Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` (4.0 is gone
from Ubuntu 24.04+ and Fedora 40+, so Linux builds always carry the
`webkit2_41` tag).
## Develop
No Wails CLI is required — `scripts/studio-build.sh` drives `go build` directly.
## Running it
```sh
cd desktop
wails dev # hot-reloads Go + frontend (Vite dev server)
make studio # build the SPA + shell and launch (macOS: inside an .app bundle)
```
Frontend-only iteration without the Go side:
On macOS that bundle is not optional: LaunchServices only treats a bundled
process as a real GUI app, and a bare binary makes native panels — "Add a
folder…" — open and close in the same beat.
For frontend iteration, run the kernel and point Vite at it:
```sh
cd desktop/frontend
pnpm install
pnpm dev # opens in a plain browser; bridge.ts uses the dev mock
go run ./cmd/reasonix serve # from the repository root
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
```
In a plain browser the native bindings are absent, so `bridge.ts` falls back to a
**mock** that streams a canned turn (text + one `edit_file` tool call) through the
exact same event contract — so layout, streaming, markdown, tool cards, and the
diff seam can all be built without rebuilding Go.
## Test
The desktop package is a nested Go module, so parent `go test ./...` does not run
it. Use the full lane before merging desktop changes, and the short lane for fast
local feedback:
## Building and testing
```sh
make desktop-test # cd desktop && go test .
make desktop-test-short # skips slow desktop integration/e2e checks
bash scripts/studio-build.sh --shell-only # compile the shell for this host
bash scripts/studio-build.sh darwin/arm64 v0.1.0 # full artifact into dist/
make studio-test # go test ./... for this module
cd frontend-next && pnpm typecheck && pnpm build
```
To find the next bottleneck, rank individual test cases from the JSON stream:
```sh
make desktop-test-times
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
```
### Frontend UI review checklist
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
component code and the CSS positioning contract:
- If a component uses `createPortal` plus `getBoundingClientRect()`, it must
handle scrollable ancestors, window resize, and `visualViewport` changes.
- Add a focused regression test when changing shared positioning primitives such
as `AnchoredPopover`, not only the specific menu that exposed the bug.
- Exercise at least one scrollable container path, such as Settings content, when
manually checking dropdown or popover changes.
## Build
```sh
cd desktop
wails build # → build/bin/Reasonix(.app/.exe)
```
**Linux on WebKitGTK 4.1 only** (Fedora 40+, Ubuntu 24.04+, Arch — no
`webkit2gtk-4.0` package): pass the Wails build tag so cgo links against 4.1.
```sh
wails build -tags webkit2_41
wails dev -tags webkit2_41 # same tag for hot-reload
```
Fedora deps: `sudo dnf install webkit2gtk4.1-devel gtk3-devel`.
`frontend/dist` is generated by the build (it's git-ignored except for a
`.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh
checkout). A bare `go build` without a prior `pnpm build` produces a blank window.
## Releases & auto-update
Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
current desktop manifest to the matching CLI release for old clients that still
ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
```sh
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
```
The app checks `latest.json` on startup (R2 first, then the
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
newer version is published; **Settings → Software update** has a manual check.
The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
not depend on homepage badge semantics. Self-update behavior by platform:
- **Linux portable (`.tar.gz`)** — download, verify the minisign signature, replace
the binaries in the install directory, and relaunch through Guard. No elevation.
- **Linux Debian/Ubuntu (`.deb`)** — download the signed `.deb`, request administrator
authorization via Polkit (`pkexec`), re-verify and install with `apt-get
--only-upgrade`, then relaunch through Guard. The first build that ships the
update helper and Polkit policy is a one-time bootstrap: existing `.deb` users
should overwrite-install once with
`sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required). After
that, in-app authorized updates work. If Polkit/`pkexec` is unavailable, use
the same manual command. Failed installs leave the running app intact so you
can retry; successful installs are managed by apt/dpkg and are not auto-downgraded.
- **Windows** — download, verify the minisign signature, then run the per-user
NSIS installer (no admin rights needed).
- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an
in-place swap would be blocked by Gatekeeper; the banner links to the download
page for a manual update instead.
### Code signing — first launch
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
per release; `release-desktop.yml` verifies every payload binary through
`scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A
brand-new version can still show SmartScreen until the signature accumulates
reputation: *More info → Run anyway*.
- **macOS** — still unsigned and un-notarized. Open
`Reasonix-darwin-universal.dmg`, drag Reasonix into Applications, then clear the
quarantine attribute when Gatekeeper reports the app "is damaged" or is from an
unidentified developer:
```sh
xattr -dr com.apple.quarantine /Applications/Reasonix.app
```
This is also why macOS has no in-place self-update: the swap would be blocked.
Adding a Developer ID certificate flips the release workflow's `HAS_APPLE_CERT`
gate to the signed path and removes both.
### Verifying a download
Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig`
signature sits next to each artifact in the release; verify with the
[minisign](https://jedisct1.github.io/minisign/) CLI:
```sh
minisign -Vm Reasonix-darwin-arm64.zip \
-P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq
```
## Editor seams and workspace file previews
Code and diff rendering go through two components with stable prop contracts and
lazy boundaries, so heavier viewers stay out of the initial bundle. `CodeViewer`
keeps the compact highlighted viewer for chat, Markdown, and tool output, while
workspace file previews opt into the searchable line-number viewer:
| Component | Props | Default impl | Upgrade |
|---|---|---|---|
| `components/CodeViewer.tsx` | `EditorProps` | `editors/HljsCode.tsx`; `editors/LineNumberCode.tsx` when `showLineNumbers` is enabled | extend the implementation selection for Monaco or CodeMirror |
| `components/DiffView.tsx` | `DiffProps` | `editors/HljsDiff.tsx` (highlighted LCS/unified diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
```sh
# Monaco
pnpm add @monaco-editor/react monaco-editor
# or CodeMirror 6
pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge
```
Then add `editors/MonacoCode.tsx` (default-export a component taking
`EditorProps`) and update the implementation selection in `CodeViewer.tsx`.
`ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through
`DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so
both seams light up everywhere at once.
`WorkspacePanel` passes `showLineNumbers` for text-file previews. The resulting
viewer provides a line-number gutter, viewer-scoped Ctrl/Cmd+F search with case
and whole-word options, copy support, and virtualized rendering above 100 lines.
Search marks are applied only to visible rows so query input does not rebuild the
entire highlighted document. Files above 512 KiB or 20,000 lines keep line
numbers, search, copy, and virtualization but use escaped plain text instead of
syntax highlighting. Workspace files are previewed up to 2 MiB; larger files
display the first 2 MiB with a localized truncation notice.
## Multi-platform adaptation
Wails is the right shell for a Go kernel (no sidecar), but a Go+webview stack uses
the **native** webview per OS, so the rough edges are platform-specific. What's
handled here, and what to reach for if a target misbehaves:
- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
that cannot access `/dev/dri`. If artifacts persist, launch with
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
- **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
or `GDK_BACKEND=x11` (forces XWayland).
- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
`REASONIX_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or off. The
older `REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU` name remains accepted. The WebView2 shell always uses a direct connection for embedded assets
and loopback remote-workspace pages; provider and other outbound traffic keeps
using Reasonix's own proxy configuration. Remote Markdown images are fetched
by the Go backend with the same proxy settings and re-served from the local
asset origin, so WebView2 never bypasses the configured proxy for them. Image
hosts must resolve locally to public addresses; direct, HTTP(S)-proxy, and
SOCKS-proxy connections are pinned to those vetted IPs while preserving the
original Host and TLS SNI. If the DOM is still not ready after 15 seconds, the
hidden startup window is shown with a native recovery prompt.
- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
room for the traffic lights.
- **Theming** — colors are CSS variables gated on `prefers-color-scheme`, which all
three webviews honor, so the UI follows the OS theme without native glue.
- **Fonts / offline** — system font stack only; no web-font fetches, so first paint
is instant and identical offline.
- **First paint** — the window background is set to the dark shell color so there's
no white flash before CSS loads (most visible on WebKitGTK).
## Files
```
desktop/
main.go Wails options, window, embed frontend/dist
app.go App (bound command surface) + eventSink (event.Sink → webview)
wire.go event.Event → JSON wire form (mirrors internal/serve/wire.go)
wails.json Wails project config (pnpm install/build/dev)
frontend/
src/
lib/
types.ts wire contract (mirrors wire.go)
bridge.ts window.go/window.runtime wrapper + browser dev mock
useController.ts event-stream reducer + command surface (the hook)
components/
Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge,
Markdown, CodeViewer, DiffView
editors/ PlainCode, PlainDiff ← editor seam impls (swap targets)
```
## Telemetry
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
a random anonymous install id (generated locally and not an account id), app
version, OS, architecture, Windows build/revision or bounded Linux
distribution/kernel/session facts, and Web Runtime/GPU mode. When the previous
process ended abnormally, the next normal launch may also send a bounded native
diagnostic (lifecycle phase, symbolized stack, WebView2 or WebKitGTK Runtime,
process reason/exit code/recovery fields, window failure kind, and coarse device
facts).
Panic values are removed and paths/secrets are scrubbed before the report is
queued. The install id is attached only while sending and is not stored in a
pending crash file. It never includes conversations, account data, API keys,
file contents, usernames, hostnames, GPU driver details, or full local paths.
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
`telemetry = false` under `[desktop]` in the global config. Dev builds
never ping or upload queued native diagnostics. Frontend crash and
performance-pressure reports remain separate and are sent only when the user
clicks "Send report" on the diagnostic UI.
Aggregate quality metrics are also enabled by default and can be disabled from
Settings > Updates > "Share aggregate quality metrics", or by setting
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
counts, lifecycle/window failure buckets, and preference buckets; they never
include conversations, prompts, keys, paths, base URLs, or file contents.
Linux additionally builds a `.deb`; it is what makes Studio self-updating there,
because the shared apply path stages single files and Studio ships a binary plus
an SPA tree. See `docs/STUDIO_RELEASE.md`.
-36
View File
@@ -1,36 +0,0 @@
package main
import (
"path/filepath"
"testing"
"reasonix/internal/agent"
"reasonix/internal/boot"
)
func TestSaveTabSessionMetaReplacesStaleAgentPreset(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
meta, err := agent.EnsureBranchMeta(path)
if err != nil {
t.Fatal(err)
}
meta.AgentPreset = boot.AgentPresetBalanced
meta.TokenMode = boot.TokenModeFull
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
t.Fatal(err)
}
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, tokenMode: boot.TokenModeDelivery}); err != nil {
t.Fatal(err)
}
got, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta = %+v, %v, %v", got, ok, err)
}
if got.AgentPreset != boot.AgentPresetDelivery || got.TokenMode != boot.TokenModeDelivery {
t.Fatalf("persisted role = preset:%q tokenMode:%q, want delivery dual-write", got.AgentPreset, got.TokenMode)
}
if restored := tabSessionProfileFromMeta(path, got).tokenMode; restored != boot.TokenModeDelivery {
t.Fatalf("restored tokenMode = %q, want delivery", restored)
}
}
-12108
View File
File diff suppressed because it is too large. Load diff
-672
View File
@@ -1,672 +0,0 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
type stubProvider struct{}
func (stubProvider) Name() string { return "stub" }
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk, 1)
close(ch)
return ch, nil
}
func controllerWithContent(t *testing.T, path string) *control.Controller {
t.Helper()
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
}
func waitForFile(t *testing.T, path, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("session file %q never contained %q", path, want)
}
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
tab.saveMu.Unlock()
if idle {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("autosave loop did not become idle")
}
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
t.Helper()
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "test_tab",
Ctrl: ctrl,
Scope: "global",
WorkspaceRoot: "",
Ready: true,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
a := &App{
tabs: map[string]*WorkspaceTab{"test_tab": tab},
activeTabID: "test_tab",
}
tab.sink.app = a
return a, tab
}
// TestTurnDonePersistsSession proves a completed turn is written to disk without
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
_, tab := appWithTab(t, path)
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
// per-token event storm doesn't thrash the disk.
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
}
}
// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
// single-flight loop neither panics nor drops the final write.
func TestScheduleSnapshotCoalesces(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a, tab := appWithTab(t, path)
_ = a
var wg sync.WaitGroup
for range 64 {
wg.Go(func() {
tab.sink.Emit(event.Event{Kind: event.TurnDone})
})
}
wg.Wait()
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, tab)
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures = tab.saveFailures
tab.saveMu.Unlock()
if failures != 0 {
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
}
}
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
originalTopic := "topic_original"
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
t.Fatalf("set original topic title: %v", err)
}
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
Scope: "global",
TopicID: originalTopic,
TopicTitle: "Original",
Preview: "first",
Turns: 2,
SchemaVersion: agent.BranchMetaCountsVersion,
}); err != nil {
t.Fatalf("SaveBranchMeta original: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: originalTopic,
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
}
if got := saved.Tabs[0].SessionPath; got != recoveryPath {
t.Fatalf("saved tab session path = %q, want recovery path %q", got, recoveryPath)
}
if tab.TopicID != originalTopic {
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
}
tabMeta := app.tabMeta(tab, true)
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.Recovered {
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
}
if node.TopicID == originalTopic {
foundOriginal = true
}
walk(node.Children)
}
}
walk(nodes)
if !foundOriginal {
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
}
}
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
OriginalPath: originalPath,
BranchMeta: agent.BranchMeta{
Name: agent.RecoveryBranchDefaultName,
Scope: "global",
TopicID: "topic_recovery",
TopicTitle: "Recovery",
},
})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
lease, err := agent.TryAcquireSessionLease(recovery.Path)
if err != nil {
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
}
defer lease.Release()
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
runtimeEvents := make(chan runtimeEventEnvelope, 4)
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]any(nil), payload...),
}
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: "topic_original",
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
err = tab.Ctrl.Snapshot()
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
}
if got := tab.Ctrl.SessionPath(); got != originalPath {
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
}
if tab.SessionPath != originalPath {
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
}
if tab.TopicID != "topic_original" {
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].SessionPath != originalPath {
t.Fatalf("saved tabs after failed recovery = %+v, want original path %q", saved.Tabs, originalPath)
}
deadline := time.After(time.Second)
for {
select {
case emitted := <-runtimeEvents:
if emitted.name != "session:recovery-failed" {
continue
}
if len(emitted.payload) != 1 {
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
}
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
if !ok {
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
}
if failed.Reason != "lease_held" {
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
}
return
case <-deadline:
t.Fatal("session:recovery-failed event was not emitted")
}
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, _ := appWithTab(t, path)
a.tabs["target_tab"] = &WorkspaceTab{
ID: "target_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabOrder = []string{"test_tab", "target_tab"}
err := a.SetActiveTab("target_tab")
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
}
if a.activeTabID != "test_tab" {
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
}
}
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
target := filepath.Join(t.TempDir(), "target.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
if err := sess.Save(target); err != nil {
t.Fatalf("save target: %v", err)
}
loaded, err := agent.LoadSession(target)
if err != nil {
t.Fatalf("load target: %v", err)
}
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
t.Fatalf("rebind error = %v, want persistence failure", err)
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
// captured session path, so its Snapshot() call landed after DeleteSession
// trashed the file, "resurrecting" it.
func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
doomed, doomedTab := appWithTab(t, path)
// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
doomed.tabs["survivor_tab"] = survivor
doomed.activeTabID = "test_tab"
// Write the session file once via the autosave loop, then wait for idle so
// the next TurnDone reliably kicks off a fresh loop.
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, doomedTab)
// Kick the autosave loop and close the tab in close succession. The loop
// will be in flight when CloseTab runs — exactly the #4384 window.
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
if err := doomed.CloseTab("test_tab"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
// CloseTab must have returned only after the autosave loop finished. Remove
// the file the way DeleteSession would (move to trash is just a remove here
// since we only care that nothing rewrites the original path).
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove session file: %v", err)
}
// Give any would-be resurrection a chance to strike. If the autosave loop
// were still alive (the bug), the file reappears here.
time.Sleep(100 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
}
// And the controller's session path must be cleared so no future Snapshot
// can write either.
if got := doomedTab.Ctrl.SessionPath(); got != "" {
t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
}
}
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs[survivor.ID] = survivor
a.tabOrder = []string{tab.ID, survivor.ID}
err := a.CloseTab(tab.ID)
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
t.Fatalf("CloseTab error = %v, want persistence failure", err)
}
if _, ok := a.tabs[tab.ID]; !ok {
t.Fatal("tab was removed even though its session could not be saved")
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
a, _ := appWithTab(t, doomedPath)
survivorCtrl := controllerWithContent(t, survivorPath)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Ctrl: survivorCtrl,
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs["survivor_tab"] = survivor
a.activeTabID = "test_tab"
survivor.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, survivorPath, "acknowledged")
waitForAutosaveIdle(t, survivor)
if err := a.CloseTab("test_tab"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if got := survivor.Ctrl.SessionPath(); got != survivorPath {
t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
}
if survivor.closing {
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
}
}
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := filepath.Join(dir, "delete-open.jsonl")
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "delete_open",
Scope: "global",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"delete_open": tab},
activeTabID: "delete_open",
}
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := app.DeleteSession(path); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("session should be in trash: %v", err)
}
}
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_clear_removed_runtime"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "trash-open-topic.jsonl")
ctrl := controllerWithContent(t, path)
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
tab := &WorkspaceTab{
ID: "trash_open",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
survivor := &WorkspaceTab{
ID: "survivor",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
tabOrder: []string{"trash_open", "survivor"},
activeTabID: "trash_open",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("topic session should be in trash: %v", err)
}
}
-27
View File
@@ -1,27 +0,0 @@
//go:build windows
package main
import (
"os"
"reasonix/internal/appidentity"
"reasonix/internal/installlayout"
)
func init() {
if err := appidentity.ApplyToCurrentProcess(); err != nil {
println("Warning: apply Windows app identity:", err.Error())
}
executable, err := os.Executable()
if err != nil {
return
}
installRoot, err := installlayout.ResolveInstallRoot(executable)
if err != nil {
return
}
if err := appidentity.RepairOwnedShortcuts(installRoot); err != nil {
println("Warning: repair Windows shortcut identity:", err.Error())
}
}
-14
View File
@@ -1,14 +0,0 @@
package main
import (
"runtime"
"testing"
)
func TestAppPlatformReturnsRuntimeGOOS(t *testing.T) {
app := NewApp()
if got := app.Platform(); got != runtime.GOOS {
t.Fatalf("Platform() = %q, want %q", got, runtime.GOOS)
}
}
-798
View File
@@ -1,798 +0,0 @@
package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func carryingController(carried []provider.Message, path string) *control.Controller {
sess := &agent.Session{}
sess.Replace(carried)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
}
// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
// config change rebuilds the controller and carries the conversation forward. Each
// rebuild must keep writing to the same file, so a run of them leaves exactly one
// history entry — not a new identical duplicate per rebuild.
func TestCarriedRebuildsKeepOneSession(t *testing.T) {
dir := t.TempDir()
path := agent.NewSessionPath(dir, "model-a")
ctrl := controllerWithContent(t, path)
if err := ctrl.Snapshot(); err != nil {
t.Fatal(err)
}
for range 5 {
prevPath := ctrl.SessionPath()
carried := ctrl.History()
ctrl.Close()
newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
ctrl = carryingController(carried, newPath)
if err := ctrl.Snapshot(); err != nil {
t.Fatal(err)
}
}
ctrl.Close()
infos, err := agent.ListSessions(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 1 {
paths := make([]string, len(infos))
for i, s := range infos {
paths[i] = filepath.Base(s.Path)
}
t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
}
}
// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
first, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if first.SessionPath == "" {
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
}
if _, err := os.Stat(first.SessionPath); err != nil {
t.Fatalf("pre-created blank session should exist: %v", err)
}
second, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
isolateDesktopUserDirs(t)
globalRoot := globalWorkspaceRoot()
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
t.Fatal(err)
}
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
app.tabs["blank"] = &WorkspaceTab{
ID: "blank",
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
SessionPath: sessionPath,
disabledMCP: map[string]ServerView{},
}
app.tabOrder = []string{"blank"}
app.activeTabID = "blank"
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.ID != "blank" {
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
}
}
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
globalRoot := globalWorkspaceRoot()
dir := desktopSessionDir(globalRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
stubPath := filepath.Join(dir, "empty-stub.jsonl")
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
t.Fatalf("write empty stub: %v", err)
}
now := time.Now()
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now,
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
}
}
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
before := time.Now().UnixMilli()
meta, err := app.EnsureBlankTab("global", "")
after := time.Now().UnixMilli()
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
createdAt := loadTopicCreatedAt("", meta.TopicID)
if createdAt < before || createdAt > after {
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
}
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
}
}
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
const topicID = "topic_20260704-104018_deadbeef"
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
if got := loadTopicCreatedAt("", topicID); got != 0 {
t.Fatalf("createdAt before reuse = %d, want 0", got)
}
app := NewApp()
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topicID {
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
}
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
if got := loadTopicCreatedAt("", topicID); got != expected {
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
}
}
// EnsureBlankTab reuses an already-open project-scoped blank tab.
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
first, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
second, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
tabA := waitForTabReady(t, app, first.ID)
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
}
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
if second.ID == first.ID {
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
}
tabB := waitForTabReady(t, app, second.ID)
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
}
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
if err != nil {
t.Fatalf("create project A empty session: %v", err)
}
tab := &WorkspaceTab{
ID: "blank-project-b",
Scope: "project",
WorkspaceRoot: projectB,
SessionPath: pathA,
}
if blankTabSessionPathHasNoContent(tab) {
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
}
}
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
forked, err := app.Fork(turn)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.ID == "" || forked.ID == second.ID {
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
}
forkTab := waitForTabReady(t, app, forked.ID)
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(forkTab.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
if err := app.Rewind(turn, "conversation"); err != nil {
t.Fatalf("Rewind: %v", err)
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
t.Helper()
if tab == nil || tab.Ctrl == nil {
t.Fatal("tab controller is required")
}
sys := systemPromptFrom(tab.Ctrl.History())
if strings.TrimSpace(sys) == "" {
t.Fatal("tab controller did not expose a system prompt")
}
sessionDir := tab.Ctrl.SessionDir()
sessionPath := tab.Ctrl.SessionPath()
workspaceRoot := tab.Ctrl.WorkspaceRoot()
label := tab.Ctrl.Label()
tab.Ctrl.Close()
sess := agent.NewSession(sys)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Runner: ag,
Executor: ag,
SessionDir: sessionDir,
SessionPath: sessionPath,
WorkspaceRoot: workspaceRoot,
Label: label,
SystemPrompt: sys,
Sink: event.Discard,
})
tab.Ctrl = ctrl
app.bindControllerDisplayRecorder(ctrl)
return ctrl
}
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
t.Helper()
ctrl.SubmitUserTurn(input, input)
waitNotRunning(t, ctrl)
deadline := time.Now().Add(time.Second)
for {
checkpoints := ctrl.Checkpoints()
if len(checkpoints) > 0 {
return checkpoints[len(checkpoints)-1].Turn
}
if time.Now().After(deadline) {
t.Fatal("controller did not record a checkpoint")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
t.Fatalf("set stale auto title: %v", err)
}
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if got := meta.TopicTitle; got != defaultTopicTitle {
t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
t.Fatalf("title source = %q, want auto", got)
}
}
func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "Manual title")
if err != nil {
t.Fatalf("create topic: %v", err)
}
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if got := meta.TopicTitle; got != "Manual title" {
t.Fatalf("reused manual topic title = %q, want Manual title", got)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
t.Fatalf("stored title = %q, want Manual title", got)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
t.Fatalf("title source = %q, want manual", got)
}
}
func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
t.Fatalf("set stale auto title: %v", err)
}
activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
app.tabs[activeTab.ID] = activeTab
app.tabs[reusableTab.ID] = reusableTab
app.tabOrder = []string{activeTab.ID, reusableTab.ID}
app.activeTabID = activeTab.ID
titlePath := topicTitlesPath(projectRoot)
if err := os.Remove(titlePath); err != nil {
t.Fatalf("remove title file: %v", err)
}
if err := os.Mkdir(titlePath, 0o755); err != nil {
t.Fatalf("replace title file with directory: %v", err)
}
if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
t.Fatal("EnsureBlankTab succeeded, want title reset error")
}
if got := app.activeTabID; got != activeTab.ID {
t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
}
}
// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for global scope.
func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatal(err)
}
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
}
}
// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for project scope.
func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatal(err)
}
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
var topics []string
for _, project := range loadProjectsFile().Projects {
if project.Root == projectRoot {
topics = project.Topics
break
}
}
if len(topics) != 1 {
t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
}
}
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := robustTempDir(t)
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("CreateTopic: %v", err)
}
dir := desktopSessionDir(projectRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
}
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == topic.ID {
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
}
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
}
}
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
// into ensureTopicIndexed, whose intentional prepend clears the delete
// tombstone and resurrects the topic the user removed.
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
// Race product on disk: deleted topic whose default title lingered in the
// global title map (title-only, absent from GlobalTopics, no sessions).
tombstonedID := "topic_tombstone_blank"
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
t.Fatalf("set lingering title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
meta, err := NewApp().EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == tombstonedID {
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
}
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
}
}
// NewSession skips the snapshot when the current tab has no real conversation content.
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := agent.NewSessionPath(dir, "model-a")
ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
app := NewApp()
app.setTestCtrl(ctrl, "model-a")
if err := app.NewSession(); err != nil {
t.Fatal(err)
}
if got := ctrl.SessionPath(); got != path {
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
}
}
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
oldTopicID := "topic_old"
oldTopicTitle := "Old topic"
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
sess := &agent.Session{}
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
app := NewApp()
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.Scope = "project"
tab.WorkspaceRoot = projectRoot
tab.TopicID = oldTopicID
tab.TopicTitle = oldTopicTitle
tab.SessionPath = oldPath
app.projectTreeChangedHook = func() {}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
if got := tab.TopicID; got == "" || got == oldTopicID {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
newPath := ctrl.SessionPath()
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
}
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
t.Fatalf("write new session: %v", err)
}
if !app.maybeAutoTitleTopic(tab) {
t.Fatalf("new session should auto-title its fresh topic")
}
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
if err != nil || !ok {
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
}
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
}
newMeta, ok, err := agent.LoadBranchMeta(newPath)
if err != nil || !ok {
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
}
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
}
}
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := agent.NewSessionPath(dir, "model-a")
ctrl := controllerWithContent(t, path)
app := NewApp()
app.projectTreeChangedHook = func() {}
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.TopicID = "topic_old"
tab.TopicTitle = "Old topic"
// Block desktopConfigDir-backed topic-index writes without affecting the
// session directory, which exercises the post-NewSession repair failure path.
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
t.Fatalf("mkdir desktop config parent: %v", err)
}
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
t.Fatalf("block desktop config dir: %v", err)
}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
}
if got := tab.TopicID; got == "" || got == "topic_old" {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
}
}
-11064
View File
File diff suppressed because it is too large. Load diff
-262
View File
@@ -1,262 +0,0 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"os"
"testing"
)
func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) {
f, err := os.Open("build/appicon.png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
assertFullCanvasRoundedIcon(t, img, 1024)
}
func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
for _, size := range []int{16, 24, 32, 48, 64, 256} {
t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) {
img := decodeICOImage(t, "build/windows/icon.ico", size)
assertFullCanvasRoundedIcon(t, img, size)
})
}
}
func TestDarwinICNSUsesMacOSIconSafeArea(t *testing.T) {
img := decodeICNSImage(t, "build/darwin/icon.icns", "ic10")
if got, want := img.Bounds(), image.Rect(0, 0, 1024, 1024); got != want {
t.Fatalf("macOS ic10 frame bounds = %v, want %v", got, want)
}
if got, want := alphaBounds(img), image.Rect(100, 100, 924, 924); got != want {
t.Fatalf("macOS icon visible bounds = %v, want %v", got, want)
}
}
func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
t.Helper()
bounds := img.Bounds()
if bounds.Dx() != size || bounds.Dy() != size {
t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy())
}
corners := []struct {
name string
x int
y int
}{
{"top-left", bounds.Min.X, bounds.Min.Y},
{"top-right", bounds.Max.X - 1, bounds.Min.Y},
{"bottom-left", bounds.Min.X, bounds.Max.Y - 1},
{"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1},
}
for _, corner := range corners {
_, _, _, a := img.At(corner.x, corner.y).RGBA()
if a > 0xff {
t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a)
}
}
_, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA()
if centerAlpha == 0 {
t.Fatal("app icon center must contain visible artwork")
}
edgePoints := []struct {
name string
x int
y int
}{
{"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y},
{"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2},
{"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1},
{"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2},
}
for _, point := range edgePoints {
_, _, _, a := img.At(point.x, point.y).RGBA()
if a == 0 {
t.Fatalf("%s edge must contain visible rounded-rect background", point.name)
}
assertReasonixBlue(t, point.name, img.At(point.x, point.y))
}
}
func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) {
t.Helper()
r16, g16, b16, _ := colorValue.RGBA()
r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8)
if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) {
t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b)
}
}
func near(got, want uint8, tolerance uint8) bool {
if got > want {
return got-want <= tolerance
}
return want-got <= tolerance
}
func alphaBounds(img image.Image) image.Rectangle {
bounds := img.Bounds()
visible := image.Rectangle{}
found := false
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
if a == 0 {
continue
}
if !found {
visible = image.Rect(x, y, x+1, y+1)
found = true
continue
}
if x < visible.Min.X {
visible.Min.X = x
}
if y < visible.Min.Y {
visible.Min.Y = y
}
if x >= visible.Max.X {
visible.Max.X = x + 1
}
if y >= visible.Max.Y {
visible.Max.Y = y + 1
}
}
}
return visible
}
func decodeICNSImage(t *testing.T, path, iconType string) image.Image {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) < 8 || string(data[:4]) != "icns" {
t.Fatal("invalid ICNS header")
}
if declaredSize := int(binary.BigEndian.Uint32(data[4:8])); declaredSize != len(data) {
t.Fatalf("ICNS size = %d, want %d", declaredSize, len(data))
}
for offset := 8; offset < len(data); {
if offset+8 > len(data) {
t.Fatalf("truncated ICNS entry header at offset %d", offset)
}
entryType := string(data[offset : offset+4])
entrySize := int(binary.BigEndian.Uint32(data[offset+4 : offset+8]))
if entrySize < 8 || offset+entrySize > len(data) {
t.Fatalf("invalid ICNS entry %q size %d at offset %d", entryType, entrySize, offset)
}
if entryType == iconType {
img, err := png.Decode(bytes.NewReader(data[offset+8 : offset+entrySize]))
if err != nil {
t.Fatalf("decode ICNS entry %q: %v", iconType, err)
}
return img
}
offset += entrySize
}
t.Fatalf("ICNS is missing %q image", iconType)
return nil
}
func decodeICOImage(t *testing.T, path string, size int) image.Image {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
r := bytes.NewReader(data)
var header struct {
Reserved uint16
Type uint16
Count uint16
}
if err := binary.Read(r, binary.LittleEndian, &header); err != nil {
t.Fatal(err)
}
if header.Reserved != 0 || header.Type != 1 {
t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type)
}
type iconEntry struct {
Width uint8
Height uint8
ColorCount uint8
Reserved uint8
Planes uint16
BitCount uint16
BytesInRes uint32
ImageOffset uint32
}
entries := make([]iconEntry, header.Count)
for i := range entries {
if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil {
t.Fatal(err)
}
}
expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false}
targetIndex := -1
for i, entry := range entries {
width := int(entry.Width)
height := int(entry.Height)
if width == 0 {
width = 256
}
if height == 0 {
height = 256
}
if width != height {
t.Fatalf("ICO image must be square, got %dx%d", width, height)
}
if _, ok := expectedSizes[width]; ok {
expectedSizes[width] = true
}
if width == size {
targetIndex = i
}
}
for expectedSize, found := range expectedSizes {
if !found {
t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize)
}
}
if targetIndex < 0 {
t.Fatalf("ICO is missing %dx%d image", size, size)
}
entry := entries[targetIndex]
end := int(entry.ImageOffset + entry.BytesInRes)
if end > len(data) {
t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data))
}
img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end]))
if err != nil {
t.Fatal(err)
}
return img
}
-427
View File
@@ -1,427 +0,0 @@
package main
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
func TestWorkspaceRelativeIn(t *testing.T) {
root := t.TempDir()
if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
}
if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
t.Fatal("a path above the workspace must not resolve as in-tree")
}
}
func TestIsImageExt(t *testing.T) {
for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
if !isImageExt(p) {
t.Errorf("%q should be an image extension", p)
}
}
for _, p := range []string{"notes.pdf", "main.go", "noext"} {
if isImageExt(p) {
t.Errorf("%q should not be an image extension", p)
}
}
}
func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(projectRoot); err != nil {
t.Fatal(err)
}
projectRoot, _ = os.Getwd()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImage: %v", err)
}
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
t.Fatalf("pasted image should be saved under active workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
}
preview, err := app.AttachmentDataURL(got)
if err != nil {
t.Fatalf("AttachmentDataURL: %v", err)
}
if !strings.HasPrefix(preview, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", preview)
}
}
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectA := t.TempDir()
projectB := t.TempDir()
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
},
activeTabID: "project",
}
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImage: %v", err)
}
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
}
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
}
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
}
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(projectRoot); err != nil {
t.Fatal(err)
}
projectRoot, _ = os.Getwd()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(projectRoot, "sub", "notes.txt")
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
got, err := app.AttachDropped(target)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
}
}
func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "shot.png")
if err := os.WriteFile(outside, raw, 0o644); err != nil {
t.Fatal(err)
}
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
t.Fatalf("got %+v, want png attachment", got)
}
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
t.Fatalf("dropped image should be saved under active workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
}
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
}
}
func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
cwd, _ := os.Getwd()
if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(cwd, "sub", "notes.txt")
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
got, err := (&App{}).AttachDropped(target)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
}
}
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
outside := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
got, err := (&App{}).AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
t.Fatalf("got %+v, want copied pdf attachment", got)
}
}
func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
cwd, _ := os.Getwd()
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
t.Fatalf("got %+v, want png attachment", got)
}
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
workspace := t.TempDir()
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
expectedOutside := outside
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
expectedOutside = resolved
}
expectedDisplayPath := filepath.ToSlash(expectedOutside)
if err := os.Chdir(workspace); err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
},
activeTabID: "project",
}
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || !got.IsDir {
t.Fatalf("got %+v, want workspace directory ref", got)
}
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
}
if got.DisplayPath != expectedDisplayPath {
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
}
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
!strings.Contains(block, "sub/") ||
!strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "test/test-model"
cfg.Desktop.ProviderAccess = []string{"test"}
cfg.Providers = []config.ProviderEntry{
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
orig, _ := os.Getwd()
defer os.Chdir(orig)
projectA := t.TempDir()
projectB := t.TempDir()
outside := filepath.Join(t.TempDir(), "External")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
sessionDirB := desktopSessionDir(projectB)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
t.Fatalf("mkdir project B sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
oldCtrl := control.New(control.Options{
SessionDir: sessionDirB,
SessionPath: sessionPathB,
WorkspaceRoot: projectB,
Sink: event.Discard,
})
app := NewApp()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "project",
Scope: "project",
WorkspaceRoot: projectB,
TopicID: "topic_external_ref",
TopicTitle: "External ref",
SessionPath: sessionPathA,
Ready: true,
model: "test/test-model",
Ctrl: oldCtrl,
sink: &tabEventSink{tabID: "project", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
})
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if tab.Ctrl == oldCtrl {
t.Fatal("stale controller was reused for external folder ref")
}
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
resolver, ok := tab.Ctrl.(interface {
ResolveScopedRefs(context.Context, string) (string, []string)
})
if !ok {
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
}
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
}
}
-95
View File
@@ -1,95 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
)
// TestBuildTabControllerIgnoresRetiredAutoRecoveryKillSwitch exercises the
// desktop wiring after boot.Build. Retired global/project keys must not disable
// Auto Guard either during controller construction or fresh-session binding.
func TestBuildTabControllerIgnoresRetiredAutoRecoveryKillSwitch(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
t.Fatalf("mkdir user config: %v", err)
}
if err := os.WriteFile(userCfg, []byte(`
default_model = "test-model"
[agent]
auto_recovery_checkpoint = "on"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`), 0o644); err != nil {
t.Fatalf("write user config: %v", err)
}
root := robustTempDir(t)
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
default_model = "test-model"
[agent]
auto_recovery_checkpoint = "off"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "project-auto-guard-off",
Scope: "project",
WorkspaceRoot: root,
model: "test-model",
tokenMode: boot.TokenModeFull,
mode: "normal",
toolApprovalMode: control.ToolApprovalAuto,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
defer func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
app.releaseTabSharedHost(tab)
tab.releaseSessionLease()
}()
app.buildTabController(tab)
if tab.StartupErr != "" {
t.Fatalf("build tab controller: %s", tab.StartupErr)
}
ctrl, ok := tab.Ctrl.(*control.Controller)
if !ok || ctrl == nil {
t.Fatalf("tab controller = %T, want *control.Controller", tab.Ctrl)
}
fresh := filepath.Join(ctrl.SessionDir(), "fresh-auto-guard-off.jsonl")
ctrl.SetFreshSessionPath(fresh)
if got := ctrl.SessionPath(); got != fresh {
t.Fatalf("fresh session path = %q, want %q", got, fresh)
}
}
-52
View File
@@ -1,52 +0,0 @@
package main
import (
"errors"
"testing"
"time"
)
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
app := NewApp()
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
failure := errors.New("disk unhappy")
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
first := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if first.IsZero() {
t.Fatal("first autosave warning did not record its timestamp")
}
// Within the window: suppressed, timestamp untouched.
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
second := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !second.Equal(first) {
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
}
// Window expired: warns again.
aged := time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = aged
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(aged) {
t.Fatal("expired window did not re-arm the autosave warning")
}
// Explicit user actions are never debounced (state untouched).
app.reportTabSnapshotError(tab, "changing model", failure)
tab.saveMu.Lock()
fourth := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !fourth.Equal(third) {
t.Fatal("action-save warning must not consume the autosave debounce window")
}
}
-332
View File
@@ -1,332 +0,0 @@
package main
import (
"fmt"
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/workspacelease"
)
// ActiveWorkView is the structured Desktop contract for work that prevents a
// controller rebuild or a destructive tab action. Jobs is always a JSON array.
type ActiveWorkView struct {
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Cancellable bool `json:"cancellable"`
Jobs []JobView `json:"jobs"`
}
// JobCancelBatchView reports which requested jobs accepted cancellation. Both
// slices are initialized so Wails never sends null to React.
type JobCancelBatchView struct {
Cancelled []string `json:"cancelled"`
NotRunning []string `json:"notRunning"`
}
// BackgroundRuntimeView is one visible or detached runtime with active work.
// TabID is an opaque process-local handle; paths and session writer ids are
// deliberately omitted from this user-facing contract.
type BackgroundRuntimeView struct {
TabID string `json:"tabId"`
Title string `json:"title"`
Detached bool `json:"detached"`
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Jobs []JobView `json:"jobs"`
}
// WorkspaceConflictView describes a currently-waiting Delivery writer without
// exposing the lock path, process id, or session path.
type WorkspaceConflictView struct {
State string `json:"state"`
OwnerTabID string `json:"ownerTabId,omitempty"`
OwnerTitle string `json:"ownerTitle,omitempty"`
OwnerWork ActiveWorkView `json:"ownerWork"`
CanReveal bool `json:"canReveal"`
CanCreateWorktree bool `json:"canCreateWorktree"`
}
func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
view := ActiveWorkView{Jobs: []JobView{}}
if ctrl == nil {
return view
}
status := ctrl.RuntimeStatus()
view.Running = status.Running
view.PendingPrompt = status.PendingPrompt
view.Cancellable = status.Cancellable
for _, job := range ctrl.Jobs() {
view.Jobs = append(view.Jobs, JobView{
ID: job.ID, Kind: job.Kind, Label: job.Label,
Status: job.Status, StartedAt: job.StartedAt,
})
}
return view
}
func (v ActiveWorkView) active() bool {
return v.Running || v.PendingPrompt || len(v.Jobs) > 0
}
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
// preflight aid only; rebuild paths still re-check active work atomically.
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
}
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
// keeps cancelled-but-unwinding jobs visible until their done channels close.
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
seen := map[string]bool{}
for _, raw := range jobIDs {
id := strings.TrimSpace(raw)
if id == "" || seen[id] {
continue
}
seen[id] = true
cancelled, err := a.CancelJobForTab(tabID, id)
if err != nil {
return result, err
}
if cancelled {
result.Cancelled = append(result.Cancelled, id)
} else {
result.NotRunning = append(result.NotRunning, id)
}
}
return result, nil
}
type backgroundRuntimeSnapshot struct {
id string
ctrl control.SessionAPI
detached bool
title string
}
// BackgroundRuntimes returns every process-local runtime that still needs a
// visible recovery path, including runtimes detached by an explicit tab close.
func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
a.mu.RLock()
snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.tabs {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
}
for _, tab := range a.detachedSessions {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
}
a.mu.RUnlock()
out := make([]BackgroundRuntimeView, 0, len(snapshots))
for _, snapshot := range snapshots {
work := activeWorkForController(snapshot.ctrl)
if !work.active() {
continue
}
out = append(out, BackgroundRuntimeView{
TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
})
}
return out
}
func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
if strings.TrimSpace(tabID) == "" {
return a.activeCtrlLocked()
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil {
return nil
}
return tab.Ctrl
}
// RevealBackgroundRuntime activates a visible owner or reopens the exact
// detached session. It never reattaches by workspace alone.
func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
a.mu.RLock()
if tab := a.tabs[tabID]; tab != nil {
a.mu.RUnlock()
if err := a.SetActiveTab(tabID); err != nil {
return TabMeta{}, err
}
a.mu.RLock()
current := a.tabs[tabID]
if current == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
meta := a.tabMeta(current, true)
a.mu.RUnlock()
return enrichTabMeta(meta), nil
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil || tab.Ctrl == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
topicID := tab.TopicID
sessionPath := tab.currentSessionPath()
a.mu.RUnlock()
if strings.TrimSpace(sessionPath) == "" {
return TabMeta{}, fmt.Errorf("background task session is unavailable")
}
return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
}
type workspaceLeaseReporter interface {
WorkspaceLeaseState() workspacelease.State
}
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
return reporter.WorkspaceLeaseState()
}
return workspacelease.State{}
}
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
// currently waiting for. An acquired process-local owner is actionable; when no
// local owner matches, the OS lock is treated as external.
func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
a.mu.RLock()
var target *WorkspaceTab
if strings.TrimSpace(tabID) == "" {
target = a.activeTabLocked()
} else {
target = a.tabByEventSinkIDLocked(tabID)
}
if target == nil {
a.mu.RUnlock()
return empty
}
targetCtrl := target.Ctrl
targetWorkspaceRoot := target.WorkspaceRoot
a.mu.RUnlock()
if targetCtrl == nil {
return empty
}
targetState := controllerWorkspaceLeaseState(targetCtrl)
if !targetState.Waiting || targetState.Acquired {
return empty
}
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
if err != nil {
return empty
}
availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
a.mu.RLock()
type candidate struct {
id string
ctrl control.SessionAPI
root string
title string
}
candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
}
a.mu.RUnlock()
for _, candidate := range candidates {
root, err := workspacelease.CanonicalWorkspace(candidate.root)
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
continue
}
return WorkspaceConflictView{
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
CanCreateWorktree: availability.Available,
}
}
empty.State = "external"
empty.CanCreateWorktree = availability.Available
return empty
}
// RevealWorkspaceWriterForTab opens the exact process-local runtime identified
// by WorkspaceConflictForTab. External writers remain non-actionable.
func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
conflict := a.WorkspaceConflictForTab(tabID)
if conflict.State != "local" || conflict.OwnerTabID == "" {
return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
}
return a.RevealBackgroundRuntime(conflict.OwnerTabID)
}
const stopAndCloseGrace = 15 * time.Second
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
// choice. stop_and_close never removes the tab until all owned work is idle.
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
switch strings.TrimSpace(policy) {
case "keep_running":
return a.closeTab(tabID, true)
case "stop_and_close":
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl == nil {
return a.closeTab(tabID, false)
}
ctrl.Cancel()
jobs := ctrl.Jobs()
ids := make([]string, 0, len(jobs))
for _, job := range jobs {
ids = append(ids, job.ID)
}
if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
return err
}
deadline := time.NewTimer(stopAndCloseGrace)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
if !activeWorkForController(ctrl).active() {
return a.closeTab(tabID, false)
}
select {
case <-deadline.C:
return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
case <-ticker.C:
}
}
default:
return fmt.Errorf("unknown close policy %q", policy)
}
}
-137
View File
@@ -1,137 +0,0 @@
package main
import (
"testing"
"reasonix/internal/control"
"reasonix/internal/jobs"
"reasonix/internal/workspacelease"
)
type backgroundRuntimeController struct {
control.SessionAPI
status control.RuntimeStatus
jobs []jobs.View
lease workspacelease.State
cancelled []string
turnCancel int
}
func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
func (c *backgroundRuntimeController) Jobs() []jobs.View {
return append([]jobs.View(nil), c.jobs...)
}
func (c *backgroundRuntimeController) CancelJob(id string) bool {
for _, job := range c.jobs {
if job.ID == id {
c.cancelled = append(c.cancelled, id)
return true
}
}
return false
}
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
return c.lease
}
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
targetCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}},
}
focusedCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}},
}
target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl}
focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl}
app := &App{
tabs: map[string]*WorkspaceTab{"focused": focused},
tabOrder: []string{"focused"},
activeTabID: "focused",
detachedSessions: map[string]*WorkspaceTab{"session-target": target},
}
work := app.ActiveWorkForTab("target")
if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" {
t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work)
}
result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"})
if err != nil {
t.Fatalf("CancelJobsForTab: %v", err)
}
if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" {
t.Fatalf("CancelJobsForTab result = %+v", result)
}
if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 {
t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled)
}
runtimes := app.BackgroundRuntimes()
if len(runtimes) != 2 {
t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes)
}
var foundDetached bool
for _, runtime := range runtimes {
if runtime.TabID == "target" {
foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1
}
}
if !foundDetached {
t.Fatalf("detached runtime missing from %+v", runtimes)
}
}
func TestCancelJobForLocalRuntimeByExplicitTab(t *testing.T) {
localCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}},
}
app := &App{
tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}},
tabOrder: []string{"local"},
activeTabID: "local",
}
listed := app.JobsForTab("local")
if len(listed) != 1 || listed[0].ID != "job-local" {
t.Fatalf("JobsForTab(local) = %+v, want local job", listed)
}
cancelled, err := app.CancelJobForTab("local", "job-local")
if err != nil {
t.Fatalf("CancelJobForTab(local): %v", err)
}
if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" {
t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled)
}
}
func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) {
root := t.TempDir()
waiterCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{Running: true},
lease: workspacelease.State{Waiting: true},
}
ownerCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}},
lease: workspacelease.State{Acquired: true},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
"owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl},
},
tabOrder: []string{"waiter", "owner"},
activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal {
t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict)
}
if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" {
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
}
}
-174
View File
@@ -1,174 +0,0 @@
package main
import (
"context"
"errors"
"testing"
"time"
"reasonix/internal/billing"
"reasonix/internal/control"
)
type billingRuntimeController struct {
control.SessionAPI
status control.RuntimeStatus
started chan struct{}
release chan struct{}
balance *billing.Balance
err error
}
func (c *billingRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
func (c *billingRuntimeController) SessionPath() string { return "" }
func (c *billingRuntimeController) Balance(ctx context.Context) (*billing.Balance, error) {
if c.started != nil {
close(c.started)
}
if c.release != nil {
select {
case <-c.release:
case <-ctx.Done():
return nil, ctx.Err()
}
}
return c.balance, c.err
}
func TestSetDesktopCurrencyRebindsLedgerWithoutControllerRebuild(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{status: control.RuntimeStatus{Running: true}}
ledger := billing.NewLedger()
ledger.Add(billing.CostQuote{
Original: billing.Money{Amount: "1", Currency: "USD"},
Valuations: map[string]billing.Valuation{
"USD": {Money: billing.Money{Amount: "1", Currency: "USD"}, Basis: billing.BasisIdentity},
"CNY": {Money: billing.Money{Amount: "7", Currency: "CNY"}, Basis: billing.BasisOfficialTable},
},
Selected: &billing.Money{Amount: "1", Currency: "USD"},
Estimated: true,
CostComplete: true,
DisplayComplete: true,
Complete: true,
DisplayStatus: billing.DisplayStatusMatched,
ModelRef: "deepseek-flash/deepseek-v4-flash",
}, billing.UsageTokens{PromptTokens: 1_000_000}, nowUTC())
tab := &WorkspaceTab{
ID: "billing", Ctrl: ctrl,
usageTelemetry: sessionUsageStats{CostLedger: ledger, SessionCost: 1, SessionCurrency: "USD", SessionCostComplete: true},
}
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
if err := app.SetDesktopCurrency("CNY"); err != nil {
t.Fatalf("SetDesktopCurrency during active work: %v", err)
}
if tab.Ctrl != ctrl {
t.Fatal("display-only currency change replaced the controller")
}
quote := tab.telemetrySnapshot().Usage.SessionCostQuote
if quote == nil || quote.Selected == nil || quote.Selected.Currency != "CNY" || quote.Selected.Amount != "7" {
t.Fatalf("rebound quote = %+v, want selected CNY 7", quote)
}
}
func TestBalanceForTabRejectsPreviousSessionResponse(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{
started: make(chan struct{}),
release: make(chan struct{}),
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
tab.resetTelemetry("old-session.jsonl")
app := &App{
ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab},
tabOrder: []string{tab.ID}, activeTabID: tab.ID,
}
done := make(chan BalanceInfo, 1)
go func() { done <- app.BalanceForTab(tab.ID) }()
<-ctrl.started
tab.resetTelemetry("new-session.jsonl")
close(ctrl.release)
<-done
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("previous-session balance rebound new session to %q", display)
}
}
func TestBalanceForTabRejectsPreviousControllerResponse(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{
started: make(chan struct{}), release: make(chan struct{}),
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
done := make(chan BalanceInfo, 1)
go func() { done <- app.BalanceForTab(tab.ID) }()
<-ctrl.started
app.mu.Lock()
tab.Ctrl = &billingRuntimeController{}
app.mu.Unlock()
close(ctrl.release)
<-done
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("previous-controller balance rebound current controller to %q", display)
}
}
func TestBalanceForTabRejectsResponseAfterExplicitCurrencyChange(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{
started: make(chan struct{}), release: make(chan struct{}),
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
done := make(chan BalanceInfo, 1)
go func() { done <- app.BalanceForTab(tab.ID) }()
<-ctrl.started
if err := app.SetDesktopCurrency("USD"); err != nil {
t.Fatalf("SetDesktopCurrency: %v", err)
}
close(ctrl.release)
<-done
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("auto balance response overrode explicit USD with %q", display)
}
}
func TestBalanceForTabFailureDoesNotChangeDisplayBinding(t *testing.T) {
isolateDesktopUserDirs(t)
ctrl := &billingRuntimeController{err: errors.New("balance unavailable")}
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
got := app.BalanceForTab(tab.ID)
if got.Err == "" {
t.Fatal("BalanceForTab error was not reported")
}
tab.telemMu.Lock()
display := tab.runtimeCostDisplayCurrency
tab.telemMu.Unlock()
if display != "" {
t.Fatalf("failed balance rebound display to %q", display)
}
}
func nowUTC() time.Time { return time.Now().UTC() }
-713
View File
@@ -1,713 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
// errDriveBusy signals that a takeover drive could not start because the target
// controller was already running a turn. The hub translates it into a
// user-facing "session busy" message rather than a generic drive failure.
var errDriveBusy = errors.New("desktop session busy")
// botBridgeHub 是 bot 网关对桌面端的"上帝视角"桥(bot.DesktopBridge 的实现)。
//
// 职责边界(刻意保持窄):
// - 观察:tabEventSink.Emit 把每个桌面会话的事件旁路到 observe;hub 记录
// 待审批/待回答项,并把审批请求、任务完成/出错推送给订阅聊天。
// - 遥控审批:/desktop approve|deny|answer 经 App.ApproveTab /
// AnswerQuestionForTab 按 tab 寻址回写。controller 侧幂等(先到者赢),
// 桌面 UI 与远端并发应答互不干扰。
// - 不做:向桌面会话注入输入、抢占写 lease。将来的显式接管在
// bot.DesktopBridge 上扩展,底层走 internal/control 的接管端口。
//
// observe 跑在 controller 的事件 goroutine 上,绝不能做网络调用——通知统一
// 进有界队列由 worker 异步发送,队列满时丢弃并告警。
type botBridgeHub struct {
sessions func() []bot.DesktopSessionInfo
approveTab func(tabID, id string, allow, session, persist bool)
answerTab func(tabID, id string, answers []QuestionAnswer)
notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
// drive 把一条远程文本提交为 tab 的新 turn,并把该 turn 的输出转发回 route。
drive func(tabID, text string, route bot.DesktopWatchRoute) error
// announce 往会话 transcript 里发一条 Notice,让桌面用户看到接管状态变化。
announce func(tabID, text string)
// persistWatchers 把订阅全集回写用户配置(bot.desktop_watchers)。
persistWatchers func(routes []bot.DesktopWatchRoute) error
// takeoverChanged 通知桌面前端刷新(TabMeta.RemoteControlled 变化)。
takeoverChanged func()
logger *slog.Logger
mu sync.Mutex
watchers map[string]bot.DesktopWatchRoute
pending map[string]desktopPendingPrompt
// takeovers: tabID -> 驾驶该会话的聊天路由;takeoverTabs: routeKey -> tabID。
takeovers map[string]bot.DesktopWatchRoute
takeoverTabs map[string]string
// watchSeq 单调递增,标记订阅快照的新旧;persist 时用它丢弃过期写入。
watchSeq uint64
// watchPersistDirty keeps a failed local mutation authoritative in memory;
// a later runtime refresh must not silently restore the older disk snapshot.
watchPersistDirty bool
// persistMu 串行化订阅落盘,并保证只写最新快照(见 SetWatch)。
persistMu sync.Mutex
lastPersistSeq uint64
queue chan desktopBridgeNotification
}
type desktopPendingPrompt struct {
tabID string
kind string // "approval" | "ask"
tool string
subject string
questions []event.AskQuestion
}
// desktopBridgeNotification 是一条待推送的桌面事件。text 与 card 都按订阅路由
// 现做,因此群聊(多用户)与私聊可给出不同详略——命令行/错误详情只发私聊,群里
// 只给摘要。route 非 nil 时定向发给该聊天(不看 watch 订阅),用于接管收回等必达通知。
type desktopBridgeNotification struct {
text func(route bot.DesktopWatchRoute) string
card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
route *bot.DesktopWatchRoute
}
// isSharedChat 判断一个聊天是否为多用户场景(群/话题群/服务器频道)。私聊/单聊
// 只有操作者本人,可安全展示命令行等敏感详情。
func isSharedChat(ct bot.ChatType) bool {
return ct != bot.ChatDM && ct != bot.ChatDirect
}
func constText(s string) func(bot.DesktopWatchRoute) string {
return func(bot.DesktopWatchRoute) string { return s }
}
const (
botBridgeQueueSize = 64
botBridgeSendTimeout = 15 * time.Second
botBridgeSubjectLimit = 200
botBridgePendingLimit = 200
botBridgeErrTextLimit = 300
botBridgePromptPreview = 500
)
// botBridgeDeps 打包 hub 对宿主(App)的全部依赖,便于测试注入。
type botBridgeDeps struct {
sessions func() []bot.DesktopSessionInfo
approveTab func(tabID, id string, allow, session, persist bool)
answerTab func(tabID, id string, answers []QuestionAnswer)
notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
drive func(tabID, text string, route bot.DesktopWatchRoute) error
announce func(tabID, text string)
persistWatchers func(routes []bot.DesktopWatchRoute) error
takeoverChanged func()
logger *slog.Logger
}
func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
logger := deps.logger
if logger == nil {
logger = slog.Default()
}
h := &botBridgeHub{
sessions: deps.sessions,
approveTab: deps.approveTab,
answerTab: deps.answerTab,
notify: deps.notify,
drive: deps.drive,
announce: deps.announce,
persistWatchers: deps.persistWatchers,
takeoverChanged: deps.takeoverChanged,
logger: logger.With("component", "bot_bridge"),
watchers: make(map[string]bot.DesktopWatchRoute),
pending: make(map[string]desktopPendingPrompt),
takeovers: make(map[string]bot.DesktopWatchRoute),
takeoverTabs: make(map[string]string),
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
}
go h.run()
return h
}
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
// 只做内存记账和入队,不做任何阻塞调用。
func (h *botBridgeHub) observe(tabID string, e event.Event) {
switch e.Kind {
case event.ApprovalRequest:
h.mu.Lock()
h.rememberPendingLocked(e.Approval.ID, desktopPendingPrompt{
tabID: tabID,
kind: "approval",
tool: e.Approval.Tool,
subject: truncateForBridge(e.Approval.Subject, botBridgeSubjectLimit),
})
watching := len(h.watchers) > 0
h.mu.Unlock()
if watching {
h.enqueue(h.approvalNotification(tabID, e.Approval))
}
case event.AskRequest:
h.mu.Lock()
h.rememberPendingLocked(e.Ask.ID, desktopPendingPrompt{
tabID: tabID,
kind: "ask",
questions: e.Ask.Questions,
})
watching := len(h.watchers) > 0
h.mu.Unlock()
if watching {
h.enqueue(h.askNotification(tabID, e.Ask))
}
case event.TurnDone:
h.mu.Lock()
for id, p := range h.pending {
if p.tabID == tabID {
delete(h.pending, id)
}
}
watching := len(h.watchers) > 0
h.mu.Unlock()
if !watching {
return
}
if e.Err != nil && strings.Contains(e.Err.Error(), "context canceled") {
// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
return
}
h.enqueue(h.turnDoneNotification(tabID, e))
}
}
// rememberPendingLocked 记录待处理项;容量兜底防泄漏(正常路径 TurnDone 会清理)。
func (h *botBridgeHub) rememberPendingLocked(id string, p desktopPendingPrompt) {
if strings.TrimSpace(id) == "" {
return
}
if len(h.pending) >= botBridgePendingLimit {
h.pending = make(map[string]desktopPendingPrompt)
}
h.pending[id] = p
}
func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
select {
case h.queue <- n:
default:
h.logger.Warn("desktop bridge notification queue full; dropping")
}
}
func (h *botBridgeHub) run() {
for n := range h.queue {
h.deliver(n)
}
}
func (h *botBridgeHub) deliver(n desktopBridgeNotification) {
h.mu.Lock()
var routes []bot.DesktopWatchRoute
if n.route != nil {
routes = []bot.DesktopWatchRoute{*n.route}
} else {
routes = h.watcherRoutesLocked()
}
notify := h.notify
h.mu.Unlock()
if notify == nil || len(routes) == 0 {
return
}
// Fan out per route: a single slow/hung connection must not hold the queue
// worker for its full timeout and back-pressure everyone else's approvals.
var wg sync.WaitGroup
for _, route := range routes {
wg.Add(1)
go func(route bot.DesktopWatchRoute) {
defer wg.Done()
msg := bot.OutboundMessage{
ChatID: route.ChatID,
ChatType: route.ChatType,
}
if n.text != nil {
msg.Text = n.text(route)
}
if n.card != nil {
msg.Card = n.card(route)
}
ctx, cancel := context.WithTimeout(context.Background(), botBridgeSendTimeout)
defer cancel()
if _, err := notify(ctx, route.ConnectionID, route.Domain, msg); err != nil {
h.logger.Warn("desktop bridge notification send failed", "platform", route.Platform, "err", err)
}
}(route)
}
wg.Wait()
}
// tabLabel 把 tabID 解析成人类可读的会话名。
func (h *botBridgeHub) tabLabel(tabID string) string {
if s, ok := h.sessionByTabID(tabID); ok {
if label := strings.TrimSpace(s.Label); label != "" {
return label
}
if title := strings.TrimSpace(s.Topic); title != "" {
return title
}
}
return "(未命名会话)"
}
func (h *botBridgeHub) sessionByTabID(tabID string) (bot.DesktopSessionInfo, bool) {
if h.sessions == nil {
return bot.DesktopSessionInfo{}, false
}
for _, s := range h.sessions() {
if s.TabID == tabID {
return s, true
}
}
return bot.DesktopSessionInfo{}, false
}
func (h *botBridgeHub) approvalNotification(tabID string, approval event.Approval) desktopBridgeNotification {
label := h.tabLabel(tabID)
// The approval subject is the pending command line; only reveal it in a
// private chat. In a shared chat show the tool name and point the operator
// to the desktop / a DM instead of leaking the command to the whole group.
subjectFor := func(route bot.DesktopWatchRoute) string {
if isSharedChat(route.ChatType) {
return "(命令详情仅在桌面端或私聊显示)"
}
return truncateForBridge(approval.Subject, botBridgeSubjectLimit)
}
return desktopBridgeNotification{
text: func(route bot.DesktopWatchRoute) string {
return fmt.Sprintf("⚠️ 桌面会话「%s」需要批准操作\n工具: %s\n操作: %s\n\nID: `%s`\n用 /desktop approve %s 批准,/desktop deny %s 拒绝。桌面端先处理则以先到者为准。",
label, approval.Tool, subjectFor(route), approval.ID, approval.ID, approval.ID)
},
card: func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
return &bot.InteractiveCard{
Header: "桌面会话需要批准",
Elements: []bot.InteractiveCardElement{
{Tag: "markdown", Content: fmt.Sprintf("**会话**: %s\n\n**工具**: %s\n\n**操作**: %s\n\nID: `%s`", label, approval.Tool, subjectFor(route), approval.ID)},
{Tag: "action", Extra: map[string]any{
"actions": []map[string]any{
desktopCardButton("允许一次", "primary", "/desktop approve "+approval.ID, route),
desktopCardButton("拒绝", "danger", "/desktop deny "+approval.ID, route),
},
}},
},
}
},
}
}
func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridgeNotification {
label := h.tabLabel(tabID)
var b strings.Builder
fmt.Fprintf(&b, "❓ 桌面会话「%s」在等待回答:\n", label)
for i, q := range ask.Questions {
fmt.Fprintf(&b, "\n**%d. %s**\n", i+1, truncateForBridge(q.Prompt, botBridgePromptPreview))
for j, opt := range q.Options {
fmt.Fprintf(&b, " %d. %s\n", j+1, opt.Label)
}
}
fmt.Fprintf(&b, "\nID: `%s`\n用 /desktop answer %s <选项编号或文本> 回答;桌面端先处理则以先到者为准。", ask.ID, ask.ID)
privateText := b.String()
sharedText := fmt.Sprintf("❓ 桌面会话「%s」正在等待回答(问题详情仅在桌面端或私聊显示)。\n\nID: `%s`", label, ask.ID)
textFor := func(route bot.DesktopWatchRoute) string {
if isSharedChat(route.ChatType) {
return sharedText
}
return privateText
}
var card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
if len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0 {
options := ask.Questions[0].Options
card = func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
if isSharedChat(route.ChatType) {
return nil
}
actions := make([]map[string]any, 0, len(options))
for i, opt := range options {
optLabel := strings.TrimSpace(opt.Label)
if optLabel == "" {
optLabel = fmt.Sprintf("选项 %d", i+1)
}
actions = append(actions, desktopCardButton(optLabel, "primary", fmt.Sprintf("/desktop answer %s %d", ask.ID, i+1), route))
}
return &bot.InteractiveCard{
Header: "桌面会话在等待回答",
Elements: []bot.InteractiveCardElement{
{Tag: "markdown", Content: privateText},
{Tag: "action", Extra: map[string]any{"actions": actions}},
},
}
}
}
return desktopBridgeNotification{text: textFor, card: card}
}
func (h *botBridgeHub) turnDoneNotification(tabID string, e event.Event) desktopBridgeNotification {
label := h.tabLabel(tabID)
if e.Outcome == event.TurnOutcomeRecoveryPaused {
return desktopBridgeNotification{text: constText(fmt.Sprintf(
"⏸️ 桌面会话「%s」已暂停自动重试。已完成的工作会保留;发送“继续”即可开始新一轮,也可以补充要求调整方向。",
label,
))}
}
if e.Err != nil {
// Error text can contain paths/tokens; only detail it in a private chat.
return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
if isSharedChat(route.ChatType) {
return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
}
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(e.Err.Error(), botBridgeErrTextLimit))
}}
}
return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
}
func desktopCardButton(label, style, command string, route bot.DesktopWatchRoute) map[string]any {
return map[string]any{
"tag": "button",
"text": map[string]string{"tag": "plain_text", "content": label},
"type": style,
"value": map[string]string{
"command": command,
"chat_type": string(route.ChatType),
},
}
}
func truncateForBridge(s string, limit int) string {
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit]) + "…"
}
// bot.DesktopBridge 实现
func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
if h.sessions == nil {
return nil
}
sessions := h.sessions()
h.mu.Lock()
byTab := make(map[string][]bot.DesktopPendingInfo, len(h.pending))
for id, p := range h.pending {
byTab[p.tabID] = append(byTab[p.tabID], bot.DesktopPendingInfo{ID: id, Kind: p.kind, Tool: p.tool})
}
h.mu.Unlock()
for i := range sessions {
if pend := byTab[sessions[i].TabID]; len(pend) > 0 {
sort.Slice(pend, func(a, b int) bool { return pend[a].ID < pend[b].ID })
sessions[i].Pending = pend
}
}
return sessions
}
func (h *botBridgeHub) SetWatch(route bot.DesktopWatchRoute, enable bool) error {
h.mu.Lock()
if enable {
h.watchers[route.Key()] = route
} else {
delete(h.watchers, route.Key())
}
h.watchSeq++
h.watchPersistDirty = true
seq := h.watchSeq
routes := h.watcherRoutesLocked()
persist := h.persistWatchers
h.mu.Unlock()
if persist == nil {
return nil
}
// Serialize persists and drop stale ones: two concurrent SetWatch calls
// (different connections) compute snapshots under h.mu but write config
// outside it, so their writes could otherwise reorder and let an older
// snapshot clobber a newer one, silently losing a subscription.
h.persistMu.Lock()
defer h.persistMu.Unlock()
if seq <= h.lastPersistSeq {
return nil
}
if err := persist(routes); err != nil {
return err
}
h.lastPersistSeq = seq
h.mu.Lock()
if h.watchSeq == seq {
h.watchPersistDirty = false
}
h.mu.Unlock()
return nil
}
func (h *botBridgeHub) watcherVersion() uint64 {
h.mu.Lock()
defer h.mu.Unlock()
return h.watchSeq
}
// seedWatchers applies a config snapshot only if no watch command changed the
// runtime after the config read began. Fresh external config edits still apply;
// stale refreshes and failed local persists do not erase newer runtime state.
func (h *botBridgeHub) seedWatchers(routes []bot.DesktopWatchRoute, expectedSeq uint64) {
h.persistMu.Lock()
defer h.persistMu.Unlock()
h.mu.Lock()
defer h.mu.Unlock()
if h.watchSeq != expectedSeq || h.watchPersistDirty {
return
}
h.watchers = make(map[string]bot.DesktopWatchRoute, len(routes))
for _, r := range routes {
if strings.TrimSpace(r.ChatID) == "" {
continue
}
h.watchers[r.Key()] = r
}
}
func (h *botBridgeHub) watcherRoutesLocked() []bot.DesktopWatchRoute {
routes := make([]bot.DesktopWatchRoute, 0, len(h.watchers))
for _, r := range h.watchers {
routes = append(routes, r)
}
sort.Slice(routes, func(i, j int) bool { return routes[i].Key() < routes[j].Key() })
return routes
}
func (h *botBridgeHub) Watching(route bot.DesktopWatchRoute) bool {
h.mu.Lock()
defer h.mu.Unlock()
_, ok := h.watchers[route.Key()]
return ok
}
func (h *botBridgeHub) Approve(approvalID string, allow bool) (string, error) {
approvalID = strings.TrimSpace(approvalID)
h.mu.Lock()
p, ok := h.pending[approvalID]
if ok && p.kind == "approval" {
delete(h.pending, approvalID)
}
h.mu.Unlock()
if !ok || p.kind != "approval" {
return "", fmt.Errorf("未找到待处理的审批 %s(可能已在桌面端处理或已超时)。用 /desktop status 查看当前会话。", approvalID)
}
if h.approveTab == nil {
return "", fmt.Errorf("桌面端审批通道不可用。")
}
h.approveTab(p.tabID, approvalID, allow, false, false)
action := "批准"
if !allow {
action = "拒绝"
}
return fmt.Sprintf("已提交%s「%s」的操作(%s)。桌面端若已先处理,以先到者为准。", action, h.tabLabel(p.tabID), p.tool), nil
}
func (h *botBridgeHub) AskQuestions(askID string) ([]event.AskQuestion, bool) {
h.mu.Lock()
defer h.mu.Unlock()
p, ok := h.pending[strings.TrimSpace(askID)]
if !ok || p.kind != "ask" {
return nil, false
}
return p.questions, true
}
func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string, error) {
askID = strings.TrimSpace(askID)
h.mu.Lock()
p, ok := h.pending[askID]
if ok && p.kind == "ask" {
delete(h.pending, askID)
}
h.mu.Unlock()
if !ok || p.kind != "ask" {
return "", fmt.Errorf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
}
if h.answerTab == nil {
return "", fmt.Errorf("桌面端问答通道不可用。")
}
out := make([]QuestionAnswer, 0, len(answers))
for _, an := range answers {
out = append(out, QuestionAnswer{QuestionID: an.QuestionID, Selected: an.Selected})
}
h.answerTab(p.tabID, askID, out)
return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
}
// 显式接管
func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
tabID = strings.TrimSpace(tabID)
// DM only. In a group the binding is keyed on the group chat, so after an
// admin takes over, ANY allowlisted member's plain message would be diverted
// to drive the session — a privilege escalation past the admin gate that
// establishes the takeover. Restricting to DM keeps the driver identical to
// the operator who established it.
if route.ChatType != bot.ChatDM {
return "", fmt.Errorf("接管仅支持私聊:在群里接管会让其他成员也能驱动你的桌面会话。请在与 bot 的私聊中接管。")
}
session, ok := h.sessionByTabID(tabID)
if !ok {
return "", fmt.Errorf("未找到会话 %s。用 /desktop status 查看可接管的会话。", tabID)
}
if session.Detached {
return "", fmt.Errorf("会话「%s」在后台运行,暂不支持接管;请先在桌面端打开它。", h.tabLabel(tabID))
}
h.mu.Lock()
if holder, held := h.takeovers[tabID]; held && holder.Key() != route.Key() {
h.mu.Unlock()
return "", fmt.Errorf("会话「%s」已被另一个聊天接管。", h.tabLabel(tabID))
}
// 同一聊天换目标:先解除旧绑定,并记下旧 tab 以便公告解除。
released := ""
if prev, ok := h.takeoverTabs[route.Key()]; ok && prev != tabID {
delete(h.takeovers, prev)
released = prev
}
h.takeovers[tabID] = route
h.takeoverTabs[route.Key()] = tabID
announce := h.announce
changed := h.takeoverChanged
h.mu.Unlock()
if announce != nil {
if released != "" {
announce(released, "IM 远程接管已解除(接管方切换到了另一个会话)。")
}
announce(tabID, "此会话已被 IM 远程接管(bot 管理员)。在此本地发送任意消息即可收回控制。")
}
if changed != nil {
changed()
}
label := h.tabLabel(tabID)
return fmt.Sprintf("已接管「%s」。现在直接发消息即可驱动它,输出会流回本聊天;/desktop release 解除接管。桌面端本地发言会自动收回控制。", label), nil
}
func (h *botBridgeHub) Release(route bot.DesktopWatchRoute) (string, error) {
h.mu.Lock()
tabID, ok := h.takeoverTabs[route.Key()]
if ok {
delete(h.takeoverTabs, route.Key())
delete(h.takeovers, tabID)
}
announce := h.announce
changed := h.takeoverChanged
h.mu.Unlock()
if !ok {
return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
}
if announce != nil {
announce(tabID, "IM 远程接管已解除。")
}
if changed != nil {
changed()
}
return fmt.Sprintf("已解除对「%s」的接管。", h.tabLabel(tabID)), nil
}
func (h *botBridgeHub) TakeoverTab(route bot.DesktopWatchRoute) string {
h.mu.Lock()
defer h.mu.Unlock()
return h.takeoverTabs[route.Key()]
}
func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (string, error) {
h.mu.Lock()
tabID := h.takeoverTabs[route.Key()]
h.mu.Unlock()
if tabID == "" {
return "", fmt.Errorf("本聊天没有接管任何桌面会话。")
}
session, ok := h.sessionByTabID(tabID)
if !ok || session.Detached {
// 会话被关闭或转入后台:自动解除绑定,避免消息黑洞。
h.mu.Lock()
delete(h.takeoverTabs, route.Key())
delete(h.takeovers, tabID)
h.mu.Unlock()
if changed := h.takeoverChanged; changed != nil {
changed()
}
return "", fmt.Errorf("被接管的会话已关闭或转入后台,接管已自动解除。")
}
if session.Running {
return "", h.busyError(tabID)
}
if h.drive == nil {
return "", fmt.Errorf("桌面端驱动通道不可用。")
}
if err := h.drive(tabID, text, route); err != nil {
if errors.Is(err, errDriveBusy) {
return "", h.busyError(tabID)
}
return "", fmt.Errorf("驱动失败: %w", err)
}
return "", nil
}
func (h *botBridgeHub) busyError(tabID string) error {
return fmt.Errorf("会话「%s」正在执行中,等它完成后再发;或用 /desktop watch on 订阅完成通知。", h.tabLabel(tabID))
}
// reclaimFromDesktop 在桌面用户本地提交输入时收回控制权:解除绑定并通知
// 远端聊天。由 App.SubmitToTab 调用(bridge 自己的驱动不走这条路)。
func (h *botBridgeHub) reclaimFromDesktop(tabID string) {
h.mu.Lock()
route, ok := h.takeovers[tabID]
if ok {
delete(h.takeovers, tabID)
delete(h.takeoverTabs, route.Key())
}
notify := h.notify
changed := h.takeoverChanged
h.mu.Unlock()
if !ok {
return
}
if changed != nil {
changed()
}
if notify == nil {
return
}
label := h.tabLabel(tabID)
// 直接入通知队列(不依赖 watch 订阅):接管者必须知道控制权没了。
h.enqueue(desktopBridgeNotification{
text: constText(fmt.Sprintf("🔓 桌面端已收回会话「%s」的控制权,接管已解除。", label)),
route: &route,
})
}
// remoteControlledTabs 返回当前被接管的 tabID 集合(TabMeta 标记用)。
func (h *botBridgeHub) remoteControlledTabs() map[string]bool {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.takeovers) == 0 {
return nil
}
out := make(map[string]bool, len(h.takeovers))
for tabID := range h.takeovers {
out[tabID] = true
}
return out
}
-184
View File
@@ -1,184 +0,0 @@
package main
import (
"errors"
"fmt"
"log/slog"
"strings"
"reasonix/internal/bot"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
// 本文件是 botBridgeHub 对 App 的全部胶水:会话枚举(含后台 detached)、
// 按 tab 寻址的审批/问答/驱动、transcript 公告、订阅持久化。
func (a *App) newBotBridge() *botBridgeHub {
return newBotBridgeHub(botBridgeDeps{
sessions: a.bridgeSessions,
approveTab: a.bridgeApprove,
answerTab: a.bridgeAnswer,
notify: a.botRuntime.SendToAdapter,
drive: a.bridgeDrive,
announce: a.bridgeAnnounce,
persistWatchers: a.bridgePersistWatchers,
takeoverChanged: a.emitProjectTreeChanged,
logger: slog.Default(),
})
}
// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
tabs := a.ListTabs()
out := make([]bot.DesktopSessionInfo, 0, len(tabs)+4)
seen := make(map[string]bool, len(tabs))
for _, t := range tabs {
seen[t.ID] = true
out = append(out, bot.DesktopSessionInfo{
TabID: t.ID,
Label: t.Label,
Workspace: t.WorkspaceName,
Topic: t.TopicTitle,
Ready: t.Ready,
Running: t.Running,
PendingPrompt: t.PendingPrompt,
})
}
a.mu.RLock()
for _, tab := range a.detachedSessions {
if tab == nil || seen[tab.ID] {
continue
}
seen[tab.ID] = true
out = append(out, bot.DesktopSessionInfo{
TabID: tab.ID,
Label: tab.TopicTitle,
Topic: tab.TopicTitle,
Ready: tab.Ctrl != nil,
Running: strings.TrimSpace(tab.ActivityStatus) != "",
Detached: true,
})
}
a.mu.RUnlock()
return out
}
// bridgeCtrlByTabID 解析可见与后台 detached 两张表(区别于 ctrlByTabID:
// 那是前端语义,空 tabID 落到活跃 tab,且不看 detached)。
func (a *App) bridgeCtrlByTabID(tabID string) control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
if tab := a.tabByEventSinkIDLocked(tabID); tab != nil {
return tab.Ctrl
}
return nil
}
func (a *App) bridgeApprove(tabID, id string, allow, session, persist bool) {
if ctrl := a.bridgeCtrlByTabID(tabID); ctrl != nil {
ctrl.Approve(id, allow, session, persist)
}
}
func (a *App) bridgeAnswer(tabID, id string, answers []QuestionAnswer) {
ctrl := a.bridgeCtrlByTabID(tabID)
if ctrl == nil {
return
}
out := make([]event.AskAnswer, len(answers))
for i, an := range answers {
out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
}
ctrl.AnswerQuestion(id, out)
}
// bridgeAnnounce 往会话 transcript 发一条 Notice,桌面用户在聊天流里可见。
func (a *App) bridgeAnnounce(tabID, text string) {
a.mu.RLock()
tab := a.tabByEventSinkIDLocked(tabID)
var sink *tabEventSink
if tab != nil {
sink = tab.sink
}
a.mu.RUnlock()
if sink == nil {
return
}
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
}
// bridgeDrive 把远程文本提交为可见 tab 的新 turn,并为这一轮挂上事件转发器,
// 让输出流回接管聊天(转发器在 TurnDone 自动卸载)。
func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error {
admission, ctrl, err := a.beginTabTurn(tabID, false)
if err != nil {
if errors.Is(err, control.ErrTurnRunning) {
return errDriveBusy
}
return err
}
defer admission.abort()
tab := admission.tab
if tab.sink == nil {
return fmt.Errorf("会话事件通道不可用,无法驱动")
}
// A local submission may have reclaimed the tab while this drive was waiting
// for the per-tab admission gate. Revalidate ownership only after the gate is
// held, immediately before attaching the route-specific forwarder.
if a.botBridge == nil || a.botBridge.TakeoverTab(route) != tabID {
return fmt.Errorf("接管已解除,请重新接管会话")
}
target := botForwardTarget{
ConnID: route.ConnectionID,
Domain: route.Domain,
ChatID: route.ChatID,
ChatType: route.ChatType,
}
generation := tab.sink.SetBotSink(newBotEventForwarder(a.botRuntime, []botForwardTarget{target}))
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitDisplay(text, text)
// Confirm the submit actually started a turn. If nothing is running now, the
// controller was rotating and the submit no-oped — detach this exact
// generation so a later turn's output does not leak.
if !admission.finish(ctrl) {
tab.sink.clearBotSink(generation)
return errDriveBusy
}
return nil
}
// bridgePersistWatchers 把订阅全集回写用户配置(bot.desktop_watchers),
// 桌面重启后由 refreshBotRuntime 重新种子。
func (a *App) bridgePersistWatchers(routes []bot.DesktopWatchRoute) error {
return a.applyConfigOnly(func(c *config.Config) error {
watchers := make([]config.BotDesktopWatcherConfig, 0, len(routes))
for _, r := range routes {
watchers = append(watchers, config.BotDesktopWatcherConfig{
Platform: string(r.Platform),
ConnectionID: r.ConnectionID,
Domain: r.Domain,
ChatType: string(r.ChatType),
ChatID: r.ChatID,
})
}
c.Bot.DesktopWatchers = watchers
return nil
})
}
func bridgeRoutesFromConfig(watchers []config.BotDesktopWatcherConfig) []bot.DesktopWatchRoute {
routes := make([]bot.DesktopWatchRoute, 0, len(watchers))
for _, w := range watchers {
routes = append(routes, bot.DesktopWatchRoute{
Platform: bot.Platform(strings.TrimSpace(w.Platform)),
ConnectionID: strings.TrimSpace(w.ConnectionID),
Domain: strings.TrimSpace(w.Domain),
ChatType: bot.ChatType(strings.TrimSpace(w.ChatType)),
ChatID: strings.TrimSpace(w.ChatID),
})
}
return routes
}
-620
View File
@@ -1,620 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"testing"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
type bridgeNotifyCall struct {
connectionID string
domain string
msg bot.OutboundMessage
}
type bridgeTestEnv struct {
hub *botBridgeHub
notified chan bridgeNotifyCall
approves chan [2]string // [tabID, id+":"+allow]
answers chan [2]string // [tabID, id]
driven chan [2]string // [tabID, text]
announced chan [2]string // [tabID, text]
persisted chan []bot.DesktopWatchRoute
driveErr error
persistErr error
}
func tabsToSessions(tabs []TabMeta) []bot.DesktopSessionInfo {
out := make([]bot.DesktopSessionInfo, 0, len(tabs))
for _, t := range tabs {
out = append(out, bot.DesktopSessionInfo{
TabID: t.ID,
Label: t.Label,
Workspace: t.WorkspaceName,
Topic: t.TopicTitle,
Ready: t.Ready,
Running: t.Running,
PendingPrompt: t.PendingPrompt,
})
}
return out
}
func newBridgeTestEnv(tabs []TabMeta) *bridgeTestEnv {
return newBridgeTestEnvSessions(tabsToSessions(tabs))
}
func newBridgeTestEnvSessions(sessions []bot.DesktopSessionInfo) *bridgeTestEnv {
env := &bridgeTestEnv{
notified: make(chan bridgeNotifyCall, 16),
approves: make(chan [2]string, 16),
answers: make(chan [2]string, 16),
driven: make(chan [2]string, 16),
announced: make(chan [2]string, 16),
persisted: make(chan []bot.DesktopWatchRoute, 16),
}
env.hub = newBotBridgeHub(botBridgeDeps{
sessions: func() []bot.DesktopSessionInfo { return sessions },
approveTab: func(tabID, id string, allow, session, persist bool) {
env.approves <- [2]string{tabID, fmt.Sprintf("%s:%t", id, allow)}
},
answerTab: func(tabID, id string, answers []QuestionAnswer) {
env.answers <- [2]string{tabID, id}
},
notify: func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
env.notified <- bridgeNotifyCall{connectionID: connectionID, domain: domain, msg: msg}
return bot.SendResult{MessageID: "sent-1"}, nil
},
drive: func(tabID, text string, route bot.DesktopWatchRoute) error {
if env.driveErr != nil {
return env.driveErr
}
env.driven <- [2]string{tabID, text}
return nil
},
announce: func(tabID, text string) {
env.announced <- [2]string{tabID, text}
},
persistWatchers: func(routes []bot.DesktopWatchRoute) error {
env.persisted <- routes
return env.persistErr
},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
return env
}
func (env *bridgeTestEnv) waitNotification(t *testing.T) bridgeNotifyCall {
t.Helper()
select {
case call := <-env.notified:
return call
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for bridge notification")
return bridgeNotifyCall{}
}
}
func (env *bridgeTestEnv) expectNoNotification(t *testing.T) {
t.Helper()
select {
case call := <-env.notified:
t.Fatalf("unexpected notification: %+v", call)
case <-time.After(100 * time.Millisecond):
}
}
func testWatchRoute() bot.DesktopWatchRoute {
return bot.DesktopWatchRoute{
ConnectionID: "feishu-main",
Domain: "feishu",
Platform: bot.PlatformFeishu,
ChatType: bot.ChatDM,
ChatID: "chat-god",
}
}
func testGroupRoute() bot.DesktopWatchRoute {
r := testWatchRoute()
r.ChatType = bot.ChatGroup
r.ChatID = "group-god"
return r
}
func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
t.Fatal("takeover from a group chat must be rejected (non-admin members could otherwise drive it)")
}
if _, err := env.hub.Takeover(testWatchRoute(), "tab-1"); err != nil {
t.Fatalf("DM takeover should work: %v", err)
}
}
func TestBridgeTakeoverSwitchAnnouncesReleaseToOldTab(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
{TabID: "tab-a", Label: "A", Ready: true},
{TabID: "tab-b", Label: "B", Ready: true},
})
route := testWatchRoute()
if _, err := env.hub.Takeover(route, "tab-a"); err != nil {
t.Fatalf("takeover A: %v", err)
}
if got := <-env.announced; got[0] != "tab-a" {
t.Fatalf("first announce = %v, want tab-a takeover", got)
}
if _, err := env.hub.Takeover(route, "tab-b"); err != nil {
t.Fatalf("switch to B: %v", err)
}
seen := map[string]bool{}
for range 2 {
select {
case got := <-env.announced:
seen[got[0]] = true
case <-time.After(time.Second):
t.Fatalf("missing announce after switch; seen=%v", seen)
}
}
if !seen["tab-a"] || !seen["tab-b"] {
t.Fatalf("switch should announce release to tab-a and takeover to tab-b; seen=%v", seen)
}
}
func TestBridgeDriveInputBusyReturnsBusyMessage(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
route := testWatchRoute()
if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
t.Fatalf("Takeover: %v", err)
}
<-env.announced
env.driveErr = errDriveBusy
_, err := env.hub.DriveInput(route, "hi")
if err == nil || !strings.Contains(err.Error(), "正在执行中") {
t.Fatalf("busy drive should surface a clean busy message, got %v", err)
}
}
func TestBridgeApprovalRedactsSubjectInGroup(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
env.hub.SetWatch(testGroupRoute(), true)
<-env.persisted
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf /secret"}})
call := env.waitNotification(t)
if strings.Contains(call.msg.Text, "rm -rf /secret") {
t.Fatalf("group notification leaked the command line: %q", call.msg.Text)
}
if call.msg.Card != nil {
for _, el := range call.msg.Card.Elements {
if strings.Contains(el.Content, "rm -rf /secret") {
t.Fatal("group card leaked the command line")
}
}
}
}
func TestBridgeApprovalShowsSubjectInDM(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
env.hub.SetWatch(testWatchRoute(), true)
<-env.persisted
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf build"}})
call := env.waitNotification(t)
if !strings.Contains(call.msg.Text, "rm -rf build") {
t.Fatalf("DM notification should show the command line: %q", call.msg.Text)
}
}
func TestBridgeAskRedactsPromptAndOptionsInGroup(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
if err := env.hub.SetWatch(testGroupRoute(), true); err != nil {
t.Fatalf("SetWatch: %v", err)
}
<-env.persisted
env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{
ID: "redacted-ask",
Questions: []event.AskQuestion{{
ID: "q1", Prompt: "INTERNAL_ONLY_PROMPT", Options: []event.AskOption{{Label: "CHOICE_INTERNAL"}},
}},
}})
call := env.waitNotification(t)
if strings.Contains(call.msg.Text, "INTERNAL_ONLY_PROMPT") || strings.Contains(call.msg.Text, "CHOICE_INTERNAL") {
t.Fatalf("group notification leaked ask details: %q", call.msg.Text)
}
if call.msg.Card != nil {
t.Fatal("group ask notification must not include option buttons")
}
}
func TestBridgeSessionsIncludePendingIDs(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash"}})
env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: "q1", Questions: []event.AskQuestion{{ID: "x", Prompt: "?"}}}})
found := map[string]bool{}
for _, s := range env.hub.Sessions() {
if s.TabID != "tab-1" {
continue
}
for _, p := range s.Pending {
found[p.ID] = true
}
}
if !found["a1"] || !found["q1"] {
t.Fatalf("Sessions() should surface pending approval and ask ids; got %v", found)
}
}
func TestBridgePersistDropsStaleSnapshot(t *testing.T) {
env := newBridgeTestEnvSessions(nil)
// Two subscribes: the second (newer seq) must be the persisted result even
// though we invoke the seed-restore afterward.
env.hub.SetWatch(testWatchRoute(), true)
<-env.persisted
env.hub.SetWatch(testGroupRoute(), true)
routes := <-env.persisted
if len(routes) != 2 {
t.Fatalf("persisted routes = %d, want both subscriptions", len(routes))
}
}
func TestBridgeApprovalNotifiesWatchersAndRoutesApproval(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "修复登录"}})
env.hub.SetWatch(testWatchRoute(), true)
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "appr-1", Tool: "bash", Subject: "rm -rf build",
}})
call := env.waitNotification(t)
if call.connectionID != "feishu-main" || call.msg.ChatID != "chat-god" {
t.Fatalf("notification routed to %s/%s, want feishu-main/chat-god", call.connectionID, call.msg.ChatID)
}
for _, want := range []string{"修复登录", "bash", "rm -rf build", "/desktop approve appr-1"} {
if !strings.Contains(call.msg.Text, want) {
t.Fatalf("notification text = %q, want it to contain %q", call.msg.Text, want)
}
}
if call.msg.Card == nil {
t.Fatal("approval notification should carry an interactive card")
}
feedback, err := env.hub.Approve("appr-1", true)
if err != nil {
t.Fatalf("Approve: %v", err)
}
if !strings.Contains(feedback, "先到者为准") {
t.Fatalf("feedback = %q, want first-wins note", feedback)
}
select {
case got := <-env.approves:
if got[0] != "tab-1" || got[1] != "appr-1:true" {
t.Fatalf("approve routed as %v, want tab-1/appr-1:true", got)
}
case <-time.After(time.Second):
t.Fatal("approve was not routed to the tab")
}
// 同一 ID 第二次应答:pending 已清,返回未找到。
if _, err := env.hub.Approve("appr-1", false); err == nil {
t.Fatal("second Approve on the same id should fail")
}
}
func TestBridgePendingRecordedWithoutWatchers(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-2", Tool: "bash"}})
env.expectNoNotification(t)
if _, err := env.hub.Approve("appr-2", false); err != nil {
t.Fatalf("Approve without watchers should still work: %v", err)
}
select {
case got := <-env.approves:
if got[1] != "appr-2:false" {
t.Fatalf("deny routed as %v", got)
}
case <-time.After(time.Second):
t.Fatal("deny was not routed")
}
}
func TestBridgeTurnDoneClearsPendingAndNotifies(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
env.hub.SetWatch(testWatchRoute(), true)
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-3", Tool: "bash"}})
env.waitNotification(t)
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
call := env.waitNotification(t)
if !strings.Contains(call.msg.Text, "✅") || !strings.Contains(call.msg.Text, "会话一") {
t.Fatalf("turn-done text = %q", call.msg.Text)
}
if _, err := env.hub.Approve("appr-3", true); err == nil {
t.Fatal("pending approval should be cleared by TurnDone")
}
}
func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
env.hub.SetWatch(testWatchRoute(), true)
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("context canceled")})
env.expectNoNotification(t)
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("boom")})
call := env.waitNotification(t)
if !strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "boom") {
t.Fatalf("error text = %q", call.msg.Text)
}
}
func TestBridgeRecoveryPauseNotifiesAsControlledPause(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
env.hub.SetWatch(testWatchRoute(), true)
env.hub.observe("tab-1", event.Event{
Kind: event.TurnDone,
Err: errors.New("automatic recovery paused"),
Outcome: event.TurnOutcomeRecoveryPaused,
})
call := env.waitNotification(t)
if strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "已暂停自动重试") || !strings.Contains(call.msg.Text, "继续") {
t.Fatalf("recovery pause text = %q, want a neutral actionable pause notice", call.msg.Text)
}
}
func TestBridgeAskAnswerRoundTrip(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
env.hub.SetWatch(testWatchRoute(), true)
env.hub.observe("tab-2", event.Event{Kind: event.AskRequest, Ask: event.Ask{
ID: "ask-1",
Questions: []event.AskQuestion{{
ID: "q1",
Prompt: "选一个方案",
Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
}},
}})
call := env.waitNotification(t)
if !strings.Contains(call.msg.Text, "/desktop answer ask-1") {
t.Fatalf("ask notification = %q, want answer hint", call.msg.Text)
}
if call.msg.Card == nil {
t.Fatal("single-choice ask should carry option buttons")
}
questions, ok := env.hub.AskQuestions("ask-1")
if !ok || len(questions) != 1 {
t.Fatalf("AskQuestions = %v/%v", questions, ok)
}
if _, err := env.hub.Answer("ask-1", []event.AskAnswer{{QuestionID: "q1", Selected: []string{"B"}}}); err != nil {
t.Fatalf("Answer: %v", err)
}
select {
case got := <-env.answers:
if got[0] != "tab-2" || got[1] != "ask-1" {
t.Fatalf("answer routed as %v", got)
}
case <-time.After(time.Second):
t.Fatal("answer was not routed")
}
}
func TestBridgeWatchLifecycleStopsNotifications(t *testing.T) {
env := newBridgeTestEnv(nil)
route := testWatchRoute()
env.hub.SetWatch(route, true)
if !env.hub.Watching(route) {
t.Fatal("route should be watching after SetWatch(true)")
}
env.hub.SetWatch(route, false)
if env.hub.Watching(route) {
t.Fatal("route should not be watching after SetWatch(false)")
}
env.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
env.expectNoNotification(t)
}
func TestBridgeSetWatchPersistsAndSeedRestores(t *testing.T) {
env := newBridgeTestEnv(nil)
route := testWatchRoute()
env.hub.SetWatch(route, true)
select {
case routes := <-env.persisted:
if len(routes) != 1 || routes[0].Key() != route.Key() {
t.Fatalf("persisted = %+v, want the subscribed route", routes)
}
case <-time.After(time.Second):
t.Fatal("SetWatch did not persist watchers")
}
// 模拟重启:全新 hub 从配置种子恢复。
env2 := newBridgeTestEnv(nil)
env2.hub.seedWatchers([]bot.DesktopWatchRoute{route}, env2.hub.watcherVersion())
if !env2.hub.Watching(route) {
t.Fatal("seeded hub should be watching the persisted route")
}
env2.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
if call := env2.waitNotification(t); !strings.Contains(call.msg.Text, "✅") {
t.Fatalf("seeded watcher did not receive notifications: %q", call.msg.Text)
}
}
func TestBridgeSeedDoesNotOverwriteNewerRuntimeWatch(t *testing.T) {
env := newBridgeTestEnv(nil)
route := testWatchRoute()
staleVersion := env.hub.watcherVersion()
if err := env.hub.SetWatch(route, true); err != nil {
t.Fatalf("SetWatch: %v", err)
}
<-env.persisted
// Simulate a runtime refresh carrying a config snapshot loaded before the
// watch command persisted. It must not erase the newer in-process route.
env.hub.seedWatchers(nil, staleVersion)
if !env.hub.Watching(route) {
t.Fatal("stale config seed overwrote the newer runtime subscription")
}
}
func TestBridgeSeedPreservesWatchAfterPersistFailure(t *testing.T) {
env := newBridgeTestEnv(nil)
env.persistErr = errors.New("disk unavailable")
route := testWatchRoute()
if err := env.hub.SetWatch(route, true); err == nil {
t.Fatal("SetWatch should report the persistence failure")
}
<-env.persisted
env.hub.seedWatchers(nil, env.hub.watcherVersion())
if !env.hub.Watching(route) {
t.Fatal("disk snapshot erased a runtime watch whose persistence failed")
}
}
func TestBridgeSeedAppliesFreshExternalConfig(t *testing.T) {
env := newBridgeTestEnv(nil)
route := testWatchRoute()
version := env.hub.watcherVersion()
env.hub.seedWatchers([]bot.DesktopWatchRoute{route}, version)
if !env.hub.Watching(route) {
t.Fatal("initial config seed did not apply")
}
env.hub.seedWatchers(nil, version)
if env.hub.Watching(route) {
t.Fatal("fresh external config update did not replace the watcher set")
}
}
func TestBridgeApprovalRoutesToDetachedSession(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
{TabID: "tab-bg", Label: "后台任务", Detached: true, Ready: true},
})
env.hub.observe("tab-bg", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-bg", Tool: "bash"}})
if _, err := env.hub.Approve("appr-bg", true); err != nil {
t.Fatalf("Approve on detached session: %v", err)
}
select {
case got := <-env.approves:
if got[0] != "tab-bg" {
t.Fatalf("approve routed to %v, want tab-bg", got)
}
case <-time.After(time.Second):
t.Fatal("detached approval was not routed")
}
}
func TestBridgeTakeoverLifecycle(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
{TabID: "tab-1", Label: "会话一", Ready: true},
{TabID: "tab-bg", Label: "后台", Detached: true},
})
route := testWatchRoute()
// 后台会话拒绝接管。
if _, err := env.hub.Takeover(route, "tab-bg"); err == nil {
t.Fatal("takeover of a detached session should fail")
}
feedback, err := env.hub.Takeover(route, "tab-1")
if err != nil {
t.Fatalf("Takeover: %v", err)
}
if !strings.Contains(feedback, "已接管") {
t.Fatalf("feedback = %q", feedback)
}
if env.hub.TakeoverTab(route) != "tab-1" {
t.Fatalf("TakeoverTab = %q, want tab-1", env.hub.TakeoverTab(route))
}
select {
case got := <-env.announced:
if got[0] != "tab-1" || !strings.Contains(got[1], "接管") {
t.Fatalf("announce = %v", got)
}
case <-time.After(time.Second):
t.Fatal("takeover was not announced to the desktop transcript")
}
// 驱动输入路由到 tab。
if _, err := env.hub.DriveInput(route, "跑一下测试"); err != nil {
t.Fatalf("DriveInput: %v", err)
}
select {
case got := <-env.driven:
if got[0] != "tab-1" || got[1] != "跑一下测试" {
t.Fatalf("driven = %v", got)
}
case <-time.After(time.Second):
t.Fatal("drive input was not routed")
}
// 另一个聊天抢同一会话被拒。
other := route
other.ChatID = "chat-other"
if _, err := env.hub.Takeover(other, "tab-1"); err == nil {
t.Fatal("takeover by another chat should be rejected while held")
}
// 释放。
if _, err := env.hub.Release(route); err != nil {
t.Fatalf("Release: %v", err)
}
if env.hub.TakeoverTab(route) != "" {
t.Fatal("binding should be cleared after release")
}
if _, err := env.hub.Release(route); err == nil {
t.Fatal("second release should report no binding")
}
}
func TestBridgeDriveInputRejectsRunningSession(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
{TabID: "tab-1", Label: "会话一", Ready: true, Running: true},
})
route := testWatchRoute()
if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
t.Fatalf("Takeover: %v", err)
}
<-env.announced
if _, err := env.hub.DriveInput(route, "hello"); err == nil || !strings.Contains(err.Error(), "正在执行中") {
t.Fatalf("DriveInput on running session = %v, want busy rejection", err)
}
}
func TestBridgeReclaimFromDesktopNotifiesController(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
{TabID: "tab-1", Label: "会话一", Ready: true},
})
route := testWatchRoute()
if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
t.Fatalf("Takeover: %v", err)
}
<-env.announced
env.hub.reclaimFromDesktop("tab-1")
if env.hub.TakeoverTab(route) != "" {
t.Fatal("reclaim should clear the binding")
}
call := env.waitNotification(t)
if !strings.Contains(call.msg.Text, "收回") || call.msg.ChatID != route.ChatID {
t.Fatalf("reclaim notification = %+v", call)
}
// 未接管 tab 的 reclaim 是 no-op。
env.hub.reclaimFromDesktop("tab-1")
env.expectNoNotification(t)
}
-982
View File
@@ -1,982 +0,0 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"reasonix/internal/bot"
"reasonix/internal/bot/feishu"
"reasonix/internal/bot/weixin"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
type BotConnectionCredentialView struct {
AppID string `json:"appId"`
AppSecretEnv string `json:"appSecretEnv"`
AccountID string `json:"accountId"`
TokenEnv string `json:"tokenEnv"`
SecretSet bool `json:"secretSet"`
}
type BotConnectionSessionMappingView struct {
RemoteID string `json:"remoteId"`
SessionID string `json:"sessionId"`
SessionSource string `json:"sessionSource"`
ChatType string `json:"chatType"`
UserID string `json:"userId"`
ThreadID string `json:"threadId"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot"`
UpdatedAt string `json:"updatedAt"`
}
type BotConnectionView struct {
ID string `json:"id"`
Provider string `json:"provider"`
Domain string `json:"domain"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Model string `json:"model"`
ToolApprovalMode string `json:"toolApprovalMode"`
WorkspaceRoot string `json:"workspaceRoot"`
Access BotAccessView `json:"access"`
Credential BotConnectionCredentialView `json:"credential"`
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
LastError string `json:"lastError"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type BotInstallStartResult struct {
OK bool `json:"ok"`
Provider string `json:"provider"`
Domain string `json:"domain"`
InstallID string `json:"installId"`
URL string `json:"url"`
DeviceCode string `json:"deviceCode"`
UserCode string `json:"userCode"`
Interval int `json:"interval"`
ExpireIn int `json:"expireIn"`
Message string `json:"message"`
}
type BotInstallPollResult struct {
Done bool `json:"done"`
Connection BotConnectionView `json:"connection"`
Status string `json:"status"`
Message string `json:"message"`
Error string `json:"error"`
}
type BotConnectionDiagnostic struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"`
Message string `json:"message"`
MessageID string `json:"messageId"`
Phase string `json:"phase"`
Code string `json:"code"`
ReportKind string `json:"reportKind"`
ReportDetail string `json:"reportDetail"`
OccurredAt string `json:"occurredAt"`
}
type botInstallSession struct {
Provider string
Domain string
PollDomain string
DeviceCode string
UserCode string
StartedAt time.Time
ExpireAt time.Time
Weixin *weixin.LoginSession
}
func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStartResult, error) {
provider, domain = normalizeBotInstallTarget(provider, domain)
if provider == "weixin" {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
session, err := weixin.StartLogin(ctx)
if err != nil {
return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: err.Error()}, nil
}
installID := randomInstallID()
a.mu.Lock()
if a.botInstalls == nil {
a.botInstalls = map[string]*botInstallSession{}
}
a.botInstalls[installID] = &botInstallSession{
Provider: provider,
Domain: domain,
DeviceCode: session.QRCode,
StartedAt: session.StartedAt,
ExpireAt: time.Now().Add(2 * time.Minute),
Weixin: session,
}
a.mu.Unlock()
return BotInstallStartResult{
OK: true, Provider: provider, Domain: domain, InstallID: installID, URL: firstNonEmptyBot(session.QRCodeURL, session.QRCode),
DeviceCode: session.QRCode, Interval: 3, ExpireIn: 120, Message: "请使用微信扫码完成连接。",
}, nil
}
if provider != "feishu" {
return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: "unsupported bot provider"}, nil
}
return a.startFeishuConnectionInstall(domain)
}
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
installID = strings.TrimSpace(installID)
// Copy the session under a.mu: overlapping polls of the same install can
// race the locked PollDomain upgrade below with unlocked field reads.
a.mu.RLock()
sessionPtr := a.botInstalls[installID]
var sessionCopy botInstallSession
if sessionPtr != nil {
sessionCopy = *sessionPtr
}
a.mu.RUnlock()
if sessionPtr == nil {
return BotInstallPollResult{Error: "install session not found"}, nil
}
session := &sessionCopy
if time.Now().After(session.ExpireAt) {
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
}
if session.Provider == "weixin" {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result, status, err := weixin.PollLogin(ctx, session.Weixin)
if err != nil {
return BotInstallPollResult{Status: status, Error: err.Error()}, nil
}
if result == nil {
return BotInstallPollResult{Status: status, Message: weixinInstallStatusMessage(status)}, nil
}
a.deleteBotInstall(installID)
conn, err := a.upsertBotConnection(config.BotConnectionConfig{
ID: connectionID("weixin", "weixin"),
Provider: "weixin",
Domain: "weixin",
Label: "微信",
Enabled: true,
Status: "connected",
Access: botInstallAccess(result.UserID),
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
}, func(c *config.Config) {
c.Bot.Enabled = true
c.Bot.Weixin.Enabled = true
c.Bot.Weixin.AccountID = result.AccountID
c.Bot.Weixin.APIBase = result.BaseURL
if c.Bot.Weixin.TokenEnv == "" {
c.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
}
c.Bot.Allowlist.WeixinUsers = appendUniqueBotString(c.Bot.Allowlist.WeixinUsers, result.UserID)
})
if err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
a.refreshBotRuntimeAsync()
return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: "微信已连接。"}, nil
}
return a.pollFeishuConnectionInstall(installID, session)
}
func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) {
cfg, err := a.loadDesktopBotConfig()
if err != nil {
return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
}
for _, conn := range cfg.Bot.Connections {
if conn.ID == id {
status := "ok"
message := "连接配置已保存。"
phase := "config"
code := "config_ok"
reportable := false
if !conn.Enabled {
status = "disabled"
message = "连接已保存但未启用。"
code = "connection_disabled"
} else if conn.Status != "connected" {
status = firstNonEmptyBot(conn.Status, "pending")
message = firstNonEmptyBot(conn.LastError, "连接还未完成。")
phase = "install"
code = "connection_not_connected"
reportable = status == "error" || strings.TrimSpace(conn.LastError) != ""
} else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) {
status = "warning"
message = conn.Credential.AppSecretEnv + " 未设置。"
phase = "credential"
code = "secret_missing"
reportable = true
} else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) {
status = "warning"
message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。"
phase = "credential"
code = "secret_missing"
reportable = true
} else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) {
status = "warning"
message = "未找到已保存的微信登录凭据。"
phase = "credential"
code = "secret_missing"
reportable = true
}
return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil
}
}
return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
}
func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) {
cfg, err := a.loadDesktopBotConfig()
if err != nil {
return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
}
var conn *config.BotConnectionConfig
for i := range cfg.Bot.Connections {
if cfg.Bot.Connections[i].ID == strings.TrimSpace(id) {
conn = &cfg.Bot.Connections[i]
break
}
}
if conn == nil {
return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
}
target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings))
if conn.Provider != "feishu" && conn.Provider != "weixin" {
return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil
}
if target == "" {
return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result bot.SendResult
switch conn.Provider {
case "feishu":
feishuCfg := cfg.Bot.Feishu
feishuCfg.Enabled = true
feishuCfg.Domain = firstNonEmptyBot(conn.Domain, feishuCfg.Domain)
feishuCfg.AppID = firstNonEmptyBot(conn.Credential.AppID, feishuCfg.AppID)
feishuCfg.AppSecretEnv = firstNonEmptyBot(conn.Credential.AppSecretEnv, feishuCfg.AppSecretEnv)
result, err = feishu.SendText(ctx, feishuCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
case "weixin":
weixinCfg := cfg.Bot.Weixin
weixinCfg.Enabled = true
weixinCfg.AccountID = firstNonEmptyBot(conn.Credential.AccountID, weixinCfg.AccountID)
weixinCfg.TokenEnv = firstNonEmptyBot(conn.Credential.TokenEnv, weixinCfg.TokenEnv)
result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
}
if err != nil {
return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil
}
_ = a.rememberBotConnectionRemote(conn.ID, target)
msg := "测试消息已发送。"
if result.MessageID != "" {
msg += " Message ID: " + result.MessageID
}
diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false)
diag.MessageID = result.MessageID
return diag, nil
}
func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
id = strings.TrimSpace(id)
label := ""
if conn != nil {
id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id)
label = strings.TrimSpace(conn.Label)
}
occurredAt := time.Now().UTC().Format(time.RFC3339)
diag := BotConnectionDiagnostic{
ID: id,
Label: label,
Status: strings.TrimSpace(status),
Message: strings.TrimSpace(message),
Phase: strings.TrimSpace(phase),
Code: strings.TrimSpace(code),
OccurredAt: occurredAt,
}
if reportable {
diag.ReportKind = "bot"
diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt)
if diag.ReportDetail == "" {
diag.ReportKind = ""
}
}
return diag
}
func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string {
provider := "unknown"
domain := "unknown"
configuredStatus := ""
enabled := false
workspaceScope := "global"
sessionMappings := 0
appIDSet := false
appSecretEnvConfigured := false
tokenEnvConfigured := false
secretAvailable := false
if conn != nil {
provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider)
domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain)
configuredStatus = strings.TrimSpace(conn.Status)
enabled = conn.Enabled
if strings.TrimSpace(conn.WorkspaceRoot) != "" {
workspaceScope = "project"
}
sessionMappings = len(conn.SessionMappings)
appIDSet = strings.TrimSpace(conn.Credential.AppID) != ""
appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != ""
tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != ""
secretAvailable = botCredentialSecretSet(*conn)
}
summary := botConnectionReportSummary(code, message)
lines := []string{
"Bot connection diagnostic",
"",
"connection_id: " + safeBotReportValue(fallbackID),
"provider: " + safeBotReportValue(provider),
"domain: " + safeBotReportValue(domain),
"status: " + safeBotReportValue(status),
"phase: " + safeBotReportValue(phase),
"code: " + safeBotReportValue(code),
fmt.Sprintf("enabled: %t", enabled),
"configured_status: " + safeBotReportValue(configuredStatus),
fmt.Sprintf("app_id_set: %t", appIDSet),
fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured),
fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured),
fmt.Sprintf("secret_available: %t", secretAvailable),
"workspace_scope: " + workspaceScope,
fmt.Sprintf("session_mappings: %d", sessionMappings),
"",
"summary: " + summary,
}
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "bot",
Source: "bot.runtime",
Label: botConnectionReportLabel(provider, domain, phase),
Message: strings.Join(lines, "\n"),
ErrorType: "BotConnectionDiagnostic",
ErrorMessage: summary,
TopFrame: "bot." + safeBotReportSegment(phase),
OccurredAt: occurredAt,
}
detail, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(detail)
}
func botConnectionReportSummary(code, message string) string {
switch strings.TrimSpace(code) {
case "config_load_failed":
return "desktop bot config could not be loaded: " + scrubSensitiveText(message)
case "connection_missing":
return "bot connection record was not found"
case "connection_not_connected":
return "bot connection is not connected: " + scrubSensitiveText(message)
case "secret_missing":
return "required bot credential is not available"
case "test_send_failed":
return "bot test message failed: " + scrubSensitiveText(message)
default:
if strings.TrimSpace(message) == "" {
return strings.TrimSpace(code)
}
return scrubSensitiveText(message)
}
}
func botConnectionReportLabel(provider, domain, phase string) string {
parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)}
return strings.Trim(strings.Join(parts, "."), ".")
}
func safeBotReportSegment(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return "unknown"
}
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
if b.Len() == 0 || strings.HasSuffix(b.String(), ".") {
continue
}
b.WriteByte('.')
}
out := strings.Trim(b.String(), ".")
if out == "" {
return "unknown"
}
return out
}
func safeBotReportValue(s string) string {
s = safeBotReportSegment(s)
if len(s) > 80 {
return s[:80]
}
return s
}
func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) {
// The official registration SDK always begins on the Feishu accounts domain.
// Lark tenants are detected from the first poll response, then polling moves
// to the Lark accounts domain for the final credential exchange.
beginDomain := "feishu"
data, err := postFeishuInstallForm(feishuAccountsBase(beginDomain), map[string]string{
"action": "begin", "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id",
})
if err != nil {
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
}
deviceCode := stringValue(data["device_code"])
verifyURL := stringValue(data["verification_uri_complete"])
userCode := stringValue(data["user_code"])
if deviceCode == "" || verifyURL == "" {
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: "飞书/Lark 授权响应缺少 device_code 或二维码 URL。"}, nil
}
qrURL, err := feishuRegistrationQRCodeURL(verifyURL)
if err != nil {
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
}
installID := randomInstallID()
interval := intValue(data["interval"], 5)
expireIn := intValue(firstAny(data["expire_in"], data["expires_in"]), 300)
a.mu.Lock()
if a.botInstalls == nil {
a.botInstalls = map[string]*botInstallSession{}
}
a.botInstalls[installID] = &botInstallSession{
Provider: "feishu", Domain: domain, PollDomain: beginDomain, DeviceCode: deviceCode, UserCode: userCode,
StartedAt: time.Now(), ExpireAt: time.Now().Add(time.Duration(expireIn) * time.Second),
}
a.mu.Unlock()
return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil
}
func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) {
pollDomain := firstNonEmptyBot(session.PollDomain, session.Domain, "feishu")
data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(pollDomain), map[string]string{"action": "poll", "device_code": session.DeviceCode})
if err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
if errText := stringValue(data["error"]); errText != "" {
if errText == "authorization_pending" || errText == "slow_down" {
return BotInstallPollResult{Status: "pending", Message: "等待扫码授权。"}, nil
}
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "error", Error: firstNonEmptyBot(stringValue(data["error_description"]), errText)}, nil
}
if statusCode >= 400 {
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "error", Error: fmt.Sprintf("HTTP %d", statusCode)}, nil
}
if feishuInstallDomain(session.Domain, data) == "lark" && pollDomain != "lark" {
a.mu.Lock()
if current := a.botInstalls[installID]; current != nil {
current.PollDomain = "lark"
}
a.mu.Unlock()
return BotInstallPollResult{Status: "pending", Message: "已识别为 Lark 授权,继续等待授权完成。"}, nil
}
appID := stringValue(data["client_id"])
appSecret := stringValue(data["client_secret"])
if appID == "" || appSecret == "" {
return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil
}
a.deleteBotInstall(installID)
domain := feishuInstallDomain(firstNonEmptyBot(pollDomain, session.Domain), data)
userID := feishuInstallUserID(data)
secretEnv := "FEISHU_BOT_APP_SECRET"
if domain == "lark" {
secretEnv = "LARK_BOT_APP_SECRET"
}
if err := upsertDotEnv(secretEnv, appSecret); err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
label := "飞书"
if domain == "lark" {
label = "Lark"
}
conn, err := a.upsertBotConnection(config.BotConnectionConfig{
ID: connectionID("feishu", domain),
Provider: "feishu",
Domain: domain,
Label: label,
Enabled: true,
Status: "connected",
Access: botInstallAccess(userID),
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
}, func(c *config.Config) {
c.Bot.Enabled = true
c.Bot.Feishu.Enabled = true
c.Bot.Feishu.Domain = domain
c.Bot.Feishu.AppID = appID
c.Bot.Feishu.AppSecretEnv = secretEnv
c.Bot.Feishu.Mode = "websocket"
c.Bot.Feishu.RequireMention = true
c.Bot.Allowlist.FeishuUsers = appendUniqueBotString(c.Bot.Allowlist.FeishuUsers, userID)
})
if err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
a.refreshBotRuntimeAsync()
return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: label + " 已连接。"}, nil
}
func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy func(*config.Config)) (BotConnectionView, error) {
now := time.Now().UTC().Format(time.RFC3339)
if conn.CreatedAt == "" {
conn.CreatedAt = now
}
conn.UpdatedAt = now
if conn.Status == "" {
conn.Status = "connected"
}
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
conn.ToolApprovalMode = "ask"
}
if conn.ID == "" {
conn.ID = connectionID(conn.Provider, conn.Domain)
}
err := a.applyConfigOnly(func(c *config.Config) error {
if updateLegacy != nil {
updateLegacy(c)
}
replaced := false
for i, existing := range c.Bot.Connections {
if existing.ID == conn.ID {
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
conn.Access = existing.Access
}
c.Bot.Connections[i] = conn
replaced = true
break
}
}
if !replaced {
c.Bot.Connections = append(c.Bot.Connections, conn)
}
return nil
})
return botConnectionView(conn), err
}
func (a *App) rememberBotConnectionRemote(id, remoteID string) error {
id = strings.TrimSpace(id)
remoteID = strings.TrimSpace(remoteID)
if id == "" || remoteID == "" {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
return a.applyConfigOnly(func(c *config.Config) error {
for i := range c.Bot.Connections {
if c.Bot.Connections[i].ID != id {
continue
}
for j := range c.Bot.Connections[i].SessionMappings {
if c.Bot.Connections[i].SessionMappings[j].RemoteID == remoteID {
workspaceRoot := firstNonEmptyBot(c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot, c.Bot.Connections[i].WorkspaceRoot)
scope := botMappingScope(c.Bot.Connections[i].SessionMappings[j].Scope, workspaceRoot)
c.Bot.Connections[i].SessionMappings[j].Scope = scope
c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot = botMappingWorkspaceRoot(scope, workspaceRoot)
c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now
c.Bot.Connections[i].UpdatedAt = now
return nil
}
}
scope := botMappingScope("", c.Bot.Connections[i].WorkspaceRoot)
c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{
RemoteID: remoteID,
SessionID: "",
Scope: scope,
WorkspaceRoot: botMappingWorkspaceRoot(scope, c.Bot.Connections[i].WorkspaceRoot),
UpdatedAt: now,
})
c.Bot.Connections[i].UpdatedAt = now
return nil
}
return nil
})
}
func firstSessionRemoteID(mappings []config.BotConnectionSessionMapping) string {
for _, mapping := range mappings {
if strings.TrimSpace(mapping.RemoteID) != "" {
return strings.TrimSpace(mapping.RemoteID)
}
}
return ""
}
func (a *App) deleteBotInstall(installID string) {
a.mu.Lock()
delete(a.botInstalls, installID)
a.mu.Unlock()
}
func normalizeBotInstallTarget(provider, domain string) (string, string) {
provider = strings.ToLower(strings.TrimSpace(provider))
domain = strings.ToLower(strings.TrimSpace(domain))
if provider == "lark" {
provider = "feishu"
domain = "lark"
}
if provider == "weixin" || provider == "wechat" {
return "weixin", "weixin"
}
if domain != "lark" {
domain = "feishu"
}
return "feishu", domain
}
func feishuAccountsBase(domain string) string {
if domain == "lark" {
return "https://accounts.larksuite.com"
}
return "https://accounts.feishu.cn"
}
func feishuRegistrationQRCodeURL(rawURL string) (string, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", err
}
query := parsedURL.Query()
query.Set("from", "sdk")
query.Set("tp", "sdk")
query.Set("source", "go-sdk")
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func postFeishuInstallForm(base string, body map[string]string) (map[string]any, error) {
data, status, err := postFeishuInstallFormResult(base, body)
if err != nil {
return nil, err
}
if status >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", status, firstNonEmptyBot(stringValue(data["error_description"]), stringValue(data["message"])))
}
return data, nil
}
func postFeishuInstallFormResult(base string, body map[string]string) (map[string]any, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reqBody := url.Values{}
for k, v := range body {
reqBody.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, resp.StatusCode, err
}
return out, resp.StatusCode, nil
}
func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
return BotConnectionView{
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
Access: botAccessViewFromConfig(conn.Access),
Credential: BotConnectionCredentialView{
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
SecretSet: botCredentialSecretSet(conn),
},
SessionMappings: botSessionMappingViews(conn.SessionMappings, conn.WorkspaceRoot),
LastError: conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt,
}
}
func botCredentialSecretSet(conn config.BotConnectionConfig) bool {
if conn.Credential.AppSecretEnv != "" {
return envIsSet(conn.Credential.AppSecretEnv)
}
if conn.Credential.TokenEnv != "" && envIsSet(conn.Credential.TokenEnv) {
return true
}
if conn.Provider == "weixin" {
return weixin.HasSavedAccount(conn.Credential.AccountID)
}
return false
}
func feishuInstallDomain(fallback string, data map[string]any) string {
if userInfo, ok := data["user_info"].(map[string]any); ok {
if strings.EqualFold(stringValue(userInfo["tenant_brand"]), "lark") {
return "lark"
}
return "feishu"
}
if strings.EqualFold(fallback, "lark") {
return "lark"
}
return "feishu"
}
func feishuInstallUserID(data map[string]any) string {
if userInfo, ok := data["user_info"].(map[string]any); ok {
return firstNonEmptyBot(
stringValue(userInfo["open_id"]),
stringValue(userInfo["union_id"]),
stringValue(userInfo["user_id"]),
)
}
return ""
}
func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectionView {
if connections == nil {
return []BotConnectionView{}
}
out := make([]BotConnectionView, 0, len(connections))
for _, conn := range connections {
out = append(out, botConnectionView(conn))
}
return out
}
func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
return config.BotConnectionConfig{
ID: strings.TrimSpace(view.ID),
Provider: strings.TrimSpace(view.Provider),
Domain: strings.TrimSpace(view.Domain),
Label: strings.TrimSpace(view.Label),
Enabled: view.Enabled,
Status: strings.TrimSpace(view.Status),
Model: strings.TrimSpace(view.Model),
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
Access: botAccessConfigFromView(view.Access),
Credential: config.BotConnectionCredential{
AppID: strings.TrimSpace(view.Credential.AppID),
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
AccountID: strings.TrimSpace(view.Credential.AccountID),
TokenEnv: strings.TrimSpace(view.Credential.TokenEnv),
},
SessionMappings: botSessionMappingConfigs(view.SessionMappings, view.WorkspaceRoot),
LastError: strings.TrimSpace(view.LastError),
CreatedAt: strings.TrimSpace(view.CreatedAt),
UpdatedAt: strings.TrimSpace(view.UpdatedAt),
}
}
func normalizeBotConnectionToolApprovalMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "ask":
return "ask"
case "auto":
return "auto"
case "yolo", "full", "full-access", "bypass":
return "yolo"
default:
return ""
}
}
func botConnectionConfigs(views []BotConnectionView) []config.BotConnectionConfig {
if views == nil {
return nil
}
out := make([]config.BotConnectionConfig, 0, len(views))
for _, view := range views {
cfg := botConnectionConfig(view)
if cfg.ID == "" || cfg.Provider == "" {
continue
}
out = append(out, cfg)
}
return out
}
func botMappingScope(scope, workspaceRoot string) string {
if strings.TrimSpace(scope) == "project" {
return "project"
}
if strings.TrimSpace(workspaceRoot) != "" {
return "project"
}
return "global"
}
func botMappingWorkspaceRoot(scope, workspaceRoot string) string {
if botMappingScope(scope, workspaceRoot) != "project" {
return ""
}
return strings.TrimSpace(workspaceRoot)
}
func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, connectionWorkspaceRoot string) []BotConnectionSessionMappingView {
if mappings == nil {
return []BotConnectionSessionMappingView{}
}
out := make([]BotConnectionSessionMappingView, 0, len(mappings))
for _, m := range mappings {
workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
scope := botMappingScope(m.Scope, workspaceRoot)
out = append(out, BotConnectionSessionMappingView{
RemoteID: m.RemoteID,
SessionID: m.SessionID,
SessionSource: m.SessionSource,
ChatType: m.ChatType,
UserID: m.UserID,
ThreadID: m.ThreadID,
Scope: scope,
WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
UpdatedAt: m.UpdatedAt,
})
}
return out
}
func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connectionWorkspaceRoot string) []config.BotConnectionSessionMapping {
if mappings == nil {
return nil
}
out := make([]config.BotConnectionSessionMapping, 0, len(mappings))
for _, m := range mappings {
workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
scope := botMappingScope(m.Scope, workspaceRoot)
out = append(out, config.BotConnectionSessionMapping{
RemoteID: strings.TrimSpace(m.RemoteID),
SessionID: strings.TrimSpace(m.SessionID),
SessionSource: strings.TrimSpace(m.SessionSource),
ChatType: strings.TrimSpace(m.ChatType),
UserID: strings.TrimSpace(m.UserID),
ThreadID: strings.TrimSpace(m.ThreadID),
Scope: scope,
WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
UpdatedAt: strings.TrimSpace(m.UpdatedAt),
})
}
return out
}
func connectionID(provider, domain string) string {
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
}
func botInstallAccess(userID string) config.BotAccessConfig {
userID = strings.TrimSpace(userID)
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
if userID != "" {
access.Users = []string{userID}
}
return access
}
func randomInstallID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("install-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func envIsSet(name string) bool {
return strings.TrimSpace(name) != "" && strings.TrimSpace(os.Getenv(name)) != ""
}
func firstAny(values ...any) any {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func firstNonEmptyBot(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func appendUniqueBotString(values []string, next string) []string {
next = strings.TrimSpace(next)
if next == "" {
return values
}
for _, value := range values {
if strings.TrimSpace(value) == next {
return values
}
}
return append(values, next)
}
func stringValue(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func intValue(value any, fallback int) int {
switch v := value.(type) {
case float64:
if v > 0 {
return int(v)
}
case int:
if v > 0 {
return v
}
case string:
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
return n
}
}
return fallback
}
func weixinInstallStatusMessage(status string) string {
switch status {
case "scaned":
return "已扫码,请在微信里确认。"
case "scaned_but_redirect":
return "已扫码,正在切换微信授权节点。"
default:
return "等待扫码。"
}
}
-620
View File
@@ -1,620 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"reasonix/internal/config"
)
func TestNormalizeBotInstallTarget(t *testing.T) {
cases := []struct {
provider string
domain string
wantProvider string
wantDomain string
}{
{provider: "lark", wantProvider: "feishu", wantDomain: "lark"},
{provider: "feishu", domain: "lark", wantProvider: "feishu", wantDomain: "lark"},
{provider: "wechat", wantProvider: "weixin", wantDomain: "weixin"},
{provider: "weixin", domain: "anything", wantProvider: "weixin", wantDomain: "weixin"},
{provider: "unknown", domain: "unknown", wantProvider: "feishu", wantDomain: "feishu"},
}
for _, tc := range cases {
gotProvider, gotDomain := normalizeBotInstallTarget(tc.provider, tc.domain)
if gotProvider != tc.wantProvider || gotDomain != tc.wantDomain {
t.Fatalf("normalizeBotInstallTarget(%q,%q) = %q,%q; want %q,%q", tc.provider, tc.domain, gotProvider, gotDomain, tc.wantProvider, tc.wantDomain)
}
}
}
func TestLarkInstallFollowsSDKDomainSwitchAndStoresSecret(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
pollCount := 0
var beginHost string
var pollHosts []string
var actions []string
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/v1/app/registration" {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Form.Get("action") {
case "begin":
beginHost = r.Header.Get("X-Test-Original-Host")
actions = append(actions, "begin")
if r.Form.Get("archetype") != "PersonalAgent" || r.Form.Get("auth_method") != "client_secret" {
http.Error(w, "wrong begin form", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"device_code": "dev-lark",
"verification_uri_complete": "https://open.feishu.cn/page/launcher?user_code=CODE",
"user_code": "CODE",
"interval": 3,
"expire_in": 300,
})
case "poll":
pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
actions = append(actions, "poll")
if r.Form.Get("device_code") != "dev-lark" {
http.Error(w, "wrong device code", http.StatusBadRequest)
return
}
pollCount++
if pollCount == 1 {
writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
return
}
writeJSON(t, w, map[string]any{
"client_id": "cli-1",
"client_secret": "secret-1",
"user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-installer"},
})
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("lark", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Domain != "lark" || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-lark" {
t.Fatalf("start result = %+v, want ok lark-capable QR result", start)
}
qrURL, err := url.Parse(start.URL)
if err != nil {
t.Fatalf("start URL = %q, want valid QR URL: %v", start.URL, err)
}
query := qrURL.Query()
if query.Get("user_code") != "CODE" || query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
t.Fatalf("start URL query = %v, want SDK registration QR metadata with user_code", query)
}
if qrURL.Host != "open.feishu.cn" {
t.Fatalf("start URL host = %q, want SDK Feishu launcher host", qrURL.Host)
}
pending, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall pending: %v", err)
}
if pending.Done || pending.Status != "pending" {
t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done {
t.Fatalf("poll result = %+v, want done", poll)
}
if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "lark" || poll.Connection.ID != "feishu-lark" {
t.Fatalf("connection = %+v, want feishu-lark from tenant_brand", poll.Connection)
}
if beginHost != "accounts.feishu.cn" {
t.Fatalf("begin host = %q, want SDK Feishu accounts host", beginHost)
}
if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
}
if got := strings.Join(actions, ","); got != "begin,poll,poll" {
t.Fatalf("registration actions = %q, want SDK begin, domain switch, final poll", got)
}
if poll.Connection.WorkspaceRoot != "" {
t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
}
if poll.Connection.Credential.AppID != "cli-1" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
t.Fatalf("credential = %+v, want stored Lark secret", poll.Connection.Credential)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "lark" || cfg.Bot.Feishu.Mode != "websocket" || !cfg.Bot.Feishu.RequireMention {
t.Fatalf("saved feishu config = %+v, want enabled websocket lark with mention gating", cfg.Bot.Feishu)
}
if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-installer" {
t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
}
if err := os.Unsetenv("LARK_BOT_APP_SECRET"); err != nil {
t.Fatalf("unset lark secret env: %v", err)
}
reloaded, err := config.Load()
if err != nil {
t.Fatalf("reload config: %v", err)
}
if got := os.Getenv("LARK_BOT_APP_SECRET"); got != "secret-1" {
t.Fatalf("reloaded LARK_BOT_APP_SECRET = %q, want persisted secret", got)
}
if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
}
}
func TestFeishuInstallSwitchesToLarkDomainWhenTenantBrandIsLark(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
pollCount := 0
var beginHost string
var pollHosts []string
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/v1/app/registration" {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Form.Get("action") {
case "begin":
beginHost = r.Header.Get("X-Test-Original-Host")
writeJSON(t, w, map[string]any{
"device_code": "dev-feishu",
"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
"user_code": "CODE",
"interval": 3,
"expire_in": 300,
})
case "poll":
pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
if r.Form.Get("device_code") != "dev-feishu" {
http.Error(w, "wrong device code", http.StatusBadRequest)
return
}
pollCount++
if pollCount == 1 {
writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
return
}
writeJSON(t, w, map[string]any{
"client_id": "cli-lark",
"client_secret": "secret-lark",
"user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-lark-installer"},
})
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("feishu", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Domain != "feishu" || start.DeviceCode != "dev-feishu" {
t.Fatalf("start result = %+v, want Feishu QR result", start)
}
pending, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall pending: %v", err)
}
if pending.Done || pending.Status != "pending" {
t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done || poll.Connection.Domain != "lark" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" {
t.Fatalf("poll result = %+v, want stored Lark connection after domain switch", poll)
}
if beginHost != "accounts.feishu.cn" {
t.Fatalf("begin host = %q, want Feishu accounts host", beginHost)
}
if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
}
}
func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() { _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") })
var hosts []string
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/v1/app/registration" {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
hosts = append(hosts, r.Form.Get("action")+":"+r.Header.Get("X-Test-Original-Host"))
switch r.Form.Get("action") {
case "begin":
writeJSON(t, w, map[string]any{
"device_code": "dev-feishu",
"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
"user_code": "CODE",
"interval": 3,
"expire_in": 300,
})
case "poll":
writeJSON(t, w, map[string]any{
"client_id": "cli-feishu",
"client_secret": "secret-feishu",
"user_info": map[string]any{"tenant_brand": "feishu", "open_id": "ou-feishu-installer"},
})
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("feishu", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Domain != "feishu" || start.InstallID == "" {
t.Fatalf("start result = %+v, want ok Feishu QR result", start)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done {
t.Fatalf("poll result = %+v, want done", poll)
}
if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "feishu" || poll.Connection.ID != "feishu-feishu" {
t.Fatalf("connection = %+v, want feishu-feishu", poll.Connection)
}
if poll.Connection.Credential.AppID != "cli-feishu" || poll.Connection.Credential.AppSecretEnv != "FEISHU_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
t.Fatalf("credential = %+v, want stored Feishu secret", poll.Connection.Credential)
}
if got := strings.Join(hosts, ","); got != "begin:accounts.feishu.cn,poll:accounts.feishu.cn" {
t.Fatalf("registration hosts = %q, want Feishu begin and poll", got)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "feishu" || cfg.Bot.Feishu.AppID != "cli-feishu" {
t.Fatalf("saved feishu config = %+v, want enabled Feishu websocket config", cfg.Bot.Feishu)
}
if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-feishu-installer" {
t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
}
if err := os.Unsetenv("FEISHU_BOT_APP_SECRET"); err != nil {
t.Fatalf("unset feishu secret env: %v", err)
}
reloaded, err := config.Load()
if err != nil {
t.Fatalf("reload config: %v", err)
}
if got := os.Getenv("FEISHU_BOT_APP_SECRET"); got != "secret-feishu" {
t.Fatalf("reloaded FEISHU_BOT_APP_SECRET = %q, want persisted secret", got)
}
if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
}
}
func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
isolateDesktopUserDirs(t)
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ilink/bot/get_bot_qrcode":
if r.URL.Query().Get("bot_type") != "3" {
http.Error(w, "missing bot type", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"qrcode": "qr-weixin",
"qrcode_img_content": "data:image/png;base64,abc",
})
case "/ilink/bot/get_qrcode_status":
if r.URL.Query().Get("qrcode") != "qr-weixin" {
http.Error(w, "wrong qr", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"status": "confirmed",
"ilink_bot_id": "weixin-account",
"bot_token": "token-1",
"ilink_user_id": "user-1",
"baseurl": "https://ilinkai.weixin.qq.com",
})
default:
http.NotFound(w, r)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("weixin", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Provider != "weixin" || start.Domain != "weixin" || start.URL != "data:image/png;base64,abc" || start.DeviceCode != "qr-weixin" {
t.Fatalf("start result = %+v, want weixin QR result", start)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done {
t.Fatalf("poll result = %+v, want done", poll)
}
if poll.Connection.Provider != "weixin" || poll.Connection.Domain != "weixin" || poll.Connection.Credential.AccountID != "weixin-account" {
t.Fatalf("connection = %+v, want weixin account connection", poll.Connection)
}
if poll.Connection.WorkspaceRoot != "" {
t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
}
if poll.Connection.Credential.TokenEnv != "WEIXIN_BOT_TOKEN" || !poll.Connection.Credential.SecretSet {
t.Fatalf("credential = %+v, want saved account to count as configured token", poll.Connection.Credential)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if !cfg.Bot.Enabled || !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" || cfg.Bot.Weixin.TokenEnv != "WEIXIN_BOT_TOKEN" {
t.Fatalf("saved weixin config = %+v, want enabled saved account", cfg.Bot.Weixin)
}
if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
}
reloaded, err := config.Load()
if err != nil {
t.Fatalf("reload config: %v", err)
}
if len(reloaded.Bot.Connections) != 1 {
t.Fatalf("reloaded connections = %+v, want saved weixin connection", reloaded.Bot.Connections)
}
reloadedConnection := botConnectionView(reloaded.Bot.Connections[0])
if reloadedConnection.Credential.AccountID != "weixin-account" || !reloadedConnection.Credential.SecretSet {
t.Fatalf("reloaded credential = %+v, want saved weixin account to survive restart", reloadedConnection.Credential)
}
}
func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) {
qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old")
if err != nil {
t.Fatalf("feishuRegistrationQRCodeURL: %v", err)
}
parsed, err := url.Parse(qrURL)
if err != nil {
t.Fatalf("parse QR URL: %v", err)
}
query := parsed.Query()
if query.Get("user_code") != "ABCD-1234" {
t.Fatalf("user_code = %q, want preserved code", query.Get("user_code"))
}
if query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
t.Fatalf("query = %v, want SDK registration metadata", query)
}
}
func TestDiagnoseBotConnectionBuildsReportDetailForMissingSecret(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("FEISHU_BOT_APP_SECRET_PRIVATE", "")
app := NewApp()
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Label: "Lark",
Enabled: true,
Status: "connected",
WorkspaceRoot: "/Users/alice/work/reasonix",
Credential: config.BotConnectionCredential{
AppID: "cli-private",
AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
},
SessionMappings: []config.BotConnectionSessionMapping{{
RemoteID: "ou-private",
SessionID: "session-private",
Scope: "project",
WorkspaceRoot: "/Users/alice/work/reasonix",
}},
}, nil); err != nil {
t.Fatalf("upsert connection: %v", err)
}
diag, err := app.DiagnoseBotConnection("feishu-lark")
if err != nil {
t.Fatalf("DiagnoseBotConnection: %v", err)
}
if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
t.Fatalf("diagnostic = %+v, want warning credential report", diag)
}
for _, leaked := range []string{"FEISHU_BOT_APP_SECRET_PRIVATE", "/Users/alice", "ou-private", "session-private"} {
if strings.Contains(diag.ReportDetail, leaked) {
t.Fatalf("diagnostic report leaked %q in %s", leaked, diag.ReportDetail)
}
}
var payload frontendCrashPayload
if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
t.Fatalf("report detail is not structured JSON: %v", err)
}
if payload.Kind != "bot" || payload.Source != "bot.runtime" || payload.Label != "bot.feishu.lark.credential" {
t.Fatalf("payload = %+v, want bot runtime credential label", payload)
}
for _, want := range []string{
"app_secret_env_configured: true",
"secret_available: false",
"workspace_scope: project",
"session_mappings: 1",
"summary: required bot credential is not available",
} {
if !strings.Contains(payload.Message, want) {
t.Fatalf("payload message = %q, want it to contain %q", payload.Message, want)
}
}
report, err := crashReportFromDetail(diag.ReportKind, diag.ReportDetail)
if err != nil {
t.Fatalf("crashReportFromDetail: %v", err)
}
if report.Kind != "bot" || report.Source != "bot.runtime" || report.ErrorType != "BotConnectionDiagnostic" {
t.Fatalf("report = %+v, want accepted bot report", report)
}
}
func TestBotConnectionSendFailureReportRedactsEnvNames(t *testing.T) {
conn := config.BotConnectionConfig{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Label: "Lark",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
},
}
diag := botConnectionDiagnostic(&conn, conn.ID, "error", "send", "test_send_failed", "feishu app_id or FEISHU_BOT_APP_SECRET_PRIVATE is not configured", true)
if diag.ReportKind != "bot" || diag.ReportDetail == "" {
t.Fatalf("diagnostic = %+v, want reportable bot diagnostic", diag)
}
if strings.Contains(diag.ReportDetail, "FEISHU_BOT_APP_SECRET_PRIVATE") {
t.Fatalf("diagnostic report leaked env name in %s", diag.ReportDetail)
}
var payload frontendCrashPayload
if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
t.Fatalf("report detail is not structured JSON: %v", err)
}
if !strings.Contains(payload.ErrorMessage, "[redacted-env]") {
t.Fatalf("payload errorMessage = %q, want redacted env marker", payload.ErrorMessage)
}
}
func TestDiagnoseWeixinConnectionDetectsMissingSavedAccountWithoutTokenEnv(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "weixin-weixin",
Provider: "weixin",
Domain: "weixin",
Label: "微信",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AccountID: "missing-account",
},
}, nil); err != nil {
t.Fatalf("upsert connection: %v", err)
}
diag, err := app.DiagnoseBotConnection("weixin-weixin")
if err != nil {
t.Fatalf("DiagnoseBotConnection: %v", err)
}
if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
t.Fatalf("diagnostic = %+v, want missing local credential warning", diag)
}
if strings.Contains(diag.ReportDetail, "missing-account") {
t.Fatalf("diagnostic report leaked account id in %s", diag.ReportDetail)
}
}
func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Label: "kun",
Enabled: true,
Status: "connected",
}, nil); err != nil {
t.Fatalf("upsert global connection: %v", err)
}
if err := app.rememberBotConnectionRemote("feishu-lark", "ou_global"); err != nil {
t.Fatalf("remember global remote: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if got := cfg.Bot.Connections[0].SessionMappings[0]; got.Scope != "global" || got.WorkspaceRoot != "" || got.RemoteID != "ou_global" {
t.Fatalf("global mapping = %+v, want scope=global without workspace", got)
}
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "weixin-project",
Provider: "weixin",
Domain: "weixin",
Label: "project",
Enabled: true,
Status: "connected",
WorkspaceRoot: "/tmp/reasonix-project",
}, nil); err != nil {
t.Fatalf("upsert project connection: %v", err)
}
if err := app.rememberBotConnectionRemote("weixin-project", "wxid_project"); err != nil {
t.Fatalf("remember project remote: %v", err)
}
cfg = config.LoadForEdit(config.UserConfigPath())
var projectMapping config.BotConnectionSessionMapping
for _, conn := range cfg.Bot.Connections {
if conn.ID == "weixin-project" && len(conn.SessionMappings) == 1 {
projectMapping = conn.SessionMappings[0]
}
}
if projectMapping.Scope != "project" || projectMapping.WorkspaceRoot != "/tmp/reasonix-project" || projectMapping.RemoteID != "wxid_project" {
t.Fatalf("project mapping = %+v, want project scope and workspace", projectMapping)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
t.Fatalf("write json: %v", err)
}
}
func withRewrittenHTTP(t *testing.T, handler http.Handler) {
t.Helper()
server := httptest.NewServer(handler)
target, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
previous := http.DefaultTransport
http.DefaultTransport = rewriteHTTPTransport{target: target, next: previous}
t.Cleanup(func() {
http.DefaultTransport = previous
server.Close()
})
}
type rewriteHTTPTransport struct {
target *url.URL
next http.RoundTripper
}
func (r rewriteHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
clone.Header.Set("X-Test-Original-Host", req.URL.Host)
clone.URL.Scheme = r.target.Scheme
clone.URL.Host = r.target.Host
clone.Host = r.target.Host
if r.next == nil {
r.next = http.DefaultTransport
}
clone.URL.Path = "/" + strings.TrimLeft(clone.URL.Path, "/")
return r.next.RoundTrip(clone)
}
-183
View File
@@ -1,183 +0,0 @@
package main
import (
"context"
"log"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
const botForwardSendTimeout = 30 * time.Second
const botForwardQueueSize = 64
// ── Forward target ──────────────────────────────────────────────────────────
// botForwardTarget identifies one remote chat to send forwarded events to.
type botForwardTarget struct {
ConnID string
Domain string
ChatID string
ChatType bot.ChatType
}
// ── Event forwarder ─────────────────────────────────────────────────────────
// botEventForwarder implements event.Sink and forwards relevant events to
// connected bot channels through the desktopBotRuntime. It is attached to a
// tabEventSink when a heartbeat task should push AI output to IM channels.
//
// It accumulates Text events and sends them as complete messages on TurnDone
// (and occasionally during generation when the buffer grows large enough), so
// the remote side sees progressive streaming output rather than one big blob.
type botEventForwarder struct {
runtime *desktopBotRuntime
targets []botForwardTarget
mu sync.Mutex
buf strings.Builder
queueMu sync.Mutex
queue chan string
closed bool
closeOnce sync.Once
}
// newBotEventForwarder creates a forwarder that sends to all given targets.
// runtime may be nil — Emit calls are then no-ops.
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
f := &botEventForwarder{
runtime: runtime,
targets: targets,
queue: make(chan string, botForwardQueueSize),
}
go f.run()
return f
}
// Emit implements event.Sink. It forwards text and lifecycle events to the
// connected bot channels; reasoning, tool dispatch, and other internal events
// are dropped to avoid noisy IM output.
func (f *botEventForwarder) Emit(e event.Event) {
if f.runtime == nil || len(f.targets) == 0 {
return
}
switch e.Kind {
case event.TurnStarted:
f.mu.Lock()
f.buf.Reset()
f.mu.Unlock()
case event.Text:
f.mu.Lock()
f.buf.WriteString(e.Text)
size := f.buf.Len()
f.mu.Unlock()
// Flush opportunistically when the buffer crosses a threshold, so long
// streams (e.g. "tell me three jokes") produce multiple messages.
if size >= 400 {
f.flush()
}
case event.TurnDone:
f.flush()
f.Close()
case event.ApprovalRequest:
// The heartbeat turn belongs to the desktop tab controller, not the bot
// gateway session, so remote /approve replies cannot satisfy this ID.
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
text += "\n请回到桌面窗口处理。"
f.sendToAll(text)
case event.AskRequest:
var qb strings.Builder
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
for i, q := range e.Ask.Questions {
if i > 0 {
qb.WriteString("\n")
}
qb.WriteString(q.Prompt)
}
qb.WriteString("\n请回到桌面窗口处理。")
f.sendToAll(qb.String())
case event.Notice:
if e.Audience == event.NoticeAudienceOperator {
// Local runtime maintenance is not actionable in the remote IM chat.
break
}
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
case event.CompactionStarted:
f.sendToAll("🔄 正在压缩上下文...")
}
}
// flush sends the accumulated buffer as one message per target channel.
func (f *botEventForwarder) flush() {
f.mu.Lock()
text := strings.TrimSpace(f.buf.String())
if text == "" {
f.mu.Unlock()
return
}
f.buf.Reset()
f.mu.Unlock()
f.sendToAll(text)
}
// sendToAll dispatches text to every target channel. Errors are logged and
// non-fatal; a failed target does not block other targets.
func (f *botEventForwarder) sendToAll(text string) {
text = strings.TrimSpace(text)
if f.runtime == nil || len(f.targets) == 0 || text == "" {
return
}
f.queueMu.Lock()
defer f.queueMu.Unlock()
if f.closed {
return
}
select {
case f.queue <- text:
default:
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
}
}
func (f *botEventForwarder) run() {
for text := range f.queue {
f.sendToAllNow(text)
}
}
func (f *botEventForwarder) sendToAllNow(text string) {
for _, tgt := range f.targets {
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
ChatID: tgt.ChatID,
ChatType: tgt.ChatType,
Text: text,
})
cancel()
if err != nil {
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
}
}
}
func (f *botEventForwarder) Close() {
f.closeOnce.Do(func() {
f.flush()
f.queueMu.Lock()
f.closed = true
close(f.queue)
f.queueMu.Unlock()
})
}
-166
View File
@@ -1,166 +0,0 @@
package main
import (
"context"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
type desktopForwardTestAdapter struct {
platform bot.Platform
name string
messages chan bot.InboundMessage
entered chan struct{}
release chan struct{}
sent chan bot.OutboundMessage
once sync.Once
}
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
return &desktopForwardTestAdapter{
platform: bot.PlatformFeishu,
name: "forward-test",
messages: make(chan bot.InboundMessage),
entered: make(chan struct{}),
release: make(chan struct{}),
sent: make(chan bot.OutboundMessage, 8),
}
}
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
func (a *desktopForwardTestAdapter) Name() string { return a.name }
func (a *desktopForwardTestAdapter) Start(context.Context) error {
return nil
}
func (a *desktopForwardTestAdapter) Stop() error { return nil }
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
return nil
}
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
return a.messages
}
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
a.once.Do(func() { close(a.entered) })
select {
case <-a.release:
case <-ctx.Done():
return bot.SendResult{}, ctx.Err()
}
a.sent <- msg
return bot.SendResult{MessageID: "sent"}, nil
}
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
ID: "feishu-lark",
Domain: "lark",
Platform: bot.PlatformFeishu,
Adapter: adapter,
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
return &desktopBotRuntime{gw: gw}
}
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
done := make(chan struct{})
go func() {
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("Emit blocked behind slow bot send")
}
select {
case <-adapter.entered:
case <-time.After(500 * time.Millisecond):
t.Fatal("adapter send did not start")
}
forwarder.Close()
close(adapter.release)
select {
case <-adapter.sent:
case <-time.After(500 * time.Millisecond):
t.Fatal("queued bot message was not sent after adapter release")
}
}
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
close(adapter.release)
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "approval-1",
Tool: "shell",
Subject: "run command",
}})
forwarder.Close()
select {
case msg := <-adapter.sent:
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
}
if !strings.Contains(msg.Text, "桌面") {
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("approval notice was not sent")
}
}
func TestBotEventForwarderSuppressesOperatorNoticesWithoutHidingUserWarnings(t *testing.T) {
forwarder := &botEventForwarder{
runtime: &desktopBotRuntime{},
targets: []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}},
queue: make(chan string, 2),
}
forwarder.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Audience: event.NoticeAudienceOperator,
Code: event.NoticeCodeSessionRecoveryForked,
Text: "local session maintenance",
})
forwarder.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: "please retry the user action",
})
if got := len(forwarder.queue); got != 1 {
t.Fatalf("queued messages = %d, want only the actionable user warning", got)
}
if got := <-forwarder.queue; got != "⚠️ please retry the user action" {
t.Fatalf("queued message = %q, want the ordinary user warning", got)
}
}
-415
View File
@@ -1,415 +0,0 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
type BotRuntimeStatusView struct {
Running bool `json:"running"`
Status string `json:"status"`
Message string `json:"message"`
Connections int `json:"connections"`
StartedAt string `json:"startedAt"`
}
type desktopBotRuntime struct {
// lifecycleMu serializes start/stop transitions so two apply/stop calls
// can't race a gateway into existence. The slow work (gw.Stop teardown,
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
// never block on a restart.
lifecycleMu sync.Mutex
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
}
func newDesktopBotRuntime() *desktopBotRuntime {
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
}
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
channel := bot.ChannelConfig{
Model: strings.TrimSpace(qq.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
}
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
return channels, connectionChannels
}
if channels == nil {
channels = make(map[bot.Platform]bot.ChannelConfig)
}
if _, ok := channels[bot.PlatformQQ]; !ok {
channels[bot.PlatformQQ] = channel
}
if connectionChannels == nil {
connectionChannels = make(map[string]bot.ChannelConfig)
}
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
connectionChannels[string(bot.PlatformQQ)] = channel
}
return channels, connectionChannels
}
func (a *App) refreshBotRuntimeAsync() {
if a.ctx == nil {
return
}
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
}
func (a *App) refreshBotRuntime() {
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
// App with no bot runtime, which must not lazily create one from a
// background goroutine (that would race a concurrent refresh).
if a.botRuntime == nil {
return
}
var watcherVersion uint64
if a.botBridge != nil {
watcherVersion = a.botBridge.watcherVersion()
}
cfg, err := a.loadDesktopBotConfig()
if err != nil {
a.botRuntime.stop("error", err.Error())
return
}
// Assign through a typed local so a nil *botBridgeHub never becomes a
// non-nil bot.DesktopBridge interface inside the gateway config.
var bridge bot.DesktopBridge
if a.botBridge != nil {
// 配置是订阅的持久化事实源:每次运行时重算前重新种子,桌面重启后
// /desktop watch 的订阅继续生效。
a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
bridge = a.botBridge
}
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
}
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
// Read-only load feeding the bot runtime and connection diagnostics. It
// must load credentials: the runtime resolves app secrets and control
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
// credential-free view load would leave unset on a fresh process.
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
if err != nil {
return nil, err
}
return cfg, nil
}
func (a *App) stopBotRuntime() {
if a.botRuntime != nil {
a.botRuntime.stop("stopped", "bot runtime stopped")
}
}
func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
if a.botRuntime == nil {
return BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}
}
return a.botRuntime.snapshot()
}
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
if r == nil {
return nil
}
if parent == nil {
parent = context.Background()
}
plan := desktopBotRuntimePlan(cfg)
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
if !plan.Start {
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
return nil
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
ctx, cancel := context.WithCancel(parent)
modelName := botruntime.ModelName(cfg, "")
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
gwCfg := bot.GatewayConfig{
Model: modelName,
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
MaxSteps: cfg.Bot.MaxSteps,
QueueMode: cfg.Bot.QueueMode,
QueueCap: cfg.Bot.QueueCap,
QueueDrop: cfg.Bot.QueueDrop,
PairingEnabled: cfg.Bot.Pairing.Enabled,
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
SelfUserIDs: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
},
ControlEnabled: cfg.Bot.Control.Enabled,
ControlAddr: cfg.Bot.Control.Addr,
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
WorkspaceRoot: workspaceRoot,
Channels: channels,
ConnectionChannels: connectionChannels,
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
Enabled: plan.Enabled,
Allowlist: bot.AllowlistConfig{
Enabled: cfg.Bot.Allowlist.Enabled,
AllowAll: cfg.Bot.Allowlist.AllowAll,
Users: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
},
Approvers: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
},
Admins: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
},
Groups: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
},
},
Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
OnInbound: botruntime.NewRemoteRememberer(logger),
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
OnToolApprovalModeChange: onToolApprovalModeChange,
Desktop: bridge,
}
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
if len(bindings) == 0 {
cancel()
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
return nil
}
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
if err := gw.Start(ctx); err != nil {
cancel()
gw.Stop()
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
return err
}
runningConnections := gw.AdapterCount()
startErrors := gw.StartErrors()
status := "running"
message := fmt.Sprintf("%d bot connection(s) running", runningConnections)
if len(startErrors) > 0 {
status = "degraded"
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
}
r.mu.Lock()
r.cancel = cancel
r.gw = gw
r.status = BotRuntimeStatusView{
Running: true,
Status: status,
Message: message,
Connections: runningConnections,
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
r.mu.Unlock()
return nil
}
func (a *App) persistRemoteBotToolApprovalMode(msg bot.InboundMessage, mode string) error {
mode = normalizeBotConnectionToolApprovalMode(mode)
if mode == "" {
return nil
}
return a.applyConfigOnly(func(c *config.Config) error {
id := strings.TrimSpace(msg.ConnectionID)
now := time.Now().UTC().Format(time.RFC3339)
if id != "" {
for i := range c.Bot.Connections {
if c.Bot.Connections[i].ID == id || botruntime.ConnectionRuntimeID(c.Bot.Connections[i]) == id {
c.Bot.Connections[i].ToolApprovalMode = mode
c.Bot.Connections[i].UpdatedAt = now
return nil
}
}
}
c.Bot.ToolApprovalMode = mode
return nil
})
}
func summarizeBotRuntimeErrors(errs []error) string {
parts := make([]string, 0, len(errs))
for _, err := range errs {
if err == nil {
continue
}
parts = append(parts, err.Error())
}
if len(parts) == 0 {
return ""
}
if len(parts) > 3 {
hidden := len(parts) - 3
parts = append(parts[:3], fmt.Sprintf("%d more", hidden))
}
return strings.Join(parts, "; ")
}
type botRuntimePlan struct {
Start bool
Status string
Message string
Enabled map[bot.Platform]bool
}
func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
if cfg == nil {
return botRuntimePlan{Status: "error", Message: "config is unavailable"}
}
if !cfg.Bot.Enabled {
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
}
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
}
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
if len(unknown) > 0 {
return botRuntimePlan{Status: "error", Message: "unknown bot channel: " + strings.Join(unknown, ", ")}
}
if !botruntime.HasEnabledPlatform(enabled) {
return botRuntimePlan{Status: "stopped", Message: "no bot channels enabled"}
}
return botRuntimePlan{Start: true, Status: "running", Message: "bot runtime can start", Enabled: enabled}
}
func (r *desktopBotRuntime) stop(status, message string) {
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
}
// stopCurrent detaches the running gateway under r.mu, then tears it down
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
func (r *desktopBotRuntime) stopCurrent() {
r.mu.Lock()
cancel := r.cancel
gw := r.gw
r.cancel = nil
r.gw = nil
r.mu.Unlock()
if cancel != nil {
cancel()
}
if gw != nil {
gw.Stop()
}
}
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
r.mu.Lock()
r.status = status
r.mu.Unlock()
}
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
r.mu.Lock()
defer r.mu.Unlock()
return r.status
}
// updateConnectionToolApprovalMode updates a connection's tool approval mode
// on the running gateway without restarting. Returns true if updated, false if
// the gateway is not running or the connection is unknown.
func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.gw == nil {
return false
}
mode = normalizeBotConnectionToolApprovalMode(mode)
// Update ConnectionChannels in the internal GatewayConfig so new sessions
// pick up the mode. Existing sessions are updated by the gateway directly.
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
return true
}
// SendToAdapter sends a message through the running gateway's adapter
// identified by connID. Returns an error if the gateway is not running
// or no matching adapter is found.
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
r.mu.Lock()
gw := r.gw
r.mu.Unlock()
if gw == nil {
return bot.SendResult{}, nil // gateway not running — silent no-op
}
return gw.SendToAdapter(ctx, connID, domain, msg)
}
// Running returns true if the bot gateway is currently active.
func (r *desktopBotRuntime) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.gw != nil
}
// ForwardTargets returns the list of bot forward targets derived from the
// current config's bot connections and their session mappings. Each mapping
// produces one target (connID + chatID + chatType) for event forwarding.
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
if cfg == nil {
return nil
}
var targets []botForwardTarget
seen := make(map[botForwardTarget]bool)
for _, conn := range cfg.Bot.Connections {
if !conn.Enabled {
continue
}
connID := botruntime.ConnectionRuntimeID(conn)
domain := strings.TrimSpace(conn.Domain)
for _, sm := range conn.SessionMappings {
remoteID := strings.TrimSpace(sm.RemoteID)
if remoteID == "" {
continue
}
chatType := bot.ChatDM
if sm.ChatType != "" {
chatType = bot.ChatType(sm.ChatType)
}
target := botForwardTarget{
ConnID: connID,
Domain: domain,
ChatID: remoteID,
ChatType: chatType,
}
if seen[target] {
continue
}
seen[target] = true
targets = append(targets, target)
}
}
return targets
}
-504
View File
@@ -1,504 +0,0 @@
package main
import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/bot"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true},
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if !plan.Start {
t.Fatalf("plan = %+v, want start", plan)
}
if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled)
}
}
func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = false
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if plan.Start || plan.Status != "blocked" {
t.Fatalf("plan = %+v, want blocked without allowlist", plan)
}
}
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = true
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if !plan.Start {
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
}
}
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
Model: "qq-model",
ToolApprovalMode: "auto",
WorkspaceRoot: "/tmp/qq-project",
}, nil, nil)
channel, ok := channels[bot.PlatformQQ]
if !ok {
t.Fatalf("platform QQ channel missing: %+v", channels)
}
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
}
connectionChannel, ok := connectionChannels["qq"]
if !ok {
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
}
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
}
}
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = false
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if plan.Start || plan.Status != "stopped" {
t.Fatalf("plan = %+v, want stopped when disabled", plan)
}
}
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
SessionMappings: []config.BotConnectionSessionMapping{
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
},
}}
targets := newDesktopBotRuntime().ForwardTargets(cfg)
if len(targets) != 2 {
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
}
seen := map[string]bool{}
for _, target := range targets {
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
if seen[key] {
t.Fatalf("duplicate target %q in %+v", key, targets)
}
seen[key] = true
}
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
t.Fatalf("targets = %+v, want group and dm targets", targets)
}
}
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.LoadForEdit(config.UserConfigPath())
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
userCfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"},
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = false
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
plan := desktopBotRuntimePlan(got)
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] {
t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan)
}
}
func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() {
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
})
userCfg := config.Default()
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"}
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"}
userCfg.Bot.Feishu.Enabled = true
userCfg.Bot.Weixin.Enabled = true
userCfg.Bot.Weixin.AccountID = "weixin-account"
userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
userCfg.Bot.Connections = []config.BotConnectionConfig{
{
ID: "feishu-feishu",
Provider: "feishu",
Domain: "feishu",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AppID: "cli-feishu",
AppSecretEnv: "FEISHU_BOT_APP_SECRET",
},
},
{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AppID: "cli-lark",
AppSecretEnv: "LARK_BOT_APP_SECRET",
},
},
{
ID: "weixin-weixin",
Provider: "weixin",
Domain: "weixin",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AccountID: "weixin-account",
TokenEnv: "WEIXIN_BOT_TOKEN",
},
},
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
t.Fatalf("create credentials dir: %v", err)
}
if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil {
t.Fatalf("write credentials: %v", err)
}
weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json")
if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil {
t.Fatalf("create weixin account dir: %v", err)
}
if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil {
t.Fatalf("write weixin account: %v", err)
}
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
got, err := NewApp().loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
views := botConnectionViews(got.Bot.Connections)
if len(views) != 3 {
t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views)
}
for _, view := range views {
if !view.Credential.SecretSet {
t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential)
}
}
plan := desktopBotRuntimePlan(got)
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger)
if len(bindings) != 3 {
t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings)
}
}
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: the merge above stays in memory and
// the user config file is not rewritten.
preWrite := config.LoadForEdit(config.UserConfigPath())
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
}
// The first locked write path performs the on-disk migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() != "dark" {
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
}
}
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
close_behavior = "quit"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
settings := NewApp().Settings()
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
}
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
}
}
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
userCfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"},
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" {
t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot)
}
}
func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) {
got := summarizeBotRuntimeErrors([]error{
errors.New("first"),
nil,
errors.New("second"),
errors.New("third"),
errors.New("fourth"),
})
for _, want := range []string{"first", "second", "third", "1 more"} {
if !strings.Contains(got, want) {
t.Fatalf("summary = %q, want %q", got, want)
}
}
if strings.Contains(got, "fourth") {
t.Fatalf("summary = %q, should cap extra errors", got)
}
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
cases := []struct {
name string
got any
}{
{"Checkpoints", app.Checkpoints()},
{"ListSessions", app.ListSessions()},
{"ListTrashedSessions", app.ListTrashedSessions()},
{"ListWorkspaces", app.ListWorkspaces()},
{"History", app.History()},
{"Jobs", app.Jobs()},
{"Commands", app.Commands()},
{"Models", app.Models()},
{"ListDir", app.ListDir("__missing__")},
{"ListDirForTab", app.ListDirForTab("missing", "")},
{"SearchFileRefsForTab", app.SearchFileRefsForTab("missing", "file")},
{"ListTabs", app.ListTabs()},
{"ListProjectTree", app.ListProjectTree()},
{"AvailableSubagentTools", app.AvailableSubagentTools()},
{"MCPServers", app.MCPServers()},
{"Plugins", app.Plugins()},
{"HeartbeatListTasks", app.HeartbeatListTasks()},
{"HeartbeatReloadTasks", app.HeartbeatReloadTasks()},
}
for _, tc := range cases {
assertNonNilSliceJSON(t, tc.name, tc.got)
}
if got := app.SlashArgs("/skill "); got.Items == nil {
t.Fatal("SlashArgs().Items is nil; frontend expects []")
}
if got := app.WorkspaceChanges(""); got.Files == nil {
t.Fatal(`WorkspaceChanges("").Files is nil; frontend expects []`)
}
if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
}
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
}
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("Settings() contains nil array fields: %+v", got)
}
if got := app.DesktopStartupSettings(); got.StatusBarItems == nil ||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("DesktopStartupSettings() contains nil array fields: %+v", got)
}
boundPayloads := []struct {
name string
got any
}{
{"CapabilityDiagnostics", app.CapabilityDiagnostics(false)},
{"Capabilities", app.Capabilities()},
{"SkillsSettings", app.SkillsSettings()},
{"HistoryPage", app.HistoryPage(0, 20)},
{"Effort", app.Effort()},
{"Memory", app.Memory()},
{"MemorySuggestions", app.MemorySuggestions()},
}
for _, tc := range boundPayloads {
assertRequiredJSONSlicesNonNil(t, tc.name, reflect.ValueOf(tc.got))
}
}
func assertNonNilSliceJSON(t *testing.T, name string, got any) {
t.Helper()
v := reflect.ValueOf(got)
if v.Kind() != reflect.Slice {
t.Fatalf("%s returned %T, want slice", name, got)
}
if v.IsNil() {
t.Fatalf("%s returned nil slice; frontend expects []", name)
}
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("%s JSON marshal: %v", name, err)
}
if string(raw) == "null" {
t.Fatalf("%s JSON encoded as null; frontend expects []", name)
}
}
func assertRequiredJSONSlicesNonNil(t *testing.T, path string, value reflect.Value) {
t.Helper()
for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
if value.IsNil() {
return
}
value = value.Elem()
}
switch value.Kind() {
case reflect.Slice, reflect.Array:
if value.Kind() == reflect.Slice && value.IsNil() {
t.Fatalf("%s is a nil slice; JSON contract requires []", path)
}
for i := range value.Len() {
assertRequiredJSONSlicesNonNil(t, path, value.Index(i))
}
case reflect.Struct:
typ := value.Type()
for i := range value.NumField() {
fieldType := typ.Field(i)
if fieldType.PkgPath != "" {
continue
}
jsonTag := fieldType.Tag.Get("json")
if jsonTag == "-" || strings.Contains(jsonTag, ",omitempty") {
continue
}
fieldPath := path + "." + fieldType.Name
assertRequiredJSONSlicesNonNil(t, fieldPath, value.Field(i))
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
<g transform="scale(2.56)">
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
<!-- Legacy ICNS renderers do not apply the modern macOS icon mask. Keep the
rounded-square artwork in the 824px icon grid used by peer applications. -->
<g transform="translate(100 100) scale(2.06)">
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened-runtime entitlements required to notarize a Wails (Go + WKWebView)
app. The web view's JS engine needs JIT and writable/executable memory;
the Go binary links system libs that aren't team-signed. Kept minimal — no
network/file entitlements, since this is a hardened-runtime carve-out, not
an App Sandbox profile. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
<g transform="scale(2.56)">
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!--
Polkit policy for Reasonix desktop in-app .deb upgrades.
The action only authorizes the fixed root-owned helper with argv1=install.
Active sessions require auth_admin every time; authorization is not retained.
-->
<policyconfig>
<vendor>Reasonix</vendor>
<vendor_url>https://reasonix.io</vendor_url>
<action id="io.reasonix.desktop.update">
<description gettext-domain="reasonix-desktop">Install a Reasonix desktop update</description>
<description xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 桌面端更新</description>
<description xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 桌面端更新</description>
<message gettext-domain="reasonix-desktop">Authentication is required to install a Reasonix update</message>
<message xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 更新需要管理员授权</message>
<message xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 更新需要管理員授權</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/reasonix/reasonix-update-helper</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">install</annotate>
</action>
</policyconfig>
-98
View File
@@ -1,98 +0,0 @@
# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch
# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths
# below are relative to desktop/). Portable clients keep using the .tar.gz under
# platforms[]; this .deb is the native package channel (native_packages) and also
# remains a human-download artifact.
#
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION follows
# Debian version ordering: stable X.Y.Z, prereleases as X.Y.Z~rc.N / X.Y.Z~canary.N
# so they sort below the matching stable package. DEB_ARCH is the Go arch name,
# which already matches Debian's (amd64, arm64).
name: "reasonix-desktop"
arch: "${DEB_ARCH}"
platform: "linux"
version: "${DEB_VERSION}"
section: "utils"
priority: "optional"
maintainer: "esengine <[email protected]>"
description: |
Reasonix desktop — a Wails shell around the Go kernel.
vendor: "Reasonix Contributors"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
license: "MIT"
scripts:
postinstall: ./build/linux/postinstall.sh
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
# pkexec is required for the in-app Polkit authorization update path.
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
- pkexec
contents:
- src: ./build/bin/reasonix-desktop
dst: /usr/bin/reasonix-desktop
file_info:
mode: 0755
- src: ./build/bin/reasonix-launcher
dst: /usr/bin/reasonix-launcher
file_info:
mode: 0755
- src: ./build/bin/reasonix
dst: /usr/bin/reasonix
file_info:
mode: 0755
- src: ./build/bin/reasonix-update-helper
dst: /usr/lib/reasonix/reasonix-update-helper
file_info:
mode: 0755
- src: ./build/linux/io.reasonix.desktop.update.policy
dst: /usr/share/polkit-1/actions/io.reasonix.desktop.update.policy
file_info:
mode: 0644
- src: ./build/linux/reasonix.desktop
dst: /usr/share/applications/reasonix.desktop
file_info:
mode: 0644
# appicon.png is 1024x1024 — not a standard hicolor size dir, so install it under
# the size-agnostic pixmaps path that the .desktop's `Icon=reasonix-desktop` finds.
- src: ./build/appicon.png
dst: /usr/share/pixmaps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/16x16/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/24x24/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/32x32/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/48x48/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/64x64/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/128x128/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/256x256/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/512x512/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg
dst: /usr/share/icons/hicolor/scalable/apps/reasonix-desktop.svg
file_info:
mode: 0644
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
# Refresh desktop integration after both fresh installs and upgrades. Debian's
# package triggers usually do this too, but an explicit best-effort refresh
# repairs stale blank icons on desktops that kept an older cache.
if [ -x /usr/bin/gtk-update-icon-cache ]; then
/usr/bin/gtk-update-icon-cache --force --quiet /usr/share/icons/hicolor || true
fi
if [ -x /usr/bin/update-desktop-database ]; then
/usr/bin/update-desktop-database --quiet /usr/share/applications || true
fi
exit 0
-9
View File
@@ -1,9 +0,0 @@
[Desktop Entry]
Type=Application
Name=Reasonix
Comment=Reasonix desktop — a Wails shell around the Go kernel
Exec=reasonix-launcher
Icon=reasonix-desktop
Categories=Development;Utility;
Terminal=false
StartupWMClass=reasonix-desktop
-510
View File
@@ -1,510 +0,0 @@
Unicode true
####
## Reasonix per-user NSIS installer.
##
## This file is COMMITTED and customized (Wails leaves an existing project.nsi
## untouched and only regenerates wails_tools.nsh). The customizations vs.
## Wails' default template:
##
## 1. REQUEST_EXECUTION_LEVEL "user" + InstallDir under $LOCALAPPDATA - install
## without administrator rights. This lets the auto-updater re-run a freshly
## downloaded installer in a visible progress-only mode with no UAC prompt.
## 2. Uninstall registry under HKCU (not HKLM). Wails' wails.writeUninstaller /
## wails.deleteUninstaller macros hard-code HKLM, which a non-admin install
## cannot write - so we inline HKCU versions below instead.
## 3. InstallDir is remembered across updates via InstallDirRegKey +
## InstallLocation (HKCU\...\Uninstall\InstallLocation). When upgrading from
## a build that did not write InstallLocation yet, .onInit falls back to the
## old DisplayIcon path before using the default. Without this, every release
## forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if they had
## moved the install to a different drive (e.g. D:\Tools\Reasonix); the
## auto-updater would overwrite the wrong dir, leaving the old install
## orphaned.
##
## Everything else mirrors Wails' generated default. Defines below override the
## ProjectInfo values that wails_tools.nsh would otherwise populate.
####
## Install per-user (no admin). Must be defined BEFORE including wails_tools.nsh,
## which only sets the "admin" default when REQUEST_EXECUTION_LEVEL is undefined.
!define REQUEST_EXECUTION_LEVEL "user"
####
## Include the wails tools (auto-generated; provides INFO_* defines and the
## wails.* macros used below).
####
!include "wails_tools.nsh"
!include "FileFunc.nsh"
!include "LogicLib.nsh"
# The build script writes this host-specific include before invoking makensis.
# Keep a Windows fallback so opening this script directly still behaves like a
# native Windows build.
!if /FileExists "reasonix_host.nsh"
!include "reasonix_host.nsh"
!endif
!ifndef REASONIX_UNINST_FINALIZE
!define REASONIX_UNINST_FINALIZE 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'
!endif
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipSetupPageForUpdate
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipSetupPageForUpdate
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!define MUI_PAGE_CUSTOMFUNCTION_SHOW reasonix.showUpdateProgress
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipFinishPageForUpdate
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_LANGUAGE "SimpChinese"
!insertmacro MUI_LANGUAGE "TradChinese"
LangString reasonixUpdateTitle ${LANG_ENGLISH} "Updating Reasonix"
LangString reasonixUpdateTitle ${LANG_SIMPCHINESE} "正在更新 Reasonix"
LangString reasonixUpdateTitle ${LANG_TRADCHINESE} "正在更新 Reasonix"
LangString reasonixUpdateSubtitle ${LANG_ENGLISH} "Installing the verified update. Reasonix will restart automatically."
LangString reasonixUpdateSubtitle ${LANG_SIMPCHINESE} "正在安装已验证的更新,完成后 Reasonix 将自动重启。"
LangString reasonixUpdateSubtitle ${LANG_TRADCHINESE} "正在安裝已驗證的更新,完成後 Reasonix 將自動重新啟動。"
## Preserve the first-pass generated uninstaller so the release workflow can
## Authenticode-sign it together with the other installed payload files.
## The second pass provides ARG_REASONIX_SIGNED_UNINSTALLER and embeds that
## signed binary instead of generating another unsigned uninstaller.
!ifndef ARG_REASONIX_SIGNED_UNINSTALLER
!uninstfinalize '${REASONIX_UNINST_FINALIZE}'
!endif
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_GUARD "reasonix-guard.exe"
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix-cli.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_LEGACY_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Reasonix"
!define REASONIX_LEGACY_PRODUCT_KEY "Software\reasonix\Reasonix"
!define REASONIX_UNLOCK_RETRIES 60
Var ReasonixUpdateMode
Var ReasonixStageMode
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
####
## Per-user uninstaller registry (HKCU). Replaces wails.writeUninstaller /
## wails.deleteUninstaller, which write HKLM and would fail without admin rights.
####
!macro reasonix.writeUninstaller
!ifdef ARG_REASONIX_SIGNED_UNINSTALLER
File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"
!else
WriteUninstaller "$INSTDIR\uninstall.exe"
!endif
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
!if /FileExists "${REASONIX_LAUNCHER}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${REASONIX_LAUNCHER}"
!else
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!endif
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
# Persist the resolved install path so a subsequent update picks it up
# via InstallDirRegKey above. Without this, every release would force the
# user back to %LOCALAPPDATA%\Programs\Reasonix even if they had moved
# the install to a different drive (e.g. D:\Tools\Reasonix). The auto-
# updater trusts this persisted path, so it has to be present before the
# visible progress-only re-install.
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
; Tauri 0.53 separately persisted $INSTDIR under its manufacturer/product key
; and restores that value before every later install. Clear only a same-root
; value so re-running 0.53 cannot overwrite the current uninstaller; preserve a
; genuinely separate legacy installation. If this cleanup fails, retain the old
; uninstall alias so a later update can retry the migration.
!macro reasonix.deleteLegacyInstallerStateIfOwned
StrCpy $1 "1"
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} $0 == "$INSTDIR"
ClearErrors
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} ${Errors}
StrCpy $1 "0"
${EndIf}
${ElseIf} $0 == "$\"$INSTDIR$\""
ClearErrors
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} ${Errors}
StrCpy $1 "0"
${EndIf}
${EndIf}
${If} $1 == "1"
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
${If} $0 == "$INSTDIR"
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${ElseIf} $0 == "$\"$INSTDIR$\""
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${Else}
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
${If} $0 == "$INSTDIR\uninstall.exe"
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${ElseIf} $0 == "$\"$INSTDIR\uninstall.exe$\""
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${EndIf}
${EndIf}
${EndIf}
!macroend
!macro reasonix.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
DeleteRegKey HKCU "${UNINST_KEY}"
!macroend
Function .onInit
!insertmacro wails.checkArchitecture
; The helper passes /REASONIXUPDATE=1 and a final /D=<current directory>.
; This mode remains visible but skips every page that could change the
; destination, then closes automatically after the file copy so the helper
; can relaunch Reasonix. A normal manual installer keeps the full wizard.
StrCpy $ReasonixUpdateMode "0"
StrCpy $ReasonixStageMode "0"
${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/REASONIXUPDATE=" $R1
IfErrors reasonix_update_mode_done
StrCmp $R1 "1" 0 reasonix_update_mode_done
StrCpy $ReasonixUpdateMode "1"
reasonix_update_mode_done:
ClearErrors
${GetOptions} $R0 "/REASONIXSTAGE=" $R2
IfErrors reasonix_stage_mode_done
StrCmp $R2 "1" 0 reasonix_stage_mode_done
StrCpy $ReasonixStageMode "1"
reasonix_stage_mode_done:
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value is
; missing. Older installers still wrote DisplayIcon, so use its parent folder
; as a compatibility bridge before falling back to the per-user default.
StrCmp $INSTDIR "" 0 done
ClearErrors
ReadRegStr $0 HKCU "${UNINST_KEY}" "DisplayIcon"
IfErrors legacy_location
StrCmp $0 "" legacy_location
${GetParent} "$0" $INSTDIR
StrCmp $INSTDIR "" legacy_location done
legacy_location:
; Tauri 0.53 used a different uninstall key and may have stored the selected
; directory with surrounding quotes (for example "D:\Reasonix"). Reuse it
; only while its uninstaller still exists so a stale registry value cannot
; redirect the repair installer into an unrelated directory.
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
IfErrors legacy_uninstaller
StrCmp $0 "" legacy_uninstaller
StrCpy $1 $0 1
StrCmp $1 "$\"" 0 legacy_location_ready
StrCpy $1 $0 1 -1
StrCmp $1 "$\"" 0 legacy_location_ready
StrCpy $0 $0 -1 1
legacy_location_ready:
IfFileExists "$0\uninstall.exe" 0 legacy_uninstaller
StrCpy $INSTDIR $0
Goto done
legacy_uninstaller:
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
IfErrors fallback
StrCmp $0 "" fallback
StrCpy $1 $0 1
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
StrCpy $1 $0 1 -1
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
StrCpy $0 $0 -1 1
legacy_uninstaller_ready:
IfFileExists "$0" 0 fallback
${GetParent} "$0" $INSTDIR
StrCmp $INSTDIR "" fallback done
fallback:
StrCpy $INSTDIR "${REASONIX_DEFAULT_INSTALLDIR}"
done:
FunctionEnd
Function reasonix.skipSetupPageForUpdate
StrCmp $ReasonixUpdateMode "1" 0 reasonix_show_setup_page
Abort
reasonix_show_setup_page:
FunctionEnd
Function reasonix.showUpdateProgress
StrCmp $ReasonixUpdateMode "1" 0 reasonix_update_progress_done
!insertmacro MUI_HEADER_TEXT "$(reasonixUpdateTitle)" "$(reasonixUpdateSubtitle)"
SetDetailsView hide
SetAutoClose true
BringToFront
reasonix_update_progress_done:
FunctionEnd
Function reasonix.skipFinishPageForUpdate
StrCmp $ReasonixUpdateMode "1" 0 reasonix_show_finish_page
Abort
reasonix_show_finish_page:
FunctionEnd
Function reasonix.waitForExecutableUnlock
StrCpy $0 0
retry:
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_versioned_target:
; A same-version recovery install replaces this directory transactionally.
; Detect the running active binary before asking the Go activator to rename it.
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_guard
ClearErrors
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_guard:
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_GUARD}" a
IfErrors locked
FileClose $1
check_launcher:
IfFileExists "$INSTDIR\${REASONIX_LAUNCHER}" 0 check_cli
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a
IfErrors locked
FileClose $1
check_cli:
IfFileExists "$INSTDIR\${REASONIX_CLI}" 0 check_portable_entry
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a
IfErrors locked
FileClose $1
check_portable_entry:
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 done
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" a
IfErrors locked
FileClose $1
Goto done
locked:
IntOp $0 $0 + 1
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
Sleep 1000
Goto retry
failed:
IfSilent silent interactive
interactive:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
Goto retry
silent:
SetErrorLevel 1618
abort:
Abort "Reasonix is still running. Close Reasonix and run the installer again."
done:
FunctionEnd
Section
!insertmacro wails.setShellContext
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
; the new helper's staging extract). Do not write shortcuts/uninstaller.
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
; with a permanent thin launcher at InstallRoot. Guard is only present in
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
; a normal install.
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
Goto reasonix_normal_install
reasonix_stage_payload:
SetOutPath $INSTDIR
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
!endif
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
!endif
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!endif
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!endif
Goto reasonix_section_done
reasonix_normal_install:
; Extract into an install-local temporary directory, then let the signed Go
; activator validate the complete release unit, transactionally publish the
; version/root entries, and strictly atomically replace current.json last.
; The normal/recovery installer therefore shares the same commit protocol as
; automatic updates instead of writing live files or current.json in place.
System::Call 'kernel32::GetCurrentProcessId() i .R8'
CreateDirectory "$INSTDIR\versions"
StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"
RMDir /r "$R9"
CreateDirectory "$R9"
SetOutPath "$R9"
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!else
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
!endif
SetOutPath "$PLUGINSDIR"
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"
!else
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
!endif
DetailPrint "Reasonix layout activator output:"
nsExec::ExecToLog /OEM '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch'
Pop $0
StrCmp $0 "0" reasonix_layout_activated
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
RMDir /r "$R9"
SetErrorLevel 1
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
reasonix_layout_activated:
RMDir /r "$R9"
SetOutPath "$INSTDIR"
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
!if /FileExists "${REASONIX_LAUNCHER}"
; Keep both target and icon on the stable launcher. Pointing IconLocation at
; versions\vX\reasonix-desktop.exe leaves a blank shortcut as soon as version
; retention removes that directory after a later update.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
!else
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
!endif
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
reasonix_section_done:
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
; Precision uninstall: flat leftovers, thin entry points, and version trees.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
Delete "$INSTDIR\${REASONIX_CLI}"
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
Delete "$INSTDIR\current.json"
RMDir /r "$INSTDIR\versions"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro reasonix.deleteUninstaller
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
; Only remove the installation directory if it is empty to prevent data loss
RMDir $INSTDIR
SectionEnd
-36
View File
@@ -1,36 +0,0 @@
package main
import (
"os"
"strings"
"testing"
)
func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) {
// The remote protocol source-revision ldflag was removed with the Remote
// Workbench protocol stack; the test now asserts the shared product docs
// identity and the packaging-order invariant below.
data, err := os.ReadFile("../scripts/desktop-build.sh")
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`,
`SOURCE_REVISION="$SOURCE_REVISION+dirty"`,
`product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION"`,
`cli_identity_ldflags="-X main.version=$VERSION -X main.gitCommit=$GIT_COMMIT -X main.buildTimeUTC=$BUILD_TIME_UTC $product_docs_ldflags"`,
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $product_docs_ldflags"`,
`cli_identity_ldflags`,
} {
if !strings.Contains(script, want) {
t.Fatalf("desktop-build.sh does not preserve the shared build identity %q", want)
}
}
revisionIndex := strings.Index(script, `SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`)
packagingMutationIndex := strings.Index(script, `node -e 'const fs=require("fs")`)
if revisionIndex < 0 || packagingMutationIndex < 0 || revisionIndex >= packagingMutationIndex {
t.Fatal("desktop-build.sh must capture the source revision before mutating packaging metadata")
}
}
-41
View File
@@ -1,41 +0,0 @@
package main
import (
"reasonix/internal/capdiag"
"reasonix/internal/plugin"
)
// CapabilityDiagnostics returns a read-only capability report for the active
// workspace. When includeSessionRuntime is true, connected/failed/deferred
// status is merged from the active tab Host only — no new MCP processes are
// started, and no controller rebuild or session snapshot is triggered.
func (a *App) CapabilityDiagnostics(includeSessionRuntime bool) capdiag.Report {
root, host := a.activeDiagSnapshot(includeSessionRuntime)
opts := capdiag.Options{Root: root, Live: false}
if !includeSessionRuntime {
return capdiag.Collect(opts)
}
opts.RuntimeHost = host
if host == nil {
return capdiag.CollectWithRuntimeUnavailable(opts)
}
return capdiag.Collect(opts)
}
// activeDiagSnapshot reads workspace root and optional Host under one lock so
// a tab switch cannot pair project A's config with project B's runtime Host.
func (a *App) activeDiagSnapshot(includeHost bool) (root string, host *plugin.Host) {
a.mu.RLock()
defer a.mu.RUnlock()
tab := a.activeTabLocked()
if tab == nil {
return "", nil
}
root = tab.WorkspaceRoot
if includeHost && tab.Ctrl != nil {
host = tab.Ctrl.Host()
}
return root, host
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/control"
"reasonix/internal/plugin"
)
func TestCapabilityDiagnosticsStaticUsesWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# workspace agents\n"), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.mu.Lock()
tab := &WorkspaceTab{
ID: "diag",
Scope: "project",
WorkspaceRoot: root,
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{"diag": tab}
app.tabOrder = []string{"diag"}
app.activeTabID = "diag"
app.mu.Unlock()
report := app.CapabilityDiagnostics(false)
if report.Live {
t.Fatal("static diagnostics must not set live")
}
if report.SchemaVersion != 1 {
t.Fatalf("schema = %d", report.SchemaVersion)
}
}
func TestCapabilityDiagnosticsRuntimeUsesActiveHost(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
host := plugin.NewHost()
ctrl := control.New(control.Options{Host: host})
app.setTestCtrl(ctrl, "test-model")
report := app.CapabilityDiagnostics(true)
if report.SchemaVersion != 1 {
t.Fatalf("schema = %d", report.SchemaVersion)
}
_ = app.CapabilityDiagnostics(true)
if ctrl.Host() != host {
t.Fatal("diagnostics must reuse the same Host pointer")
}
}
func TestCapabilityDiagnosticsAtomicSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
host := plugin.NewHost()
ctrl := control.New(control.Options{Host: host})
root := t.TempDir()
app.mu.Lock()
app.tabs = map[string]*WorkspaceTab{
"t1": {
ID: "t1", Scope: "project", WorkspaceRoot: root, Ready: true,
Ctrl: ctrl, disabledMCP: map[string]ServerView{},
},
}
app.tabOrder = []string{"t1"}
app.activeTabID = "t1"
app.mu.Unlock()
gotRoot, gotHost := app.activeDiagSnapshot(true)
if gotRoot != root {
t.Fatalf("root = %q, want %q", gotRoot, root)
}
if gotHost != host {
t.Fatal("host mismatch from atomic snapshot")
}
}
func TestCapabilityDiagnosticsRuntimeUnavailableWithoutTab(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
// Explicitly empty tabs.
app.mu.Lock()
app.tabs = map[string]*WorkspaceTab{}
app.tabOrder = nil
app.activeTabID = ""
app.mu.Unlock()
report := app.CapabilityDiagnostics(true)
found := false
for _, is := range report.Issues {
if is.Code == "mcp.runtime_unavailable" {
found = true
break
}
}
if !found {
t.Fatalf("expected mcp.runtime_unavailable, issues=%+v", report.Issues)
}
}
-267
View File
@@ -1,267 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/checkpoint"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T) {
dir := t.TempDir()
root := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(root, "a.txt")
if err := os.WriteFile(filePath, []byte("after"), 0o644); err != nil {
t.Fatal(err)
}
fileInfo, err := os.Stat(filePath)
if err != nil {
t.Fatal(err)
}
diskMode := uint32(fileInfo.Mode().Perm())
before := "before"
afterExists := true
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: time.Now(), Prompt: "edit", MsgIndex: 3,
Coverage: checkpoint.CoverageComplete,
Files: []checkpoint.FileSnap{{
Path: "a.txt", Content: &before, SHA256: checkpoint.Digest([]byte(before)), Mode: diskMode,
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("after")), AfterMode: diskMode,
CaptureSource: checkpoint.CaptureBeforeMutation,
}},
})
session := agent.NewSession("")
session.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first"},
{Role: provider.RoleAssistant, Content: "answer"},
{Role: provider.RoleUser, Content: "edit"},
{Role: provider.RoleAssistant, Content: "done"},
})
if err := session.Save(sessionPath); err != nil {
t.Fatal(err)
}
ag := agent.New(nil, nil, session, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, Runner: ag, SessionDir: dir, SessionPath: sessionPath, WorkspaceRoot: root, Label: "test"})
app := &App{}
app.setTestCtrl(ctrl, "test")
plan := app.PreviewRewindForTab("test", 1, "both")
if !plan.OK || !plan.CanFiles || !plan.CanConversation {
t.Fatalf("preview = %+v", plan)
}
result := app.CommitRewindForTab("test", plan.PlanID, 1, "both")
if !result.OK || !result.UndoAvailable || result.TransactionID == "" {
t.Fatalf("commit = %+v", result)
}
if got, err := os.ReadFile(filePath); err != nil || string(got) != before {
t.Fatalf("file after commit = %q err=%v", got, err)
}
if got := ctrl.History(); len(got) != 3 {
t.Fatalf("controller history after commit = %d, want 3", len(got))
}
if got := app.HistoryForTab("test"); len(got) != 3 {
t.Fatalf("desktop history after commit = %d, want 3", len(got))
}
undo := app.UndoRewindForTab("test", result.TransactionID)
if !undo.OK {
t.Fatalf("undo = %+v", undo)
}
if got, err := os.ReadFile(filePath); err != nil || string(got) != "after" {
t.Fatalf("file after undo = %q err=%v", got, err)
}
if got := ctrl.History(); len(got) != 5 {
t.Fatalf("controller history after undo = %d, want 5", len(got))
}
if got := app.HistoryForTab("test"); len(got) != 5 {
t.Fatalf("desktop history after undo = %d, want 5", len(got))
}
}
func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
t.Helper()
b, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ckptDir, "turn-"+strconv.Itoa(c.Turn)+".json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
t.Helper()
raw, err := json.Marshal(metas)
if err != nil {
t.Fatal(err)
}
var payload []struct {
Files json.RawMessage `json:"files"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
for i, item := range payload {
if string(item.Files) == "null" {
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
}
if len(item.Files) == 0 || item.Files[0] != '[' {
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
}
}
}
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
// reverts files touched in that turn or any later one, so a turn with no file
// changes of its own can still rewind code when a later turn changed files. The
// desktop CanCode flag must reflect that suffix capability, not just the turn's
// own paths.
func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
afterExists := true
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
Coverage: checkpoint.CoverageComplete,
Files: []checkpoint.FileSnap{{
Path: "a.txt", Content: &content, SHA256: checkpoint.Digest([]byte(content)),
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("new")), CaptureSource: checkpoint.CaptureBeforeMutation,
}}})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 3 {
t.Fatalf("checkpoints = %d, want 3", len(metas))
}
got := map[int]bool{}
for _, m := range metas {
got[m.Turn] = m.CanCode
}
if !got[0] {
t.Error("turn 0 (no files of its own) should allow code rewind — turn 1 changed files")
}
if !got[1] {
t.Error("turn 1 changed files, should allow code rewind")
}
if got[2] {
t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != 1 {
t.Fatalf("turn 1 file count = %d, want 1 for this turn", metas[1].TurnFileCount)
}
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
}
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
}
if len(metas[2].Files) != 0 {
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsCanCodeDoesNotReenableLegacySuffix(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "before", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "legacy edit", MsgIndex: 2,
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
for _, meta := range metas {
if meta.CanCode {
t.Fatalf("legacy suffix re-enabled code rewind at turn %d: %+v", meta.Turn, meta)
}
}
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
for i := range checkpointFilePreviewLimit + 5 {
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
}
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
}
if len(metas[0].Files) != checkpointFilePreviewLimit {
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
}
if !metas[0].FilesTruncated {
t.Fatal("turn 0 should mark file preview as truncated")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
+1 -1
View File
@@ -37,7 +37,7 @@ func TestParseNumericVersion(t *testing.T) {
}
func TestStampExecutableSupportsWindowsReleaseArchitectures(t *testing.T) {
icon, err := filepath.Abs(filepath.Join("..", "..", "build", "windows", "icon.ico"))
icon, err := filepath.Abs(filepath.Join("..", "..", "next", "build", "windows", "icon.ico"))
if err != nil {
t.Fatal(err)
}
-32
View File
@@ -1,32 +0,0 @@
package main
const configLoadWarningsEvent = "config:load-warnings"
func (a *App) nextConfigLoadWarningsRevision() uint64 {
if a == nil {
return 0
}
return a.runtimeEvents.configWarningsRevision.Add(1)
}
func (a *App) configLoadWarningsHandler() func([]string) bool {
if a == nil {
return nil
}
revision := a.nextConfigLoadWarningsRevision()
return func(warnings []string) bool {
return a.emitConfigLoadWarnings(revision, warnings)
}
}
// emitConfigLoadWarnings transfers ownership of resilient-loader diagnostics
// to the persistent desktop banner. Returning false keeps boot's diagnostic
// notice when no Wails event context is available.
func (a *App) emitConfigLoadWarnings(revision uint64, warnings []string) bool {
if a == nil || a.ctx == nil || len(warnings) == 0 {
return false
}
owned := append([]string(nil), warnings...)
a.runtimeEvents.Emit(a.ctx, configLoadWarningsEvent, owned, revision)
return true
}
-110
View File
@@ -1,110 +0,0 @@
package main
import (
"context"
"testing"
"time"
)
func TestEmitConfigLoadWarningsRequiresContextAndOwnsPayload(t *testing.T) {
if (&App{}).emitConfigLoadWarnings(1, []string{"warning"}) {
t.Fatal("handler accepted warnings without a Wails context")
}
if (&App{ctx: context.Background()}).emitConfigLoadWarnings(1, nil) {
t.Fatal("handler accepted an empty warning list")
}
type emittedEvent struct {
name string
warnings []string
revision uint64
}
started := make(chan struct{})
release := make(chan struct{})
events := make(chan emittedEvent, 1)
app := &App{ctx: context.Background()}
app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
close(started)
<-release
warnings, _ := payload[0].([]string)
revision, _ := payload[1].(uint64)
events <- emittedEvent{name: name, warnings: warnings, revision: revision}
}
warnings := []string{"user config is invalid"}
if !app.emitConfigLoadWarnings(42, warnings) {
t.Fatal("handler rejected warnings with a Wails context")
}
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("runtime event emitter did not start")
}
warnings[0] = "mutated by caller"
close(release)
select {
case got := <-events:
if got.name != configLoadWarningsEvent {
t.Fatalf("event name = %q, want %q", got.name, configLoadWarningsEvent)
}
if got.revision != 42 {
t.Fatalf("event revision = %d, want 42", got.revision)
}
if len(got.warnings) != 1 || got.warnings[0] != "user config is invalid" {
t.Fatalf("event warnings = %v, want owned original payload", got.warnings)
}
case <-time.After(5 * time.Second):
t.Fatal("runtime event was not delivered")
}
}
func TestConfigLoadWarningRevisionsFenceEventsStartedBeforeReload(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
app := &App{ctx: context.Background()}
type warningEvent struct {
warnings []string
revision uint64
}
events := make(chan warningEvent, 2)
app.runtimeEvents.emit = func(_ context.Context, _ string, payload ...any) {
warnings, _ := payload[0].([]string)
revision, _ := payload[1].(uint64)
events <- warningEvent{warnings: warnings, revision: revision}
}
oldBuildHandler := app.configLoadWarningsHandler()
reloaded, err := app.ReloadUserConfig()
if err != nil {
t.Fatalf("ReloadUserConfig: %v", err)
}
if reloaded.ConfigWarningsRevision == 0 {
t.Fatal("reload did not return a config warning revision")
}
if !oldBuildHandler([]string{"stale warning"}) {
t.Fatal("old build handler rejected warning")
}
newBuildHandler := app.configLoadWarningsHandler()
if !newBuildHandler([]string{"new warning"}) {
t.Fatal("new build handler rejected warning")
}
receive := func() warningEvent {
t.Helper()
select {
case payload := <-events:
return payload
case <-time.After(5 * time.Second):
t.Fatal("runtime warning event was not delivered")
return warningEvent{}
}
}
oldPayload := receive()
newPayload := receive()
if len(oldPayload.warnings) != 1 || oldPayload.revision >= reloaded.ConfigWarningsRevision {
t.Fatalf("old build event = %+v, reload barrier = %d", oldPayload, reloaded.ConfigWarningsRevision)
}
if len(newPayload.warnings) != 1 || newPayload.revision <= reloaded.ConfigWarningsRevision {
t.Fatalf("new build event = %+v, reload barrier = %d", newPayload, reloaded.ConfigWarningsRevision)
}
}
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"time"
"reasonix/internal/agent"
"reasonix/internal/billing"
)
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
// and Window both zero means no context-window data yet.
type ContextInfo struct {
Used int `json:"used"`
Window int `json:"window"`
SessionTokens int `json:"sessionTokens"`
CompactRatio float64 `json:"compactRatio,omitempty"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
Estimated bool `json:"estimated,omitempty"`
SessionCostComplete bool `json:"sessionCostComplete,omitempty"`
SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
Maintenance *ContextMaintenanceInfo `json:"maintenance,omitempty"`
}
// ContextMaintenanceInfo is the Wails-safe current-view snapshot. Optional
// fields preserve compatibility with older desktop/front-end combinations.
type ContextMaintenanceInfo struct {
CanonicalTokens int `json:"canonicalTokens,omitempty"`
ProjectedTokens int `json:"projectedTokens,omitempty"`
SummaryTokens int `json:"summaryTokens,omitempty"`
LastSavedTokens int `json:"lastSavedTokens,omitempty"`
SnipTrigger int `json:"snipTrigger,omitempty"` // always 0; legacy compatibility
FoldTrigger int `json:"foldTrigger,omitempty"` // alias of TriggerTokens
ForceTrigger int `json:"forceTrigger,omitempty"` // always 0; legacy compatibility
TriggerTokens int `json:"triggerTokens,omitempty"`
CheckpointState string `json:"checkpointState,omitempty"` // none|restored|applied
HardInputCeiling int `json:"hardInputCeiling,omitempty"`
Headroom int `json:"headroom,omitempty"`
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
Blocked bool `json:"blocked,omitempty"`
LastReceipt *ContextMaintenanceReceiptInfo `json:"lastReceipt,omitempty"`
}
type ContextMaintenanceReceiptInfo struct {
OperationID string `json:"operationId,omitempty"`
Status string `json:"status,omitempty"`
Action string `json:"action,omitempty"`
Trigger string `json:"trigger,omitempty"`
SourceProjection uint64 `json:"sourceProjection,omitempty"`
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
CoveredCount int `json:"coveredCount,omitempty"`
CoveredPrefixHash string `json:"coveredPrefixHash,omitempty"`
InputHash string `json:"inputHash,omitempty"`
OutputHash string `json:"outputHash,omitempty"`
InputTokens int `json:"inputTokens,omitempty"`
ResultTokens int `json:"resultTokens,omitempty"`
SavedTokens int `json:"savedTokens,omitempty"`
AffectedToolResults int `json:"affectedToolResults,omitempty"`
SummaryHash string `json:"summaryHash,omitempty"`
CacheBreak bool `json:"cacheBreak,omitempty"`
Reason string `json:"reason,omitempty"`
BlockedInputHash string `json:"blockedInputHash,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
func contextMaintenanceInfo(snapshot agent.ContextMaintenanceSnapshot) *ContextMaintenanceInfo {
if snapshot.CanonicalTokens == 0 && snapshot.ProjectedTokens == 0 && snapshot.LastReceipt == nil {
return nil
}
info := &ContextMaintenanceInfo{
CanonicalTokens: snapshot.CanonicalTokens, ProjectedTokens: snapshot.ProjectedTokens,
SummaryTokens: snapshot.SummaryTokens, LastSavedTokens: snapshot.LastSavedTokens,
// Snip/Force remain zero for one-version compatibility with older frontends.
FoldTrigger: snapshot.TriggerTokens, TriggerTokens: snapshot.TriggerTokens,
CheckpointState: snapshot.CheckpointState, HardInputCeiling: snapshot.HardInputCeiling,
Headroom: snapshot.Headroom, ProjectionVersion: snapshot.ProjectionVersion,
Blocked: snapshot.Blocked,
}
if source := snapshot.LastReceipt; source != nil {
info.LastReceipt = contextMaintenanceReceiptInfo(source)
}
return info
}
func contextMaintenanceReceiptInfo(source *agent.ContextMaintenanceReceipt) *ContextMaintenanceReceiptInfo {
receipt := &ContextMaintenanceReceiptInfo{
OperationID: source.OperationID, Status: source.Status, Action: source.Action, Trigger: source.Trigger,
SourceProjection: source.SourceProjection, ProjectionVersion: source.ProjectionVersion,
CoveredCount: source.CoveredCount, CoveredPrefixHash: source.CoveredPrefixHash,
InputHash: source.InputHash, OutputHash: source.OutputHash,
InputTokens: source.InputTokens, ResultTokens: source.ResultTokens, SavedTokens: source.SavedTokens,
AffectedToolResults: source.AffectedToolResults, SummaryHash: source.SummaryHash,
CacheBreak: source.CacheBreak, Reason: source.Reason, BlockedInputHash: source.BlockedInputHash,
}
if !source.CreatedAt.IsZero() {
receipt.CreatedAt = source.CreatedAt.Format(time.RFC3339Nano)
}
return receipt
}
@@ -1,33 +0,0 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) {
b, err := json.Marshal(ContextInfo{Maintenance: &ContextMaintenanceInfo{
CanonicalTokens: 800_000, ProjectedTokens: 160_000,
TriggerTokens: 850_000, CheckpointState: "applied",
LastReceipt: &ContextMaintenanceReceiptInfo{
OperationID: "op", Action: "summary", SavedTokens: 4096,
},
}})
if err != nil {
t.Fatal(err)
}
got := string(b)
for _, want := range []string{
`"maintenance"`, `"projectedTokens":160000`, `"canonicalTokens":800000`,
`"triggerTokens":850000`, `"checkpointState":"applied"`,
`"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`, `"action":"summary"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("ContextInfo JSON missing %s: %s", want, got)
}
}
if strings.Contains(got, "operation_id") || strings.Contains(got, "saved_tokens") {
t.Fatalf("ContextInfo leaked sidecar snake_case into Wails JSON: %s", got)
}
}
-293
View File
@@ -1,293 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"runtime"
"strings"
"reasonix/internal/config"
)
// crash_app.go is the crash/feedback/performance reporting surface. Frontend
// reports are sent on an explicit user click. Native fatal/lifecycle reports are
// queued locally and sent on a later launch only when desktop telemetry is on.
var crashEndpoint = "https://crash.reasonix.io/v1/report"
const maxCrashDetailBytes = 16 << 10
const maxCrashStackBytes = 8 << 10
const maxCrashFieldBytes = 4 << 10
const maxCrashBreadcrumbs = 30
var (
userPathSegment = regexp.MustCompile(`(?i)([A-Z]:\\Users\\|/(?:home|Users)/)[^/\\:\s"']+`)
emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`)
secretKeyValuePattern = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?`)
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`)
explicitKeyPattern = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`)
envIdentifierPattern = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`)
jwtPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`)
longBase64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`)
longBase64URLPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`)
)
func scrubUserPaths(s string) string {
return userPathSegment.ReplaceAllString(s, "${1}_")
}
func scrubSensitiveText(s string) string {
s = scrubUserPaths(s)
s = emailPattern.ReplaceAllString(s, "[redacted-email]")
s = bearerTokenPattern.ReplaceAllString(s, "Bearer [redacted]")
s = secretKeyValuePattern.ReplaceAllString(s, "${1}=[redacted]")
s = envIdentifierPattern.ReplaceAllString(s, "[redacted-env]")
s = jwtPattern.ReplaceAllString(s, "[redacted-jwt]")
s = explicitKeyPattern.ReplaceAllString(s, "[redacted-key]")
s = longHexPattern.ReplaceAllString(s, "[redacted-hex]")
s = longBase64Pattern.ReplaceAllString(s, "[redacted-token]")
s = longBase64URLPattern.ReplaceAllString(s, "[redacted-token]")
return s
}
type crashBreadcrumb struct {
T int64 `json:"t,omitempty"`
Cat string `json:"cat,omitempty"`
Msg string `json:"msg,omitempty"`
}
type crashReport struct {
InstallID string `json:"installId,omitempty"`
Kind string `json:"kind"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Message string `json:"message"`
Device deviceInfo `json:"device"`
SchemaVersion int `json:"schemaVersion,omitempty"`
Source string `json:"source,omitempty"`
Label string `json:"label,omitempty"`
ErrorType string `json:"errorType,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Stack string `json:"stack,omitempty"`
ComponentStack string `json:"componentStack,omitempty"`
TopFrame string `json:"topFrame,omitempty"`
FingerprintHint string `json:"fingerprintHint,omitempty"`
BuildCommit string `json:"buildCommit,omitempty"`
Channel string `json:"channel,omitempty"`
Language string `json:"language,omitempty"`
View string `json:"view,omitempty"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs,omitempty"`
OccurredAt string `json:"occurredAt,omitempty"`
WebRuntime *webRuntimeDiagnostic `json:"webRuntime,omitempty"`
// WebView2 is retained only so pending reports written by preview builds can
// still be decoded and forwarded after upgrade. New reports use WebRuntime.
WebView2 *webView2Diagnostic `json:"webview2,omitempty"`
}
type frontendCrashPayload struct {
SchemaVersion int `json:"schemaVersion"`
Kind string `json:"kind"`
Source string `json:"source"`
Label string `json:"label"`
Message string `json:"message"`
ErrorType string `json:"errorType"`
ErrorMessage string `json:"errorMessage"`
Stack string `json:"stack"`
ComponentStack string `json:"componentStack"`
TopFrame string `json:"topFrame"`
FingerprintHint string `json:"fingerprintHint"`
BuildCommit string `json:"buildCommit"`
Channel string `json:"channel"`
Language string `json:"language"`
View string `json:"view"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs"`
OccurredAt string `json:"occurredAt"`
}
func normalizeReportKind(kind string) (string, bool) {
switch strings.TrimSpace(kind) {
case "crash", "exception", "feedback", "performance", "bot":
return strings.TrimSpace(kind), true
default:
return "", false
}
}
func clipCrashField(s string, max int) string {
if len(s) > max {
return s[:max]
}
return s
}
func sanitizeCrashField(s string, max int) string {
return clipCrashField(scrubUserPaths(strings.TrimSpace(s)), max)
}
func sanitizeCrashText(s string, max int) string {
return clipCrashField(strings.TrimSpace(scrubSensitiveText(s)), max)
}
func sanitizeBreadcrumbs(in []crashBreadcrumb) []crashBreadcrumb {
if len(in) > maxCrashBreadcrumbs {
in = in[len(in)-maxCrashBreadcrumbs:]
}
out := make([]crashBreadcrumb, 0, len(in))
for _, b := range in {
cat := sanitizeCrashField(b.Cat, 64)
msg := sanitizeCrashText(b.Msg, 240)
if cat == "" && msg == "" {
continue
}
out = append(out, crashBreadcrumb{T: b.T, Cat: cat, Msg: msg})
}
return out
}
func baseCrashReport(kind string) crashReport {
return crashReport{
Kind: kind,
Version: version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Device: collectDeviceInfo(),
Channel: channel,
}
}
func topFrameFromStack(stack string) string {
for line := range strings.SplitSeq(stack, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.Contains(line, ".go:") || strings.Contains(line, ".ts:") || strings.Contains(line, ".tsx:") || strings.Contains(line, ".js:") || strings.Contains(line, ".jsx:") {
if strings.Contains(line, "/runtime/") || strings.Contains(line, `\runtime\`) || strings.Contains(line, "crash_pending.go") {
continue
}
return sanitizeCrashText(line, 300)
}
}
return ""
}
func nativeResourceContext() string {
var m runtime.MemStats
runtime.ReadMemStats(&m)
mb := func(n uint64) string {
return fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
}
return strings.Join([]string{
"go heap alloc: " + mb(m.Alloc),
"go heap sys: " + mb(m.HeapSys),
"go total sys: " + mb(m.Sys),
fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()),
fmt.Sprintf("gc cycles: %d", m.NumGC),
}, "\n")
}
func appendNativeResourceContext(kind, message string) string {
if kind != "performance" {
return message
}
return sanitizeCrashText(message+"\n\n--- native runtime context ---\n"+nativeResourceContext(), maxCrashDetailBytes)
}
func crashReportFromDetail(kind, detail string) (crashReport, error) {
rawKind := kind
kind, ok := normalizeReportKind(kind)
if !ok {
return crashReport{}, fmt.Errorf("unknown report kind %q", rawKind)
}
if strings.TrimSpace(detail) == "" {
return crashReport{}, fmt.Errorf("empty report")
}
r := baseCrashReport(kind)
var payload frontendCrashPayload
if json.Unmarshal([]byte(detail), &payload) == nil && payload.SchemaVersion == 2 {
if payloadKind, ok := normalizeReportKind(payload.Kind); ok {
r.Kind = payloadKind
}
r.SchemaVersion = payload.SchemaVersion
r.Source = sanitizeCrashField(payload.Source, 32)
r.Label = sanitizeCrashField(payload.Label, 64)
r.ErrorType = sanitizeCrashField(payload.ErrorType, 128)
r.ErrorMessage = sanitizeCrashText(payload.ErrorMessage, maxCrashFieldBytes)
r.Stack = sanitizeCrashText(payload.Stack, maxCrashStackBytes)
r.ComponentStack = sanitizeCrashText(payload.ComponentStack, maxCrashStackBytes)
r.TopFrame = sanitizeCrashText(payload.TopFrame, 300)
r.FingerprintHint = sanitizeCrashText(payload.FingerprintHint, 300)
r.BuildCommit = sanitizeCrashField(payload.BuildCommit, 64)
r.Channel = sanitizeCrashField(payload.Channel, 32)
r.Language = sanitizeCrashField(payload.Language, 64)
r.View = sanitizeCrashText(payload.View, 200)
r.Breadcrumbs = sanitizeBreadcrumbs(payload.Breadcrumbs)
r.OccurredAt = sanitizeCrashField(payload.OccurredAt, 64)
r.Message = sanitizeCrashText(payload.Message, maxCrashDetailBytes)
if r.TopFrame == "" {
r.TopFrame = topFrameFromStack(r.Stack)
}
if r.Message == "" {
r.Message = sanitizeCrashText(fmt.Sprintf("[%s]\n\n%s", r.Label, r.ErrorMessage), maxCrashDetailBytes)
}
if r.Source == "" {
r.Source = "frontend"
}
r.Message = appendNativeResourceContext(r.Kind, r.Message)
return r, nil
}
r.SchemaVersion = 1
r.Source = "legacy"
r.Label = kind
r.Message = sanitizeCrashText(detail, maxCrashDetailBytes)
r.Message = appendNativeResourceContext(r.Kind, r.Message)
return r, nil
}
func (a *App) ReportCrash(kind, detail string) error {
r, err := crashReportFromDetail(kind, detail)
if err != nil {
return err
}
c, err := httpClient()
if err != nil {
return err
}
return postCrashReport(a.reqCtx(), c, crashEndpoint, r)
}
func postCrashReport(ctx context.Context, c *http.Client, endpoint string, r crashReport) error {
// Pending crash files deliberately omit the anonymous installation id. Add
// it only at send time, under the same desktop.telemetry opt-in as pings.
if cfg, err := config.Load(); err == nil && cfg.DesktopTelemetry() {
if id, idErr := installID(); idErr == nil {
r.InstallID = id
}
}
body, err := json.Marshal(r)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("crash endpoint returned %s", resp.Status)
}
return nil
}
-212
View File
@@ -1,212 +0,0 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestScrubUserPaths(t *testing.T) {
cases := map[string]string{
`at C:\Users\yuhua\proj\app.ts:12:3`: `at C:\Users\_\proj\app.ts:12:3`,
`at c:\users\someone\x.go`: `at c:\users\_\x.go`,
`/home/bob/.reasonix/config.toml`: `/home/_/.reasonix/config.toml`,
`/Users/alice/Library/Logs`: `/Users/_/Library/Logs`,
`Error: ENOENT open '/home/bob/secret'`: `Error: ENOENT open '/home/_/secret'`,
`no user path here: /usr/lib/node`: `no user path here: /usr/lib/node`,
"first /home/a/x\nsecond C:\\Users\\b\\y": "first /home/_/x\nsecond C:\\Users\\_\\y",
}
for in, want := range cases {
if got := scrubUserPaths(in); got != want {
t.Errorf("scrubUserPaths(%q) = %q, want %q", in, got, want)
}
}
}
func TestScrubSensitiveText(t *testing.T) {
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
longHex := "0123456789abcdef0123456789abcdef"
jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature"
got := scrubSensitiveText("user [email protected] Authorization: Bearer " + bearer + " api_key=" + apiKey + " jwt " + jwt + " hash " + longHex + " env FEISHU_BOT_APP_SECRET WEIXIN_BOT_TOKEN short abc1234 path /Users/alice/x")
for _, leaked := range []string{"[email protected]", bearer, apiKey, jwt, longHex, "FEISHU_BOT_APP_SECRET", "WEIXIN_BOT_TOKEN", "alice"} {
if strings.Contains(got, leaked) {
t.Fatalf("sensitive text leaked %q in %q", leaked, got)
}
}
for _, want := range []string{"[redacted-email]", "Authorization=[redacted]", "api_key=[redacted]", "[redacted-jwt]", "[redacted-hex]", "[redacted-env]", "short abc1234", "/Users/_/x"} {
if !strings.Contains(got, want) {
t.Fatalf("scrubSensitiveText() = %q, want it to contain %q", got, want)
}
}
}
func TestPostCrashReport(t *testing.T) {
var got crashReport
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("content-type = %q", ct)
}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &got); err != nil {
t.Errorf("body not JSON: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
r := crashReport{Kind: "crash", Version: "v9.9.9", OS: "windows", Arch: "amd64", Message: "[react]\nboom"}
if err := postCrashReport(context.Background(), srv.Client(), srv.URL, r); err != nil {
t.Fatal(err)
}
if !installIDPattern.MatchString(got.InstallID) {
t.Fatalf("send-time install id = %q", got.InstallID)
}
got.InstallID = ""
if !reflect.DeepEqual(got, r) {
t.Errorf("server received %+v, want %+v", got, r)
}
}
func TestPostCrashReportRejectedStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
err := postCrashReport(context.Background(), srv.Client(), srv.URL, crashReport{Kind: "crash"})
if err == nil || !strings.Contains(err.Error(), "429") {
t.Fatalf("want 429 error, got %v", err)
}
}
func TestReportCrashRejectsBadInput(t *testing.T) {
app := NewApp()
if err := app.ReportCrash("telemetry", "x"); err == nil {
t.Error("unknown kind should be rejected")
}
if err := app.ReportCrash("crash", ""); err == nil {
t.Error("empty detail should be rejected")
}
}
func TestCrashReportFromStructuredDetail(t *testing.T) {
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
secretHex := "abcdefabcdefabcdefabcdefabcdef12"
buildCommit := "0123456789abcdef0123456789abcdef01234567"
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "exception",
Source: "frontend",
Label: "unhandledrejection",
Message: "[unhandledrejection]\n\ninvalid argument at C:\\Users\\alice\\app.ts:1 from [email protected]",
ErrorType: "TypeError",
ErrorMessage: "invalid argument at /Users/alice/project/app.ts api_key=" + apiKey,
Stack: "TypeError: invalid argument\n at run (/Users/alice/project/app.ts:12:3)\nsecret=" + secretHex,
TopFrame: "at run (/Users/alice/project/app.ts:12:3)",
FingerprintHint: "build:01234567|view:app://reasonix/|cats:startup>tabs",
BuildCommit: buildCommit,
Channel: "canary",
Language: "zh-CN",
View: "wails://wails.localhost/index.html?token=" + secretHex,
Breadcrumbs: []crashBreadcrumb{{T: 1, Cat: "bridge", Msg: "turn SubmitToTab token=" + apiKey}},
}
detail, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
r, err := crashReportFromDetail("crash", string(detail))
if err != nil {
t.Fatal(err)
}
if r.Kind != "exception" || r.Source != "frontend" || r.Label != "unhandledrejection" {
t.Fatalf("structured fields not preserved: %+v", r)
}
if strings.Contains(r.Message, "alice") || strings.Contains(r.ErrorMessage, "alice") || strings.Contains(r.Stack, "alice") {
t.Fatalf("user path was not scrubbed: %+v", r)
}
if r.TopFrame == "" || r.FingerprintHint != payload.FingerprintHint || r.BuildCommit != buildCommit || r.Channel != "canary" || len(r.Breadcrumbs) != 1 {
t.Fatalf("metadata missing: %+v", r)
}
freeText := strings.Join([]string{
r.Message,
r.ErrorMessage,
r.Stack,
r.ComponentStack,
r.TopFrame,
r.View,
r.Breadcrumbs[0].Msg,
}, "\n")
for _, leaked := range []string{apiKey, secretHex, "[email protected]"} {
if strings.Contains(freeText, leaked) {
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
}
}
}
func TestCrashReportFromPerformanceDetail(t *testing.T) {
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "performance",
Source: "frontend.performance",
Label: "performance.pressure",
Message: "[performance.pressure]\n\n--- performance context ---\nreason: event loop lag 1300ms",
ErrorType: "PerformancePressure",
ErrorMessage: "UI responsiveness degraded because the app observed long tasks, event-loop lag, or high JS heap pressure.",
TopFrame: "frontend.performance",
}
detail, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
r, err := crashReportFromDetail("performance", string(detail))
if err != nil {
t.Fatal(err)
}
if r.Kind != "performance" || r.Source != "frontend.performance" || r.Label != "performance.pressure" {
t.Fatalf("performance fields not preserved: %+v", r)
}
if !strings.Contains(r.Message, "--- native runtime context ---") || !strings.Contains(r.Message, "goroutines:") {
t.Fatalf("native runtime context missing from performance report: %q", r.Message)
}
}
func TestCrashReportFromBotDetail(t *testing.T) {
token := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "bot",
Source: "bot.runtime",
Label: "bot.feishu.lark.send",
Message: "[bot]\n\nfailed at /Users/alice/project with token=" + token,
ErrorType: "BotConnectionDiagnostic",
ErrorMessage: "send failed with Bearer " + token,
TopFrame: "bot.send",
}
detail, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
r, err := crashReportFromDetail("bot", string(detail))
if err != nil {
t.Fatal(err)
}
if r.Kind != "bot" || r.Source != "bot.runtime" || r.Label != "bot.feishu.lark.send" {
t.Fatalf("bot fields not preserved: %+v", r)
}
if strings.Contains(r.Message, "alice") || strings.Contains(r.Message, token) || strings.Contains(r.ErrorMessage, token) {
t.Fatalf("bot report was not scrubbed: %+v", r)
}
if strings.Contains(r.Message, "--- native runtime context ---") {
t.Fatalf("bot report should not include performance runtime context: %q", r.Message)
}
}
-404
View File
@@ -1,404 +0,0 @@
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/repair"
)
func TestParseWebView2ProcessFailure(t *testing.T) {
kind, ok := parseWebView2ProcessFailure("windows | WebVie2wProcess failed with kind 6")
if !ok || kind != 6 {
t.Fatalf("kind=%d ok=%v", kind, ok)
}
if _, ok := parseWebView2ProcessFailure("unrelated failure"); ok {
t.Fatal("unrelated log message matched WebView2 failure")
}
}
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
report := webView2ProcessFailureReportWithContext(2, 3, "132.0.2957.140")
if report.Source != "web.runtime.fallback" || report.Label != "windows.webview2.process_failed" {
t.Fatalf("report = %+v", report)
}
if report.FingerprintHint != "web.runtime.webview2.render_process_unresponsive.unknown.unknown" {
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
}
for _, want := range []string{"runtime version: 132.0.2957.140", "same-process occurrence: 3", "exit code: unavailable"} {
if !strings.Contains(report.Message, want) {
t.Fatalf("report message missing %q: %s", want, report.Message)
}
}
}
func TestWebView2FailureTrackerDeduplicatesSessionBursts(t *testing.T) {
var tracker webView2FailureTracker
base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
if occurrence, report := tracker.observe(2, base); occurrence != 1 || !report {
t.Fatalf("first observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(2, base.Add(time.Minute)); occurrence != 2 || report {
t.Fatalf("burst observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(2, base.Add(webView2FailureReportCooldown)); occurrence != 3 || !report {
t.Fatalf("post-cooldown observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(6, base.Add(time.Minute)); occurrence != 1 || !report {
t.Fatalf("different kind observation = (%d, %v)", occurrence, report)
}
}
func TestWebView2NativeFailureClassification(t *testing.T) {
tests := []struct {
name string
event webView2NativeEvent
kind string
outcome string
recovery string
}{
{name: "browser fatal", event: webView2NativeEvent{Kind: 0, ReasonAvailable: true, Reason: 3}, kind: "crash", outcome: "fatal_app_exit", recovery: webView2RecoveryNotApplicable},
{name: "renderer recovered", event: webView2NativeEvent{Kind: 1, Recovery: webView2RecoverySucceeded}, kind: "performance", outcome: "recovered", recovery: webView2RecoverySucceeded},
{name: "renderer recovery failed", event: webView2NativeEvent{Kind: 2, Recovery: webView2RecoveryFailed}, kind: "exception", outcome: "recovery_failed", recovery: webView2RecoveryFailed},
{name: "gpu degraded", event: webView2NativeEvent{Kind: 6}, kind: "performance", outcome: "degraded", recovery: webView2RecoveryNotApplicable},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
report, outcome := webView2NativeFailureReport(tt.event, "132.0.1", true)
if report.Kind != tt.kind || outcome != tt.outcome || report.WebRuntime == nil || report.WebRuntime.Recovery != tt.recovery {
t.Fatalf("report=%+v diagnostic=%+v outcome=%q", report, report.WebRuntime, outcome)
}
})
}
}
func TestWebView2NativeFailureSanitizesModuleAndFingerprint(t *testing.T) {
report, _ := webView2NativeFailureReport(webView2NativeEvent{
Kind: 2,
Reason: 1,
ReasonAvailable: true,
ExitCode: 259,
ExitCodeAvailable: true,
FailureSourceModule: `C:\Users\alice\Security Suite\inject.dll`,
Recovery: webView2RecoveryFailed,
}, "", false)
if got := report.WebRuntime.FailureSourceModule; got != "inject.dll" {
t.Fatalf("failure source module = %q", got)
}
if strings.Contains(report.Message, `C:\Users`) || strings.Contains(report.FingerprintHint, "259") {
t.Fatalf("report leaked a path or fingerprinted STILL_ACTIVE: %+v", report)
}
if report.FingerprintHint != "web.runtime.webview2.render_process_unresponsive.unresponsive.unknown" {
t.Fatalf("fingerprint = %q", report.FingerprintHint)
}
}
func TestWebKitNativeFailureClassification(t *testing.T) {
context := webRuntimeContext{Engine: "webkitgtk", RuntimeVersion: "2.42.5", GPUMode: "on_demand"}
recovered, outcome, failure := webKitNativeFailureReport(webKitNativeEvent{reason: 0, recovery: webKitRecoverySucceeded, runtimeContext: context})
if recovered.Kind != "performance" || outcome != "recovered" || failure != "webkitgtk.web_process.crashed" {
t.Fatalf("recovered report=%+v outcome=%q failure=%q", recovered, outcome, failure)
}
if recovered.WebRuntime == nil || recovered.WebRuntime.Engine != "webkitgtk" || recovered.WebRuntime.GPUMode != "on_demand" {
t.Fatalf("runtime diagnostic=%+v", recovered.WebRuntime)
}
failed, outcome, _ := webKitNativeFailureReport(webKitNativeEvent{reason: 1, recovery: webKitRecoveryFailed, runtimeContext: context})
if failed.Kind != "exception" || outcome != "recovery_failed" || failed.WebRuntime.Reason != "out_of_memory" {
t.Fatalf("failed report=%+v outcome=%q", failed, outcome)
}
degraded, outcome, _ := webKitNativeFailureReport(webKitNativeEvent{reason: 99, recovery: webKitRecoveryNotApplicable, runtimeContext: context})
if degraded.Kind != "performance" || outcome != "degraded" || degraded.WebRuntime.Reason != "unknown" {
t.Fatalf("degraded report=%+v outcome=%q", degraded, outcome)
}
}
func TestWebKitObserverReloadIsAbnormalPathOnly(t *testing.T) {
source, err := os.ReadFile("webkit_diagnostics_linux.c")
if err != nil {
t.Fatal(err)
}
text := string(source)
if strings.Count(text, "webkit_web_view_reload(") != 1 {
t.Fatalf("observer must have exactly one native reload call site")
}
_, recoveryTail, ok := strings.Cut(text, "static gboolean reasonix_reload_after_termination")
if !ok {
t.Fatal("native reload is not deferred until after termination signal dispatch")
}
reloadHelper, terminationTail, ok := strings.Cut(recoveryTail, "static void reasonix_web_process_terminated")
if !ok || !strings.Contains(reloadHelper, "webkit_web_view_reload(") {
t.Fatal("native reload escaped the deferred recovery helper")
}
terminationBody, _, ok := strings.Cut(terminationTail, "static gboolean reasonix_load_failed")
if !ok || !strings.Contains(terminationBody, "reasonix_reload_after_termination") {
t.Fatal("web-process termination does not schedule the bounded recovery helper")
}
if !strings.Contains(text, "if (!reasonix_recovery_pending) return;") ||
!strings.Contains(text, "if (reasonix_recovery_load_started && event == WEBKIT_LOAD_FINISHED)") {
t.Fatal("ordinary load-finished events are not gated by recovery state")
}
if !strings.Contains(text, "reasonix_recovery_pending && reasonix_recovery_load_started") {
t.Fatal("a stale load failure could be attributed to the recovery navigation")
}
for _, forbidden := range []string{"fopen(", "open(", "curl_", "send(", "recv("} {
if strings.Contains(text, forbidden) {
t.Fatalf("GTK callback source contains blocking I/O primitive %q", forbidden)
}
}
}
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
report := previousRunReport(repair.PreviousRunObservation{
Abnormal: true,
Phase: "healthy",
Version: "v2",
InstallProfile: "installer",
UpdateFrom: "v1",
UpdateTo: "v2",
UptimeBucket: "m_2_10",
})
if report.Source != "native.lifecycle.legacy" || report.Label != "desktop.legacy_abnormal_exit" {
t.Fatalf("report = %+v", report)
}
if !strings.Contains(report.Message, "uptime bucket: m_2_10") {
t.Fatalf("message missing bounded uptime: %q", report.Message)
}
}
func TestDesktopLifecycleReportUsesCurrentLifecycleNamespace(t *testing.T) {
report := desktopLifecycleReport(desktopLifecycleObservation{
Version: "v1.23.0", Channel: "stable", Phase: "healthy",
StartedAt: "2026-08-10T01:00:00Z", UpdatedAt: "2026-08-10T02:00:00Z",
})
if report.Source != "native.lifecycle" || report.Label != "desktop.abnormal_exit.v2" {
t.Fatalf("report = %+v", report)
}
if report.FingerprintHint != "desktop.abnormal_exit.v2."+runtime.GOOS+".healthy" {
t.Fatalf("fingerprint = %q", report.FingerprintHint)
}
}
func TestCapturePreviousFatalCrashQueuesAndRemovesRawDump(t *testing.T) {
resetFatalCrashArtifacts(t)
const crashedPID = 424242
raw := "fatal error: concurrent map writes\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
path := fatalCrashPathForPID(crashedPID)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("raw fatal dump was not removed: %v", err)
}
report, ok := readPending(t)
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
t.Fatalf("queued report = %+v ok=%v", report, ok)
}
if strings.Contains(report.Stack, "/home/alice") {
t.Fatalf("fatal stack leaked home path: %q", report.Stack)
}
}
func TestSanitizeFatalRuntimeDumpRemovesPanicValue(t *testing.T) {
got := sanitizeFatalRuntimeDump("panic: private prompt text\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n")
if strings.Contains(got, "private prompt text") || strings.Contains(got, "/home/alice") {
t.Fatalf("fatal dump leaked user-controlled text: %q", got)
}
if !strings.Contains(got, "panic: [redacted panic value]") || !strings.Contains(got, "main.go:12") {
t.Fatalf("fatal dump lost diagnostic structure: %q", got)
}
}
func TestCapturePreviousFatalCrashSkipsRuntimeDuplicateOfStructuredPanic(t *testing.T) {
resetFatalCrashArtifacts(t)
const crashedPID = 424243
path := fatalCrashPathForPID(crashedPID)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("panic: duplicate\n\ngoroutine 1 [running]:\nmain.run()\n"), 0o600); err != nil {
t.Fatal(err)
}
markFatalCrashCoveredForPID(crashedPID)
capturePreviousFatalCrash()
if _, ok := readPending(t); ok {
t.Fatal("runtime duplicate was queued despite structured panic marker")
}
}
func TestCapturePreviousFatalCrashDoesNotTouchLiveOwner(t *testing.T) {
resetFatalCrashArtifacts(t)
const livePID = 424244
path := fatalCrashPathForPID(livePID)
raw := []byte("fatal error: still being written\n\ngoroutine 1 [running]:\n")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
oldProcessAlive := fatalCrashProcessAlive
fatalCrashProcessAlive = func(pid int) bool { return pid == livePID }
t.Cleanup(func() { fatalCrashProcessAlive = oldProcessAlive })
capturePreviousFatalCrash()
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("live owner's fatal file was removed: %v", err)
}
if string(got) != string(raw) {
t.Fatalf("live owner's fatal file changed: got %q want %q", got, raw)
}
if _, ok := readPending(t); ok {
t.Fatal("live owner's partial fatal output was queued")
}
}
func TestCapturePreviousFatalCrashKeepsEmptyLegacyFile(t *testing.T) {
resetFatalCrashArtifacts(t)
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyFatalCrashPath(), nil, 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(legacyFatalCrashPath()); err != nil {
t.Fatalf("empty legacy fatal file may belong to a live older process: %v", err)
}
}
func TestCapturePreviousFatalCrashMigratesLegacyDump(t *testing.T) {
resetFatalCrashArtifacts(t)
raw := "fatal error: legacy crash\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyFatalCrashPath(), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(legacyFatalCrashPath()); !os.IsNotExist(err) {
t.Fatalf("captured legacy fatal dump was not removed: %v", err)
}
report, ok := readPending(t)
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
t.Fatalf("legacy fatal dump was not migrated: report=%+v ok=%v", report, ok)
}
}
func TestAwaitWindowRestoreRequiresNativeConfirmation(t *testing.T) {
ticks := make(chan time.Time)
deadline := make(chan time.Time, 1)
deadline <- time.Now()
if awaitWindowRestoreConfirmation(func() bool { return false }, ticks, deadline) {
t.Fatal("an enqueued show request without native confirmation was treated as restored")
}
}
func TestAwaitWindowRestoreAcceptsConfirmationAfterTick(t *testing.T) {
ticks := make(chan time.Time, 1)
deadline := make(chan time.Time)
var confirmed atomic.Bool
result := make(chan bool, 1)
go func() {
result <- awaitWindowRestoreConfirmation(confirmed.Load, ticks, deadline)
}()
confirmed.Store(true)
ticks <- time.Now()
if !<-result {
t.Fatal("native window confirmation was not accepted")
}
}
func TestSupersededWindowRestoreDoesNotRemoveLatestJournal(t *testing.T) {
path := windowRestoreStatePath()
_ = os.Remove(path)
t.Cleanup(func() { _ = os.Remove(path) })
oldSequence := windowRestoreSequence.Load()
t.Cleanup(func() { windowRestoreSequence.Store(oldSequence) })
latest := windowRestoreState{
SchemaVersion: windowRestoreStateVersion,
PID: os.Getpid(),
AttemptID: 2,
Source: "tray",
StartedAt: "2026-07-24T08:01:00Z",
}
if !writeWindowRestoreState(latest) {
t.Fatal("write latest window restore state")
}
windowRestoreSequence.Store(latest.AttemptID)
NewApp().completeWindowRestoreAttempt(1, windowRestoreState{AttemptID: 1}, true)
got, err := readWindowRestoreState()
if err != nil {
t.Fatalf("latest journal was removed by superseded attempt: %v", err)
}
if got != latest {
t.Fatalf("latest journal changed: got %+v want %+v", got, latest)
}
}
func resetFatalCrashArtifacts(t *testing.T) {
t.Helper()
oldProcessAlive := fatalCrashProcessAlive
fatalCrashProcessAlive = func(int) bool { return false }
removeAllPendingCrashes()
_ = os.Remove(legacyFatalCrashPath())
_ = os.Remove(legacyFatalCrashCoveredPath())
_ = os.RemoveAll(fatalCrashDir())
t.Cleanup(func() {
removeAllPendingCrashes()
_ = os.Remove(legacyFatalCrashPath())
_ = os.Remove(legacyFatalCrashCoveredPath())
_ = os.RemoveAll(fatalCrashDir())
fatalCrashProcessAlive = oldProcessAlive
})
}
func TestWindowRestoreFailureReportSeparatesTimeoutAndSource(t *testing.T) {
report := windowRestoreFailureReport("timeout", "second_instance", "2026-07-24T08:00:00Z")
if report.Label != "windows.window_restore.timeout" || report.TopFrame != "windows.window_restore.second_instance" {
t.Fatalf("report = %+v", report)
}
}
func TestWriteWindowRestoreStateCreatesReadableJournal(t *testing.T) {
path := windowRestoreStatePath()
_ = os.Remove(path)
t.Cleanup(func() { _ = os.Remove(path) })
want := windowRestoreState{
SchemaVersion: windowRestoreStateVersion,
PID: os.Getpid(),
Source: "tray",
StartedAt: "2026-07-24T08:00:00Z",
}
if !writeWindowRestoreState(want) {
t.Fatal("writeWindowRestoreState returned false")
}
got, err := readWindowRestoreState()
if err != nil {
t.Fatalf("readWindowRestoreState: %v", err)
}
if got != want {
t.Fatalf("journal = %+v, want %+v", got, want)
}
}
-203
View File
@@ -1,203 +0,0 @@
package main
import (
"io"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"time"
"reasonix/internal/config"
)
const (
fatalCrashDirName = "crash-fatal"
fatalCrashLogSuffix = ".log"
fatalCrashCoveredSuffix = ".covered"
legacyFatalCrashFile = "crash-fatal.log"
legacyFatalCrashCoveredFile = "crash-fatal-covered"
)
var fatalCrashProcessAlive = desktopProcessAlive
func fatalCrashDir() string {
return filepath.Join(config.MemoryUserDir(), fatalCrashDirName)
}
func fatalCrashPath() string {
return fatalCrashPathForPID(os.Getpid())
}
func fatalCrashCoveredPath() string {
return fatalCrashCoveredPathForPID(os.Getpid())
}
func fatalCrashPathForPID(pid int) string {
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashLogSuffix)
}
func fatalCrashCoveredPathForPID(pid int) string {
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashCoveredSuffix)
}
func legacyFatalCrashPath() string {
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashFile)
}
func legacyFatalCrashCoveredPath() string {
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashCoveredFile)
}
func markFatalCrashCovered() {
markFatalCrashCoveredForPID(os.Getpid())
}
func markFatalCrashCoveredForPID(pid int) {
path := fatalCrashCoveredPathForPID(pid)
if os.MkdirAll(filepath.Dir(path), 0o700) == nil {
_ = os.WriteFile(path, []byte("structured\n"), 0o600)
}
}
// capturePreviousFatalCrash converts runtime.SetCrashOutput dumps from dead
// processes into the normal scrubbed queue. Per-PID files keep a routine second
// launch from truncating or unlinking the running primary process's dump.
func capturePreviousFatalCrash() {
// Preserve compatibility with the single-file format used by older builds.
// An empty legacy file may still be owned by a running older process, so it
// must be left untouched until it contains a completed crash dump.
captureFatalCrashFile(legacyFatalCrashPath(), legacyFatalCrashCoveredPath(), false)
entries, err := os.ReadDir(fatalCrashDir())
if err != nil {
return
}
for _, entry := range entries {
pid, ok := fatalCrashPID(entry.Name())
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
continue
}
captureFatalCrashFile(
filepath.Join(fatalCrashDir(), entry.Name()),
fatalCrashCoveredPathForPID(pid),
true,
)
}
for _, entry := range entries {
pid, ok := fatalCrashCoveredPID(entry.Name())
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
continue
}
if _, err := os.Stat(fatalCrashPathForPID(pid)); os.IsNotExist(err) {
_ = os.Remove(filepath.Join(fatalCrashDir(), entry.Name()))
}
}
// Best effort: succeeds only when no live/current process artifacts remain.
_ = os.Remove(fatalCrashDir())
}
func fatalCrashPID(name string) (int, bool) {
return fatalCrashPIDWithSuffix(name, fatalCrashLogSuffix)
}
func fatalCrashCoveredPID(name string) (int, bool) {
return fatalCrashPIDWithSuffix(name, fatalCrashCoveredSuffix)
}
func fatalCrashPIDWithSuffix(name, suffix string) (int, bool) {
if !strings.HasSuffix(name, suffix) {
return 0, false
}
pid, err := strconv.Atoi(strings.TrimSuffix(name, suffix))
return pid, err == nil && pid > 0
}
func captureFatalCrashFile(path, coveredPath string, removeEmpty bool) {
f, err := os.Open(path)
if err != nil {
return
}
occurredAt := time.Now().UTC()
if info, statErr := f.Stat(); statErr == nil {
occurredAt = info.ModTime().UTC()
}
raw, readErr := io.ReadAll(io.LimitReader(f, maxCrashStackBytes+1))
_ = f.Close()
if readErr != nil || len(strings.TrimSpace(string(raw))) == 0 {
if removeEmpty {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
}
return
}
if _, err := os.Stat(coveredPath); err == nil {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
return
}
stack := sanitizeFatalRuntimeDump(string(raw))
report := baseCrashReport("crash")
report.SchemaVersion = 2
report.Source = "go.runtime"
report.Label = "go.fatal"
report.ErrorType = "GoRuntimeFatal"
report.ErrorMessage = "Go runtime terminated the desktop process."
report.Stack = stack
report.TopFrame = topFrameFromStack(stack)
report.FingerprintHint = "go.runtime.fatal"
report.OccurredAt = occurredAt.Format(time.RFC3339)
report.Message = sanitizeCrashText("[go.runtime.fatal]\n\n"+stack, maxCrashDetailBytes)
if writePendingReport(report, true) {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
}
}
// sanitizeFatalRuntimeDump removes panic values and preamble text that could
// originate in user-controlled errors, while retaining runtime classification
// and symbolized goroutine stacks for diagnosis.
func sanitizeFatalRuntimeDump(raw string) string {
lines := strings.Split(raw, "\n")
classification := "runtime crash output"
stackStart := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "fatal error:"):
classification = sanitizeCrashText(trimmed, 256)
case strings.HasPrefix(trimmed, "panic:"):
classification = "panic: [redacted panic value]"
}
if strings.HasPrefix(trimmed, "goroutine ") {
stackStart = i
break
}
}
stack := ""
if stackStart >= 0 {
stack = strings.Join(lines[stackStart:], "\n")
}
return sanitizeCrashText(classification+"\n\n"+stack, maxCrashStackBytes)
}
// installFatalCrashOutput asks the Go runtime to mirror unrecovered panics and
// fatal runtime errors to a durable file. The runtime duplicates the descriptor,
// so the file may be closed after SetCrashOutput returns.
func installFatalCrashOutput() {
path := fatalCrashPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return
}
if err := debug.SetCrashOutput(f, debug.CrashOptions{}); err != nil {
_ = f.Close()
_ = os.Remove(path)
return
}
_ = f.Close()
}
-181
View File
@@ -1,181 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"reasonix/internal/config"
)
// crash_pending.go captures Go-side panics to disk and ships them on the next
// launch. Frontend crashes are click-to-send, but an unrecovered Go panic kills the
// process before the user can react, so the whole agent/provider/tool layer would
// otherwise never surface a single report. The resend is gated on the same
// desktop.telemetry opt-out as the launch ping.
const (
pendingCrashFile = "crash-pending.json" // legacy single-report path
pendingCrashQueueDir = "crash-pending"
maxPendingCrashes = 10
)
var (
pendingCrashMu sync.Mutex
pendingCrashSequence atomic.Uint64
)
func pendingCrashPath() string {
return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
}
func pendingCrashDir() string {
return filepath.Join(config.MemoryUserDir(), pendingCrashQueueDir)
}
// recoverToPending records a panicking goroutine to the pending-crash file and
// re-raises, so the process still crashes exactly as before — the stack is now
// shipped next launch instead of lost.
func (a *App) recoverToPending(site string) {
r := recover()
if r == nil {
return
}
writePendingCrash(site, r, debug.Stack())
panic(r)
}
func writePendingCrash(site string, r any, stack []byte) {
stackText := string(stack)
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s\n\n%s", site, stackText), maxCrashDetailBytes)
report := baseCrashReport("crash")
report.SchemaVersion = 2
report.Source = "go"
report.Label = sanitizeCrashField(site, 64)
report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
report.ErrorMessage = sanitizeCrashText("Go panic captured at "+site+".", maxCrashFieldBytes)
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
report.TopFrame = topFrameFromStack(report.Stack)
report.Message = msg
if writePendingReport(report, true) {
markFatalCrashCovered()
}
}
func writePendingReport(report crashReport, overwrite bool) bool {
_ = overwrite // retained for source compatibility; the queue never overwrites.
body, err := json.Marshal(report)
if err != nil {
return false
}
dir := pendingCrashDir()
if os.MkdirAll(dir, 0o700) != nil {
return false
}
pendingCrashMu.Lock()
defer pendingCrashMu.Unlock()
name := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + "-" +
strconv.Itoa(os.Getpid()) + "-" +
strconv.FormatUint(pendingCrashSequence.Add(1), 10) + ".json"
path := filepath.Join(dir, name)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false
}
n, err := f.Write(body)
closeErr := f.Close()
if err != nil || closeErr != nil || n != len(body) {
_ = os.Remove(path)
return false
}
paths := pendingCrashQueuePaths()
for len(paths) > maxPendingCrashes {
_ = os.Remove(paths[0])
paths = paths[1:]
}
return true
}
func pendingCrashQueuePaths() []string {
entries, err := os.ReadDir(pendingCrashDir())
if err != nil {
return nil
}
paths := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
paths = append(paths, filepath.Join(pendingCrashDir(), entry.Name()))
}
}
sort.Strings(paths)
return paths
}
func pendingCrashPaths() []string {
paths := make([]string, 0, maxPendingCrashes+1)
if _, err := os.Stat(pendingCrashPath()); err == nil {
paths = append(paths, pendingCrashPath())
}
return append(paths, pendingCrashQueuePaths()...)
}
func removeAllPendingCrashes() {
_ = os.Remove(pendingCrashPath())
_ = os.RemoveAll(pendingCrashDir())
_ = os.Remove(fatalCrashCoveredPath())
}
func (a *App) goSafe(site string, fn func()) {
go func() {
defer a.recoverToPending(site)
fn()
}()
}
// flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
// clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
// dropping the file unsent.
func (a *App) flushPendingCrash() {
if version == "dev" {
return
}
paths := pendingCrashPaths()
if len(paths) == 0 {
return
}
cfg, err := config.Load()
if err != nil {
return
}
if !cfg.DesktopTelemetry() {
removeAllPendingCrashes()
return
}
c, err := httpClient()
if err != nil {
return
}
for _, path := range paths {
body, readErr := readFileUTF8(path)
if readErr != nil {
continue
}
var r crashReport
if json.Unmarshal(body, &r) != nil {
_ = os.Remove(path)
continue
}
if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
break
}
_ = os.Remove(path)
}
_ = os.Remove(pendingCrashDir())
}
-247
View File
@@ -1,247 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
)
func readPending(t *testing.T) (crashReport, bool) {
t.Helper()
paths := pendingCrashPaths()
if len(paths) == 0 {
return crashReport{}, false
}
body, err := os.ReadFile(paths[0])
if err != nil {
return crashReport{}, false
}
var r crashReport
if err := json.Unmarshal(body, &r); err != nil {
t.Fatalf("pending file not valid JSON: %v", err)
}
return r, true
}
func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
func() {
defer func() {
if recover() == nil {
t.Fatal("recoverToPending must re-raise the panic")
}
}()
app := NewApp()
defer app.recoverToPending("unit")
panic(`boom at C:\Users\alice\proj\x.go`)
}()
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
if r.Kind != "crash" {
t.Errorf("kind = %q, want crash", r.Kind)
}
if !strings.Contains(r.Message, "[go panic] unit") {
t.Errorf("message missing site prefix: %q", r.Message)
}
if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
t.Errorf("structured panic metadata missing: %+v", r)
}
if strings.Contains(r.Message, `Users\alice`) {
t.Errorf("message not scrubbed: %q", r.Message)
}
}
func TestWritePendingCrashCaps(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
if len(r.Message) > maxCrashDetailBytes {
t.Errorf("message len = %d, want <= %d", len(r.Message), maxCrashDetailBytes)
}
}
func TestWritePendingReportQueuesWithoutOverwritingExistingCrash(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
writePendingCrash("panic", "boom", []byte("stack"))
before, ok := readPending(t)
if !ok {
t.Fatal("expected initial pending crash")
}
hang := baseCrashReport("performance")
hang.Source = "native.watchdog"
hang.Label = "mac.main_thread.hang"
hang.Message = "hang"
if !writePendingReport(hang, false) {
t.Fatal("writePendingReport should enqueue the second report")
}
after, ok := readPending(t)
if !ok {
t.Fatal("expected pending crash after skipped write")
}
if after.Label != before.Label || after.Message != before.Message {
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
}
if got := len(pendingCrashPaths()); got != 2 {
t.Fatalf("pending reports = %d, want 2", got)
}
}
func TestWritePendingReportQueueIsBoundedUnderConcurrentWriters(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
const writers = 32
start := make(chan struct{})
var ready sync.WaitGroup
var done sync.WaitGroup
var successes atomic.Int32
for range writers {
ready.Add(1)
done.Go(func() {
report := baseCrashReport("performance")
report.Source = "native.watchdog"
report.Label = "mac.main_thread.hang"
report.Message = strings.Repeat("hang", 1024)
ready.Done()
<-start
if writePendingReport(report, false) {
successes.Add(1)
}
})
}
ready.Wait()
close(start)
done.Wait()
if got := successes.Load(); got != writers {
t.Fatalf("successful queued writers = %d, want %d", got, writers)
}
if got := len(pendingCrashPaths()); got != maxPendingCrashes {
t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
}
}
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
longHex := "0123456789abcdef0123456789abcdef"
privatePanicValue := "private user prompt contents"
writePendingCrash("unit", privatePanicValue+" api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
for _, leaked := range []string{privatePanicValue, apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
if strings.Contains(freeText, leaked) {
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
}
}
}
func TestFlushPendingCrashSendsAndClears(t *testing.T) {
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
removeAllPendingCrashes()
})
version = "v9.9.9"
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
crashEndpoint = srv.URL
writePendingCrash("flush", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if hits.Load() != 1 {
t.Errorf("server hits = %d, want 1", hits.Load())
}
if _, ok := readPending(t); ok {
t.Error("pending file should be cleared after a successful send")
}
}
func TestFlushPendingCrashDevGuard(t *testing.T) {
oldVersion := version
t.Cleanup(func() {
version = oldVersion
removeAllPendingCrashes()
})
version = "dev"
writePendingCrash("dev", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if _, ok := readPending(t); !ok {
t.Error("dev build must leave the pending file untouched")
}
}
func TestFlushPendingCrashIgnoresSafeModeEnv(t *testing.T) {
// v1.20+: REASONIX_SAFE_MODE no longer blocks crash flush.
t.Setenv("REASONIX_SAFE_MODE", "1")
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version = oldVersion
crashEndpoint = oldEndpoint
removeAllPendingCrashes()
})
version = "v9.9.9"
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
crashEndpoint = srv.URL
writePendingCrash("safe", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if hits.Load() != 1 {
t.Fatalf("server hits = %d, want 1", hits.Load())
}
if _, ok := readPending(t); ok {
t.Fatal("safe-mode compatibility left a sent report pending")
}
}
func TestPendingCrashDoesNotPersistInstallID(t *testing.T) {
removeAllPendingCrashes()
t.Cleanup(removeAllPendingCrashes)
report := baseCrashReport("crash")
report.Message = "pending"
if !writePendingReport(report, true) {
t.Fatal("writePendingReport failed")
}
paths := pendingCrashQueuePaths()
if len(paths) != 1 {
t.Fatalf("pending paths = %v", paths)
}
body, err := os.ReadFile(paths[0])
if err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte("installId")) || bytes.Contains(body, []byte("install-id")) {
t.Fatalf("pending report persisted an installation identity: %s", body)
}
}
-473
View File
@@ -1,473 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"strings"
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/secrets"
)
// deferredRebuildRetryInterval is how often the retry loop probes a held
// session lease. Package-level so tests can shorten it.
var deferredRebuildRetryInterval = 2 * time.Second
const deferredStartupBuildLabel = "__startup__"
// deferredRuntimeReloadLabel marks a queued ReloadRuntime in the pending map.
// Like the startup label it is not a user setting name; the retry loop routes
// it to the boot.Rebuild reload path instead of a settings rebuild.
const deferredRuntimeReloadLabel = "__reload__"
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
// runtime could not refresh, plus tabs whose initial startup failed, because
// the session lease was held by another Reasonix process. A single background
// loop probes the lease and replays the rebuild once the other side releases
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
// hook); tests that never call it get the pending bookkeeping without a
// background goroutine.
type deferredRebuildState struct {
mu sync.Mutex
pending map[string]string // tab ID -> setting label for notices
enabled bool
running bool
stopped bool
stop chan struct{}
}
// enableDeferredRebuildRetry arms the retry loop; called from startup.
func (a *App) enableDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
d.enabled = true
a.startDeferredRebuildLoopLocked()
}
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
// has work. Callers must hold d.mu.
func (a *App) startDeferredRebuildLoopLocked() {
d := &a.deferredRebuild
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
return
}
d.running = true
if d.stop == nil {
d.stop = make(chan struct{})
}
go a.deferredRebuildLoop(d.stop)
}
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
// setting and starts the retry loop if it is not running yet. Repeated calls
// for the same tab collapse into one retry carrying the latest label.
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
tabID = strings.TrimSpace(tabID)
if tabID == "" {
return
}
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
if d.pending == nil {
d.pending = map[string]string{}
}
d.pending[tabID] = setting
a.startDeferredRebuildLoopLocked()
}
func (a *App) scheduleDeferredStartupBuild(tabID string) {
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
}
func isDeferredStartupBuild(setting string) bool {
return setting == deferredStartupBuildLabel
}
func isDeferredRuntimeReload(setting string) bool {
return setting == deferredRuntimeReloadLabel
}
func (a *App) clearDeferredRebuild(tabID string) {
d := &a.deferredRebuild
d.mu.Lock()
delete(d.pending, tabID)
d.mu.Unlock()
}
func (a *App) deferredRebuildPending(tabID string) bool {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
_, ok := d.pending[tabID]
return ok
}
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
// and by tests.
func (a *App) stopDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
if d.stop != nil {
close(d.stop)
}
}
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
ticker := time.NewTicker(deferredRebuildRetryInterval)
defer ticker.Stop()
for {
select {
case <-stop:
d := &a.deferredRebuild
d.mu.Lock()
d.running = false
d.mu.Unlock()
return
case <-ticker.C:
}
if a.deferredRebuildTickDone() {
return
}
}
}
// deferredRebuildTickDone runs one retry pass and reports true when the loop
// should exit because nothing is pending anymore.
func (a *App) deferredRebuildTickDone() bool {
return a.deferredRebuildTick(true)
}
func (a *App) deferredRebuildTick(markIdle bool) bool {
d := &a.deferredRebuild
d.mu.Lock()
if d.stopped || len(d.pending) == 0 {
if markIdle {
d.running = false
}
d.mu.Unlock()
return true
}
pending := make(map[string]string, len(d.pending))
maps.Copy(pending, d.pending)
d.mu.Unlock()
for tabID, setting := range pending {
a.retryDeferredRebuild(tabID, setting)
}
return false
}
func (a *App) kickDeferredRebuildRetry() {
if a.ctx == nil {
return
}
a.goSafe("deferredRebuildKick", func() {
_ = a.deferredRebuildTick(false)
})
}
func (a *App) retryDeferredRebuild(tabID, setting string) {
if a.ctx == nil {
return
}
tab := a.tabByID(tabID)
if tab == nil || tab.ID != tabID {
// The tab is gone; nothing left to refresh.
a.clearDeferredRebuild(tabID)
return
}
if isDeferredStartupBuild(setting) {
a.retryDeferredStartupBuild(tabID, tab)
return
}
if isDeferredRuntimeReload(setting) {
a.retryDeferredRuntimeReload(tabID, tab)
return
}
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
// the session lease, and a concurrent manual rebuild's ensureSessionLease
// would see that probe as "held by another runtime" and spuriously defer.
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
// rebuildSettingLocked refreshes the active tab only. Wait until the user
// is back on this tab so we refresh the runtime the pending setting was
// meant for, not whichever tab happens to be focused.
if a.activeTab() != tab {
return
}
ctrl := a.controllerForTab(tab)
if ctrl == nil {
// Mid-(re)build on another path (provider retarget, workspace repair);
// racing a second build+swap against it is what this loop must avoid.
return
}
if controllerHasActiveRuntimeWork(ctrl) {
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildSettingLocked(setting)
if err == nil {
// rebuildSettingLocked already cleared the pending entry for the tab it
// refreshed; just announce it.
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return // grabbed back before we could rebuild; keep waiting
}
var busy *rebuildBusyError
if errors.As(err, &busy) {
return // a turn started meanwhile; retry once it finishes
}
// Anything else will not resolve by waiting; give up loudly instead of
// retrying forever.
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
}
// retryDeferredRuntimeReload drives one queued ReloadRuntime pass. The
// probing contract mirrors retryDeferredRebuild: wait for the tab to be
// active and idle and for its lease to look free, then run the boot.Rebuild
// reload; busy/lease answers keep waiting, anything else gives up loudly.
func (a *App) retryDeferredRuntimeReload(tabID string, tab *WorkspaceTab) {
// Hold the rebuild mutex across probe + reload: the probe briefly
// acquires the session lease, and a concurrent rebuild's ensure lease
// would read that probe as "held by another runtime" and spuriously
// defer (same contract as retryDeferredRebuild).
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
// The reload shares rebuildSettingTurnLocked, whose bookkeeping is written
// for the active tab; wait until the user is back on this tab rather than
// refreshing whichever tab happens to be focused.
if a.activeTab() != tab {
return
}
ctrl := a.controllerForTab(tab)
if ctrl == nil {
// Mid-(re)build on another path; racing a second build+swap against it
// is what this loop must avoid.
return
}
if controllerHasActiveRuntimeWork(ctrl) {
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.reloadRuntimeTurnLocked(tab)
if err == nil {
// rebuildSettingTurnLocked already cleared the pending entry for the
// tab it refreshed; just announce it.
a.noticeForTab(tabID, "runtime reloaded after the session went idle")
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return // grabbed back before we could reload; keep waiting
}
var busy *rebuildBusyError
if errors.As(err, &busy) {
return // a turn started meanwhile; retry once it finishes
}
// Anything else will not resolve by waiting; give up loudly instead of
// retrying forever. The error may come from provider/config plumbing and
// carry credential-shaped values (passwords, resolved API keys) — the
// tested helper redacts before the text reaches logs or the frontend.
a.clearDeferredRebuild(tabID)
failure := deferredReloadFailedText(err)
slog.Warn("desktop: "+failure, "tab", tabID)
a.warnForTab(tabID, failure)
}
// deferredReloadFailedText is the failure line for an unrecoverable deferred
// reload — the single formatter both the log and the tab warning use, so a
// credential-shaped error can never reach either sink unredacted.
func deferredReloadFailedText(err error) string {
return "runtime reload failed: " + secrets.RedactCredentials(err.Error())
}
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
a.clearDeferredRebuild(tabID)
return
}
a.mu.RLock()
path := strings.TrimSpace(tab.SessionPath)
a.mu.RUnlock()
if path != "" && a.attachExistingSessionRuntime(tab, path, a.ctx) {
a.clearDeferredRebuild(tabID)
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tabID)
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return
}
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
}
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
}
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
buildCtx, cancel := context.WithCancel(a.bootContext())
a.mu.Lock()
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
a.mu.Unlock()
cancel()
return nil
}
if tab.Ctrl != nil {
a.mu.Unlock()
cancel()
return nil
}
if !tab.StartupErrLeaseHeld {
a.mu.Unlock()
cancel()
return nil
}
tab.buildGeneration++
generation := tab.buildGeneration
if tab.buildCancel != nil {
tab.buildCancel()
}
tab.buildCancel = cancel
tab.Ready = false
clearTabStartupError(tab)
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
tab.ActivityStatus = ""
if tab.sink == nil {
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
}
a.saveTabsLocked()
a.mu.Unlock()
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
a.mu.RLock()
stillCurrent := false
var ctrl control.SessionAPI
startupErr := ""
leaseHeld := false
if tab != nil {
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
ctrl = tab.Ctrl
startupErr = tab.StartupErr
leaseHeld = tab.StartupErrLeaseHeld
}
a.mu.RUnlock()
if !stillCurrent || ctrl != nil {
return nil
}
if leaseHeld {
return agent.ErrSessionLeaseHeld
}
if strings.TrimSpace(startupErr) != "" {
return fmt.Errorf("session startup: %s", startupErr)
}
return fmt.Errorf("session startup: controller was not built")
}
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
return false
}
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
return a.controllerForTab(tab) != nil
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return false
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tab.ID)
return a.controllerForTab(tab) != nil
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
a.scheduleDeferredStartupBuild(tab.ID)
} else {
a.clearDeferredRebuild(tab.ID)
}
return false
}
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
// could be acquired right now, without touching tab.sessionLease (only the
// serialized rebuild paths may mutate that). The probe path can lag the
// reconciled path the rebuild will use; a stale answer either re-defers on the
// next tick or lets the rebuild fail back into the pending set, so a mismatch
// only delays the retry.
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
a.mu.RLock()
ctrl := tab.Ctrl
path := strings.TrimSpace(tab.SessionPath)
a.mu.RUnlock()
if ctrl != nil {
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
path = p
}
}
if path == "" {
return true // nothing to probe; let the rebuild decide
}
key := sessionRuntimeKey(path)
a.mu.RLock()
rt := a.runtimeBySessionKey[key]
ownedByTab := rt != nil && rt.Owner == tab
ownedByOther := rt != nil && rt.Owner != nil && rt.Owner != tab && a.runtimeOwnerLiveLocked(rt)
a.mu.RUnlock()
if ownedByOther {
return false
}
if ownedByTab && tab.sessionLeaseRuntimeKey() == key {
return true
}
lease, err := agent.TryAcquireSessionLease(key)
if err != nil {
if sameCurrentProcessLease(err) {
// The registry ruled out a live sibling owner above, so this is an
// orphaned current-process lease. Let the rebuild helper reclaim it
// under runtimeRebuildMu instead of looping against our own marker.
return true
}
return !errors.Is(err, agent.ErrSessionLeaseHeld)
}
lease.Release()
return true
}
-33
View File
@@ -1,33 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
)
// TestDeferredReloadFailedTextRedactsCredentials is the regression for the
// CodeQL credential-disclosure finding: a deferred-reload failure may carry
// provider error text containing passwords or resolved API keys, and the
// user-visible notice must never repeat them.
func TestDeferredReloadFailedTextRedactsCredentials(t *testing.T) {
for _, tc := range []struct {
name string
err error
secret string
}{
{"password field", errors.New(`provider auth failed: password=hunter2hunter2`), "hunter2hunter2"},
{"api key assignment", errors.New(`401 unauthorized: api_key=sk-abcdef1234567890SECRETKEY`), "sk-abcdef1234567890SECRETKEY"},
{"bearer token", errors.New(`upstream rejected: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U`), "eyJhbGciOiJIUzI1NiJ9"},
} {
t.Run(tc.name, func(t *testing.T) {
text := deferredReloadFailedText(tc.err)
if strings.Contains(text, tc.secret) {
t.Fatalf("notice leaks the credential: %q", text)
}
if !strings.HasPrefix(text, "runtime reload failed: ") {
t.Fatalf("notice lost its context prefix: %q", text)
}
})
}
}
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"fmt"
"strings"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/worktree"
)
var (
inspectDeliveryWorktree = worktree.Inspect
createDeliveryWorktree = worktree.Create
)
// DeliveryWorktreeOpenResult is returned after an isolated Git workspace has
// been created and opened as a normal Reasonix project.
type DeliveryWorktreeOpenResult struct {
WorkspaceRoot string `json:"workspaceRoot"`
WorktreeRoot string `json:"worktreeRoot"`
SourceRoot string `json:"sourceRoot"`
Branch string `json:"branch"`
SourceDirty bool `json:"sourceDirty"`
Tab TabMeta `json:"tab"`
}
// DeliveryWorktreeAvailability reports whether workspaceRoot can use the
// optional Git isolation path. A false result never disables Delivery itself;
// the cross-platform workspace writer lease remains the no-Git fallback.
func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
}
// CreateDeliveryWorktree creates a durable branch-backed worktree and opens it
// as a project. It never switches or modifies the source checkout, and it does
// not delete the new worktree if later UI registration fails.
func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
workspaceRoot = strings.TrimSpace(workspaceRoot)
created, err := createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
if err != nil {
return DeliveryWorktreeOpenResult{}, err
}
var tab TabMeta
if a.singleSurfaceLayoutEnabled() {
tab, err = a.ensureBlankSurface("project", created.WorkspaceRoot, boot.TokenModeDelivery)
} else {
tab, err = a.ensureBlankTab("project", created.WorkspaceRoot, boot.TokenModeDelivery)
}
if err != nil {
return DeliveryWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
}
return DeliveryWorktreeOpenResult{
WorkspaceRoot: created.WorkspaceRoot,
WorktreeRoot: created.WorktreeRoot,
SourceRoot: created.SourceRoot,
Branch: created.Branch,
SourceDirty: created.SourceDirty,
Tab: tab,
}, nil
}
-66
View File
@@ -1,66 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
"reasonix/internal/worktree"
)
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
original := inspectDeliveryWorktree
t.Cleanup(func() { inspectDeliveryWorktree = original })
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
}
got := NewApp().DeliveryWorktreeAvailability("project")
if got.Available || got.Reason != "Git is not installed" || got.RepoRoot != "project" {
t.Fatalf("availability = %+v", got)
}
}
func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", home)
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
managed := config.DeliveryWorktreeDir()
isolatedRoot := filepath.Join(managed, "repo", "id", "project")
if err := os.MkdirAll(isolatedRoot, 0o755); err != nil {
t.Fatal(err)
}
original := createDeliveryWorktree
t.Cleanup(func() { createDeliveryWorktree = original })
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
if source != "source-project" {
t.Fatalf("source = %q", source)
}
if gotManaged != managed {
t.Fatalf("managed root = %q, want %q", gotManaged, managed)
}
return worktree.Result{
WorkspaceRoot: isolatedRoot,
WorktreeRoot: filepath.Dir(isolatedRoot),
SourceRoot: "source-project",
Branch: "reasonix/delivery-test",
SourceDirty: true,
}, nil
}
app := NewApp()
t.Cleanup(func() { app.shutdown(context.Background()) })
result, err := app.CreateDeliveryWorktree("source-project")
if err != nil {
t.Fatal(err)
}
if result.WorkspaceRoot != isolatedRoot || result.Branch != "reasonix/delivery-test" || !result.SourceDirty {
t.Fatalf("result = %+v", result)
}
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active || result.Tab.TokenMode != "delivery" {
t.Fatalf("opened tab = %+v", result.Tab)
}
}
-14
View File
@@ -1,14 +0,0 @@
package main
func initialDesktopWindowSize() (int, int) {
width, height := 1240, 720
if saved, ok := loadWindowState(); ok {
if saved.Width > 0 {
width = saved.Width
}
if saved.Height > 0 {
height = saved.Height
}
}
return width, height
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"runtime"
"strconv"
"strings"
)
// devinfo.go collects coarse machine facts attached to crash reports: OS version,
// CPU model, core count, RAM. Nothing here identifies a user or machine.
type deviceInfo struct {
OSVersion string `json:"osVersion,omitempty"`
OSBuild int `json:"osBuild,omitempty"`
OSRevision int `json:"osRevision,omitempty"`
DistroID string `json:"distroId,omitempty"`
DistroVersion string `json:"distroVersion,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"`
SessionType string `json:"sessionType,omitempty"`
CPU string `json:"cpu,omitempty"`
Cores int `json:"cores"`
RAMGB int `json:"ramGb,omitempty"`
}
type platformEnvironment struct {
DistroID string
DistroVersion string
KernelVersion string
SessionType string
}
const gib = 1 << 30
func collectDeviceInfo() deviceInfo {
osBuild, osRevision := platformOSBuild()
environment := platformEnvironmentInfo()
return deviceInfo{
OSVersion: boundedPlatformField(platformOSVersion(), 128),
OSBuild: osBuild,
OSRevision: osRevision,
DistroID: environment.DistroID,
DistroVersion: environment.DistroVersion,
KernelVersion: environment.KernelVersion,
SessionType: environment.SessionType,
CPU: boundedPlatformField(platformCPU(), 128),
Cores: runtime.NumCPU(),
RAMGB: int((platformRAMBytes() + gib/2) / gib),
}
}
func boundedPlatformField(value string, limit int) string {
value = strings.TrimSpace(value)
value = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, value)
if len(value) > limit {
value = value[:limit]
}
return value
}
func parseOSRelease(osRelease string) map[string]string {
result := make(map[string]string)
for line := range strings.SplitSeq(osRelease, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
value = value[1 : len(value)-1]
value = strings.ReplaceAll(value, `\"`, `"`)
value = strings.ReplaceAll(value, `\\`, `\`)
}
result[key] = value
}
return result
}
func parseCPUModel(cpuinfo string) string {
for line := range strings.SplitSeq(cpuinfo, "\n") {
if name, ok := strings.CutPrefix(line, "model name"); ok {
if _, v, ok := strings.Cut(name, ":"); ok {
return strings.TrimSpace(v)
}
}
}
return ""
}
func parseMemTotalBytes(meminfo string) uint64 {
for line := range strings.SplitSeq(meminfo, "\n") {
if rest, ok := strings.CutPrefix(line, "MemTotal:"); ok {
kb, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimSpace(rest), " kB"), 10, 64)
if err != nil {
return 0
}
return kb * 1024
}
}
return 0
}
func parseOSReleasePrettyName(osRelease string) string {
return parseOSRelease(osRelease)["PRETTY_NAME"]
}
func linuxSessionType(getenv func(string) string) string {
if getenv("SSH_CONNECTION") != "" || getenv("XRDP_SESSION") != "" {
return "remote"
}
switch strings.ToLower(strings.TrimSpace(getenv("XDG_SESSION_TYPE"))) {
case "wayland":
return "wayland"
case "x11":
return "x11"
}
if getenv("WAYLAND_DISPLAY") != "" {
return "wayland"
}
if getenv("DISPLAY") != "" {
return "x11"
}
return "unknown"
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"os/exec"
"strconv"
"strings"
)
func sysctlString(name string) string {
out, err := exec.Command("sysctl", "-n", name).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func platformOSVersion() string {
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return "macOS"
}
return "macOS " + strings.TrimSpace(string(out))
}
func platformOSBuild() (int, int) { return 0, 0 }
func platformEnvironmentInfo() platformEnvironment { return platformEnvironment{} }
func platformCPU() string {
return sysctlString("machdep.cpu.brand_string")
}
func platformRAMBytes() uint64 {
n, err := strconv.ParseUint(sysctlString("hw.memsize"), 10, 64)
if err != nil {
return 0
}
return n
}
-45
View File
@@ -1,45 +0,0 @@
package main
import (
"os"
"strings"
)
func readOr(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(b)
}
func platformOSVersion() string {
if name := parseOSReleasePrettyName(readOr("/etc/os-release")); name != "" {
return name
}
return "Linux"
}
func platformOSBuild() (int, int) { return 0, 0 }
func platformEnvironmentInfo() platformEnvironment {
release := parseOSRelease(readOr("/etc/os-release"))
distroID := boundedPlatformField(strings.ToLower(release["ID"]), 64)
if distroID == "" {
distroID = "unknown"
}
return platformEnvironment{
DistroID: distroID,
DistroVersion: boundedPlatformField(release["VERSION_ID"], 64),
KernelVersion: boundedPlatformField(readOr("/proc/sys/kernel/osrelease"), 128),
SessionType: linuxSessionType(os.Getenv),
}
}
func platformCPU() string {
return parseCPUModel(readOr("/proc/cpuinfo"))
}
func platformRAMBytes() uint64 {
return parseMemTotalBytes(readOr("/proc/meminfo"))
}
-88
View File
@@ -1,88 +0,0 @@
package main
import "testing"
func TestParseCPUModel(t *testing.T) {
cpuinfo := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i7-12700K\nflags\t: fpu vme\n"
if got := parseCPUModel(cpuinfo); got != "Intel(R) Core(TM) i7-12700K" {
t.Errorf("parseCPUModel = %q", got)
}
if got := parseCPUModel("no such field"); got != "" {
t.Errorf("parseCPUModel on garbage = %q, want empty", got)
}
}
func TestParseMemTotalBytes(t *testing.T) {
meminfo := "MemTotal: 32652284 kB\nMemFree: 1234 kB\n"
if got := parseMemTotalBytes(meminfo); got != 32652284*1024 {
t.Errorf("parseMemTotalBytes = %d", got)
}
if got := parseMemTotalBytes("MemFree: 1 kB"); got != 0 {
t.Errorf("parseMemTotalBytes without MemTotal = %d, want 0", got)
}
}
func TestParseOSReleasePrettyName(t *testing.T) {
osRelease := "NAME=\"Ubuntu\"\nPRETTY_NAME=\"Ubuntu 24.04.1 LTS\"\nID=ubuntu\n"
if got := parseOSReleasePrettyName(osRelease); got != "Ubuntu 24.04.1 LTS" {
t.Errorf("parseOSReleasePrettyName = %q", got)
}
}
func TestParseOSReleaseTechnicalFields(t *testing.T) {
fields := parseOSRelease("# comment\nID=fedora\nVERSION_ID=\"40\"\nPRETTY_NAME='Fedora Linux 40'\nBROKEN\n")
if fields["ID"] != "fedora" || fields["VERSION_ID"] != "40" || fields["PRETTY_NAME"] != "Fedora Linux 40" {
t.Fatalf("os-release fields = %#v", fields)
}
}
func TestParseOSReleaseDistributionMatrix(t *testing.T) {
tests := []struct {
name, body, id, version string
}{
{name: "ubuntu", body: "ID=ubuntu\nVERSION_ID=22.04\n", id: "ubuntu", version: "22.04"},
{name: "debian", body: "ID=debian\nVERSION_ID=12\n", id: "debian", version: "12"},
{name: "fedora", body: "ID=fedora\nVERSION_ID=40\n", id: "fedora", version: "40"},
{name: "arch", body: "ID=arch\n", id: "arch"},
{name: "unknown", body: "NAME=Custom Linux\n", id: ""},
{name: "malformed", body: "BROKEN\n=bad\n", id: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fields := parseOSRelease(test.body)
if fields["ID"] != test.id || fields["VERSION_ID"] != test.version {
t.Fatalf("fields = %#v", fields)
}
})
}
}
func TestLinuxSessionTypeUsesBoundedBuckets(t *testing.T) {
tests := []struct {
name string
env map[string]string
want string
}{
{name: "wayland", env: map[string]string{"XDG_SESSION_TYPE": "Wayland"}, want: "wayland"},
{name: "x11 fallback", env: map[string]string{"DISPLAY": ":0"}, want: "x11"},
{name: "remote wins", env: map[string]string{"SSH_CONNECTION": "present", "XDG_SESSION_TYPE": "wayland"}, want: "remote"},
{name: "unknown", env: map[string]string{"XDG_SESSION_TYPE": "tty"}, want: "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := linuxSessionType(func(key string) string { return tt.env[key] }); got != tt.want {
t.Fatalf("linuxSessionType() = %q, want %q", got, tt.want)
}
})
}
}
func TestCollectDeviceInfoSane(t *testing.T) {
d := collectDeviceInfo()
if d.Cores < 1 {
t.Errorf("Cores = %d", d.Cores)
}
if d.OSVersion == "" {
t.Error("OSVersion empty")
}
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"fmt"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
func platformOSVersion() string {
v := windows.RtlGetVersion()
return fmt.Sprintf("Windows %d.%d build %d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
}
func platformOSBuild() (int, int) {
v := windows.RtlGetVersion()
revision := 0
if k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE); err == nil {
defer k.Close()
if ubr, _, readErr := k.GetIntegerValue("UBR"); readErr == nil {
revision = int(ubr)
}
}
return int(v.BuildNumber), revision
}
func platformEnvironmentInfo() platformEnvironment { return platformEnvironment{} }
func platformCPU() string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
name, _, err := k.GetStringValue("ProcessorNameString")
if err != nil {
return ""
}
return strings.TrimSpace(name)
}
func platformRAMBytes() uint64 {
var kb uint64
proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("GetPhysicallyInstalledSystemMemory")
if ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&kb))); ret == 0 {
return 0
}
return kb * 1024
}
-18
View File
@@ -1,18 +0,0 @@
package main
import "testing"
// The diverged-recovery marker is only useful if it survives the shared status
// whitelist. An earlier revision set a status string the whitelist dropped, so
// the project tree silently lost the prompt.
func TestNormalizeTopicStatusKeepsDivergedRecovery(t *testing.T) {
if got := normalizeTopicStatus(topicStatusDivergedRecovery); got != topicStatusDivergedRecovery {
t.Fatalf("normalizeTopicStatus(%q) = %q, want it preserved", topicStatusDivergedRecovery, got)
}
}
func TestNormalizeTopicStatusStillRejectsUnknown(t *testing.T) {
if got := normalizeTopicStatus("not_a_status"); got != "" {
t.Fatalf("normalizeTopicStatus(unknown) = %q, want empty", got)
}
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"reasonix/internal/config"
"reasonix/internal/fileutil"
)
// upsertDotEnv stores KEY=value in Reasonix's global .env and applies it to the
// running process so a rebuild picks it up without a restart.
func upsertDotEnv(key, value string) error {
_, err := config.SetCredential(key, value)
return err
}
func removeDotEnv(key string) error {
return config.RemoveCredential(key)
}
// upsertEnvFile merges KEY=value into a KEY=value file at path, preserving
// comments and unrelated lines, writing atomically via a sibling temp + rename.
func upsertEnvFile(path, key, value string) error {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
var lines []string
if b, err := readFileUTF8(path); err == nil {
lines = strings.Split(strings.TrimRight(string(b), "\n"), "\n")
}
replaced := false
for i, ln := range lines {
t := strings.TrimSpace(ln)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key {
lines[i] = key + "=" + value
replaced = true
break
}
}
if !replaced {
lines = append(lines, key+"="+value)
}
out := strings.Join(lines, "\n") + "\n"
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.WriteString(out); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return os.Setenv(key, value)
}
func removeEnvFile(path, key string) error {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
data, err := readFileUTF8(path)
if err != nil {
if os.IsNotExist(err) {
return os.Unsetenv(key)
}
return err
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
outLines := make([]string, 0, len(lines))
for _, ln := range lines {
t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(ln), "export "))
if t == "" || strings.HasPrefix(t, "#") {
outLines = append(outLines, ln)
continue
}
if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key {
continue
}
outLines = append(outLines, ln)
}
out := ""
if len(outLines) > 0 {
out = strings.Join(outLines, "\n") + "\n"
}
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.WriteString(out); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return os.Unsetenv(key)
}
-85
View File
@@ -1,85 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/config"
)
// TestUpsertEnvFile proves a new key is appended, an existing key is replaced in
// place, comments/other lines survive, and the process env is updated.
func TestUpsertEnvFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials")
if err := os.WriteFile(path, []byte("# comment\nFOO=old\nBAR=keep\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := upsertEnvFile(path, "FOO", "new"); err != nil {
t.Fatalf("replace: %v", err)
}
if err := upsertEnvFile(path, "BAZ", "added"); err != nil {
t.Fatalf("append: %v", err)
}
b, _ := os.ReadFile(path)
got := string(b)
for _, want := range []string{"# comment", "FOO=new", "BAR=keep", "BAZ=added"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "FOO=old") {
t.Errorf("old value should be replaced:\n%s", got)
}
if os.Getenv("FOO") != "new" || os.Getenv("BAZ") != "added" {
t.Errorf("process env not updated: FOO=%q BAZ=%q", os.Getenv("FOO"), os.Getenv("BAZ"))
}
}
func TestRemoveEnvFileDeletesKeyAndUnsetsProcessEnv(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials")
if err := os.WriteFile(path, []byte("# comment\nFOO=old\nexport BAR=remove\nBAZ=keep\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("BAR", "remove")
if err := removeEnvFile(path, "BAR"); err != nil {
t.Fatalf("remove: %v", err)
}
b, _ := os.ReadFile(path)
got := string(b)
for _, want := range []string{"# comment", "FOO=old", "BAZ=keep"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "BAR=") {
t.Errorf("removed key should be absent:\n%s", got)
}
if _, ok := os.LookupEnv("BAR"); ok {
t.Errorf("process env BAR should be unset")
}
}
func TestLegacyHomeEnvProviderKeyIsNotPromoted(t *testing.T) {
home := isolateDesktopUserDirs(t)
homeEnv := filepath.Join(home, ".env")
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := config.LoadForRoot(t.TempDir()); err != nil {
t.Fatalf("LoadForRoot: %v", err)
}
if data, err := os.ReadFile(config.UserCredentialsPath()); err == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
t.Errorf("legacy ~/.env provider key must not be imported:\n%s", data)
}
rest, _ := os.ReadFile(homeEnv)
if !strings.Contains(string(rest), "DEEPSEEK_API_KEY=sk-test") || !strings.Contains(string(rest), "NPM_TOKEN=secret") {
t.Errorf("legacy ~/.env should be left untouched:\n%s", rest)
}
}
-37
View File
@@ -1,37 +0,0 @@
package main
import (
"fmt"
"io"
"os"
fileencoding "reasonix/internal/fileutil/encoding"
)
func readFileUTF8(path string) ([]byte, error) {
return fileencoding.ReadFileUTF8(path)
}
func readFileUTF8Limit(path string, limit int64) ([]byte, bool, error) {
if limit < 0 {
return nil, false, fmt.Errorf("invalid read limit %d", limit)
}
f, err := os.Open(path)
if err != nil {
return nil, false, err
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, false, err
}
if int64(len(raw)) > limit {
return nil, true, nil
}
decoded := fileencoding.DecodeToUTF8(raw)
if int64(len(decoded)) > limit {
return nil, true, nil
}
return decoded, false, nil
}
-346
View File
@@ -1,346 +0,0 @@
package main
import (
"bytes"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestSaveExportFileWritesTextAndBinaryPayloads(t *testing.T) {
t.Parallel()
app := &App{}
dir := t.TempDir()
textPath := filepath.Join(dir, "session.md")
if err := app.SaveExportFile(textPath, "# 会话\n", false); err != nil {
t.Fatalf("save text export: %v", err)
}
text, err := os.ReadFile(textPath)
if err != nil {
t.Fatalf("read text export: %v", err)
}
if got, want := string(text), "# 会话\n"; got != want {
t.Fatalf("text export = %q, want %q", got, want)
}
binaryPath := filepath.Join(dir, "session.png")
binary := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0xff}
if err := app.SaveExportFile(binaryPath, base64.StdEncoding.EncodeToString(binary), true); err != nil {
t.Fatalf("save binary export: %v", err)
}
written, err := os.ReadFile(binaryPath)
if err != nil {
t.Fatalf("read binary export: %v", err)
}
if string(written) != string(binary) {
t.Fatalf("binary export = %v, want %v", written, binary)
}
}
func TestSaveExportFileRejectsInvalidBase64(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "broken.pdf")
err := (&App{}).SaveExportFile(path, "not base64!", true)
if err == nil {
t.Fatal("expected invalid base64 error")
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("invalid payload should not create a file, stat error = %v", statErr)
}
}
func TestExportErrorsDoNotExposeSelectedDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
missingDir := filepath.Join(dir, "private-export-directory")
payload := base64.StdEncoding.EncodeToString([]byte("image"))
tests := []struct {
name string
path string
run func(string) error
}{
{
name: "single file",
path: filepath.Join(missingDir, "session.pdf"),
run: func(path string) error {
return (&App{}).SaveExportFile(path, payload, true)
},
},
{
name: "multipart image",
path: filepath.Join(missingDir, "session.png"),
run: func(path string) error {
return (&App{}).SaveExportImageFiles(path, []string{payload, payload})
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.run(test.path)
if err == nil {
t.Fatal("expected missing export directory to fail")
}
if strings.Contains(err.Error(), dir) {
t.Fatalf("export error exposed selected directory: %q", err)
}
if !strings.Contains(err.Error(), "session") {
t.Fatalf("export error should retain a safe file name: %q", err)
}
})
}
}
func TestSaveExportImageFilesWritesNumberedParts(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.archive.png")
payloads := [][]byte{{0x01, 0x02}, {0x03, 0x04}, {0x05, 0x06}}
encoded := make([]string, len(payloads))
for i, payload := range payloads {
encoded[i] = base64.StdEncoding.EncodeToString(payload)
}
if err := (&App{}).SaveExportImageFiles(path, encoded); err != nil {
t.Fatalf("save image parts: %v", err)
}
for i, want := range payloads {
partPath := filepath.Join(dir, fmt.Sprintf("session.archive-%d-of-3.png", i+1))
got, err := os.ReadFile(partPath)
if err != nil {
t.Fatalf("read image part %d: %v", i+1, err)
}
if !bytes.Equal(got, want) {
t.Fatalf("image part %d = %v, want %v", i+1, got, want)
}
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("multi-part export should not write the selected base path, stat error = %v", err)
}
}
func TestSaveExportImageFilesPreservesSelectedPath(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Windows normalizes trailing spaces in file names")
}
dir := t.TempDir()
selectedPath := filepath.Join(dir, "session.png ")
neighborPath := filepath.Join(dir, "session.png")
if err := os.WriteFile(neighborPath, []byte("keep me"), 0o644); err != nil {
t.Fatalf("seed neighboring file: %v", err)
}
payload := base64.StdEncoding.EncodeToString([]byte("new image"))
if err := (&App{}).SaveExportImageFiles(selectedPath, []string{payload}); err != nil {
t.Fatalf("save exact selected path: %v", err)
}
if got, err := os.ReadFile(selectedPath); err != nil || string(got) != "new image" {
t.Fatalf("selected path data = %q, err = %v", got, err)
}
if got, err := os.ReadFile(neighborPath); err != nil || string(got) != "keep me" {
t.Fatalf("neighboring file changed: data=%q err=%v", got, err)
}
}
func TestSaveExportImageFilesMatchesNormalExportPermissions(t *testing.T) {
t.Parallel()
dir := t.TempDir()
referencePath := filepath.Join(dir, "reference.png")
if err := (&App{}).SaveExportFile(referencePath, base64.StdEncoding.EncodeToString([]byte("reference")), true); err != nil {
t.Fatalf("save reference export: %v", err)
}
payload := base64.StdEncoding.EncodeToString([]byte("image"))
if err := (&App{}).SaveExportImageFiles(filepath.Join(dir, "session.png"), []string{payload, payload}); err != nil {
t.Fatalf("save multipart export: %v", err)
}
referenceInfo, err := os.Stat(referencePath)
if err != nil {
t.Fatalf("stat reference export: %v", err)
}
partInfo, err := os.Stat(filepath.Join(dir, "session-1-of-2.png"))
if err != nil {
t.Fatalf("stat multipart export: %v", err)
}
if got, want := partInfo.Mode().Perm(), referenceInfo.Mode().Perm(); got != want {
t.Fatalf("multipart permissions = %v, want normal export permissions %v", got, want)
}
if matches, err := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); err != nil || len(matches) != 0 {
t.Fatalf("staged files remain after successful export: matches=%v err=%v", matches, err)
}
}
func TestSaveExportImageFilesRejectsCollisionWithoutPartialOutput(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.png")
collisionPath := filepath.Join(dir, "session-2-of-3.png")
if err := os.WriteFile(collisionPath, []byte("keep me"), 0o644); err != nil {
t.Fatalf("seed collision: %v", err)
}
payload := base64.StdEncoding.EncodeToString([]byte("new image"))
err := (&App{}).SaveExportImageFiles(path, []string{payload, payload, payload})
if err == nil {
t.Fatal("expected existing numbered export to reject the batch")
}
if got, readErr := os.ReadFile(collisionPath); readErr != nil || string(got) != "keep me" {
t.Fatalf("existing image part changed: data=%q err=%v", got, readErr)
}
for _, name := range []string{"session-1-of-3.png", "session-3-of-3.png"} {
if _, statErr := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(statErr) {
t.Fatalf("collision should leave no partial output %s, stat error = %v", name, statErr)
}
}
}
func TestSaveExportImageFilesDecodesAllPartsBeforeWriting(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.png")
valid := base64.StdEncoding.EncodeToString([]byte("image"))
err := (&App{}).SaveExportImageFiles(path, []string{valid, "not base64!", valid})
if err == nil {
t.Fatal("expected invalid image payload to reject the batch")
}
for i := 1; i <= 3; i++ {
partPath := filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i))
if _, statErr := os.Stat(partPath); !os.IsNotExist(statErr) {
t.Fatalf("invalid payload should leave no image part %d, stat error = %v", i, statErr)
}
}
if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
t.Fatalf("invalid payload left staged files: matches=%v err=%v", matches, globErr)
}
}
func TestSaveExclusiveExportFilesRollsBackCommittedTargets(t *testing.T) {
t.Parallel()
target := filepath.Join(t.TempDir(), "duplicate.png")
err := saveExclusiveExportFiles(
[]string{target, target},
[][]byte{[]byte("first"), []byte("second")},
)
if err == nil {
t.Fatal("expected duplicate exclusive target to fail")
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("failed batch should roll back its committed target, stat error = %v", statErr)
}
}
func TestRollbackDoesNotRemoveReplacedExportTarget(t *testing.T) {
t.Parallel()
dir := t.TempDir()
tempPath := filepath.Join(dir, ".staged.png")
targetPath := filepath.Join(dir, "session.png")
if err := os.WriteFile(tempPath, []byte("staged"), 0o644); err != nil {
t.Fatalf("write staged file: %v", err)
}
created, err := commitStagedExportFile(tempPath, targetPath)
if err != nil {
t.Fatalf("commit staged file: %v", err)
}
tempInfo, err := os.Lstat(tempPath)
if err != nil {
t.Fatalf("stat staged file: %v", err)
}
if !os.SameFile(created, tempInfo) {
t.Fatal("commit must return the staged inode identity")
}
if err := os.Remove(targetPath); err != nil {
t.Fatalf("replace committed target: %v", err)
}
if err := os.WriteFile(targetPath, []byte("replacement"), 0o644); err != nil {
t.Fatalf("write replacement target: %v", err)
}
rollbackCommittedExportFiles([]committedExportFile{{path: targetPath, info: created}})
if got, err := os.ReadFile(targetPath); err != nil || string(got) != "replacement" {
t.Fatalf("rollback removed replacement: data=%q err=%v", got, err)
}
}
func TestConcurrentMultipartExportsHaveSingleCompleteWinner(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.png")
encode := func(values ...string) []string {
encoded := make([]string, len(values))
for i, value := range values {
encoded[i] = base64.StdEncoding.EncodeToString([]byte(value))
}
return encoded
}
batches := [][]string{
encode("a-1", "a-2", "a-3"),
encode("b-1", "b-2", "b-3"),
}
start := make(chan struct{})
errs := make(chan error, len(batches))
var ready sync.WaitGroup
ready.Add(len(batches))
for _, batch := range batches {
go func() {
ready.Done()
<-start
errs <- (&App{}).SaveExportImageFiles(path, batch)
}()
}
ready.Wait()
close(start)
successes := 0
for range batches {
if err := <-errs; err == nil {
successes++
}
}
if successes != 1 {
t.Fatalf("successful concurrent exports = %d, want exactly one", successes)
}
first, err := os.ReadFile(filepath.Join(dir, "session-1-of-3.png"))
if err != nil {
t.Fatalf("read winning first part: %v", err)
}
winner := string(first[:1])
for i := 1; i <= 3; i++ {
got, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i)))
if err != nil {
t.Fatalf("read winning part %d: %v", i, err)
}
if want := fmt.Sprintf("%s-%d", winner, i); string(got) != want {
t.Fatalf("winning part %d = %q, want %q from one batch", i, got, want)
}
}
if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
t.Fatalf("concurrent export left staged files: matches=%v err=%v", matches, globErr)
}
}
func TestExportFileFiltersSelectExpectedNativePattern(t *testing.T) {
t.Parallel()
tests := []struct {
mime string
ext string
want string
}{
{mime: "application/pdf", ext: ".pdf", want: "*.pdf"},
{mime: "image/png", ext: ".png", want: "*.png"},
{mime: "application/octet-stream", ext: ".bin", want: "*.bin"},
}
for _, test := range tests {
filters := exportFileFilters(test.mime, test.ext)
if len(filters) != 1 || filters[0].Pattern != test.want {
t.Fatalf("filters for %s = %#v, want pattern %q", test.mime, filters, test.want)
}
}
}
-108
View File
@@ -1,108 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/taskcatalog"
)
func TestExtensionGenerationBumpsOnMCPMutationSites(t *testing.T) {
// Guard mutation sites that publish shared Host / config changes while
// controller builds may still be running off the lifecycle lock.
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
var sources []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
raw, readErr := os.ReadFile(filepath.Join(".", name))
if readErr != nil {
t.Fatal(readErr)
}
sources = append(sources, string(raw))
}
src := strings.Join(sources, "\n")
for _, name := range []string{
"func (a *App) InstallMCPServer",
"func (a *App) UpdateMCPServer",
"func (a *App) RemoveMCPServer",
"func (a *App) ReconnectMCPServer",
"func (a *App) ClearMCPServerAuthentication",
} {
idx := strings.Index(src, name)
if idx < 0 {
t.Fatalf("missing %s", name)
}
rest := src[idx:]
next := strings.Index(rest[len(name):], "\nfunc (a *App) ")
body := rest
if next >= 0 {
body = rest[:len(name)+next]
}
if !strings.Contains(body, "a.bumpExtensionGeneration()") && !strings.Contains(body, "saveDesktopMCPServerAndBump") {
t.Fatalf("%s does not bump extensionGeneration", name)
}
}
}
func TestControllerPublicationRejectsMutationThatCompletesAfterBoot(t *testing.T) {
app := NewApp()
generation := app.currentExtensionGeneration()
// Model a build that has finished extension boot while an MCP mutation is
// queued. The writer must finish and bump the generation before publication.
app.extensionBuildMu.RLock()
mutationLocked := make(chan struct{})
releaseMutation := make(chan struct{})
go func() {
unlock := app.lockMCPMutation("test-publication-fence")
close(mutationLocked)
<-releaseMutation
unlock()
}()
app.extensionBuildMu.RUnlock()
<-mutationLocked
publication := make(chan bool, 1)
go func() {
unlock, ok := app.lockTabControllerPublication(generation)
if ok {
unlock()
}
publication <- ok
}()
close(releaseMutation)
if <-publication {
t.Fatal("controller published after an MCP mutation changed its extension generation")
}
}
func TestTaskActionProjectKeepsAllowlistedRoot(t *testing.T) {
root := t.TempDir()
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{
"active": {ID: "active", Scope: "project", WorkspaceRoot: root},
},
activeTabID: "active",
}
key := taskcatalog.ProjectKey(root)
project, err := app.taskActionProject(key)
if err != nil {
t.Fatal(err)
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
if project.Root != root {
t.Fatalf("root = %q, want allowlisted %q", project.Root, root)
}
}
-138
View File
@@ -1,138 +0,0 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// extensionCatalogCtrl stubs only what the model discovery/switch paths read
// from a tab controller; everything else falls through the embedded nil
// SessionAPI and must not be called.
type extensionCatalogCtrl struct {
control.SessionAPI
catalog []provider.Descriptor
}
func (c *extensionCatalogCtrl) ProviderCatalog() []provider.Descriptor { return c.catalog }
// TestModelsForTabIncludesExtensionProviders: the desktop model switcher
// merges the tab controller's extension catalog into the config-backed list —
// plugin refs arrive fully namespaced, duplicates collapse, and the active
// plugin ref is marked current.
func TestModelsForTabIncludesExtensionProviders(t *testing.T) {
reloadRuntimeFixture(t) // one configured provider "old" with model old-model
app := NewApp()
tab := &WorkspaceTab{
ID: "tab_a",
Scope: "global",
Ready: true,
model: "plugin/demo/fake/x",
Ctrl: &extensionCatalogCtrl{catalog: []provider.Descriptor{
{Ref: "plugin/demo/fake/x", Model: "x"},
{Ref: "plugin/demo/fake/y", Model: "y"},
{Ref: "old/old-model"}, // claim-replaced duplicate must not repeat
// ProviderCatalog is merged, so it can also contain unnamespaced
// config-backed descriptors. They are not extension models and must
// never leak into a synthetic PLUGIN group in the desktop picker.
{Ref: "deepseek-responses", DisplayName: "deepseek-responses"},
{Ref: "siliconflow-cn", DisplayName: "siliconflow-cn"},
}},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
models := app.ModelsForTab(tab.ID)
var pluginX, pluginY, oldCount *ModelInfo
oldSeen := 0
for i := range models {
switch models[i].Ref {
case "plugin/demo/fake/x":
pluginX = &models[i]
case "plugin/demo/fake/y":
pluginY = &models[i]
case "old/old-model":
oldSeen++
oldCount = &models[i]
}
}
if pluginX == nil || pluginY == nil {
t.Fatalf("ModelsForTab = %+v, want both plugin refs", models)
}
if pluginX.Provider != "plugin/demo" || pluginX.Model != "fake/x" {
t.Fatalf("plugin ref display = %+v, want provider plugin/demo model fake/x", pluginX)
}
if !pluginX.Current {
t.Fatalf("active plugin ref not marked current: %+v", pluginX)
}
if oldSeen != 1 || oldCount == nil {
t.Fatalf("config ref repeated %d times, want exactly one", oldSeen)
}
for _, info := range models {
if info.Ref == "deepseek-responses" || info.Ref == "siliconflow-cn" || info.Provider == "plugin" {
t.Fatalf("unnamespaced provider descriptor leaked into plugin models: %+v", info)
}
}
// A tab whose controller has no extension catalog (nil → no sidecar
// declared providers) enumerates config only, as before.
tab.Ctrl = &extensionCatalogCtrl{}
for _, info := range app.ModelsForTab(tab.ID) {
if strings.HasPrefix(info.Ref, "plugin/") {
t.Fatalf("empty catalog produced plugin ref %+v", info)
}
}
}
// TestSetModelForTabPluginRefReachesBoot: with a controller whose merged
// catalog declares the plugin ref, SetModelForTab's config gate accepts the
// ref and hands it to boot — which is the resolver of record (here it fails
// only because no sidecar is installed in this test's REASONIX_HOME). A
// non-plugin unknown ref must still fail at the desktop gate, before boot.
func TestSetModelForTabPluginRefReachesBoot(t *testing.T) {
dir := reloadRuntimeFixture(t)
oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
oldCtrl := control.New(control.Options{
Executor: oldExec,
SessionDir: dir,
Label: "old",
Sink: event.Discard,
// The merged catalog this controller would expose with the demo
// sidecar live: plugin/demo/fake/x is a valid switch target.
ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{
{Ref: "plugin/demo/fake/x", Model: "x"},
}},
})
reloadRuntimeTab(t, app, dir, oldCtrl)
err := app.SetModelForTab("tab_a", "plugin/demo/fake/x")
if err == nil {
t.Fatal("SetModelForTab succeeded without an installed sidecar; expected boot to gate the plugin ref")
}
if !errors.Is(err, boot.ErrUnknownModel) {
t.Fatalf("SetModelForTab error = %v, want boot.ErrUnknownModel (the desktop gate must pass plugin refs through to boot)", err)
}
// The desktop config gate still rejects unknown NON-plugin refs itself.
err = app.SetModelForTab("tab_a", "ghost/never-existed")
if err == nil || !strings.Contains(err.Error(), `unknown model "ghost/never-existed"`) {
t.Fatalf("SetModelForTab non-plugin error = %v, want the desktop unknown-model gate", err)
}
if errors.Is(err, boot.ErrUnknownModel) {
t.Fatalf("non-plugin unknown ref reached boot: %v", err)
}
}
-91
View File
@@ -1,91 +0,0 @@
package main
import (
"fmt"
"strings"
"reasonix/internal/control"
)
// ExtensionActionView is the JSON twin of control.ExtensionActionView for the
// desktop frontend: one handshake-declared extension UI action. Description
// carries the control view's Label — the protocol declares a single
// human-readable label per action, so the frontend shows it as the action's
// description line. Slash is the public invocation name, "/<plugin>:<action>".
type ExtensionActionView struct {
Plugin string `json:"plugin"`
Action string `json:"action"`
Slash string `json:"slash"`
Description string `json:"description,omitempty"`
}
// extensionActionsForCtrl maps the control port's action views to their JSON
// twins. Nil controller → empty, so the palette simply shows no extension
// group while a tab's runtime is still starting.
func extensionActionsForCtrl(ctrl control.SessionAPI) []ExtensionActionView {
out := []ExtensionActionView{}
if ctrl == nil {
return out
}
for _, action := range ctrl.ExtensionActions() {
out = append(out, ExtensionActionView{
Plugin: action.PluginID,
Action: action.ActionID,
Slash: action.Slash,
Description: action.Label,
})
}
return out
}
// ExtensionActions enumerates the handshake-declared extension UI actions of
// the runtime owning tabID (empty tabID = active tab). Unknown tabs and tabs
// without a running runtime yield an empty list rather than an error so the
// command palette can poll freely.
func (a *App) ExtensionActions(tabID string) ([]ExtensionActionView, error) {
return extensionActionsForCtrl(a.ctrlByTabID(tabID)), nil
}
// extensionCtrlForTab resolves the driving controller for extension calls,
// erroring instead of silently no-opping (unlike the read path above, invoke
// and submit are user-initiated mutations that must surface misrouting).
func (a *App) extensionCtrlForTab(tabID string) (control.SessionAPI, error) {
if a.tabByID(tabID) == nil {
return nil, fmt.Errorf("no tab %q", tabID)
}
ctrl := a.ctrlByTabID(tabID)
if ctrl == nil {
return nil, fmt.Errorf("tab %q has no running runtime", tabID)
}
return ctrl, nil
}
// InvokeExtensionAction invokes one registered extension action by its public
// "/<plugin>:<action>" name on behalf of tabID (empty = active tab) and
// returns the extension's (already redacted) result message.
func (a *App) InvokeExtensionAction(tabID, name string, args map[string]string) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("extension action name is required")
}
ctrl, err := a.extensionCtrlForTab(tabID)
if err != nil {
return "", err
}
return ctrl.InvokeExtensionAction(a.bootContext(), name, args)
}
// SubmitExtensionForm delivers one extension form surface's values back to the
// owning sidecar. A dismissal rides the same path as values{"cancelled": true}
// — the structured-UI protocol has no dedicated cancel verb on the submit
// channel, so the frontend reports cancellation as an explicit value, distinct
// from an empty value set.
func (a *App) SubmitExtensionForm(tabID, pluginID, surfaceID string, values map[string]any) error {
if strings.TrimSpace(pluginID) == "" || strings.TrimSpace(surfaceID) == "" {
return fmt.Errorf("extension form submission requires plugin and surface ids")
}
ctrl, err := a.extensionCtrlForTab(tabID)
if err != nil {
return err
}
return ctrl.SubmitExtensionForm(a.bootContext(), pluginID, surfaceID, values)
}
-174
View File
@@ -1,174 +0,0 @@
package main
import (
"context"
"errors"
"testing"
"reasonix/internal/control"
)
// extensionUIController fakes the extension-UI slice of control.SessionAPI:
// the Capabilities port methods plus the concrete controller's
// SubmitExtensionForm (asserted locally by the binding until the port grows
// it).
type extensionUIController struct {
control.SessionAPI
actions []control.ExtensionActionView
invokeMessage string
invokeErr error
submitErr error
invokeName string
invokeArgs map[string]string
submitPluginID string
submitSurfaceID string
submitValues map[string]any
}
func (c *extensionUIController) ExtensionActions() []control.ExtensionActionView {
return append([]control.ExtensionActionView(nil), c.actions...)
}
func (c *extensionUIController) InvokeExtensionAction(_ context.Context, name string, args map[string]string) (string, error) {
c.invokeName = name
c.invokeArgs = args
return c.invokeMessage, c.invokeErr
}
func (c *extensionUIController) SubmitExtensionForm(_ context.Context, pluginID, surfaceID string, values map[string]any) error {
c.submitPluginID = pluginID
c.submitSurfaceID = surfaceID
c.submitValues = values
return c.submitErr
}
func newExtensionTestApp(ctrl control.SessionAPI) *App {
return &App{
tabs: map[string]*WorkspaceTab{
"target": {ID: "target", Scope: "global", Ready: true, Ctrl: ctrl, disabledMCP: map[string]ServerView{}},
"focused": {ID: "focused", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}},
},
tabOrder: []string{"target", "focused"},
activeTabID: "focused",
}
}
func TestExtensionActionsMapsControlViewsAndRoutesByTab(t *testing.T) {
isolateDesktopUserDirs(t)
targetCtrl := &extensionUIController{actions: []control.ExtensionActionView{
{PluginID: "alpha", ActionID: "act1", Label: "Run the thing", Slash: "/alpha:act1"},
{PluginID: "beta", ActionID: "act2", Label: "", Slash: "/beta:act2"},
}}
app := newExtensionTestApp(targetCtrl)
views, err := app.ExtensionActions("target")
if err != nil {
t.Fatalf("ExtensionActions: %v", err)
}
if len(views) != 2 {
t.Fatalf("ExtensionActions = %+v, want 2 views", views)
}
if views[0] != (ExtensionActionView{Plugin: "alpha", Action: "act1", Slash: "/alpha:act1", Description: "Run the thing"}) {
t.Fatalf("view[0] = %+v, want JSON twin of the control view", views[0])
}
if views[1].Description != "" {
t.Fatalf("empty label must stay an omitted description, got %+v", views[1])
}
// Empty tab id resolves the active tab.
active, err := app.ExtensionActions("")
if err != nil {
t.Fatalf("ExtensionActions(active): %v", err)
}
if len(active) != 0 {
t.Fatalf("active tab actions = %+v, want empty (focused tab has none)", active)
}
// Unknown tabs and tabs without a running runtime yield an empty list.
for _, tabID := range []string{"missing", "focused-noruntime"} {
app.tabs["focused-noruntime"] = &WorkspaceTab{ID: "focused-noruntime", Scope: "global", disabledMCP: map[string]ServerView{}}
got, err := app.ExtensionActions(tabID)
if err != nil {
t.Fatalf("ExtensionActions(%q): %v", tabID, err)
}
if len(got) != 0 {
t.Fatalf("ExtensionActions(%q) = %+v, want empty", tabID, got)
}
}
}
func TestInvokeExtensionActionForwardsNameAndArgs(t *testing.T) {
isolateDesktopUserDirs(t)
targetCtrl := &extensionUIController{invokeMessage: "done"}
focusedCtrl := &extensionUIController{}
app := newExtensionTestApp(targetCtrl)
message, err := app.InvokeExtensionAction("target", "/alpha:act1", map[string]string{"k": "v"})
if err != nil {
t.Fatalf("InvokeExtensionAction: %v", err)
}
if message != "done" {
t.Fatalf("message = %q, want done", message)
}
if targetCtrl.invokeName != "/alpha:act1" || targetCtrl.invokeArgs["k"] != "v" {
t.Fatalf("controller saw name=%q args=%+v", targetCtrl.invokeName, targetCtrl.invokeArgs)
}
if focusedCtrl.invokeName != "" {
t.Fatalf("focused tab received scoped invoke: %q", focusedCtrl.invokeName)
}
targetCtrl.invokeErr = errors.New("sidecar said no")
if _, err := app.InvokeExtensionAction("target", "/alpha:act1", nil); err == nil || err.Error() != "sidecar said no" {
t.Fatalf("controller error must pass through, got %v", err)
}
if _, err := app.InvokeExtensionAction("target", " ", nil); err == nil {
t.Fatal("blank action name must fail before reaching the controller")
}
if _, err := app.InvokeExtensionAction("missing", "/alpha:act1", nil); err == nil {
t.Fatal("unknown tab must fail")
}
if _, err := app.InvokeExtensionAction("focused-noruntime", "/alpha:act1", nil); err == nil {
t.Fatal("tab without a running runtime must fail")
}
}
func TestSubmitExtensionFormRoutesValues(t *testing.T) {
isolateDesktopUserDirs(t)
targetCtrl := &extensionUIController{}
app := newExtensionTestApp(targetCtrl)
values := map[string]any{"name": "x", "flags": []any{"a", "b"}, "agree": true}
if err := app.SubmitExtensionForm("target", "alpha", "f1", values); err != nil {
t.Fatalf("SubmitExtensionForm: %v", err)
}
if targetCtrl.submitPluginID != "alpha" || targetCtrl.submitSurfaceID != "f1" {
t.Fatalf("controller saw %q/%q", targetCtrl.submitPluginID, targetCtrl.submitSurfaceID)
}
if targetCtrl.submitValues["name"] != "x" || targetCtrl.submitValues["agree"] != true {
t.Fatalf("values = %+v", targetCtrl.submitValues)
}
// A dismissal rides the same path as an explicit cancelled value.
if err := app.SubmitExtensionForm("target", "alpha", "f1", map[string]any{"cancelled": true}); err != nil {
t.Fatalf("SubmitExtensionForm(cancel): %v", err)
}
if targetCtrl.submitValues["cancelled"] != true {
t.Fatalf("cancelled value = %+v", targetCtrl.submitValues)
}
targetCtrl.submitErr = errors.New("not accepted")
if err := app.SubmitExtensionForm("target", "alpha", "f1", nil); err == nil || err.Error() != "not accepted" {
t.Fatalf("controller error must pass through, got %v", err)
}
if err := app.SubmitExtensionForm("target", "", "f1", nil); err == nil {
t.Fatal("blank plugin id must fail before reaching the controller")
}
if err := app.SubmitExtensionForm("target", "alpha", "", nil); err == nil {
t.Fatal("blank surface id must fail before reaching the controller")
}
if err := app.SubmitExtensionForm("missing", "alpha", "f1", nil); err == nil {
t.Fatal("unknown tab must fail")
}
}
-425
View File
@@ -1,425 +0,0 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/internal/config"
)
const (
externalOpenerFileManager = "file-manager"
externalOpenerEditor = "editor"
externalOpenerTerminal = "terminal"
externalOpenerCatalogTTL = 15 * time.Second
)
// ExternalOpenerView is the renderer-safe description of one installed app.
// Target executable paths and launch arguments stay in the native shell so the
// frontend can never turn this feature into an arbitrary command runner.
type ExternalOpenerView struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
IconDataURL string `json:"iconDataUrl,omitempty"`
}
// ExternalOpenersView is the complete state for the Codex-style Open control.
type ExternalOpenersView struct {
Openers []ExternalOpenerView `json:"openers"`
Preferred string `json:"preferred"`
WorkspaceOpenable bool `json:"workspaceOpenable,omitempty"`
}
type externalOpenerSpec struct {
View ExternalOpenerView
Target string
LaunchMode string
IconSource string
}
type externalOpenerCatalogCache struct {
mu sync.Mutex
ttl time.Duration
discover func() []externalOpenerSpec
now func() time.Time
loaded bool
loadedAt time.Time
specs []externalOpenerSpec
refreshDone chan struct{}
}
var platformExternalOpenerCatalog = newExternalOpenerCatalogCache(externalOpenerCatalogTTL, platformExternalOpenerSpecs)
func newExternalOpenerCatalogCache(ttl time.Duration, discover func() []externalOpenerSpec) *externalOpenerCatalogCache {
return &externalOpenerCatalogCache{ttl: ttl, discover: discover, now: time.Now}
}
func cloneExternalOpenerSpecs(specs []externalOpenerSpec) []externalOpenerSpec {
return append([]externalOpenerSpec(nil), specs...)
}
func (c *externalOpenerCatalogCache) get() []externalOpenerSpec {
for {
c.mu.Lock()
now := c.now()
if c.loaded && now.Sub(c.loadedAt) < c.ttl {
specs := cloneExternalOpenerSpecs(c.specs)
c.mu.Unlock()
return specs
}
if done := c.refreshDone; done != nil {
c.mu.Unlock()
<-done
continue
}
done := make(chan struct{})
c.refreshDone = done
discover := c.discover
c.mu.Unlock()
specs := discover()
c.mu.Lock()
c.specs = cloneExternalOpenerSpecs(specs)
c.loaded = true
c.loadedAt = c.now()
c.refreshDone = nil
close(done)
result := cloneExternalOpenerSpecs(c.specs)
c.mu.Unlock()
return result
}
}
func cachedPlatformExternalOpenerSpecs() []externalOpenerSpec {
return platformExternalOpenerCatalog.get()
}
// startDetachedExternalOpener reaps the launched process in the background so
// exited children do not pile up as zombies for the desktop app's lifetime.
func startDetachedExternalOpener(cmd *exec.Cmd) error {
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
}
func externalOpenerByID(specs []externalOpenerSpec, id string) (externalOpenerSpec, bool) {
id = strings.ToLower(strings.TrimSpace(id))
for _, spec := range specs {
if spec.View.ID == id {
return spec, true
}
}
return externalOpenerSpec{}, false
}
func resolveExternalOpener(specs []externalOpenerSpec, preferred string) (externalOpenerSpec, bool) {
if spec, ok := externalOpenerByID(specs, preferred); ok {
return spec, true
}
for _, spec := range specs {
if spec.View.Kind == externalOpenerFileManager {
return spec, true
}
}
if len(specs) == 0 {
return externalOpenerSpec{}, false
}
return specs[0], true
}
func externalOpenerViews(specs []externalOpenerSpec) []ExternalOpenerView {
views := make([]ExternalOpenerView, 0, len(specs))
seen := make(map[string]bool, len(specs))
for _, spec := range specs {
id := strings.ToLower(strings.TrimSpace(spec.View.ID))
if id == "" || seen[id] || strings.TrimSpace(spec.View.Name) == "" {
continue
}
spec.View.ID = id
views = append(views, spec.View)
seen[id] = true
}
return views
}
func externalOpenerViewsWithIcons(specs []externalOpenerSpec) []ExternalOpenerView {
withIcons := make([]externalOpenerSpec, len(specs))
copy(withIcons, specs)
for i := range withIcons {
withIcons[i].View.IconDataURL = externalOpenerIconDataURL(withIcons[i])
}
return externalOpenerViews(withIcons)
}
func (a *App) preferredExternalOpenerID() string {
cfg, _, err := a.loadDesktopUserConfigForView()
if err != nil || cfg == nil {
return ""
}
return cfg.DesktopExternalOpener()
}
// ExternalOpeners returns only applications detected on the current machine.
// If a preference was copied from another OS or the app was uninstalled, the
// returned preferred id falls back without rewriting the user's config.
func (a *App) ExternalOpeners() ExternalOpenersView {
specs := cachedPlatformExternalOpenerSpecs()
views := externalOpenerViewsWithIcons(specs)
selected, ok := resolveExternalOpener(specs, a.preferredExternalOpenerID())
if !ok {
return ExternalOpenersView{Openers: views}
}
return ExternalOpenersView{Openers: views, Preferred: selected.View.ID}
}
// ExternalOpenersForTab adds the tab-scoped local-workspace capability used by
// the chat-header Open control. Scope is intentionally not part of the check:
// both project tabs and Global tabs can own a real local workspace directory.
func (a *App) ExternalOpenersForTab(tabID string) ExternalOpenersView {
if _, err := a.externalOpenerWorkspacePathForTab(tabID); err != nil {
return ExternalOpenersView{Openers: []ExternalOpenerView{}}
}
view := a.ExternalOpeners()
view.WorkspaceOpenable = true
return view
}
// SetPreferredExternalOpener persists an installed, platform-owned opener id.
func (a *App) SetPreferredExternalOpener(id string) error {
specs := cachedPlatformExternalOpenerSpecs()
spec, ok := externalOpenerByID(specs, id)
if !ok {
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
}
return a.applyConfigOnly(func(c *config.Config) error {
return c.SetDesktopExternalOpener(spec.View.ID)
})
}
// OpenWorkspaceInExternalOpener opens the active workspace using either the
// requested installed app or the persisted/fallback selection when id is empty.
func (a *App) OpenWorkspaceInExternalOpener(id string) error {
return a.OpenWorkspaceInExternalOpenerForTab("", id)
}
// OpenWorkspaceInExternalOpenerForTab is tab-scoped so a rapid tab switch cannot
// send the wrong project to an external application.
func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
path, err := a.externalOpenerWorkspacePathForTab(tabID)
if err != nil {
return err
}
specs := cachedPlatformExternalOpenerSpecs()
var spec externalOpenerSpec
var ok bool
if strings.TrimSpace(id) == "" {
spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
} else {
spec, ok = externalOpenerByID(specs, id)
}
if !ok {
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
}
return launchPlatformExternalOpener(spec, path, true)
}
func (a *App) externalOpenerWorkspacePathForTab(tabID string) (string, error) {
root, _, ok := a.workspaceTargetForTab(tabID)
if !ok {
return "", os.ErrNotExist
}
// A bound tab with no root has no stable workspace to expose. Keep the legacy
// no-tab current-directory fallback, but do not turn an incomplete tab into
// an opener for the Desktop process's launch directory.
if strings.TrimSpace(root) == "" {
return "", os.ErrNotExist
}
path, err := workspaceBaseFromRoot(root)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("workspace is not a directory")
}
return path, nil
}
// OpenLocalPathInExternalOpener opens an absolute local path with one of the
// detected, platform-owned applications. It shares OpenLocalPath's safety
// checks so a markdown link cannot turn an AI-generated executable path into
// an executable launch.
func (a *App) OpenLocalPathInExternalOpener(path, id string) error {
path, err := normalizeLocalOpenPath(path)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
if !openTargetPathAllowed(path, info) {
return fmt.Errorf("refusing to open executable target %q", path)
}
specs := cachedPlatformExternalOpenerSpecs()
var spec externalOpenerSpec
var ok bool
if strings.TrimSpace(id) == "" {
spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
} else {
spec, ok = externalOpenerByID(specs, id)
}
if !ok {
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
}
return launchPlatformExternalOpener(spec, path, info.IsDir())
}
// SaveLocalPathAs copies a local file to a user-selected destination without
// changing the source. Directories are intentionally excluded: Finder's
// reveal action is the appropriate operation for them.
func (a *App) SaveLocalPathAs(path string) (string, error) {
path, err := normalizeLocalOpenPath(path)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", err
}
if info.IsDir() {
return "", fmt.Errorf("cannot save a directory as a file")
}
if a.ctx == nil {
return "", nil
}
target, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Save file as",
DefaultDirectory: filepath.Dir(path),
DefaultFilename: filepath.Base(path),
CanCreateDirectories: true,
})
if err != nil || target == "" {
return "", err
}
if filepath.Clean(target) == filepath.Clean(path) {
return "", fmt.Errorf("destination is the same as the source")
}
if err := copyLocalPathAs(path, target); err != nil {
return "", err
}
return target, nil
}
func copyLocalPathAs(path, target string) (err error) {
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
sourceInfo, err := src.Stat()
if err != nil {
return err
}
if sourceInfo.IsDir() {
return fmt.Errorf("cannot save a directory as a file")
}
sameFile, err := localSaveDestinationIsSource(sourceInfo, target)
if err != nil {
return err
}
if sameFile {
return fmt.Errorf("destination is the same as the source")
}
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".reasonix-copy-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() {
if tmpName != "" {
_ = os.Remove(tmpName)
}
}()
if _, err = io.Copy(tmp, src); err != nil {
_ = tmp.Close()
return err
}
if err = tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err = tmp.Chmod(sourceInfo.Mode().Perm()); err != nil {
_ = tmp.Close()
return err
}
if err = tmp.Close(); err != nil {
return err
}
if err = replaceLocalSaveDestination(tmpName, target); err != nil {
return err
}
tmpName = ""
return nil
}
// localSaveDestinationIsSource compares filesystem identity, not just path
// spelling. os.Stat follows aliases, so this catches case-insensitive paths,
// symlinks, and hard links before the destination is replaced.
func localSaveDestinationIsSource(sourceInfo os.FileInfo, target string) (bool, error) {
targetInfo, err := os.Stat(target)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, err
}
return os.SameFile(sourceInfo, targetInfo), nil
}
// externalOpenerWorkingDirectory returns a valid directory for process launch.
// Editors still receive the original file path as an argument; only the
// process CWD changes when the requested path is a regular file.
func externalOpenerWorkingDirectory(path string) string {
info, err := os.Stat(path)
if err == nil && !info.IsDir() {
return filepath.Dir(path)
}
return path
}
func externalOpenerLaunchPath(spec externalOpenerSpec, path string) string {
if spec.View.Kind == externalOpenerTerminal || isTerminalLaunchMode(spec.LaunchMode) {
return externalOpenerWorkingDirectory(path)
}
return path
}
func isTerminalLaunchMode(mode string) bool {
switch mode {
case "ghostty", "gnome-terminal", "konsole", "kitty", "alacritty", "cwd", "windows-terminal", "console":
return true
default:
return false
}
}
-181
View File
@@ -1,181 +0,0 @@
//go:build darwin
package main
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func darwinInstalledApplicationIndex() map[string]string {
home, _ := os.UserHomeDir()
roots := []string{
"/Applications",
"/System/Applications",
"/System/Applications/Utilities",
"/System/Library/CoreServices",
}
if home != "" {
roots = append(roots, filepath.Join(home, "Applications"))
}
index := make(map[string]string)
add := func(path string) {
path = strings.TrimSpace(path)
if info, err := os.Stat(path); err == nil && info.IsDir() && strings.HasSuffix(strings.ToLower(path), ".app") {
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
if _, exists := index[name]; !exists {
index[name] = path
}
}
}
for _, root := range roots {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, entry := range entries {
if strings.HasSuffix(strings.ToLower(entry.Name()), ".app") {
add(filepath.Join(root, entry.Name()))
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "/usr/bin/mdfind", "kMDItemContentType == 'com.apple.application-bundle'").Output()
if err == nil {
for line := range strings.SplitSeq(string(out), "\n") {
add(line)
}
}
return index
}
func darwinApplicationPath(index map[string]string, names ...string) string {
for _, name := range names {
if path := index[strings.ToLower(strings.TrimSpace(name))]; path != "" {
return path
}
}
return ""
}
func platformExternalOpenerSpecs() []externalOpenerSpec {
installed := darwinInstalledApplicationIndex()
var specs []externalOpenerSpec
addMode := func(id, name, kind, mode, _ string, appNames ...string) {
if path := darwinApplicationPath(installed, appNames...); path != "" {
specs = append(specs, externalOpenerSpec{
View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
Target: path,
LaunchMode: mode,
IconSource: path,
})
}
}
add := func(id, name, kind, bundleID string, appNames ...string) {
addMode(id, name, kind, "application", bundleID, appNames...)
}
// Keep the order stable and close to the native Codex menu: common editors,
// the platform browser, terminals, then the broader IDE catalog.
add("vscode", "VS Code", externalOpenerEditor, "com.microsoft.VSCode", "Visual Studio Code")
add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "com.microsoft.VSCodeInsiders", "Visual Studio Code - Insiders")
add("cursor", "Cursor", externalOpenerEditor, "com.todesktop.230313mzl4w4u92", "Cursor")
add("finder", "Finder", externalOpenerFileManager, "com.apple.finder", "Finder")
add("terminal", "Terminal", externalOpenerTerminal, "com.apple.Terminal", "Terminal")
add("iterm", "iTerm2", externalOpenerTerminal, "com.googlecode.iterm2", "iTerm", "iTerm2")
addMode("ghostty", "Ghostty", externalOpenerTerminal, "ghostty", "com.mitchellh.ghostty", "Ghostty")
add("xcode", "Xcode", externalOpenerEditor, "com.apple.dt.Xcode", "Xcode")
add("android-studio", "Android Studio", externalOpenerEditor, "com.google.android.studio", "Android Studio")
add("goland", "GoLand", externalOpenerEditor, "com.jetbrains.goland", "GoLand")
add("pycharm", "PyCharm", externalOpenerEditor, "com.jetbrains.pycharm", "PyCharm", "PyCharm CE")
add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "com.jetbrains.intellij", "IntelliJ IDEA", "IntelliJ IDEA Ultimate", "IntelliJ IDEA CE")
add("webstorm", "WebStorm", externalOpenerEditor, "com.jetbrains.WebStorm", "WebStorm")
add("datagrip", "DataGrip", externalOpenerEditor, "com.jetbrains.datagrip", "DataGrip")
add("codebuddy", "CodeBuddy", externalOpenerEditor, "com.tencent.codebuddy", "CodeBuddy")
add("windsurf", "Windsurf", externalOpenerEditor, "com.exafunction.windsurf", "Windsurf")
add("zed", "Zed", externalOpenerEditor, "dev.zed.Zed", "Zed")
add("sublime-text", "Sublime Text", externalOpenerEditor, "com.sublimetext.4", "Sublime Text")
add("kiro", "Kiro", externalOpenerEditor, "dev.kiro.desktop", "Kiro")
return specs
}
func darwinBundleIconPath(appPath string) string {
resources := filepath.Join(appPath, "Contents", "Resources")
infoPlist := filepath.Join(appPath, "Contents", "Info.plist")
for _, key := range []string{"CFBundleIconFile", "CFBundleIconName"} {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
out, err := exec.CommandContext(ctx, "/usr/libexec/PlistBuddy", "-c", "Print :"+key, infoPlist).Output()
cancel()
if err != nil {
continue
}
name := strings.TrimSpace(string(out))
if name == "" {
continue
}
if filepath.Ext(name) == "" {
name += ".icns"
}
path := filepath.Join(resources, name)
if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
return path
}
}
// Older and some Electron bundles omit the icon key. The application icon
// is normally the largest ICNS resource; document-type icons are much smaller.
matches, _ := filepath.Glob(filepath.Join(resources, "*.icns"))
var best string
var bestSize int64
for _, candidate := range matches {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > bestSize {
best = candidate
bestSize = info.Size()
}
}
return best
}
func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
iconPath := darwinBundleIconPath(spec.IconSource)
if iconPath == "" {
return ""
}
tempDir, err := os.MkdirTemp("", "reasonix-opener-icon-*")
if err != nil {
return ""
}
defer os.RemoveAll(tempDir)
outPath := filepath.Join(tempDir, "icon.png")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/sips", "-s", "format", "png", "-z", "64", "64", iconPath, "--out", outPath)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Run(); err != nil {
return ""
}
data, err := os.ReadFile(outPath)
if err != nil {
return ""
}
return externalOpenerPNGDataURL(data)
}
func darwinExternalOpenerCommand(spec externalOpenerSpec, path string) *exec.Cmd {
launchPath := externalOpenerLaunchPath(spec, path)
if spec.LaunchMode == "ghostty" {
return exec.Command("/usr/bin/open", "-na", spec.Target, "--args", "--working-directory="+launchPath)
}
return exec.Command("/usr/bin/open", "-a", spec.Target, launchPath)
}
func launchPlatformExternalOpener(spec externalOpenerSpec, path string, _ bool) error {
return startDetachedExternalOpener(darwinExternalOpenerCommand(spec, path))
}
-64
View File
@@ -1,64 +0,0 @@
//go:build darwin
package main
import (
"reflect"
"strings"
"testing"
)
func TestDarwinExternalOpenersUseNativeApplicationMetadata(t *testing.T) {
specs := platformExternalOpenerSpecs()
finder, ok := externalOpenerByID(specs, "finder")
if !ok {
t.Fatal("Finder must always be present on macOS")
}
if !strings.HasSuffix(finder.IconSource, "Finder.app") {
t.Fatalf("Finder icon source = %q, want native application bundle", finder.IconSource)
}
if icon := externalOpenerIconDataURL(finder); !strings.HasPrefix(icon, "data:image/png;base64,") {
t.Fatalf("Finder native icon = %q, want PNG data URL", icon)
}
if iterm, installed := externalOpenerByID(specs, "iterm"); installed && iterm.View.Name != "iTerm2" {
t.Fatalf("iTerm label = %q, want official iTerm2 name", iterm.View.Name)
}
if ghostty, installed := externalOpenerByID(specs, "ghostty"); installed && ghostty.LaunchMode != "ghostty" {
t.Fatalf("Ghostty launch mode = %q, want working-directory aware mode", ghostty.LaunchMode)
} else if installed {
path := "/tmp/reasonix workspace"
want := []string{"/usr/bin/open", "-na", ghostty.Target, "--args", "--working-directory=" + path}
if got := darwinExternalOpenerCommand(ghostty, path).Args; !reflect.DeepEqual(got, want) {
t.Fatalf("Ghostty command = %#v, want %#v", got, want)
}
}
}
func TestDarwinCatalogIncludesInstalledCodexApplications(t *testing.T) {
installed := darwinInstalledApplicationIndex()
specs := platformExternalOpenerSpecs()
for appName, id := range map[string]string{
"xcode": "xcode",
"android studio": "android-studio",
"pycharm": "pycharm",
} {
if installed[appName] == "" {
continue
}
if _, ok := externalOpenerByID(specs, id); !ok {
t.Errorf("installed %s is missing from external opener catalog", appName)
}
}
}
func TestDarwinInstalledOpenersExposeNativeIcons(t *testing.T) {
for _, spec := range platformExternalOpenerSpecs() {
if spec.IconSource == "" {
t.Errorf("%s has no native icon source", spec.View.Name)
continue
}
if icon := externalOpenerIconDataURL(spec); !strings.HasPrefix(icon, "data:image/png;base64,") {
t.Errorf("%s native icon could not be extracted", spec.View.Name)
}
}
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"strings"
"sync"
)
const maxExternalOpenerIconBytes = 768 << 10
var externalOpenerIconCache sync.Map
func externalOpenerIconDataURL(spec externalOpenerSpec) string {
source := strings.TrimSpace(spec.IconSource)
if source == "" {
return ""
}
cacheKey := source
if info, err := os.Stat(source); err == nil {
cacheKey = fmt.Sprintf("%s:%d:%d", source, info.ModTime().UnixNano(), info.Size())
}
if cached, ok := externalOpenerIconCache.Load(cacheKey); ok {
return cached.(string)
}
icon := platformExternalOpenerIconDataURL(spec)
if icon != "" {
externalOpenerIconCache.Store(cacheKey, icon)
}
return icon
}
func externalOpenerIconFileDataURL(path string) string {
info, err := os.Stat(path)
if err != nil || info.IsDir() || info.Size() <= 0 || info.Size() > maxExternalOpenerIconBytes {
return ""
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 || len(data) > maxExternalOpenerIconBytes {
return ""
}
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".png":
return externalOpenerPNGDataURL(data)
case ".jpg", ".jpeg":
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
case ".webp":
return "data:image/webp;base64," + base64.StdEncoding.EncodeToString(data)
case ".svg":
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(data)
default:
return ""
}
}
func externalOpenerPNGDataURL(data []byte) string {
if len(data) == 0 || len(data) > maxExternalOpenerIconBytes {
return ""
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data)
}
func externalOpenerPNGFromBGRAComposites(black, white []byte, width, height int) []byte {
if width <= 0 || height <= 0 || len(black) != width*height*4 || len(white) != len(black) {
return nil
}
clampByte := func(value int) uint8 {
if value < 0 {
return 0
}
if value > 255 {
return 255
}
return uint8(value)
}
imageData := image.NewNRGBA(image.Rect(0, 0, width, height))
for source := 0; source < len(black); source += 4 {
blackB, blackG, blackR := int(black[source]), int(black[source+1]), int(black[source+2])
whiteB, whiteG, whiteR := int(white[source]), int(white[source+1]), int(white[source+2])
diff := func(light, dark int) int {
if light <= dark {
return 0
}
return int(clampByte(light - dark))
}
alpha := 255 - (diff(whiteR, blackR)+diff(whiteG, blackG)+diff(whiteB, blackB))/3
destination := source
if alpha > 0 {
imageData.Pix[destination] = clampByte(blackR * 255 / alpha)
imageData.Pix[destination+1] = clampByte(blackG * 255 / alpha)
imageData.Pix[destination+2] = clampByte(blackB * 255 / alpha)
}
imageData.Pix[destination+3] = uint8(alpha)
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, imageData); err != nil {
return nil
}
return encoded.Bytes()
}
-294
View File
@@ -1,294 +0,0 @@
//go:build !darwin && !windows
package main
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type linuxDesktopEntry struct {
Path string
ID string
Name string
Icon string
}
func linuxExecutable(names ...string) string {
for _, name := range names {
if path, err := exec.LookPath(name); err == nil {
return path
}
}
return ""
}
func linuxDesktopEntries() []linuxDesktopEntry {
home, _ := os.UserHomeDir()
roots := []string{"/usr/share/applications", "/usr/local/share/applications"}
if home != "" {
roots = append([]string{filepath.Join(home, ".local", "share", "applications")}, roots...)
}
var entries []linuxDesktopEntry
for _, root := range roots {
files, err := os.ReadDir(root)
if err != nil {
continue
}
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(strings.ToLower(file.Name()), ".desktop") {
continue
}
path := filepath.Join(root, file.Name())
if entry, ok := parseLinuxDesktopEntry(path); ok {
entries = append(entries, entry)
}
}
}
return entries
}
func parseLinuxDesktopEntry(path string) (linuxDesktopEntry, bool) {
file, err := os.Open(path)
if err != nil {
return linuxDesktopEntry{}, false
}
defer file.Close()
entry := linuxDesktopEntry{Path: path, ID: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))}
inDesktopEntry := false
hidden := false
entryType := ""
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
inDesktopEntry = line == "[Desktop Entry]"
continue
}
if !inDesktopEntry || line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
switch strings.TrimSpace(key) {
case "Name":
entry.Name = strings.TrimSpace(value)
case "Icon":
entry.Icon = strings.TrimSpace(value)
case "Type":
entryType = strings.TrimSpace(value)
case "Hidden":
hidden = strings.EqualFold(strings.TrimSpace(value), "true")
}
}
if scanner.Err() != nil || hidden || (entryType != "" && entryType != "Application") || entry.Name == "" {
return linuxDesktopEntry{}, false
}
return entry, true
}
func normalizeLinuxAppName(value string) string {
value = strings.ToLower(value)
return strings.NewReplacer(" ", "", "-", "", "_", "", ".", "").Replace(value)
}
func findLinuxDesktopEntry(entries []linuxDesktopEntry, aliases ...string) (linuxDesktopEntry, bool) {
for _, alias := range aliases {
want := normalizeLinuxAppName(alias)
for _, entry := range entries {
if normalizeLinuxAppName(entry.ID) == want || normalizeLinuxAppName(entry.Name) == want {
return entry, true
}
}
}
for _, alias := range aliases {
want := normalizeLinuxAppName(alias)
if len(want) < 5 {
continue
}
for _, entry := range entries {
if strings.Contains(normalizeLinuxAppName(entry.ID), want) || strings.Contains(normalizeLinuxAppName(entry.Name), want) {
return entry, true
}
}
}
return linuxDesktopEntry{}, false
}
func linuxDefaultDirectoryDesktopEntry(entries []linuxDesktopEntry) (linuxDesktopEntry, bool) {
xdgMime := linuxExecutable("xdg-mime")
if xdgMime != "" {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
out, err := exec.CommandContext(ctx, xdgMime, "query", "default", "inode/directory").Output()
cancel()
if err == nil {
id := strings.TrimSpace(string(out))
if entry, ok := findLinuxDesktopEntry(entries, id, strings.TrimSuffix(id, ".desktop")); ok {
return entry, true
}
}
}
return findLinuxDesktopEntry(entries, "org.gnome.Nautilus", "org.kde.dolphin", "thunar")
}
func resolveLinuxDesktopIcon(icon string) string {
icon = strings.TrimSpace(icon)
if icon == "" {
return ""
}
if filepath.IsAbs(icon) {
if info, err := os.Stat(icon); err == nil && !info.IsDir() {
return icon
}
return ""
}
ext := filepath.Ext(icon)
name := strings.TrimSuffix(icon, ext)
exts := []string{".png", ".svg", ".webp", ".jpg"}
if ext != "" {
exts = []string{ext}
}
home, _ := os.UserHomeDir()
iconRoots := []string{"/usr/share/icons", "/usr/local/share/icons"}
if home != "" {
iconRoots = append([]string{filepath.Join(home, ".local", "share", "icons"), filepath.Join(home, ".icons")}, iconRoots...)
}
sizes := []string{"64x64", "128x128", "48x48", "256x256", "512x512", "scalable"}
for _, root := range iconRoots {
for _, size := range sizes {
for _, suffix := range exts {
patterns := []string{
filepath.Join(root, "hicolor", size, "apps", name+suffix),
filepath.Join(root, "*", size, "apps", name+suffix),
}
for _, pattern := range patterns {
matches, _ := filepath.Glob(pattern)
for _, match := range matches {
if info, err := os.Stat(match); err == nil && !info.IsDir() {
return match
}
}
}
}
}
}
for _, root := range []string{"/usr/share/pixmaps", "/usr/local/share/pixmaps"} {
for _, suffix := range exts {
path := filepath.Join(root, name+suffix)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path
}
}
}
return ""
}
func platformExternalOpenerSpecs() []externalOpenerSpec {
entries := linuxDesktopEntries()
gio := linuxExecutable("gio")
var specs []externalOpenerSpec
add := func(id, name, kind, mode string, executables, desktopAliases []string) {
target := linuxExecutable(executables...)
entry, hasEntry := findLinuxDesktopEntry(entries, desktopAliases...)
launchMode := mode
if target == "" && hasEntry && gio != "" {
target = entry.Path
launchMode = "gio"
}
if target == "" {
return
}
iconSource := ""
if hasEntry {
iconSource = resolveLinuxDesktopIcon(entry.Icon)
}
specs = append(specs, externalOpenerSpec{
View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
Target: target,
LaunchMode: launchMode,
IconSource: iconSource,
})
}
add("vscode", "VS Code", externalOpenerEditor, "path", []string{"code"}, []string{"code", "visual-studio-code", "com.visualstudio.code"})
add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "path", []string{"code-insiders"}, []string{"code-insiders", "visual-studio-code-insiders"})
add("cursor", "Cursor", externalOpenerEditor, "path", []string{"cursor"}, []string{"cursor", "com.todesktop.230313mzl4w4u92"})
if target := linuxExecutable("xdg-open"); target != "" {
name := "File Manager"
iconSource := ""
if entry, ok := linuxDefaultDirectoryDesktopEntry(entries); ok {
name = entry.Name
iconSource = resolveLinuxDesktopIcon(entry.Icon)
}
specs = append(specs, externalOpenerSpec{
View: ExternalOpenerView{ID: "file-manager", Name: name, Kind: externalOpenerFileManager},
Target: target,
LaunchMode: "path",
IconSource: iconSource,
})
}
add("ghostty", "Ghostty", externalOpenerTerminal, "ghostty", []string{"ghostty"}, []string{"com.mitchellh.ghostty", "ghostty"})
add("gnome-terminal", "GNOME Terminal", externalOpenerTerminal, "gnome-terminal", []string{"gnome-terminal"}, []string{"org.gnome.Terminal", "gnome-terminal"})
add("konsole", "Konsole", externalOpenerTerminal, "konsole", []string{"konsole"}, []string{"org.kde.konsole", "konsole"})
add("kitty", "Kitty", externalOpenerTerminal, "kitty", []string{"kitty"}, []string{"kitty"})
add("alacritty", "Alacritty", externalOpenerTerminal, "alacritty", []string{"alacritty"}, []string{"Alacritty", "alacritty"})
add("terminal", "Terminal", externalOpenerTerminal, "cwd", []string{"x-terminal-emulator"}, []string{"terminal"})
add("android-studio", "Android Studio", externalOpenerEditor, "path", []string{"android-studio", "studio", "studio.sh"}, []string{"android-studio", "com.google.AndroidStudio"})
add("goland", "GoLand", externalOpenerEditor, "path", []string{"goland", "goland.sh"}, []string{"jetbrains-goland", "goland"})
add("pycharm", "PyCharm", externalOpenerEditor, "path", []string{"pycharm", "pycharm.sh"}, []string{"jetbrains-pycharm", "pycharm"})
add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "path", []string{"idea", "idea.sh", "intellij-idea"}, []string{"jetbrains-idea", "intellij-idea"})
add("webstorm", "WebStorm", externalOpenerEditor, "path", []string{"webstorm", "webstorm.sh"}, []string{"jetbrains-webstorm", "webstorm"})
add("datagrip", "DataGrip", externalOpenerEditor, "path", []string{"datagrip", "datagrip.sh"}, []string{"jetbrains-datagrip", "datagrip"})
add("codebuddy", "CodeBuddy", externalOpenerEditor, "path", []string{"codebuddy"}, []string{"codebuddy", "com.tencent.codebuddy"})
add("windsurf", "Windsurf", externalOpenerEditor, "path", []string{"windsurf"}, []string{"windsurf", "com.exafunction.windsurf"})
add("zed", "Zed", externalOpenerEditor, "path", []string{"zed"}, []string{"dev.zed.Zed", "zed"})
add("sublime-text", "Sublime Text", externalOpenerEditor, "path", []string{"subl", "sublime_text"}, []string{"sublime_text", "sublime-text"})
add("kiro", "Kiro", externalOpenerEditor, "path", []string{"kiro"}, []string{"kiro", "dev.kiro.desktop"})
return specs
}
func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
return externalOpenerIconFileDataURL(spec.IconSource)
}
func linuxExternalOpenerCommand(spec externalOpenerSpec, path string) (*exec.Cmd, error) {
target := spec.Target
launchPath := externalOpenerLaunchPath(spec, path)
var args []string
switch spec.LaunchMode {
case "path":
args = []string{path}
case "gio":
args = []string{"launch", spec.Target, path}
target = linuxExecutable("gio")
case "ghostty", "gnome-terminal":
args = []string{"--working-directory=" + launchPath}
case "konsole":
args = []string{"--workdir", launchPath}
case "kitty":
args = []string{"--directory", launchPath}
case "alacritty":
args = []string{"--working-directory", launchPath}
}
if target == "" {
return nil, os.ErrNotExist
}
cmd := exec.Command(target, args...)
cmd.Dir = externalOpenerWorkingDirectory(path)
return cmd, nil
}
func launchPlatformExternalOpener(spec externalOpenerSpec, path string, _ bool) error {
cmd, err := linuxExternalOpenerCommand(spec, path)
if err != nil {
return err
}
return startDetachedExternalOpener(cmd)
}
-188
View File
@@ -1,188 +0,0 @@
//go:build !darwin && !windows
package main
import (
"errors"
"os"
"path/filepath"
"reflect"
"testing"
)
func writeLinuxDesktopFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestParseLinuxDesktopEntryReadsDesktopEntrySectionOnly(t *testing.T) {
dir := t.TempDir()
path := writeLinuxDesktopFile(t, dir, "code.desktop", `[Desktop Entry]
Type=Application
Name=Visual Studio Code
Icon=vscode
[Desktop Action new-window]
Name=New Window
Icon=new-window
`)
entry, ok := parseLinuxDesktopEntry(path)
if !ok {
t.Fatal("valid desktop entry was rejected")
}
want := linuxDesktopEntry{Path: path, ID: "code", Name: "Visual Studio Code", Icon: "vscode"}
if entry != want {
t.Fatalf("parseLinuxDesktopEntry = %+v, want %+v", entry, want)
}
}
func TestParseLinuxDesktopEntryRejectsHiddenAndNonApplications(t *testing.T) {
dir := t.TempDir()
for name, content := range map[string]string{
"hidden.desktop": "[Desktop Entry]\nType=Application\nName=Ghost\nHidden=true\n",
"link.desktop": "[Desktop Entry]\nType=Link\nName=Bookmark\n",
"unnamed.desktop": "[Desktop Entry]\nType=Application\n",
"wrong-section.desktop": "[Desktop Action open]\nName=Open\n",
} {
path := writeLinuxDesktopFile(t, dir, name, content)
if entry, ok := parseLinuxDesktopEntry(path); ok {
t.Errorf("%s was accepted as %+v, want rejection", name, entry)
}
}
}
func TestFindLinuxDesktopEntryMatchesNormalizedAliasesThenSubstrings(t *testing.T) {
entries := []linuxDesktopEntry{
{ID: "org.gnome.Terminal", Name: "Terminal"},
{ID: "code", Name: "Visual Studio Code"},
{ID: "jetbrains-idea", Name: "IntelliJ IDEA Ultimate Edition"},
{ID: "kitty", Name: "kitty"},
}
if entry, ok := findLinuxDesktopEntry(entries, "org.gnome.terminal"); !ok || entry.ID != "org.gnome.Terminal" {
t.Fatalf("normalized id lookup = (%+v, %v), want org.gnome.Terminal", entry, ok)
}
if entry, ok := findLinuxDesktopEntry(entries, "visual-studio-code"); !ok || entry.ID != "code" {
t.Fatalf("normalized name lookup = (%+v, %v), want code", entry, ok)
}
if entry, ok := findLinuxDesktopEntry(entries, "intellij-idea"); !ok || entry.ID != "jetbrains-idea" {
t.Fatalf("substring lookup = (%+v, %v), want jetbrains-idea", entry, ok)
}
if entry, ok := findLinuxDesktopEntry(entries, "kit"); ok {
t.Fatalf("short alias %q unexpectedly matched %+v via substring", "kit", entry)
}
}
func TestLinuxExternalOpenerCommandPerTerminalArguments(t *testing.T) {
const workdir = "/tmp/reasonix workspace"
cases := []struct {
mode string
target string
want []string
}{
{"path", "/usr/bin/code", []string{"/usr/bin/code", workdir}},
{"ghostty", "/usr/bin/ghostty", []string{"/usr/bin/ghostty", "--working-directory=" + workdir}},
{"gnome-terminal", "/usr/bin/gnome-terminal", []string{"/usr/bin/gnome-terminal", "--working-directory=" + workdir}},
{"konsole", "/usr/bin/konsole", []string{"/usr/bin/konsole", "--workdir", workdir}},
{"kitty", "/usr/bin/kitty", []string{"/usr/bin/kitty", "--directory", workdir}},
{"alacritty", "/usr/bin/alacritty", []string{"/usr/bin/alacritty", "--working-directory", workdir}},
{"cwd", "/usr/bin/x-terminal-emulator", []string{"/usr/bin/x-terminal-emulator"}},
}
for _, tc := range cases {
spec := externalOpenerSpec{Target: tc.target, LaunchMode: tc.mode}
cmd, err := linuxExternalOpenerCommand(spec, workdir)
if err != nil {
t.Errorf("%s: linuxExternalOpenerCommand error = %v", tc.mode, err)
continue
}
if !reflect.DeepEqual(cmd.Args, tc.want) {
t.Errorf("%s: args = %#v, want %#v", tc.mode, cmd.Args, tc.want)
}
if cmd.Dir != workdir {
t.Errorf("%s: dir = %q, want workspace directory", tc.mode, cmd.Dir)
}
}
}
func TestLinuxExternalOpenerCommandUsesParentForRegularFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "report.md")
if err := os.WriteFile(path, []byte("report"), 0o600); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
spec externalOpenerSpec
want []string
}{
{
name: "editor",
spec: externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerEditor}, Target: "/usr/bin/code", LaunchMode: "path"},
want: []string{"/usr/bin/code", path},
},
{
name: "terminal",
spec: externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerTerminal}, Target: "/usr/bin/ghostty", LaunchMode: "ghostty"},
want: []string{"/usr/bin/ghostty", "--working-directory=" + dir},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd, err := linuxExternalOpenerCommand(tc.spec, path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cmd.Args, tc.want) {
t.Fatalf("args = %#v, want %#v", cmd.Args, tc.want)
}
if cmd.Dir != dir {
t.Fatalf("dir = %q, want parent directory %q", cmd.Dir, dir)
}
})
}
}
func TestLinuxExternalOpenerCommandLaunchesDesktopEntriesViaGio(t *testing.T) {
binDir := t.TempDir()
gio := filepath.Join(binDir, "gio")
if err := os.WriteFile(gio, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir)
spec := externalOpenerSpec{Target: "/usr/share/applications/code.desktop", LaunchMode: "gio"}
cmd, err := linuxExternalOpenerCommand(spec, "/tmp/project")
if err != nil {
t.Fatalf("linuxExternalOpenerCommand error = %v", err)
}
want := []string{gio, "launch", "/usr/share/applications/code.desktop", "/tmp/project"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("gio args = %#v, want %#v", cmd.Args, want)
}
t.Setenv("PATH", t.TempDir())
if _, err := linuxExternalOpenerCommand(spec, "/tmp/project"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing gio error = %v, want os.ErrNotExist", err)
}
}
func TestResolveLinuxDesktopIconAcceptsOnlyExistingAbsoluteFiles(t *testing.T) {
dir := t.TempDir()
icon := filepath.Join(dir, "vscode.png")
if err := os.WriteFile(icon, []byte("png"), 0o600); err != nil {
t.Fatal(err)
}
if got := resolveLinuxDesktopIcon(icon); got != icon {
t.Fatalf("existing absolute icon = %q, want %q", got, icon)
}
if got := resolveLinuxDesktopIcon(filepath.Join(dir, "missing.png")); got != "" {
t.Fatalf("missing absolute icon = %q, want empty", got)
}
if got := resolveLinuxDesktopIcon(dir); got != "" {
t.Fatalf("directory icon = %q, want empty", got)
}
}
-371
View File
@@ -1,371 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"image/color"
"image/png"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func testExternalOpener(id, name, kind string) externalOpenerSpec {
return externalOpenerSpec{View: ExternalOpenerView{ID: id, Name: name, Kind: kind}, Target: id}
}
func TestResolveExternalOpenerPrefersInstalledSelection(t *testing.T) {
specs := []externalOpenerSpec{
testExternalOpener("files", "Files", externalOpenerFileManager),
testExternalOpener("code", "Code", externalOpenerEditor),
}
got, ok := resolveExternalOpener(specs, " CODE ")
if !ok || got.View.ID != "code" {
t.Fatalf("resolveExternalOpener = (%+v, %v), want installed code", got, ok)
}
}
func TestResolveExternalOpenerFallsBackAcrossOperatingSystems(t *testing.T) {
specs := []externalOpenerSpec{
testExternalOpener("files", "Files", externalOpenerFileManager),
testExternalOpener("code", "Code", externalOpenerEditor),
}
got, ok := resolveExternalOpener(specs, "finder")
if !ok || got.View.ID != "files" {
t.Fatalf("resolveExternalOpener unavailable preference = (%+v, %v), want file manager fallback", got, ok)
}
}
func TestExternalOpenerViewsAreStableAndDeduplicated(t *testing.T) {
specs := []externalOpenerSpec{
testExternalOpener("code", "VS Code", externalOpenerEditor),
testExternalOpener("CODE", "Duplicate", externalOpenerEditor),
testExternalOpener("", "Invalid", externalOpenerEditor),
}
want := []ExternalOpenerView{{ID: "code", Name: "VS Code", Kind: externalOpenerEditor}}
if got := externalOpenerViews(specs); !reflect.DeepEqual(got, want) {
t.Fatalf("externalOpenerViews = %+v, want %+v", got, want)
}
}
func TestExternalOpenerCatalogCachesUntilTTLExpires(t *testing.T) {
now := time.Unix(100, 0)
discoveryCalls := 0
cache := newExternalOpenerCatalogCache(15*time.Second, func() []externalOpenerSpec {
discoveryCalls++
return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)}
})
cache.now = func() time.Time { return now }
first := cache.get()
first[0].View.Name = "mutated"
if got := cache.get(); discoveryCalls != 1 || got[0].View.Name != "Code" {
t.Fatalf("fresh cache = (%d calls, %+v), want one isolated discovery result", discoveryCalls, got)
}
now = now.Add(15 * time.Second)
if got := cache.get(); discoveryCalls != 2 || got[0].View.Name != "Code" {
t.Fatalf("expired cache = (%d calls, %+v), want a refreshed result", discoveryCalls, got)
}
}
func TestExternalOpenerCatalogCoalescesConcurrentRefreshes(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var callsMu sync.Mutex
discoveryCalls := 0
cache := newExternalOpenerCatalogCache(time.Minute, func() []externalOpenerSpec {
callsMu.Lock()
discoveryCalls++
callsMu.Unlock()
close(started)
<-release
return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)}
})
const callers = 8
results := make(chan []externalOpenerSpec, callers)
for range callers {
go func() { results <- cache.get() }()
}
<-started
close(release)
for range callers {
if got := <-results; len(got) != 1 || got[0].View.ID != "code" {
t.Fatalf("coalesced cache result = %+v, want code", got)
}
}
callsMu.Lock()
defer callsMu.Unlock()
if discoveryCalls != 1 {
t.Fatalf("concurrent discovery calls = %d, want 1", discoveryCalls)
}
}
func BenchmarkExternalOpenerCatalogCacheHit(b *testing.B) {
cache := newExternalOpenerCatalogCache(time.Minute, func() []externalOpenerSpec {
return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)}
})
cache.get()
b.ResetTimer()
for b.Loop() {
cache.get()
}
}
func TestPlatformExternalOpenersHaveUniqueSafeIds(t *testing.T) {
specs := platformExternalOpenerSpecs()
if len(specs) == 0 {
t.Fatal("platformExternalOpenerSpecs returned no fallback opener")
}
views := externalOpenerViews(specs)
if len(views) != len(specs) {
t.Fatalf("platform opener ids are invalid or duplicated: specs=%+v views=%+v", specs, views)
}
if _, ok := resolveExternalOpener(specs, "definitely-not-installed"); !ok {
t.Fatal("platform opener list has no usable fallback")
}
}
func TestSetPreferredExternalOpenerRejectsRendererCommands(t *testing.T) {
app := NewApp()
for _, id := range []string{"", "../../bin/sh", "vscode; rm -rf /"} {
if err := app.SetPreferredExternalOpener(id); err == nil {
t.Fatalf("SetPreferredExternalOpener(%q) unexpectedly succeeded", id)
}
}
}
func TestExternalOpenerWorkspaceCapabilityUsesTheTabDirectoryNotScope(t *testing.T) {
projectRoot := t.TempDir()
globalRoot := t.TempDir()
missingRoot := filepath.Join(t.TempDir(), "missing")
fileRoot := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(fileRoot, []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot},
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot},
"missing": {ID: "missing", Scope: "project", WorkspaceRoot: missingRoot},
"file": {ID: "file", Scope: "global", WorkspaceRoot: fileRoot},
"empty": {ID: "empty", Scope: "global"},
}
for _, tabID := range []string{"project", "global"} {
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err != nil {
t.Errorf("externalOpenerWorkspacePathForTab(%q) = %v, want available", tabID, err)
}
}
for _, tabID := range []string{"missing", "file", "empty", "unknown"} {
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err == nil {
t.Errorf("externalOpenerWorkspacePathForTab(%q) succeeded, want unavailable", tabID)
}
}
}
func TestExternalOpenersForGlobalTabReportsWorkspaceCapability(t *testing.T) {
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"global": {ID: "global", Scope: "global", WorkspaceRoot: t.TempDir()},
}
view := app.ExternalOpenersForTab("global")
if !view.WorkspaceOpenable {
t.Fatal("ExternalOpenersForTab(global) workspaceOpenable = false, want true")
}
if view.Openers == nil {
t.Fatal("ExternalOpenersForTab(global) openers = nil, want a Wails-safe array")
}
unavailable := app.ExternalOpenersForTab("unknown")
if unavailable.WorkspaceOpenable || unavailable.Openers == nil {
t.Fatalf("ExternalOpenersForTab(unknown) = %+v, want unavailable with an empty Wails-safe array", unavailable)
}
}
func TestLocalSaveDestinationIsSourceDetectsFilesystemAliases(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "Readme.md")
if err := os.WriteFile(source, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
info, err := os.Stat(source)
if err != nil {
t.Fatal(err)
}
hardLink := filepath.Join(dir, "readme-hardlink.md")
if err := os.Link(source, hardLink); err != nil {
t.Fatalf("create hard link: %v", err)
}
if same, err := localSaveDestinationIsSource(info, hardLink); err != nil || !same {
t.Fatalf("hard-link alias = (%v, %v), want (true, nil)", same, err)
}
symlink := filepath.Join(dir, "readme-symlink.md")
if err := os.Symlink(source, symlink); err == nil {
if same, err := localSaveDestinationIsSource(info, symlink); err != nil || !same {
t.Fatalf("symlink alias = (%v, %v), want (true, nil)", same, err)
}
}
missing := filepath.Join(dir, "new-copy.md")
if same, err := localSaveDestinationIsSource(info, missing); err != nil || same {
t.Fatalf("missing destination = (%v, %v), want (false, nil)", same, err)
}
}
func TestCopyLocalPathAsRejectsAliasWithoutChangingSource(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source.md")
alias := filepath.Join(dir, "alias.md")
content := []byte("source content")
if err := os.WriteFile(source, content, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Link(source, alias); err != nil {
t.Fatalf("create hard link: %v", err)
}
if err := copyLocalPathAs(source, alias); err == nil {
t.Fatal("copyLocalPathAs(alias) succeeded, want same-source error")
}
if got, err := os.ReadFile(source); err != nil || !reflect.DeepEqual(got, content) {
t.Fatalf("source after rejected alias copy = (%q, %v), want original content", got, err)
}
}
func TestCopyLocalPathAsReplacesDestinationWithoutChangingSource(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source.md")
target := filepath.Join(dir, "target.md")
sourceContent := []byte("source content")
if err := os.WriteFile(source, sourceContent, 0o640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte("old destination"), 0o600); err != nil {
t.Fatal(err)
}
if err := copyLocalPathAs(source, target); err != nil {
t.Fatalf("copyLocalPathAs = %v", err)
}
if got, err := os.ReadFile(target); err != nil || !reflect.DeepEqual(got, sourceContent) {
t.Fatalf("destination after copy = (%q, %v), want source content", got, err)
}
if got, err := os.ReadFile(source); err != nil || !reflect.DeepEqual(got, sourceContent) {
t.Fatalf("source after copy = (%q, %v), want unchanged source", got, err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".target.md.reasonix-copy-") {
t.Fatalf("temporary copy was not cleaned up: %s", entry.Name())
}
}
}
func TestExternalOpenerLaunchPathUsesParentForTerminalFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "report.md")
if err := os.WriteFile(path, []byte("report"), 0o600); err != nil {
t.Fatal(err)
}
terminal := externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerTerminal}, LaunchMode: "application"}
if got := externalOpenerLaunchPath(terminal, path); got != dir {
t.Fatalf("terminal launch path = %q, want parent directory %q", got, dir)
}
editor := externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerEditor}, LaunchMode: "application"}
if got := externalOpenerLaunchPath(editor, path); got != path {
t.Fatalf("editor launch path = %q, want file %q", got, path)
}
}
func TestExternalOpenerIconFileDataURLAcceptsBoundedImages(t *testing.T) {
path := filepath.Join(t.TempDir(), "icon.png")
if err := os.WriteFile(path, []byte("png-data"), 0o600); err != nil {
t.Fatal(err)
}
got := externalOpenerIconFileDataURL(path)
if !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("externalOpenerIconFileDataURL = %q, want PNG data URL", got)
}
}
func TestExternalOpenerPNGRestoresAlphaFromBlackAndWhiteComposites(t *testing.T) {
black := []byte{
0, 0, 0, 255,
0, 0, 0, 255,
0, 0, 128, 255,
}
white := []byte{
0, 0, 0, 255,
255, 255, 255, 255,
127, 127, 255, 255,
}
encoded := externalOpenerPNGFromBGRAComposites(black, white, 3, 1)
decoded, err := png.Decode(bytes.NewReader(encoded))
if err != nil {
t.Fatal(err)
}
want := []color.NRGBA{
{R: 0, G: 0, B: 0, A: 255},
{0, 0, 0, 0},
{R: 255, G: 0, B: 0, A: 128},
}
for x, expected := range want {
got := color.NRGBAModel.Convert(decoded.At(x, 0)).(color.NRGBA)
if got != expected {
t.Fatalf("pixel %d = %#v, want %#v", x, got, expected)
}
}
}
func TestExternalOpenerViewIconIsBackwardCompatible(t *testing.T) {
withoutIcon, err := json.Marshal(ExternalOpenerView{ID: "vscode", Name: "VS Code", Kind: externalOpenerEditor})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(withoutIcon), "iconDataUrl") {
t.Fatalf("empty optional icon should be omitted: %s", withoutIcon)
}
withIcon, err := json.Marshal(ExternalOpenerView{ID: "vscode", Name: "VS Code", Kind: externalOpenerEditor, IconDataURL: "data:image/png;base64,AA=="})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(withIcon), `"iconDataUrl":"data:image/png;base64,AA=="`) {
t.Fatalf("native icon missing from JSON contract: %s", withIcon)
}
withoutCapability, err := json.Marshal(ExternalOpenersView{Openers: []ExternalOpenerView{}, Preferred: "finder"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(withoutCapability), "workspaceOpenable") {
t.Fatalf("false workspace capability should be omitted for old readers: %s", withoutCapability)
}
withCapability, err := json.Marshal(ExternalOpenersView{
Openers: []ExternalOpenerView{},
Preferred: "finder",
WorkspaceOpenable: true,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(withCapability), `"workspaceOpenable":true`) {
t.Fatalf("workspace capability missing from JSON contract: %s", withCapability)
}
var oldReader struct {
Openers []ExternalOpenerView `json:"openers"`
Preferred string `json:"preferred"`
}
if err := json.Unmarshal(withCapability, &oldReader); err != nil || oldReader.Preferred != "finder" || oldReader.Openers == nil {
t.Fatalf("old reader rejected additive workspace capability: reader=%+v err=%v", oldReader, err)
}
}
-395
View File
@@ -1,395 +0,0 @@
//go:build windows
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
const (
shgfiIcon = 0x000000100
shgfiLargeIcon = 0x000000000
dibRGBColors = 0
drawIconNormal = 0x0003
windowsOpenerIconWidth = 32
)
type windowsShellFileInfo struct {
Icon windows.Handle
IconIndex int32
Attributes uint32
DisplayName [260]uint16
TypeName [80]uint16
}
type windowsBitmapInfoHeader struct {
Size uint32
Width int32
Height int32
Planes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter int32
YPelsPerMeter int32
ColorsUsed uint32
ColorsNeeded uint32
}
type windowsBitmapInfo struct {
Header windowsBitmapInfoHeader
Colors [1]uint32
}
var (
windowsShell32 = windows.NewLazySystemDLL("shell32.dll")
windowsUser32 = windows.NewLazySystemDLL("user32.dll")
windowsGDI32 = windows.NewLazySystemDLL("gdi32.dll")
windowsSHGetFileInfo = windowsShell32.NewProc("SHGetFileInfoW")
windowsDestroyIcon = windowsUser32.NewProc("DestroyIcon")
windowsDrawIconEx = windowsUser32.NewProc("DrawIconEx")
windowsGetDC = windowsUser32.NewProc("GetDC")
windowsReleaseDC = windowsUser32.NewProc("ReleaseDC")
windowsCreateCompatibleDC = windowsGDI32.NewProc("CreateCompatibleDC")
windowsCreateDIBSection = windowsGDI32.NewProc("CreateDIBSection")
windowsSelectObject = windowsGDI32.NewProc("SelectObject")
windowsDeleteObject = windowsGDI32.NewProc("DeleteObject")
windowsDeleteDC = windowsGDI32.NewProc("DeleteDC")
)
func joinWindowsInstallPath(root string, parts ...string) string {
if root == "" {
return ""
}
return filepath.Join(append([]string{root}, parts...)...)
}
func firstWindowsExecutable(names []string, candidates ...string) string {
for _, name := range names {
if path, err := exec.LookPath(name); err == nil {
return path
}
if path := windowsAppPathExecutable(name); path != "" {
return path
}
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if matches, _ := filepath.Glob(candidate); len(matches) > 0 {
for _, match := range matches {
if info, err := os.Stat(match); err == nil && !info.IsDir() {
return match
}
}
}
}
return ""
}
// windowsAppPathExecutable resolves an install registered under
// HKCU/HKLM ...\App Paths\<name>. Custom VS Code / editor installs often only
// appear there, not on PATH or under the default Program Files trees.
func windowsAppPathExecutable(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
subKey := `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\` + name
for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} {
key, err := registry.OpenKey(root, subKey, registry.QUERY_VALUE)
if err != nil {
continue
}
raw, _, err := key.GetStringValue("")
key.Close()
if err != nil {
continue
}
path := strings.Trim(strings.TrimSpace(raw), `"`)
if path == "" {
continue
}
path = os.ExpandEnv(path)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path
}
}
return ""
}
// windowsTerminalIconSource prefers a real Windows Terminal package binary for
// SHGetFileInfo. Store installs expose wt.exe as an App Execution Alias (often
// zero bytes under LocalAppData\Microsoft\WindowsApps). The package binary may
// live under Program Files\WindowsApps, but non-elevated processes frequently
// cannot enumerate that protected directory — so a renderable console-host
// fallback must win over a zero-byte alias (see pickWindowsTerminalIconSource).
func windowsTerminalIconSource(wtPath string) string {
var resolved []windowsIconCandidate
for _, candidate := range windowsTerminalIconCandidatePaths(
wtPath,
os.Getenv("LOCALAPPDATA"),
os.Getenv("ProgramFiles"),
) {
matches := []string{candidate}
if strings.ContainsAny(candidate, `*?[`) {
globbed, err := filepath.Glob(candidate)
if err != nil || len(globbed) == 0 {
continue
}
matches = globbed
}
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.IsDir() {
continue
}
resolved = append(resolved, windowsIconCandidate{Path: match, Size: info.Size()})
}
}
// Prefer a normal console host icon over a zero-byte wt.exe alias so the
// menu never ships a blank glyph when WindowsApps is unreadable.
renderable := firstWindowsExecutable([]string{"powershell.exe"},
joinWindowsInstallPath(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
if picked := pickWindowsTerminalIconSource(resolved, renderable); picked != "" {
return picked
}
return strings.TrimSpace(wtPath)
}
// shellExecuteOpenFile launches file via ShellExecuteW("open") without cmd.exe.
// parameters and directory are optional; directory becomes the process CWD.
func shellExecuteOpenFile(file, parameters, directory string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
filePtr, err := windows.UTF16PtrFromString(file)
if err != nil {
return err
}
var paramsPtr *uint16
if parameters != "" {
paramsPtr, err = windows.UTF16PtrFromString(parameters)
if err != nil {
return err
}
}
var dirPtr *uint16
if directory != "" {
dirPtr, err = windows.UTF16PtrFromString(directory)
if err != nil {
return err
}
}
return windows.ShellExecute(0, verb, filePtr, paramsPtr, dirPtr, windows.SW_SHOWNORMAL)
}
func platformExternalOpenerSpecs() []externalOpenerSpec {
local := os.Getenv("LOCALAPPDATA")
programFiles := os.Getenv("ProgramFiles")
programFilesX86 := os.Getenv("ProgramFiles(x86)")
windowsDir := os.Getenv("WINDIR")
var specs []externalOpenerSpec
add := func(id, name, kind, mode string, names []string, candidates ...string) {
if path := firstWindowsExecutable(names, candidates...); path != "" {
specs = append(specs, externalOpenerSpec{
View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
Target: path,
LaunchMode: mode,
IconSource: path,
})
}
}
jetbrainsProgram := func(product, executable string) string {
return joinWindowsInstallPath(programFiles, "JetBrains", product+" *", "bin", executable)
}
jetbrainsToolbox := func(product, executable string) string {
return joinWindowsInstallPath(local, "JetBrains", "Toolbox", "apps", product, "*", "*", "bin", executable)
}
add("vscode", "VS Code", externalOpenerEditor, "path", []string{"Code.exe"},
joinWindowsInstallPath(local, "Programs", "Microsoft VS Code", "Code.exe"),
joinWindowsInstallPath(programFiles, "Microsoft VS Code", "Code.exe"),
joinWindowsInstallPath(programFilesX86, "Microsoft VS Code", "Code.exe"))
add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "path", []string{"Code - Insiders.exe"},
joinWindowsInstallPath(local, "Programs", "Microsoft VS Code Insiders", "Code - Insiders.exe"))
add("cursor", "Cursor", externalOpenerEditor, "path", []string{"Cursor.exe"},
joinWindowsInstallPath(local, "Programs", "cursor", "Cursor.exe"),
joinWindowsInstallPath(programFiles, "Cursor", "Cursor.exe"))
add("file-explorer", "File Explorer", externalOpenerFileManager, "shell-open", []string{"explorer.exe"},
joinWindowsInstallPath(windowsDir, "explorer.exe"))
if wt := firstWindowsExecutable([]string{"wt.exe"}); wt != "" {
specs = append(specs, externalOpenerSpec{
View: ExternalOpenerView{ID: "windows-terminal", Name: "Windows Terminal", Kind: externalOpenerTerminal},
Target: wt,
LaunchMode: "windows-terminal",
IconSource: windowsTerminalIconSource(wt),
})
}
add("powershell", "PowerShell", externalOpenerTerminal, "console", []string{"pwsh.exe", "powershell.exe"},
joinWindowsInstallPath(windowsDir, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
add("command-prompt", "Command Prompt", externalOpenerTerminal, "console", []string{"cmd.exe"},
joinWindowsInstallPath(windowsDir, "System32", "cmd.exe"))
add("android-studio", "Android Studio", externalOpenerEditor, "path", []string{"studio64.exe", "studio.exe"},
joinWindowsInstallPath(programFiles, "Android", "Android Studio", "bin", "studio64.exe"))
add("goland", "GoLand", externalOpenerEditor, "path", []string{"goland64.exe", "goland.exe"},
jetbrainsProgram("GoLand", "goland64.exe"), jetbrainsToolbox("GoLand", "goland64.exe"))
add("pycharm", "PyCharm", externalOpenerEditor, "path", []string{"pycharm64.exe", "pycharm.exe"},
jetbrainsProgram("PyCharm", "pycharm64.exe"), jetbrainsToolbox("PyCharm*", "pycharm64.exe"))
add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "path", []string{"idea64.exe", "idea.exe"},
jetbrainsProgram("IntelliJ IDEA", "idea64.exe"), jetbrainsToolbox("IDEA*", "idea64.exe"))
add("webstorm", "WebStorm", externalOpenerEditor, "path", []string{"webstorm64.exe", "webstorm.exe"},
jetbrainsProgram("WebStorm", "webstorm64.exe"), jetbrainsToolbox("WebStorm", "webstorm64.exe"))
add("datagrip", "DataGrip", externalOpenerEditor, "path", []string{"datagrip64.exe", "datagrip.exe"},
jetbrainsProgram("DataGrip", "datagrip64.exe"), jetbrainsToolbox("DataGrip", "datagrip64.exe"))
add("codebuddy", "CodeBuddy", externalOpenerEditor, "path", []string{"CodeBuddy.exe"},
joinWindowsInstallPath(local, "Programs", "CodeBuddy", "CodeBuddy.exe"),
joinWindowsInstallPath(programFiles, "CodeBuddy", "CodeBuddy.exe"))
add("windsurf", "Windsurf", externalOpenerEditor, "path", []string{"Windsurf.exe"},
joinWindowsInstallPath(local, "Programs", "Windsurf", "Windsurf.exe"))
add("zed", "Zed", externalOpenerEditor, "path", []string{"zed.exe"},
joinWindowsInstallPath(local, "Programs", "Zed", "zed.exe"))
add("sublime-text", "Sublime Text", externalOpenerEditor, "path", []string{"sublime_text.exe"},
joinWindowsInstallPath(programFiles, "Sublime Text", "sublime_text.exe"))
add("kiro", "Kiro", externalOpenerEditor, "path", []string{"Kiro.exe"},
joinWindowsInstallPath(local, "Programs", "Kiro", "Kiro.exe"))
return specs
}
func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
if spec.IconSource == "" {
return ""
}
path, err := windows.UTF16PtrFromString(spec.IconSource)
if err != nil {
return ""
}
var info windowsShellFileInfo
result, _, _ := windowsSHGetFileInfo.Call(
uintptr(unsafe.Pointer(path)),
0,
uintptr(unsafe.Pointer(&info)),
unsafe.Sizeof(info),
shgfiIcon|shgfiLargeIcon,
)
if result == 0 || info.Icon == 0 {
return ""
}
defer windowsDestroyIcon.Call(uintptr(info.Icon))
black, ok := renderWindowsExternalOpenerIcon(info.Icon, 0)
if !ok {
return ""
}
white, ok := renderWindowsExternalOpenerIcon(info.Icon, 255)
if !ok {
return ""
}
return externalOpenerPNGDataURL(externalOpenerPNGFromBGRAComposites(
black,
white,
windowsOpenerIconWidth,
windowsOpenerIconWidth,
))
}
func renderWindowsExternalOpenerIcon(icon windows.Handle, background byte) ([]byte, bool) {
screenDC, _, _ := windowsGetDC.Call(0)
if screenDC == 0 {
return nil, false
}
defer windowsReleaseDC.Call(0, screenDC)
memoryDC, _, _ := windowsCreateCompatibleDC.Call(screenDC)
if memoryDC == 0 {
return nil, false
}
defer windowsDeleteDC.Call(memoryDC)
bitmapInfo := windowsBitmapInfo{Header: windowsBitmapInfoHeader{
Size: uint32(unsafe.Sizeof(windowsBitmapInfoHeader{})),
Width: windowsOpenerIconWidth,
Height: -windowsOpenerIconWidth,
Planes: 1,
BitCount: 32,
Compression: 0,
}}
var bits unsafe.Pointer
bitmap, _, _ := windowsCreateDIBSection.Call(
memoryDC,
uintptr(unsafe.Pointer(&bitmapInfo)),
dibRGBColors,
uintptr(unsafe.Pointer(&bits)),
0,
0,
)
if bitmap == 0 || bits == nil {
return nil, false
}
defer windowsDeleteObject.Call(bitmap)
previous, _, _ := windowsSelectObject.Call(memoryDC, bitmap)
if previous == 0 {
return nil, false
}
defer windowsSelectObject.Call(memoryDC, previous)
pixels := unsafe.Slice((*byte)(bits), windowsOpenerIconWidth*windowsOpenerIconWidth*4)
for offset := 0; offset < len(pixels); offset += 4 {
pixels[offset] = background
pixels[offset+1] = background
pixels[offset+2] = background
pixels[offset+3] = 255
}
drawn, _, _ := windowsDrawIconEx.Call(
memoryDC,
0,
0,
uintptr(icon),
windowsOpenerIconWidth,
windowsOpenerIconWidth,
0,
0,
drawIconNormal,
)
if drawn == 0 {
return nil, false
}
return append([]byte(nil), pixels...), true
}
func launchPlatformExternalOpener(spec externalOpenerSpec, path string, isDir bool) error {
var cmd *exec.Cmd
launchPath := externalOpenerLaunchPath(spec, path)
switch spec.LaunchMode {
case "shell-open":
return openWorkspacePathWithType(path, isDir)
case "path":
cmd = exec.Command(spec.Target, path)
case "windows-terminal":
// exec.Command uses CreateProcess argument escaping, not cmd.exe, so
// workspace paths with shell metacharacters stay a single -d argument.
cmd = exec.Command(spec.Target, "-d", launchPath)
case "console":
// Never route console openers through cmd.exe / start: working-directory
// text would be re-parsed as shell syntax (& | ^ etc.). ShellExecute
// opens the binary with lpDirectory set to the workspace path.
plan := planWindowsConsoleLaunch(spec.Target, launchPath)
if plan.File == "" {
return os.ErrNotExist
}
return shellExecuteOpenFile(plan.File, "", plan.Dir)
default:
cmd = exec.Command(spec.Target, path)
}
return startDetachedExternalOpener(cmd)
}
@@ -1,99 +0,0 @@
package main
import (
"path/filepath"
"strings"
)
// windowsTerminalIconCandidatePaths returns ordered icon lookup paths for
// Windows Terminal. Store installs register only a wt.exe App Execution Alias
// under LocalAppData\Microsoft\WindowsApps; the real WindowsTerminal.exe lives
// under Program Files\WindowsApps\Microsoft.WindowsTerminal_*.
//
// Non-elevated processes often cannot glob the protected WindowsApps package
// directory, so callers must treat a missing package hit as normal and apply
// pickWindowsTerminalIconSource (renderable fallback before zero-byte aliases).
//
// Patterns may contain filepath globs; callers expand them on the host.
func windowsTerminalIconCandidatePaths(wtPath, localAppData, programFiles string) []string {
var out []string
if programFiles = strings.TrimSpace(programFiles); programFiles != "" {
out = append(out,
filepath.Join(programFiles, "WindowsApps", "Microsoft.WindowsTerminal_*", "WindowsTerminal.exe"),
filepath.Join(programFiles, "WindowsApps", "Microsoft.WindowsTerminalPreview_*", "WindowsTerminal.exe"),
)
}
// Legacy/portable layouts occasionally nest the package under LocalAppData.
if localAppData = strings.TrimSpace(localAppData); localAppData != "" {
out = append(out,
filepath.Join(localAppData, "Microsoft", "WindowsApps", "Microsoft.WindowsTerminal_*", "WindowsTerminal.exe"),
filepath.Join(localAppData, "Microsoft", "WindowsApps", "Microsoft.WindowsTerminalPreview_*", "WindowsTerminal.exe"),
)
}
if wtPath = strings.TrimSpace(wtPath); wtPath != "" {
out = append(out, wtPath)
}
return out
}
// windowsIconCandidate is a filesystem path considered for SHGetFileInfo, with
// its observed size. Size 0 is typical for App Execution Alias reparse points.
type windowsIconCandidate struct {
Path string
Size int64
}
// pickWindowsTerminalIconSource chooses an IconSource path for the WT menu row.
// Order:
// 1. first non-zero-size package/binary path (real WindowsTerminal.exe)
// 2. renderableFallback (e.g. powershell.exe) — must beat zero-byte aliases so
// SHGetFileInfo is not pointed at an empty stub that yields a blank glyph
// 3. zero-size alias / last-known path only when nothing else is available
func pickWindowsTerminalIconSource(resolved []windowsIconCandidate, renderableFallback string) string {
for _, c := range resolved {
if strings.TrimSpace(c.Path) == "" || c.Size <= 0 {
continue
}
return c.Path
}
if fb := strings.TrimSpace(renderableFallback); fb != "" {
return fb
}
for _, c := range resolved {
if path := strings.TrimSpace(c.Path); path != "" {
return path
}
}
return ""
}
// windowsConsoleLaunch describes a console opener launch that never goes
// through cmd.exe / start (which re-parse working directories as shell text).
type windowsConsoleLaunch struct {
File string // absolute or PATH-resolved executable
Dir string // working directory passed to ShellExecute lpDirectory
}
// planWindowsConsoleLaunch builds a shell-free console launch. File is opened
// with ShellExecute("open") and Dir is the process working directory — paths
// with spaces or shell metacharacters (& | ( ) ^ %) are never concatenated into
// a cmd.exe command line.
func planWindowsConsoleLaunch(target, workdir string) windowsConsoleLaunch {
return windowsConsoleLaunch{
File: strings.TrimSpace(target),
Dir: workdir,
}
}
// windowsConsoleLaunchIsDirect reports that the plan opens the target binary
// itself (via ShellExecute) rather than a cmd.exe /c start wrapper. Command
// Prompt as a terminal is still direct: File is cmd.exe and Dir is the
// workspace — there is no intermediate shell command line.
func windowsConsoleLaunchIsDirect(plan windowsConsoleLaunch) bool {
if strings.TrimSpace(plan.File) == "" {
return false
}
// The plan struct has no Args/CommandLine field by design; a regression that
// reintroduces cmd /c start would need extra fields or a different shape.
return true
}
@@ -1,106 +0,0 @@
package main
import (
"path/filepath"
"strings"
"testing"
)
func TestWindowsTerminalIconCandidatePathsPreferPackageBinary(t *testing.T) {
// Use fixed Windows-style roots so the assertion is OS-independent; helpers
// only join path segments and never touch the filesystem.
wtAlias := `C:\Users\x\AppData\Local\Microsoft\WindowsApps\wt.exe`
local := `C:\Users\x\AppData\Local`
programFiles := `C:\Program Files`
got := windowsTerminalIconCandidatePaths(wtAlias, local, programFiles)
if len(got) < 2 {
t.Fatalf("candidates = %v, want package paths before alias", got)
}
wantFirst := filepath.Join(programFiles, "WindowsApps", "Microsoft.WindowsTerminal_*", "WindowsTerminal.exe")
if got[0] != wantFirst {
t.Fatalf("first candidate = %q, want %q", got[0], wantFirst)
}
// Primary hit must be the Store package under Program Files\WindowsApps, not
// a nested path under the App Execution Alias directory.
if !strings.Contains(got[0], "WindowsApps"+string(filepath.Separator)+"Microsoft.WindowsTerminal_") {
t.Fatalf("first candidate = %q, want Microsoft.WindowsTerminal_* under WindowsApps", got[0])
}
last := got[len(got)-1]
if last != wtAlias {
t.Fatalf("last candidate = %q, want wt alias %q", last, wtAlias)
}
}
func TestWindowsConsoleLaunchPlanBypassesCmdAndPreservesMetacharacters(t *testing.T) {
cases := []struct {
name string
target string
workdir string
}{
{name: "spaces", target: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, workdir: `C:\Users\demo\My Project`},
{name: "ampersand", target: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, workdir: `C:\src\repo&calc`},
{name: "pipe_parens", target: `C:\Windows\System32\cmd.exe`, workdir: `C:\src\a|b(c)^%d`},
{name: "command_prompt_is_direct_target", target: `C:\Windows\System32\cmd.exe`, workdir: `D:\work`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plan := planWindowsConsoleLaunch(tc.target, tc.workdir)
if !windowsConsoleLaunchIsDirect(plan) {
t.Fatalf("plan is not a direct ShellExecute open: %+v", plan)
}
if plan.File != tc.target {
t.Fatalf("File = %q, want %q (target binary, not cmd /c start wrapper)", plan.File, tc.target)
}
if plan.Dir != tc.workdir {
t.Fatalf("Dir = %q, want exact workdir (no shell rewriting)", plan.Dir)
}
})
}
}
func TestWindowsConsoleLaunchIsDirectRejectsEmptyFile(t *testing.T) {
if windowsConsoleLaunchIsDirect(windowsConsoleLaunch{}) {
t.Fatal("empty plan must not count as direct")
}
}
func TestPickWindowsTerminalIconSourcePrefersRenderableOverZeroByteAlias(t *testing.T) {
wtAlias := `C:\Users\x\AppData\Local\Microsoft\WindowsApps\wt.exe`
packageBin := `C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe\WindowsTerminal.exe`
powershell := `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
t.Run("nonzero_package_wins", func(t *testing.T) {
got := pickWindowsTerminalIconSource([]windowsIconCandidate{
{Path: wtAlias, Size: 0},
{Path: packageBin, Size: 1_024_000},
}, powershell)
if got != packageBin {
t.Fatalf("got %q, want package binary", got)
}
})
t.Run("renderable_beats_zero_alias", func(t *testing.T) {
// Protected WindowsApps often cannot be globbed; only the zero-byte
// execution alias remains. PowerShell must win so SHGetFileInfo has a
// real PE to extract (blank glyph fix for #6547).
got := pickWindowsTerminalIconSource([]windowsIconCandidate{
{Path: wtAlias, Size: 0},
}, powershell)
if got != powershell {
t.Fatalf("got %q, want powershell renderable fallback", got)
}
})
t.Run("zero_alias_only_without_fallback", func(t *testing.T) {
got := pickWindowsTerminalIconSource([]windowsIconCandidate{
{Path: wtAlias, Size: 0},
}, "")
if got != wtAlias {
t.Fatalf("got %q, want alias when no renderable fallback exists", got)
}
})
t.Run("empty", func(t *testing.T) {
if got := pickWindowsTerminalIconSource(nil, ""); got != "" {
t.Fatalf("got %q, want empty", got)
}
})
}
-90
View File
@@ -1,90 +0,0 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWindowsExternalOpenerIconUsesShellIcon(t *testing.T) {
explorer := filepath.Join(os.Getenv("WINDIR"), "explorer.exe")
if info, err := os.Stat(explorer); err != nil || info.IsDir() {
t.Skip("Windows Explorer executable is unavailable")
}
got := platformExternalOpenerIconDataURL(externalOpenerSpec{IconSource: explorer})
if !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("native Explorer icon = %q, want PNG data URL", got)
}
}
func TestWindowsAppPathExecutableEmptyName(t *testing.T) {
if got := windowsAppPathExecutable(""); got != "" {
t.Fatalf("empty name = %q, want empty", got)
}
if got := windowsAppPathExecutable(" "); got != "" {
t.Fatalf("blank name = %q, want empty", got)
}
}
func TestWindowsTerminalIconSourceSkipsZeroByteAliasForPowershell(t *testing.T) {
// When only a zero-byte execution alias is visible (package dir unreadable),
// IconSource must not stick on the alias — PowerShell is a known renderable
// PE that SHGetFileInfo can extract.
dir := t.TempDir()
alias := filepath.Join(dir, "wt.exe")
if err := os.WriteFile(alias, nil, 0o644); err != nil {
t.Fatalf("write zero-byte alias: %v", err)
}
// Point candidate resolution at our temp alias only by calling pick through
// the real resolver with overridden env via windowsTerminalIconSource on a
// path that will not find package globs, then assert via pick contract.
// Direct integration: windowsTerminalIconSource(alias) should return a
// non-zero file when powershell is on PATH / System32.
got := windowsTerminalIconSource(alias)
if got == "" {
t.Fatal("icon source empty")
}
if got == alias {
info, err := os.Stat(got)
if err != nil {
t.Fatalf("stat result: %v", err)
}
if info.Size() == 0 {
t.Fatalf("icon source stayed on zero-byte alias %q; want renderable fallback", got)
}
}
if info, err := os.Stat(got); err != nil || info.IsDir() || info.Size() == 0 {
t.Fatalf("icon source %q is not a non-zero file (err=%v)", got, err)
}
}
func TestWindowsConsoleLaunchDoesNotInvokeCmdStart(t *testing.T) {
// Integration-free: planWindowsConsoleLaunch + ShellExecute path must never
// build cmd /c start. The pure helpers test covers special-character dirs;
// here we only assert the platform launcher uses that plan shape.
ps := firstWindowsExecutable([]string{"powershell.exe"},
joinWindowsInstallPath(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
if ps == "" {
t.Skip("powershell.exe unavailable")
}
workdir := filepath.Join(t.TempDir(), "repo&calc")
if err := os.MkdirAll(workdir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
plan := planWindowsConsoleLaunch(ps, workdir)
if !windowsConsoleLaunchIsDirect(plan) {
t.Fatalf("console plan must be a direct ShellExecute open: %+v", plan)
}
if plan.File != ps {
t.Fatalf("File = %q, want powershell target %q", plan.File, ps)
}
if plan.Dir != workdir {
t.Fatalf("Dir = %q, want %q", plan.Dir, workdir)
}
// Do not call launchPlatformExternalOpener here: it would open a real
// interactive console window in CI. ShellExecute wiring is covered by
// openWorkspacePath and compile-time type checks.
}
-22
View File
@@ -1,22 +0,0 @@
node_modules
# Build output is generated by `pnpm build` / `wails build`; keep only the
# placeholder so the Go `//go:embed all:frontend/dist` stays compilable on a
# fresh checkout before the first build.
dist/*
!dist/.gitkeep
# Wails-generated bindings (we hand-write a typed bridge instead; regenerate with
# `wails generate module` if you prefer the generated ones).
wailsjs
*.local
# Wails dev install-checksum cache
package.json.md5
# This project uses pnpm (see wails.json `frontend:install`); reject the npm lockfile.
package-lock.json
# Playwright browsers for the real-DOM bench (kept inside the repo; see bench/).
.pw-browsers
bench/results.json
-3
View File
@@ -1,3 +0,0 @@
# pnpm 11: don't re-check deps before `pnpm build` and never prompt to purge node_modules headless.
confirm-modules-purge=false
verify-deps-before-run=false
-1
View File
@@ -1 +0,0 @@
24
@@ -1,83 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
? path.join(frontendDir, ".pw-browsers")
: process.env.PLAYWRIGHT_BROWSERS_PATH;
const { chromium } = await import("playwright");
const port = Number(process.env.REASONIX_APPROVAL_BROWSER_PORT ?? 4620);
const url = `http://127.0.0.1:${port}/`;
function assert(condition, message) {
if (!condition) throw new Error(message);
process.stdout.write(` PASS ${message}\n`);
}
async function waitForServer() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const ready = await new Promise((resolve) => {
const request = http.get(url, (response) => {
response.resume();
resolve((response.statusCode ?? 500) < 500);
});
request.on("error", () => resolve(false));
});
if (ready) return;
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error("approval browser preview did not become ready");
}
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: "ignore",
});
let browser;
try {
await waitForServer();
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
await page.addInitScript(() => {
window.__reasonixApprovalAnimationCalls = [];
const original = Element.prototype.animate;
Element.prototype.animate = function (frames, options) {
if (this instanceof HTMLElement && this.querySelector(".prompt-shelf")) {
window.__reasonixApprovalAnimationCalls.push({
easing: typeof options === "object" && options ? String(options.easing ?? "") : "",
});
}
return original.call(this, frames, options);
};
});
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
const composer = page.locator("#composer-input");
await composer.waitFor({ state: "visible", timeout: 30_000 });
await composer.fill("/mock-tool-approval");
await page.locator(".composer__btn--send").click();
const action = page.locator(".prompt-shelf__actions .prompt-action").first();
await action.waitFor({ state: "visible", timeout: 30_000 });
await action.click();
await page.locator(".decision-confirm-bar__confirm").click();
await page.waitForFunction(() => !document.querySelector(".prompt-shelf--tool-approval"), undefined, { timeout: 10_000 });
const calls = await page.evaluate(() => window.__reasonixApprovalAnimationCalls ?? []);
assert(calls.length === 1, `approval invokes one native Web Animation (${JSON.stringify(calls)})`);
assert(calls[0]?.easing === "cubic-bezier(0.8, 0, 0.8, 0.28)", `approval uses the CSS easing contract (${calls[0]?.easing})`);
assert(pageErrors.length === 0, `approval interaction completes without page errors (${JSON.stringify(pageErrors)})`);
process.stdout.write("\napproval animation browser gate passed\n");
} finally {
await browser?.close();
preview.kill("SIGTERM");
}
-463
View File
@@ -1,463 +0,0 @@
#!/usr/bin/env node
// Real-DOM performance benchmark for the session-switch/history pipeline
// (Phase F). Drives the production build (vite preview) in real Chromium via
// Playwright against the ?mock=bench dev-mock fixtures (see makeMockApp in
// src/lib/bridge.ts): a tool-dense 38-turn session (~3.2k messages) and a
// markdown-heavy 46-turn session (one ~500KiB answer + oversized code block).
//
// Scenario A: cold open — surface first paint and time-to-interactive
// (composer enabled + first transcript slice rendered).
// Scenario B: N alternating switches between the two heaviest sessions —
// per-switch latency, long-task/event-timing stats, INP-ish input probes, and
// a forced-GC retained-heap + cache-budget check against the warmup baseline.
//
// Usage:
// pnpm build # once (the bench reuses dist/; REASONIX_BENCH_BUILD=1 forces a rebuild)
// pnpm test:bench
//
// Gates (env-overridable, defaults = plan values):
// REASONIX_BENCH_FIRST_PAINT_P95_MS (100)
// REASONIX_BENCH_INTERACTIVE_P95_MS (300)
// REASONIX_BENCH_INP_P95_MS (200)
// REASONIX_BENCH_LONGTASK_P95_MS (50)
// REASONIX_BENCH_LONGTASK_MAX_MS (500)
// REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS (3000)
// REASONIX_BENCH_HEAP_GROWTH_MIB (20)
// REASONIX_BENCH_SWITCHES (100)
// REASONIX_BENCH_COLD_RUNS (5)
// REASONIX_BENCH_WARMUP (6)
// REASONIX_BENCH_PORT (4617)
//
// Exit code is 0 when every gate passes, 1 otherwise. Results are written to
// bench/results.json and summarized on stdout.
import { spawn } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import http from "node:http";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
// Keep the browser download inside the repo when the caller did not pin one.
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
? path.join(frontendDir, ".pw-browsers")
: process.env.PLAYWRIGHT_BROWSERS_PATH;
const { chromium } = await import("playwright");
function numEnv(name, fallback) {
const raw = process.env[name];
const value = raw === undefined ? NaN : Number(raw);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
const GATES = {
firstPaintP95Ms: numEnv("REASONIX_BENCH_FIRST_PAINT_P95_MS", 100),
interactiveP95Ms: numEnv("REASONIX_BENCH_INTERACTIVE_P95_MS", 300),
inpP95Ms: numEnv("REASONIX_BENCH_INP_P95_MS", 200),
longTaskP95Ms: numEnv("REASONIX_BENCH_LONGTASK_P95_MS", 50),
longTaskMaxMs: numEnv("REASONIX_BENCH_LONGTASK_MAX_MS", 500),
markdownParseMaxMs: numEnv("REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS", 3000),
heapGrowthMiB: numEnv("REASONIX_BENCH_HEAP_GROWTH_MIB", 20),
};
const SWITCHES = Math.round(numEnv("REASONIX_BENCH_SWITCHES", 100));
const COLD_RUNS = Math.round(numEnv("REASONIX_BENCH_COLD_RUNS", 5));
const WARMUP_SWITCHES = Math.round(numEnv("REASONIX_BENCH_WARMUP", 6));
const PORT = Math.round(numEnv("REASONIX_BENCH_PORT", 4617));
// Cache budgets mirrored from transcriptStore.ts defaults (asserted via the
// __reasonixPerf debug hook, which reports the store's own numbers).
const BODY_BUDGET_BYTES = 32 << 20;
const MARKDOWN_BUDGET_BYTES = 16 << 20;
const MAX_RESIDENT_SESSIONS = 3;
const PAGE_URL = `http://127.0.0.1:${PORT}/?mock=bench&bench=1`;
// Single-surface (workbench) layout: switches are sidebar topic clicks driving
// the ticketed StartTopicActivation flow. Active state is the --active class
// on the topic row; the transcript marker text proves the target session's
// first slice rendered.
const TAB = {
// Markers must be in the STUCK-TO-BOTTOM viewport (the virtual list only
// mounts overscan rows): the tools session ends on its last read_file card,
// the markdown session on the oversized code block (worker-parsed).
markdown: { label: "bench:markdown-46t", marker: "generated migration" },
tools: { label: "bench:tools-38t", marker: "pkg-41/mod.go" },
};
function percentile(values, p) {
if (values.length === 0) return undefined;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
return sorted[Math.max(0, index)];
}
function statsOf(values) {
const nums = values.filter((v) => Number.isFinite(v));
return {
n: nums.length,
p50: percentile(nums, 50),
p95: percentile(nums, 95),
max: nums.length ? Math.max(...nums) : undefined,
};
}
async function waitForServer(url, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const ok = await new Promise((resolve) => {
const req = http.get(url, (res) => {
res.resume();
resolve(res.statusCode !== undefined && res.statusCode < 500);
});
req.on("error", () => resolve(false));
req.setTimeout(2000, () => {
req.destroy();
resolve(false);
});
});
if (ok) return;
if (Date.now() > deadline) throw new Error(`preview server did not start at ${url}`);
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
async function ensureBuild() {
const distIndex = path.join(frontendDir, "dist", "index.html");
const force = process.env.REASONIX_BENCH_BUILD === "1";
if (existsSync(distIndex) && !force) return;
console.log("[bench] building frontend (vite build)…");
await new Promise((resolve, reject) => {
const child = spawn("pnpm", ["build"], { cwd: frontendDir, stdio: "inherit" });
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`pnpm build exited ${code}`))));
});
}
async function startPreview() {
const child = spawn("pnpm", ["exec", "vite", "preview", "--port", String(PORT), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
const message = chunk.toString();
stderr += message;
process.stderr.write(`[preview] ${message}`);
});
await new Promise((resolve, reject) => {
let stdout = "";
const timeout = setTimeout(() => {
cleanup();
child.kill();
reject(new Error(`preview server did not announce readiness on port ${PORT}`));
}, 30_000);
const cleanup = () => {
clearTimeout(timeout);
child.stdout.off("data", onStdout);
child.off("exit", onExit);
};
const onStdout = (chunk) => {
stdout += chunk.toString();
if (!/\bLocal:\s+http/.test(stdout)) return;
cleanup();
resolve();
};
const onExit = (code, signal) => {
cleanup();
const detail = stderr.trim();
reject(new Error(
`preview server exited before readiness (code ${code ?? "null"}, signal ${signal ?? "none"})${detail ? `: ${detail}` : ""}`,
));
};
child.stdout.on("data", onStdout);
child.once("exit", onExit);
});
await waitForServer(`http://127.0.0.1:${PORT}/`);
return child;
}
// Installed before any app script: long-task, event-timing (INP-ish) and paint
// collectors. All bounded by the scenario durations (a few minutes).
const COLLECTOR_INIT = () => {
const metrics = { longTasks: [], events: [], paints: [] };
window.__benchMetrics = metrics;
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) metrics.longTasks.push({ start: entry.startTime, duration: entry.duration });
}).observe({ type: "longtask", buffered: true });
} catch { /* longtask unsupported */ }
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) metrics.events.push({ name: entry.name, duration: entry.duration });
}).observe({ type: "event", durationThreshold: 16, buffered: true });
} catch { /* event timing unsupported */ }
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) metrics.paints.push({ name: entry.name, start: entry.startTime });
}).observe({ type: "paint", buffered: true });
} catch { /* paint timing unsupported */ }
};
async function forceGcAndHeap(cdp, page) {
await cdp.send("HeapProfiler.enable").catch(() => {});
await cdp.send("HeapProfiler.collectGarbage").catch(() => {});
await page.waitForTimeout(150);
await cdp.send("HeapProfiler.collectGarbage").catch(() => {});
await page.waitForTimeout(150);
return page.evaluate(() => {
const memory = performance.memory;
return {
usedJSHeapBytes: memory ? memory.usedJSHeapSize : undefined,
totalJSHeapBytes: memory ? memory.totalJSHeapSize : undefined,
domNodes: document.getElementsByTagName("*").length,
};
});
}
const INTERACTIVE_FN = () => {
const input = document.querySelector("textarea.composer__input");
const inputReady = Boolean(input && !input.disabled);
const rows = document.querySelectorAll(".transcript__row").length;
return inputReady && rows > 0;
};
async function coldOpenOnce(browser) {
const context = await browser.newContext();
const page = await context.newPage();
await page.addInitScript(COLLECTOR_INIT);
await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" });
await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" });
const result = await page.evaluate(() => {
const paints = (window.__benchMetrics?.paints ?? []);
const fcp = paints.find((p) => p.name === "first-contentful-paint") ?? paints.find((p) => p.name === "first-paint");
return { firstPaintMs: fcp ? fcp.start : undefined, interactiveMs: performance.now() };
});
await context.close();
return result;
}
async function waitForSessionVisible(page, tab, timeoutMs = 15_000) {
await page.waitForFunction(
({ label, marker }) => {
const active = document.querySelector(".project-tree__topic--active .project-tree__topic-label");
if (!active || !active.textContent?.includes(label)) return false;
const transcript = document.querySelector(".transcript");
return Boolean(transcript && transcript.textContent?.includes(marker));
},
{ label: tab.label, marker: tab.marker },
{ timeout: timeoutMs, polling: "raf" },
);
}
async function switchTo(page, tab) {
const startedAt = await page.evaluate(() => performance.now());
await page.click(`.project-tree__topic-main:has-text("${tab.label}")`);
await waitForSessionVisible(page, tab);
const settledAt = await page.evaluate(() => performance.now());
return settledAt - startedAt;
}
// Let in-flight worker parses finish so the parsed-markdown cache is populated
// and exercised (a fast switch-away cancels pending parses by design). Runs
// AFTER the switch-latency measurement so click→first-render stays pure.
async function settleMarkdownWorker(page, timeoutMs = 10_000) {
await page
.waitForFunction(() => (window.__reasonixPerf?.stats()?.markdownWorker?.pending ?? 0) === 0, undefined, {
timeout: timeoutMs,
polling: 100,
})
.catch(() => {});
}
// Progressive history blocks intentionally mount in idle slices after parsing.
// Stabilize that deferred work only around heap/DOM snapshots; waiting on every
// switch would change the interaction workload the benchmark is measuring.
async function settleMarkdownMounts(page, timeoutMs = 10_000) {
try {
await page.waitForFunction(() => (
[...document.querySelectorAll(".transcript [data-markdown-blocks]")].every((element) => (
element.getAttribute("data-markdown-visible-blocks") === element.getAttribute("data-markdown-blocks")
))
), undefined, { timeout: timeoutMs, polling: 100 });
} catch (error) {
const counts = await page.evaluate(() => (
[...document.querySelectorAll(".transcript [data-markdown-blocks]")].map((element) => ({
total: element.getAttribute("data-markdown-blocks"),
visible: element.getAttribute("data-markdown-visible-blocks"),
}))
));
throw new Error(`active Markdown mounts did not settle: ${JSON.stringify(counts)}`, { cause: error });
}
}
async function main() {
await ensureBuild();
const preview = await startPreview();
const report = {
startedAt: new Date().toISOString(),
url: PAGE_URL,
gates: GATES,
switches: SWITCHES,
coldRuns: COLD_RUNS,
checks: [],
scenarioA: {},
scenarioB: {},
};
const check = (name, value, gate, pass, unit = "ms") => {
report.checks.push({ name, value, gate, pass, unit });
console.log(` ${pass ? "PASS" : "FAIL"} ${name}: ${typeof value === "number" ? value.toFixed(1) : value}${unit} (gate ${gate}${unit})`);
};
const browser = await chromium.launch({
headless: true,
args: ["--enable-precise-memory-info", "--disable-dev-shm-usage"],
});
try {
// ── Scenario A: cold open ──────────────────────────────────────────────
console.log(`[bench] scenario A: ${COLD_RUNS} cold opens of the markdown-heavy session…`);
const coldFirstPaint = [];
const coldInteractive = [];
for (let i = 0; i < COLD_RUNS; i += 1) {
const { firstPaintMs, interactiveMs } = await coldOpenOnce(browser);
if (firstPaintMs !== undefined) coldFirstPaint.push(firstPaintMs);
coldInteractive.push(interactiveMs);
}
report.scenarioA = { firstPaintMs: statsOf(coldFirstPaint), interactiveMs: statsOf(coldInteractive) };
check("A first-paint P95", report.scenarioA.firstPaintMs.p95, GATES.firstPaintP95Ms, report.scenarioA.firstPaintMs.p95 <= GATES.firstPaintP95Ms);
check("A interactive P95", report.scenarioA.interactiveMs.p95, GATES.interactiveP95Ms, report.scenarioA.interactiveMs.p95 <= GATES.interactiveP95Ms);
// ── Scenario B: alternating heaviest-session switches ──────────────────
console.log(`[bench] scenario B: ${SWITCHES} alternating switches (warmup ${WARMUP_SWITCHES})…`);
const context = await browser.newContext();
const page = await context.newPage();
await page.addInitScript(COLLECTOR_INIT);
await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" });
await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" });
await page.waitForFunction(() => Boolean(window.__reasonixPerf), { timeout: 10_000 });
const cdp = await context.newCDPSession(page);
// Warmup: fill the LRU with both sessions, then settle back on the
// markdown tab so the baseline and the final reading show the same view.
for (let i = 0; i < WARMUP_SWITCHES; i += 1) {
const target = i % 2 === 0 ? TAB.tools : TAB.markdown;
await switchTo(page, target);
if (target === TAB.markdown) await settleMarkdownWorker(page);
}
await settleMarkdownMounts(page);
const baseline = await forceGcAndHeap(cdp, page);
const baselineStats = await page.evaluate(() => window.__reasonixPerf.stats());
await page.evaluate(() => {
window.__benchMetrics.longTasks.length = 0;
window.__benchMetrics.events.length = 0;
window.__reasonixPerf.reset();
});
const switchLatencies = [];
for (let i = 0; i < SWITCHES; i += 1) {
// First switch leaves the markdown tab (warmup ended there); even count
// lands back on markdown so the final heap/DOM reading compares like for
// like with the baseline.
const target = i % 2 === 0 ? TAB.tools : TAB.markdown;
switchLatencies.push(await switchTo(page, target));
if (target === TAB.markdown) await settleMarkdownWorker(page);
if ((i + 1) % 10 === 0) {
// INP-ish probe: real key events against the composer while the
// pipeline is warm; event-timing entries capture the latency.
const composer = page.locator("textarea.composer__input");
await composer.click();
await page.keyboard.type("x", { delay: 5 });
await page.keyboard.press("Backspace");
}
}
await settleMarkdownMounts(page);
// Freeze interaction metrics before the explicit HeapProfiler GC below.
// The GC is part of retained-heap measurement, not the user switching
// workflow, and can itself create a >50ms task on a loaded test host.
const { finalStats, activations, benchMetrics } = await page.evaluate(() => ({
finalStats: window.__reasonixPerf.stats(),
activations: window.__reasonixPerf.activations(),
benchMetrics: {
longTasks: window.__benchMetrics.longTasks.map((t) => t.duration),
events: window.__benchMetrics.events.map((e) => e.duration),
},
}));
const final = await forceGcAndHeap(cdp, page);
await context.close();
const switchStats = statsOf(switchLatencies);
const readyStats = statsOf(
activations.filter((a) => a.outcome === "ready" && a.settledAtMs !== undefined).map((a) => a.settledAtMs - a.requestedAtMs),
);
const longTaskStats = statsOf(benchMetrics.longTasks);
const eventStats = statsOf(benchMetrics.events);
const heapGrowthBytes = (final.usedJSHeapBytes ?? 0) - (baseline.usedJSHeapBytes ?? 0);
const heapGrowthMiB = heapGrowthBytes / (1 << 20);
const cache = finalStats?.transcriptCache ?? {};
const worker = finalStats?.markdownWorker ?? {};
report.scenarioB = {
switchMs: switchStats,
activationReadyMs: readyStats,
longTasksMs: longTaskStats,
inputEventsMs: eventStats,
heap: {
baselineUsedMiB: (baseline.usedJSHeapBytes ?? 0) / (1 << 20),
finalUsedMiB: (final.usedJSHeapBytes ?? 0) / (1 << 20),
growthMiB: heapGrowthMiB,
},
domNodes: { baseline: baseline.domNodes, final: final.domNodes },
transcriptCache: cache,
markdownWorker: worker,
baselineCache: baselineStats?.transcriptCache,
};
check("B switch latency P95 (click→target session rendered)", switchStats.p95, GATES.interactiveP95Ms, switchStats.p95 <= GATES.interactiveP95Ms);
if (readyStats.n > 0) {
check("B activation ready P95", readyStats.p95, GATES.interactiveP95Ms, readyStats.p95 <= GATES.interactiveP95Ms);
}
check("B input-event P95 (INP-ish)", eventStats.p95 ?? 0, GATES.inpP95Ms, (eventStats.p95 ?? 0) <= GATES.inpP95Ms);
check("B long-task P95", longTaskStats.p95 ?? 0, GATES.longTaskP95Ms, (longTaskStats.p95 ?? 0) <= GATES.longTaskP95Ms);
check("B long-task max", longTaskStats.max ?? 0, GATES.longTaskMaxMs, (longTaskStats.max ?? 0) <= GATES.longTaskMaxMs);
check("B markdown Worker completed parses (minimum)", worker.completed ?? 0, 1, (worker.completed ?? 0) >= 1, "");
check(
"B markdown Worker max parse",
worker.maxParseMs ?? 0,
GATES.markdownParseMaxMs,
(worker.completed ?? 0) >= 1 && (worker.maxParseMs ?? Infinity) <= GATES.markdownParseMaxMs,
);
check("B retained heap growth", heapGrowthMiB, GATES.heapGrowthMiB, heapGrowthMiB <= GATES.heapGrowthMiB, "MiB");
check(
"B cache: body bytes within budget",
(cache.bodyBytes ?? 0) / (1 << 20),
BODY_BUDGET_BYTES / (1 << 20),
(cache.bodyBytes ?? 0) <= BODY_BUDGET_BYTES,
"MiB",
);
check(
"B cache: markdown bytes within budget",
(cache.markdownBytes ?? 0) / (1 << 20),
MARKDOWN_BUDGET_BYTES / (1 << 20),
(cache.markdownBytes ?? 0) <= MARKDOWN_BUDGET_BYTES,
"MiB",
);
check("B cache: resident sessions", cache.residentSessions ?? 0, MAX_RESIDENT_SESSIONS, (cache.residentSessions ?? 0) <= MAX_RESIDENT_SESSIONS, "");
const domGrowthPct = baseline.domNodes > 0 ? ((final.domNodes - baseline.domNodes) / baseline.domNodes) * 100 : 0;
check("B DOM node growth vs warmup baseline", domGrowthPct, 10, domGrowthPct <= 10, "%");
} finally {
await browser.close();
preview.kill();
}
report.finishedAt = new Date().toISOString();
const failed = report.checks.filter((c) => !c.pass);
report.verdict = failed.length === 0 ? "PASS" : "FAIL";
writeFileSync(path.join(frontendDir, "bench", "results.json"), JSON.stringify(report, null, 2));
console.log(`\n[bench] verdict: ${report.verdict} (${failed.length} gate${failed.length === 1 ? "" : "s"} failed) — results in bench/results.json`);
if (failed.length > 0) process.exitCode = 1;
}
main().catch((err) => {
console.error(`[bench] fatal: ${err instanceof Error ? err.stack ?? err.message : err}`);
process.exitCode = 2;
});
@@ -1,249 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
? path.join(frontendDir, ".pw-browsers")
: process.env.PLAYWRIGHT_BROWSERS_PATH;
const { chromium } = await import("playwright");
const port = Number(process.env.REASONIX_TRANSCRIPT_SCROLL_PORT ?? 4619);
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
function assert(condition, message) {
if (!condition) throw new Error(message);
process.stdout.write(` PASS ${message}\n`);
}
async function waitForServer() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const ready = await new Promise((resolve) => {
const request = http.get(url, (response) => {
response.resume();
resolve((response.statusCode ?? 500) < 500);
});
request.on("error", () => resolve(false));
});
if (ready) return;
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error("transcript scroll preview did not become ready");
}
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: "ignore",
});
let browser;
try {
await waitForServer();
browser = await chromium.launch({
headless: true,
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
});
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go"), undefined, { timeout: 30_000 });
const transcript = page.locator(".transcript");
const box = await transcript.boundingBox();
assert(box != null, "bench exposes the Virtuoso transcript viewport");
assert(await page.locator('[data-virtuoso-scroller="true"]').count() === 1, "Transcript is backed by React Virtuoso");
// Start away from either edge and record a visible stable row. Growing an
// already-mounted row above it reproduces async Markdown/tool hydration.
const beforeGrowth = await transcript.evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight * 2);
element.dispatchEvent(new Event("scroll"));
const viewport = element.getBoundingClientRect();
const rows = [...element.querySelectorAll(".transcript__row")];
const visible = rows
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
const anchor = visible.find((row) => row.getBoundingClientRect().top >= viewport.top) ?? visible[0];
const above = rows
.filter((row) => row.getBoundingClientRect().bottom <= anchor?.getBoundingClientRect().top)
.sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom)[0];
return {
top: element.scrollTop,
anchorKey: anchor?.dataset.rowKey ?? null,
anchorOffset: anchor ? anchor.getBoundingClientRect().top - viewport.top : null,
grownKey: above?.dataset.rowKey ?? null,
};
});
assert(beforeGrowth.anchorKey && beforeGrowth.grownKey, "bench exposes a visible anchor and mounted dynamic row above it");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await transcript.evaluate((element) => {
const initialTop = element.scrollTop;
let previousTop = initialTop;
let stableFrames = 0;
element.dataset.benchWheelSettled = "false";
const sample = () => {
const currentTop = element.scrollTop;
const moved = Math.abs(currentTop - initialTop) > 1;
stableFrames = moved && Math.abs(currentTop - previousTop) <= 0.5 ? stableFrames + 1 : 0;
previousTop = currentTop;
if (stableFrames >= 2) {
element.dataset.benchWheelSettled = "true";
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
await page.mouse.wheel(0, -360);
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.benchWheelSettled === "true");
const gestureStart = await transcript.evaluate((element) => element.scrollTop);
await transcript.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const rows = [...element.querySelectorAll(".transcript__row")];
const visible = rows
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
const above = rows
.filter((row) => row.getBoundingClientRect().bottom <= viewport.top)
.sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom)[0];
if (above instanceof HTMLElement) above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
window.__reasonixScrollSamples = [];
const sample = () => {
const currentRows = [...element.querySelectorAll(".transcript__row")];
const rect = element.getBoundingClientRect();
const occupied = currentRows.some((row) => {
const rowRect = row.getBoundingClientRect();
return rowRect.bottom > rect.top && rowRect.top < rect.bottom;
});
window.__reasonixScrollSamples.push({ top: element.scrollTop, occupied });
};
element.addEventListener("scroll", sample, { passive: true });
sample();
});
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const afterGrowth = await transcript.evaluate((element) => ({
top: element.scrollTop,
samples: window.__reasonixScrollSamples ?? [],
}));
assert(afterGrowth.top >= gestureStart - 2, `dynamic measurement never reverses an upward gesture into a multi-screen jump (${gestureStart} → ${afterGrowth.top})`);
assert(afterGrowth.samples.every((sample) => sample.occupied), "dynamic measurement never exposes a blank transcript viewport");
// Rapid direction changes are the exact user report. Sample every frame and
// require that Virtuoso always maintains mounted coverage.
for (const delta of [-700, -700, 480, -600, 520, -460]) {
await page.mouse.wheel(0, delta);
await page.waitForTimeout(24);
}
const rapid = await transcript.evaluate((element) => {
const rect = element.getBoundingClientRect();
const visible = [...element.querySelectorAll(".transcript__row")].filter((row) => {
const rowRect = row.getBoundingClientRect();
return rowRect.bottom > rect.top && rowRect.top < rect.bottom;
});
return { visible: visible.length, top: element.scrollTop, max: element.scrollHeight - element.clientHeight };
});
assert(rapid.visible > 0, `rapid bidirectional scrolling leaves rendered coverage (${rapid.visible} visible rows)`);
assert(rapid.top >= 0 && rapid.top <= rapid.max + 1, `rapid scrolling stays within the native scroll range (${rapid.top}/${rapid.max})`);
// A native scrollbar thumb drag owns the browser's scroll range. Keep
// Virtuoso's estimated size tree fixed until pointer release so newly
// visited variable-height rows cannot resize the thumb under the pointer.
// This is deliberately pointer-gutter-specific; the wheel assertions above
// continue to exercise ordinary chat-content scrolling and live measuring.
await transcript.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await page.waitForFunction(() => document.querySelector(".transcript__row"));
const nativeThumbProbe = await transcript.evaluate((element) => {
const rect = element.getBoundingClientRect();
const scaleX = rect.width / element.offsetWidth;
const contentRight = rect.left + (element.clientLeft + element.clientWidth) * scaleX;
const row = element.querySelector(".transcript__row");
if (!(row instanceof HTMLElement)) return null;
row.dataset.nativeScrollbarProbe = "true";
return {
x: Math.min(rect.right - 1, contentRight + Math.max(1, (rect.right - contentRight) / 2)),
y: rect.top + 5,
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
gutter: rect.right - contentRight,
scrollHeight: element.scrollHeight,
};
});
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
await page.mouse.down();
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
await page.waitForFunction(
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
nativeThumbProbe.knownSize,
);
await transcript.evaluate((element) => {
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
const content = row?.firstElementChild;
if (content instanceof HTMLElement) content.style.paddingBottom = `${Number.parseFloat(content.style.paddingBottom || "0") + 900}px`;
});
await page.waitForTimeout(100);
const duringNativeThumbDrag = await transcript.evaluate((element) => {
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
return {
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
scrollHeight: element.scrollHeight,
};
});
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
await page.mouse.up();
await page.waitForFunction(
(knownSize) => {
const transcriptElement = document.querySelector(".transcript");
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
return transcriptElement?.dataset.nativeScrollbarDrag !== "true"
&& row instanceof HTMLElement
&& Number.parseFloat(row.dataset.knownSize || "0") > knownSize + 800;
},
nativeThumbProbe.knownSize,
);
assert(true, "native thumb release resumes real row measurement");
// Explicit bottom owns the tail. Subsequent async growth must use Virtuoso's
// autoscroll API and remain at the physical bottom without Reasonix scrollTop
// correction loops.
const jumpBottom = page.locator(".transcript__jump-bottom");
await transcript.evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight * 2);
});
await jumpBottom.waitFor({ state: "visible" });
await jumpBottom.click();
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return element
&& element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
});
await transcript.evaluate((element) => {
const tail = [...element.querySelectorAll(".transcript__row")].at(-1);
if (tail instanceof HTMLElement) tail.style.paddingBottom = `${Number.parseFloat(tail.style.paddingBottom || "0") + 320}px`;
});
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return element && element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
});
assert(true, "pinned dynamic tail growth remains at the physical bottom");
process.stdout.write("\ntranscript scroll stability browser gate passed\n");
} finally {
await browser?.close();
preview.kill("SIGTERM");
}
@@ -1,447 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
? path.join(frontendDir, ".pw-browsers")
: process.env.PLAYWRIGHT_BROWSERS_PATH;
const { chromium } = await import("playwright");
const port = Number(process.env.REASONIX_TRANSCRIPT_BROWSER_PORT ?? 4618);
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
function assert(condition, message) {
if (!condition) throw new Error(message);
process.stdout.write(` PASS ${message}\n`);
}
async function waitForServer() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const ready = await new Promise((resolve) => {
const request = http.get(url, (response) => {
response.resume();
resolve((response.statusCode ?? 500) < 500);
});
request.on("error", () => resolve(false));
});
if (ready) return;
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error("transcript browser preview did not become ready");
}
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: "ignore",
});
let browser;
try {
await waitForServer();
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const cdp = await page.context().newCDPSession(page);
await cdp.send("Performance.enable");
const retainedHeap = async () => {
await cdp.send("HeapProfiler.collectGarbage");
await page.waitForTimeout(100);
const metrics = await cdp.send("Performance.getMetrics");
return metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0;
};
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
await page.waitForFunction(() => (
document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t")
&& document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go")
), undefined, { timeout: 30_000 });
await page.waitForTimeout(300);
for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (transcript) transcript.scrollTop = 0;
});
await page.waitForTimeout(100);
const older = page.locator(".transcript__older");
if (await older.count() === 0) break;
await older.click();
await page.waitForTimeout(350);
}
const jumpBottom = page.locator(".transcript__jump-bottom");
if (await jumpBottom.count()) await jumpBottom.click();
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
return transcript && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 1;
}, undefined, { timeout: 30_000 });
for (let index = 0; index < 20; index += 1) {
const visibleSelectable = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return false;
const viewport = transcript.getBoundingClientRect();
const visible = [...transcript.querySelectorAll("[data-transcript-selectable]")].some((element) => {
const rect = element.getBoundingClientRect();
return rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
});
if (!visible) transcript.scrollTop -= transcript.clientHeight;
return visible;
});
if (visibleSelectable) break;
await page.waitForTimeout(50);
}
const baselineRows = await page.locator(".transcript__row").count();
const points = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
const viewport = transcript.getBoundingClientRect();
const candidates = [...transcript.querySelectorAll("[data-transcript-selectable]")]
.map((element) => ({
element,
turn: Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1),
rect: element.getBoundingClientRect(),
}))
.filter(({ turn, rect }) => turn >= 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom)
.sort((left, right) => right.turn - left.turn);
const candidate = candidates[0];
const textRects = candidate ? (() => {
const walker = document.createTreeWalker(candidate.element, NodeFilter.SHOW_TEXT);
const rects = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.textContent?.trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
rects.push(...range.getClientRects());
}
return rects;
})() : [];
const start = textRects.find((rect) => rect.width > 8 && rect.bottom > viewport.top && rect.top < viewport.bottom) ?? candidate?.rect;
if (!start) return null;
// Press mid-text rather than at the row's left edge. A left-edge caret
// freezes the anchor at offset 0, so an upward drag legitimately copies
// nothing from the anchor turn and the 20-turn count would depend only on
// how far the focus lands. Mid-text also exercises the frozen anchor's
// real character offset instead of the trivial row boundary.
const startX = Math.min(start.right - 4, start.left + Math.max(start.width * 0.45, 60));
return {
start: { x: startX, y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
activate: { x: Math.min(start.right - 2, startX + 30), y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
edge: { x: startX, y: viewport.top + 2 },
anchorTurn: candidate.turn,
};
});
assert(points != null, "bench transcript exposes a selectable visible message");
await page.evaluate(() => {
window.__transcriptProgrammaticWrites = [];
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => {
window.__transcriptProgrammaticWrites.push({ owner, top });
};
});
await page.mouse.move(points.start.x, points.start.y);
await page.mouse.down();
const downState = await page.evaluate(({ x, y }) => ({
mode: document.querySelector(".transcript")?.dataset.scrollMode,
target: document.elementFromPoint(x, y)?.outerHTML.slice(0, 300),
}), points.start);
assert(downState.mode === "native-selecting", `primary pointerdown transfers scroll ownership to native selection (${downState.mode}; ${downState.target})`);
await page.mouse.move(points.activate.x, points.activate.y, { steps: 6 });
await page.waitForTimeout(50);
for (let index = 0; index < 8; index += 1) {
await page.mouse.wheel(0, -650);
await page.mouse.move(points.edge.x, points.edge.y, { steps: 4 });
await page.waitForTimeout(60);
}
await page.mouse.move(points.edge.x, points.edge.y);
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return false;
const max = transcript.scrollHeight - transcript.clientHeight;
return max > 0 && transcript.scrollTop <= max * 0.3;
}, undefined, { timeout: 30_000 });
const neutralPoint = await page.evaluate(() => {
const rect = document.querySelector(".transcript")?.getBoundingClientRect();
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null;
});
assert(neutralPoint != null, "deep logical drag keeps the transcript mounted");
await page.mouse.move(neutralPoint.x, neutralPoint.y);
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
transcript.scrollTop = (transcript.scrollHeight - transcript.clientHeight) * 0.1;
transcript.dispatchEvent(new Event("scroll"));
});
await page.waitForTimeout(300);
// One extra turn of margin below the 20-turn contract: Virtuoso can still
// be settling row heights after edge scrolling, so the caret may land one
// row away from the measured target when the pointer move is delivered.
const focusTargetTurn = Math.max(0, points.anchorTurn - 21);
// Target one extra turn beyond the 20-turn contract: Virtuoso can still be
// settling row heights after edge scrolling, so the caret may land one row
// away from the measured target when the pointer move is delivered.
const findLogicalFocusPoint = () => page.evaluate((targetTurn) => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
const viewport = transcript.getBoundingClientRect();
const root = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((element) => {
const rect = element.getBoundingClientRect();
const turn = element.textContent?.match(/\bbench turn (\d+):/);
return turn && Number(turn[1]) <= targetTurn
&& rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
});
if (!root) return null;
const rect = root.getBoundingClientRect();
return {
x: Math.min(rect.right - 2, rect.left + 8),
y: (Math.max(rect.top, viewport.top) + Math.min(rect.bottom, viewport.bottom)) / 2,
};
}, Math.max(0, points.anchorTurn - 21));
let logicalFocusPoint = null;
for (let index = 0; index < 40 && !logicalFocusPoint; index += 1) {
logicalFocusPoint = await findLogicalFocusPoint();
if (!logicalFocusPoint) {
await page.mouse.wheel(0, -250);
await page.waitForTimeout(50);
}
}
assert(logicalFocusPoint != null, "deep logical drag settles over a visible 20+ turn target");
// Rows can shift between measuring the focus target and delivering the
// pointer move. Re-derive the coordinates on every attempt so the caret
// ends on a mounted selectable row and the overlay actually paints.
let overlayPainted = false;
for (let attempt = 0; attempt < 5 && !overlayPainted; attempt += 1) {
await page.mouse.move(logicalFocusPoint.x + 24, logicalFocusPoint.y, { steps: 4 });
await page.mouse.move(logicalFocusPoint.x, logicalFocusPoint.y, { steps: 8 });
overlayPainted = await page.waitForFunction(
() => document.querySelectorAll(".transcript-selection-overlay__rect").length > 0,
undefined,
{ timeout: 3_000 },
).then(() => true, () => false);
if (!overlayPainted) logicalFocusPoint = (await findLogicalFocusPoint()) ?? logicalFocusPoint;
}
assert(overlayPainted, "cross-page drag paints the logical selection overlay");
const during = await page.evaluate(({ x, y }) => {
const selection = document.getSelection();
const writes = window.__transcriptProgrammaticWrites ?? [];
const transcript = document.querySelector(".transcript");
const viewport = transcript?.getBoundingClientRect();
const rowIndex = (node) => {
const element = node instanceof Element ? node : node?.parentElement;
const value = element?.closest(".transcript__row")?.dataset.index;
return value == null ? null : Number(value);
};
const selectableRoots = [...document.querySelectorAll("[data-transcript-selectable]")];
const visibleSelectableRows = selectableRoots
.filter((root) => {
const rect = root.getBoundingClientRect();
return viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
})
.map(rowIndex);
const positiveRangeRows = selectableRoots
.filter((root) => {
const range = document.createRange();
range.selectNodeContents(root);
return [...range.getClientRects()].some((rect) => rect.width > 0 && rect.height > 0);
})
.map(rowIndex);
const hit = document.elementFromPoint(x, y);
const caret = document.caretPositionFromPoint?.(x, y)?.offsetNode
?? document.caretRangeFromPoint?.(x, y)?.startContainer;
return {
collapsed: selection?.isCollapsed ?? true,
rows: document.querySelectorAll(".transcript__row").length,
writeCount: writes.length,
writeOwners: [...new Set(writes.map((write) => write.owner))],
mode: transcript?.dataset.scrollMode,
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
scrollTop: transcript?.scrollTop ?? null,
scrollHeight: transcript?.scrollHeight ?? null,
clientHeight: transcript?.clientHeight ?? null,
hitRow: rowIndex(hit),
hitSelectable: Boolean(hit?.closest("[data-transcript-selectable]")),
caretRow: rowIndex(caret),
mountedSelectableRows: selectableRoots.map(rowIndex),
visibleSelectableRows,
positiveRangeRows,
};
}, logicalFocusPoint);
assert(during.collapsed, `cross-row drag releases the browser Range after logical promotion (${JSON.stringify(during)})`);
assert(during.mode === "logical-selecting", `cross-page drag remains owned by logical selection (${during.mode})`);
assert(during.rows <= Math.ceil(baselineRows * 1.1) + 2, `logical selection keeps the virtual DOM bounded (${baselineRows} → ${during.rows})`);
assert(during.overlayRects > 0, `logical selection paints mounted-row overlay rectangles (${JSON.stringify(during)})`);
if (during.writeOwners.some((owner) => owner !== "selection-edge-scroll")) {
throw new Error(`logical gesture admitted non-selection scroll owners: ${JSON.stringify(during.writeOwners)}`);
}
assert(true, "logical gesture rejects every non-selection programmatic scroll owner");
await page.mouse.up();
await page.waitForTimeout(250);
const settled = await page.evaluate(() => ({
mode: document.querySelector(".transcript")?.dataset.scrollMode,
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
scrollTop: document.querySelector(".transcript")?.scrollTop ?? 0,
}));
assert(settled.mode === "manual", "pointerup settles logical selection without a delayed page jump");
assert(settled.overlayRects > 0, "settled logical selection keeps its visible overlay");
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
transcript.scrollTop = transcript.scrollHeight;
transcript.dispatchEvent(new Event("scroll"));
});
await page.waitForTimeout(200);
await page.evaluate((top) => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
transcript.scrollTop = top;
transcript.dispatchEvent(new Event("scroll"));
}, settled.scrollTop);
await page.waitForTimeout(250);
const restoredRects = await page.locator(".transcript-selection-overlay__rect").count();
assert(restoredRects > 0, "logical overlay restores after selected rows scroll out and back in");
await page.evaluate(() => {
window.__logicalClipboardText = null;
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async (text) => { window.__logicalClipboardText = text; } },
});
});
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
await page.waitForFunction(() => typeof window.__logicalClipboardText === "string", undefined, { timeout: 30_000 });
const copied = await page.evaluate(() => window.__logicalClipboardText);
const copiedTurnValues = [...copied.matchAll(/bench turn (\d+):/g)].map((match) => Number(match[1]));
const copiedTurns = copiedTurnValues.length;
assert(copiedTurns >= 20, `logical copy resolves a 20+ turn frozen snapshot (${copiedTurns} turns: ${copiedTurnValues.join(",")})`);
await page.waitForTimeout(100);
const after = await page.evaluate(() => {
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
return {
collapsed: document.getSelection()?.isCollapsed ?? true,
rows: document.querySelectorAll(".transcript__row").length,
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
};
});
assert(after.collapsed, "logical copy leaves no synthetic browser Range behind");
assert(after.overlayRects === 0, "successful copy clears the logical overlay");
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing logical selection preserves the normal virtual DOM window");
const selectionHeapBaseline = await retainedHeap();
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
transcript.scrollTop = 0;
transcript.dispatchEvent(new Event("scroll"));
});
// Query and scroll advancement are separate steps: advancing scrollTop in
// the same evaluate as a missed query lets the scroll position run ahead of
// Virtuoso's row mounting under load, skipping every selectable row. Give
// each position two settle periods and wrap around at the bottom.
let forwardPoints = null;
for (let index = 0; index < 120 && !forwardPoints; index += 1) {
await page.waitForTimeout(50);
forwardPoints = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
const viewport = transcript.getBoundingClientRect();
const textRects = [...transcript.querySelectorAll("[data-transcript-selectable]")].flatMap((element) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const rects = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.textContent?.trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
rects.push(...range.getClientRects());
}
return rects;
}).filter((rect) => rect.width > 8 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4);
const start = textRects[0];
if (!start) return null;
const y = (Math.max(start.top, viewport.top + 4) + Math.min(start.bottom, viewport.bottom - 4)) / 2;
return {
start: { x: start.left + 2, y },
activate: { x: Math.min(start.right - 2, start.left + 32), y },
edge: { x: start.left + 2, y: viewport.bottom - 2 },
};
});
if (!forwardPoints && index % 2 === 1) {
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
const max = transcript.scrollHeight - transcript.clientHeight;
transcript.scrollTop = transcript.scrollTop >= max - 4 ? 0 : transcript.scrollTop + transcript.clientHeight / 2;
});
}
}
assert(forwardPoints != null, "settled reverse selection leaves a viewport where forward selection can start");
await page.evaluate(() => {
window.__transcriptProgrammaticWrites = [];
window.__logicalClipboardText = null;
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => {
window.__transcriptProgrammaticWrites.push({ owner, top });
};
});
await page.mouse.move(forwardPoints.start.x, forwardPoints.start.y);
await page.mouse.down();
await page.mouse.move(forwardPoints.activate.x, forwardPoints.activate.y, { steps: 6 });
for (let index = 0; index < 60; index += 1) {
await page.mouse.wheel(0, 500);
await page.waitForTimeout(50);
const visibleTarget = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
const viewport = transcript.getBoundingClientRect();
const element = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.width > 8 && rect.height > 0 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4;
});
if (!element) return null;
const rect = element.getBoundingClientRect();
return {
x: Math.min(rect.right - 2, rect.left + 8),
y: (Math.max(rect.top, viewport.top + 4) + Math.min(rect.bottom, viewport.bottom - 4)) / 2,
};
});
if (visibleTarget) {
await page.mouse.move(visibleTarget.x, visibleTarget.y, { steps: 4 });
} else {
await page.mouse.move(
forwardPoints.edge.x + (index % 2 === 0 ? 2 : -2),
forwardPoints.edge.y - (index % 2),
{ steps: 4 },
);
}
const mode = await page.locator(".transcript").getAttribute("data-scroll-mode");
if (mode === "logical-selecting") break;
}
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y);
await page.waitForTimeout(8_000);
const forwardDuring = await page.evaluate(() => ({
mode: document.querySelector(".transcript")?.dataset.scrollMode,
rows: document.querySelectorAll(".transcript__row").length,
owners: [...new Set((window.__transcriptProgrammaticWrites ?? []).map((write) => write.owner))],
}));
assert(forwardDuring.mode === "logical-selecting", "downward cross-page drag also promotes to logical selection");
assert(forwardDuring.rows <= Math.ceil(baselineRows * 1.1) + 2, "forward logical selection also keeps the virtual DOM bounded");
assert(forwardDuring.owners.every((owner) => owner === "selection-edge-scroll"), "forward logical gesture preserves scroll ownership");
await page.mouse.up();
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
await page.waitForFunction(() => typeof window.__logicalClipboardText === "string", undefined, { timeout: 30_000 });
const forwardCopiedTurns = await page.evaluate(() => (window.__logicalClipboardText.match(/bench turn /g) ?? []).length);
assert(forwardCopiedTurns >= 20, `forward logical copy resolves a 20+ turn frozen snapshot (${forwardCopiedTurns} turns)`);
await page.waitForFunction(() => document.querySelectorAll(".transcript-selection-overlay__rect").length === 0);
const retainedSelectionBytes = Math.max(0, (await retainedHeap()) - selectionHeapBaseline);
assert(retainedSelectionBytes <= 2 * 1024 * 1024, `cleared logical selection retains at most 2MiB (${(retainedSelectionBytes / 1024 / 1024).toFixed(2)}MiB)`);
await page.evaluate(() => { window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined; });
} finally {
await browser?.close();
preview.kill("SIGTERM");
}
-1
View File
@@ -1 +0,0 @@
-26
View File
@@ -1,26 +0,0 @@
import tsParser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import reactHooks from "eslint-plugin-react-hooks";
export default defineConfig([
{
files: ["src/**/*.{ts,tsx}"],
linterOptions: {
reportUnusedDisableDirectives: "off",
},
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: "latest",
sourceType: "module",
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
"react-hooks/rules-of-hooks": "error",
},
},
]);
Loaded 100 of 937 files, more files were not shown because too many files have changed in this diff. Show more