chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:08 +08:00
commit 6345505628
1516 changed files with 473769 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copy to .env and fill in. Only secrets belong here — a provider's base_url and
# model are configured in reasonix.toml, where api_key_env points at these variables.
DEEPSEEK_API_KEY=
MIMO_API_KEY=
+28
View File
@@ -0,0 +1,28 @@
.gitattributes text eol=lf
.gitignore text eol=lf
.npmrc text eol=lf
*.astro text eol=lf
*.css text eol=lf
*.go text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mjs text eol=lf
*.mod text eol=lf
*.nsi text eol=lf
*.plist text eol=lf
*.py text eol=lf
*.rs text eol=lf
*.sh text eol=lf
*.sql text eol=lf
*.sum text eol=lf
*.toml text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.txt text eol=lf
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Go-native pre-push gate replacing the retired v1 npm hook (install: make hooks).
# Full tests run in CI; vet is the fast, cross-platform local check.
set -e
go vet ./...
+3
View File
@@ -0,0 +1,3 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
+60
View File
@@ -0,0 +1,60 @@
name: Bug report
description: Report a bug in DeepSeek-Reasonix
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug. Pick which version line you're on below — it gets
labeled (`v1`/`v2`) automatically, no maintainer action needed.
- type: dropdown
id: version-line
attributes:
label: Version line
description: Which line are you running?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
validations:
required: true
- type: input
id: version
attributes:
label: Exact version
description: Output of `reasonix --version`.
placeholder: e.g. 1.0.0
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug, and what you expected instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: input
id: os
attributes:
label: OS / platform
placeholder: e.g. macOS 15.3 (arm64), Ubuntu 24.04, Windows 11
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs or output
description: Paste any error output. Automatically formatted as code.
render: shell
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & discussions
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
@@ -0,0 +1,29 @@
name: Feature request
description: Suggest an idea or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: dropdown
id: version-line
attributes:
label: Version line
description: Which line is this for?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
validations:
required: true
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: What are you trying to do, and what gets in the way today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How would you like it to work?
validations:
required: false
+49
View File
@@ -0,0 +1,49 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "gomod"
directory: "/desktop"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/desktop/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/site"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
+51
View File
@@ -0,0 +1,51 @@
# Area labels auto-applied to PRs by .github/workflows/pr-auto-label.yml
# (actions/labeler), based on the changed file paths. Keep the area set in sync
# with issue-auto-label.yml. severity/platform are issue-only — a PR is a change,
# not a bug report — and v1/v2 are handled by pr-version-label.yml.
agent:
- changed-files:
- any-glob-to-any-file:
- 'internal/agent/**'
- 'internal/control/**'
mcp:
- changed-files:
- any-glob-to-any-file:
- 'internal/plugin/**'
- 'internal/codegraph/**'
config:
- changed-files:
- any-glob-to-any-file:
- 'internal/config/**'
- 'internal/boot/**'
provider:
- changed-files:
- any-glob-to-any-file:
- 'internal/provider/**'
skills:
- changed-files:
- any-glob-to-any-file:
- 'internal/skill/**'
- 'internal/tool/**'
tui:
- changed-files:
- any-glob-to-any-file:
- 'internal/cli/**'
desktop:
- changed-files:
- any-glob-to-any-file:
- 'desktop/**'
updater:
- changed-files:
- any-glob-to-any-file:
- 'desktop/cmd/sign/**'
- 'desktop/updater*.go'
- 'scripts/desktop-build.sh'
- '.github/workflows/release*.yml'
+19
View File
@@ -0,0 +1,19 @@
## Summary
-
## Verification
-
## Cache impact
Cache-impact: TODO
Cache-guard: TODO
System-prompt-review: N/A
For cache-sensitive changes, fill these lines before requesting review:
- `Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason.
- `Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change.
- `System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+37
View File
@@ -0,0 +1,37 @@
name: Cache impact guard
on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
permissions:
contents: read
pull-requests: read
concurrency:
group: cache-impact-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
cache-impact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Collect PR changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/cache-impact-files.txt"
- name: Check cache-sensitive PR metadata
env:
PR_BODY: ${{ github.event.pull_request.body }}
CACHE_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/cache-impact-files.txt
run: bash scripts/check-cache-impact.sh
+283
View File
@@ -0,0 +1,283 @@
name: CI
on:
push:
branches: [main-v2]
pull_request:
branches: [main-v2]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
# Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
# file. gofmt output is OS-independent, so the Unix legs already cover it.
- name: gofmt
if: runner.os != 'Windows'
run: |
# Root module only — desktop/ is a separate module with its own tooling.
unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: vet
run: go vet ./...
- name: build
run: go build ./...
- name: test
if: runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test
if: runner.os == 'Windows'
timeout-minutes: 10
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./...
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
# The matrix never runs -race (it needs cgo); the project's concurrency
# (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
# ship without race coverage.
- name: test -race
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
desktop:
runs-on: ubuntu-22.04
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
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@v2.12.0
- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: go.mod tidy
run: |
go mod tidy
if ! git diff --quiet -- go.mod go.sum; then
echo "desktop/go.mod or go.sum is stale - run 'cd desktop && go mod tidy' and commit."
git diff -- go.mod go.sum
exit 1
fi
# WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag).
- name: Install Linux build deps
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: vet
run: go vet ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
working-directory: desktop
args: --timeout=5m
- name: build
run: go build ./...
- name: test
run: go test ./...
# 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.
desktop-windows:
runs-on: windows-latest
defaults:
run:
shell: bash
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
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@v2.12.0
# 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 (Windows-only path semantics)
timeout-minutes: 10
run: go test . -run 'OnWindows$' -v
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
args: --timeout=5m
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
site:
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: test
run: npm test
govulncheck:
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck
run: govulncheck ./...
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: test with coverage
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage.out
retention-days: 7
+40
View File
@@ -0,0 +1,40 @@
name: CodeQL
on:
push:
branches: ["main-v2"]
pull_request:
branches: ["main-v2"]
schedule:
- cron: "27 3 * * 1"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
@@ -0,0 +1,49 @@
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
+34
View File
@@ -0,0 +1,34 @@
name: Deploy crash worker
on:
push:
branches: [main-v2]
paths:
- 'workers/crash-report/**'
- '.github/workflows/deploy-crash-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-crash-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npm ci
npx wrangler deploy
+38
View File
@@ -0,0 +1,38 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
+160
View File
@@ -0,0 +1,160 @@
name: e2e-bot
# Comment "/e2e" (fixed suite) or "/e2e diff" (generate tests for the PR's diff)
# on a pull request to run the e2e benchmark against the real provider and post a
# report back. Gated to trusted authors: the job checks out PR-head code and runs
# it with the provider API key, so only the repo owner, members, and collaborators
# may trigger it.
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
concurrency:
group: e2e-bot-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
e2e:
if: >-
github.event.issue.pull_request &&
contains(github.event.comment.body, '/e2e') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
# Running PR-head code with the provider secret is gated twice: the author_
# association check above, and this deployment environment. Configure the
# `e2e-bot` environment with required reviewers in repo settings to force a
# human approval per run (actions/untrusted-checkout-toctou).
environment: e2e-bot
steps:
- name: Acknowledge
uses: actions/github-script@v9
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: context.payload.comment.id, content: 'eyes',
});
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
# suite, and a run --metrics-capable agent live.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Build harness + fallback agent from the default branch
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
# its own grader or tests. The agent is rebuilt from the PR head below; this
# main-v2 build is only the fallback for heads that predate `run --metrics`.
run: |
go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix
go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench
cp -r benchmarks/e2e "$RUNNER_TEMP/suite"
- name: Check out the PR head
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pin to the head commit resolved now and check it out detached, not the
# mutable PR ref: a force-push mid-run can't swap in different code after
# the trusted-author gate passed.
run: |
SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid)
git fetch -q origin "$SHA"
git checkout -q --detach "$SHA"
- name: Build the agent from the PR head
# The whole point of the bot is to drive the PR's code, not main-v2's. Fall
# back to the main-v2 build only when the PR head can't yield a
# run --metrics-capable binary (build break or predates the flag).
id: agent
run: |
bin="$RUNNER_TEMP/reasonix-base"
src="main-v2 fallback (PR head lacks run --metrics)"
if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \
&& "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then
bin="$RUNNER_TEMP/reasonix-pr"
src="PR head ($(git rev-parse --short HEAD))"
fi
echo "bin=$bin" >> "$GITHUB_OUTPUT"
echo "src=$src" >> "$GITHUB_OUTPUT"
echo "agent under test: $src"
- name: Write provider config
# User config covers suite tasks (they run in temp dirs); the repo-root copy
# covers diff mode (the agent runs in the repo root, where project config wins).
run: |
mkdir -p ~/.config/reasonix
cat > /tmp/reasonix-e2e.toml <<EOF
default_model = "e2e"
[[providers]]
name = "e2e"
kind = "openai"
base_url = "${{ vars.REASONIX_E2E_BASE_URL || 'https://api.deepseek.com' }}"
model = "${{ vars.REASONIX_E2E_MODEL || 'deepseek-v4-flash' }}"
api_key_env = "DEEPSEEK_API_KEY"
context_window = 20000 # small so the suite actually exercises compaction + cache churn
[providers.price]
cache_hit = ${{ vars.REASONIX_E2E_PRICE_CACHE_HIT || '0.02' }}
input = ${{ vars.REASONIX_E2E_PRICE_INPUT || '1' }}
output = ${{ vars.REASONIX_E2E_PRICE_OUTPUT || '2' }}
currency = "${{ vars.REASONIX_E2E_PRICE_CURRENCY || '¥' }}"
[permissions]
mode = "allow"
[codegraph]
enabled = false
EOF
cp /tmp/reasonix-e2e.toml ~/.config/reasonix/config.toml
cp /tmp/reasonix-e2e.toml ./reasonix.toml
- name: Run e2e
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [ -z "$DEEPSEEK_API_KEY" ]; then
printf 'missing DEEPSEEK_API_KEY secret\n\nAdd a repository secret named DEEPSEEK_API_KEY to enable the e2e bot.\n' > report.md
exit 0
fi
if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then
ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1)
[ -z "$ATTEMPTS" ] && ATTEMPTS=1
[ "$ATTEMPTS" -gt 5 ] && ATTEMPTS=5
BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)
git fetch -q origin "$BASE_REF"
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
else
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
fi
- name: Post report
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TRIGGER_USER: ${{ github.event.comment.user.login }}
run: |
printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md
gh pr comment ${{ github.event.issue.number }} --body-file report.md
- name: Report failure
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr comment ${{ github.event.issue.number }} --body "🤖 e2e bot failed — see the [run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
+99
View File
@@ -0,0 +1,99 @@
name: Auto-label issues
# Classify new issues into area / platform / severity labels using the DeepSeek
# API (the project's own model). The model is constrained to a fixed label set —
# anything it returns outside the set is dropped — so it can't invent labels.
# Issues it can't place into any area get `needs-triage` for a human to look at.
# Version (v1/v2) labels are handled separately by issue-version-label.yml.
on:
issues:
types: [opened, reopened]
permissions:
issues: write
concurrency:
group: issue-autolabel-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
classify:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
with:
script: |
const AREA = ['agent','mcp','config','updater','provider','desktop','tui','skills','rendering'];
const PLATFORM = ['windows','macos','linux'];
const SEVERITY = ['crash','data-loss','security'];
const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]);
const issue = context.payload.issue;
const title = issue.title || '';
const body = (issue.body || '').slice(0, 4000);
const system = [
'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.',
'Pick labels ONLY from these fixed sets. Never invent labels.',
'area (0-2, the affected subsystem):',
' agent: core agent loop / tool-calling / reasoning',
' mcp: MCP servers, plugins, codegraph',
' config: configuration, setup wizard, .toml/.env',
' updater: auto-update, installer, release packaging',
' provider: model providers, model selection/switching',
' desktop: Wails desktop GUI',
' tui: terminal UI / CLI',
' skills: skills system',
' rendering: terminal rendering / flicker / repaint',
'platform (only if clearly specific to one OS): windows, macos, linux',
'severity (only if clearly applicable):',
' crash: app crashes, hangs, or freezes',
' data-loss: loss of sessions, config, or history',
' security: credential/secret exposure or a security flaw',
'Be conservative: omit a label when unsure. The issue may be in Chinese.',
'Reply with JSON only: {"area":[],"platform":[],"severity":[]}',
].join('\n');
let labels = [];
try {
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-chat',
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: `Title: ${title}\n\nBody:\n${body}` },
],
}),
});
if (!res.ok) {
core.warning(`DeepSeek API ${res.status}: ${await res.text()}`);
return;
}
const data = await res.json();
const parsed = JSON.parse(data.choices[0].message.content);
labels = [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])]
.filter(l => ALLOWED.has(l));
} catch (e) {
core.warning(`Classification failed: ${e.message}`);
return;
}
if (!labels.some(l => AREA.includes(l))) labels.push('needs-triage');
if (labels.length) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels,
});
core.info(`Applied: ${labels.join(', ')}`);
}
+48
View File
@@ -0,0 +1,48 @@
name: Label issue version
# Issue forms can't let a reporter set a label directly (that needs triage rights),
# so the bug/feature forms carry a "Version line" dropdown and this workflow reads
# the submitted value and applies the matching v1/v2 label on their behalf.
on:
issues:
types: [opened, edited]
permissions:
issues: write
concurrency:
group: issue-version-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const body = context.payload.issue.body || '';
// Pull the "Version line" section out of the rendered issue form.
const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || '';
const m = section.match(/\bv([12])\b/i);
if (!m) {
core.info('No version line found; nothing to label.');
return;
}
const choice = 'v' + m[1];
const other = choice === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels: [choice],
});
// Keep v1/v2 mutually exclusive in case an edit flipped the choice.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.issue.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
+47
View File
@@ -0,0 +1,47 @@
name: Deploy site
on:
push:
branches: [main-v2]
paths: ['site/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v5
+28
View File
@@ -0,0 +1,28 @@
name: Auto-label PRs
# Apply area labels to PRs based on changed paths (see .github/labeler.yml).
# Complements the AI-based issue labeler (issue-auto-label.yml). Version (v1/v2)
# labels are handled separately by pr-version-label.yml.
#
# pull_request_target runs in the base repo context so it has the write token
# needed to label PRs from forks; actions/labeler only reads the diff file list
# (it never checks out or runs PR code), so this is safe.
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-autolabel-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
sync-labels: false
+49
View File
@@ -0,0 +1,49 @@
name: Label PR version
# Contributors (especially from forks) can't set labels themselves — that needs
# triage rights. A PR's version line is fully determined by its base branch, so
# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs
# in the base repo's context so it has write access even for fork PRs; it only
# reads trusted metadata (the base ref) and never checks out or runs PR code.
on:
pull_request_target:
types: [opened, reopened, edited]
permissions:
pull-requests: write
concurrency:
group: pr-version-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const base = context.payload.pull_request.base.ref;
const label = base === 'v1' ? 'v1'
: base === 'main-v2' ? 'v2'
: null;
if (!label) {
core.info(`Base branch '${base}' isn't a release line; skipping.`);
return;
}
const other = label === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [label],
});
// Drop the other line's label if the base was changed after opening.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.payload.pull_request.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
+485
View File
@@ -0,0 +1,485 @@
name: Release desktop
# Desktop (Wails) release line. Tag namespace `desktop-v<semver>` triggers this —
# plain `v<semver>` tags are the CLI release (release.yml) and intentionally do NOT
# trigger here (GitHub glob `v*` does not match `desktop-v*`). Bump with:
# git tag desktop-vX.Y.Z && git push origin desktop-vX.Y.Z
#
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
# pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary
# and uploads only to R2's canary/ pointer — no GitHub release, so canary never
# shows on the releases page and never touches latest/.
on:
push:
tags: ["desktop-v*"]
workflow_dispatch:
inputs:
channel:
description: "Release channel"
type: choice
options: [stable, canary]
default: stable
tag:
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
base_version:
description: "canary: intended next stable version, e.g. 1.5.0"
required: false
type: string
permissions:
contents: write # create the release and upload artifacts
jobs:
cache-guard:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Cache hit guard
run: ./scripts/cache-guard.sh
build:
name: build (${{ matrix.name }})
needs: cache-guard
permissions:
contents: read # checkout only; the publish job holds contents: write
actions: read # SignPath reads run details + downloads the unsigned artifact
strategy:
fail-fast: false
matrix:
include:
# One universal macOS build (Intel + Apple Silicon) on a single arm64
# runner — avoids the scarce/slow macos-13 Intel runner.
- { runner: macos-14, platform: darwin/universal, name: darwin-universal }
- { runner: windows-latest, platform: windows/amd64, name: windows-amd64 }
- { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 }
- { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
runs-on: ${{ matrix.runner }}
env:
# Windows Authenticode signing engages only when the SignPath token is set, so
# forks / token-less runs still build (unsigned), mirroring the APPLE_* gate.
HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
defaults:
run:
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v6
with:
node-version: "22"
- uses: pnpm/action-setup@v6
with:
version: 10
# Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh).
# 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+.
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
# Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install
# drops it in ~/go/bin, already on PATH (same place the wails CLI lands below).
- name: Install nfpm
if: runner.os == 'Linux'
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3
# Windows: NSIS provides makensis for `wails build -nsis`.
- name: Install NSIS
if: runner.os == 'Windows'
run: |
choco install nsis -y --no-progress
echo "C:\Program Files (x86)\NSIS" >> "$GITHUB_PATH"
# macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
- name: Install create-dmg
if: runner.os == 'macOS'
run: brew install create-dmg
# macOS signing: import the Developer ID cert into a throwaway keychain and
# stage the notarization key. No-ops (and the build ad-hoc signs) when the
# APPLE_* secrets aren't set, so forks still build.
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$APPLE_CERT_P12" ]; then
echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASS="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
# Prepend the signing keychain to the search list so codesign / find-identity see it.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
rm -f "$RUNNER_TEMP/cert.p12"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
- name: Resolve version
id: ver
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
- name: Build and package
env:
# macOS Developer ID + notarization path turns on only when all five
# APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
# Harmless on Windows/Linux runners (only the darwin branch reads these).
HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}"
# Authenticode-sign the Windows installer through SignPath. Runs BEFORE minisign:
# the signature rewrites the installer's bytes, so the updater's minisign sig and
# the manifest SHA must be computed over the signed build. SignPath pulls the file
# from the Actions artifact API, so it must be uploaded first. Canary signs with the
# test certificate; stable/rc sign with the Foundation release certificate, but only
# once SIGNPATH_RELEASE_SIGNING_READY is set — until that cert clears CSR-pending,
# stable ships unsigned instead of failing the whole release on an invalid policy.
- name: Upload unsigned installer for SignPath
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
id: unsigned-installer
uses: actions/upload-artifact@v7
with:
name: unsigned-${{ matrix.name }}
path: dist/*installer*.exe
if-no-files-found: error
retention-days: 1
- name: Submit installer for Authenticode signing
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: DeepSeek-Reasonix
signing-policy-slug: ${{ steps.ver.outputs.channel == 'canary' && 'test-signing' || 'release-signing' }}
artifact-configuration-slug: windows-installer
github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }}
github-token: ${{ github.token }}
wait-for-completion: true
# The policy uses an approval process — leave headroom for a human to approve
# the request (the approver gets an email on submit) before the job gives up.
wait-for-completion-timeout-in-seconds: 1800
output-artifact-directory: signed
- name: Replace installer with signed build
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
run: cp signed/*installer*.exe dist/
- name: Sign artifacts (minisign)
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: go run ./cmd/sign sign ../dist/*
- uses: actions/upload-artifact@v7
with:
name: dist-${{ matrix.name }}
path: dist/*
if-no-files-found: error
publish:
name: publish release
needs: build
runs-on: ubuntu-latest
# Stable publishes go through the `release` environment (esengine must
# approve); canary uses the open `canary` environment so maintainers self-serve.
environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/download-artifact@v8
with:
path: dist
pattern: dist-*
merge-multiple: true
- name: Resolve version
id: ver
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
# Generate latest.json with GitHub release download URLs; the mirror step
# rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
- name: Generate manifest
working-directory: desktop
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
# Canary never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the canary-dist artifact below. Stable publishes a
# GitHub release as usual.
- name: Publish GitHub release
if: steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
# Keep the repository homepage focused on the installable desktop app;
# the CLI release line is configured not to claim repository-wide latest.
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
args+=(--prerelease --latest=false)
else
args+=(--latest)
fi
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
- name: Upload canary dist for mirror
if: steps.ver.outputs.channel == 'canary'
uses: actions/upload-artifact@v7
with:
name: canary-dist
path: dist/*
if-no-files-found: error
mirror:
name: mirror to R2
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
if: ${{ github.repository_owner == 'esengine' }}
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
- uses: actions/checkout@v7
- name: Resolve version
id: ver
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
# Canary has no GitHub release — pull the signed dist from the workflow
# artifact. Stable pulls from the published release.
- name: Download canary dist
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary'
uses: actions/download-artifact@v8
with:
name: canary-dist
path: assets
- name: Download release assets
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p assets
gh release download "${{ steps.ver.outputs.tag }}" -R "${{ github.repository }}" -D assets
# Rewrite both url and sig inside latest.json from github.com to the R2 CDN,
# so the updater pulls the manifest AND the heavy artifacts from R2.
- name: Rewrite latest.json URLs to R2
if: env.HAS_R2 == 'true'
env:
R2_PUBLIC_BASE: https://dl.reasonix.io
TAG: ${{ steps.ver.outputs.tag }}
run: |
f=assets/latest.json
jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
.platforms |= with_entries(
.value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
| .value.sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
)
' "$f" > "$f.new"
mv "$f.new" "$f"
cat "$f"
- name: Configure AWS CLI for R2
if: env.HAS_R2 == 'true'
run: |
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
- name: Mirror to R2
if: env.HAS_R2 == 'true'
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT"
# The pointer a release moves is its channel's alone: canary writes
# canary/, stable writes latest/. A stable prerelease (rc) moves neither.
if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then
aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT"
elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "prerelease — leaving R2 latest/ untouched"
else
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so smoke the mirrored objects over the authenticated S3 API
# instead of the public edge. This verifies the mirror landed; the public
# edge itself is not reachable from CI and is covered by end users' traffic.
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
TAG: ${{ steps.ver.outputs.tag }}
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
.download_page == "https://reasonix.io/#start" and
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
pointer=""
else
pointer="latest"
fi
if [ -n "$pointer" ]; then
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
fi
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
key="${asset#https://dl.reasonix.io/}"
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
done
# Best-effort probe of the release gateway — the updater's second
# manifest source — over the same public edge and Go client UA end users
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
# datacenter/proxy egress gets blocked before the worker runs) and must
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
# lands; it is surfaced as a warning so the run shows whether the edge
# is open. Anything else unexpected (404, 5xx, wrong version) means the
# gateway route or pointer regressed and fails hard.
- name: Probe public release gateway
if: env.HAS_R2 == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
echo "prerelease — no pointer moved; skipping gateway probe"
exit 0
fi
chan="stable"
[ "$CHANNEL" = "canary" ] && chan="canary"
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
# curl already prints 000 for a transport failure; || true keeps -e
# from killing the step so the case below can route it.
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
case "$code" in
200)
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
echo "gateway serves $VERSION on $chan"
else
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
exit 1
fi
;;
403)
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
;;
000|"")
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
;;
*)
echo "::error::gateway returned $code for $url — route or pointer regression"
exit 1
;;
esac
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release upload "$VERSION" assets/latest.json --clobber
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+114
View File
@@ -0,0 +1,114 @@
name: Release npm
# npm line. Tag namespace `npm-v<semver>` triggers this — plain `v<semver>` tags
# are the CLI binary + Homebrew release (release.yml) and intentionally do NOT
# trigger here (GitHub glob `npm-v*` does not match `v*`). Splitting npm onto its
# own tag lets a prerelease ship to npm's `next` channel without touching the
# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave
# its rc). Bump with:
# git tag npm-vX.Y.Z # stable -> publishes under `latest`
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# A manual workflow_dispatch can publish either an opt-in canary from the current
# ref or an approved stable/next version when tag creation is restricted. Canary
# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The verify step below asserts the tag actually landed.
on:
push:
tags: ['npm-v*']
workflow_dispatch:
inputs:
channel:
description: "Publish channel"
required: true
default: canary
type: choice
options:
- canary
- stable
base_version:
description: "Version to publish. Canary appends -canary.<run_number>; stable publishes exactly this version."
required: true
type: string
permissions:
contents: read
jobs:
cache-guard:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
npm:
name: publish npm packages
needs: cache-guard
runs-on: ubuntu-latest
# A tag push (npm-v*) or manual stable dispatch publishes a stable/next
# release and goes through the `release` environment (esengine approves);
# canary dispatches run free.
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Tag push uses the npm-v* tag. A canary dispatch synthesizes a
# <base>-canary.<run_number> version; a manual stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
- name: Resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
base="${{ inputs.base_version }}"; base="${base#v}"
if [ "${{ inputs.channel }}" = "canary" ]; then
echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
else
echo "arg=v${base}" >> "$GITHUB_OUTPUT"
fi
else
echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
# to `next`, and nothing noticed until users got downgraded. The registry
# applies tags asynchronously after publish, hence the poll.
- name: Verify dist-tags
run: |
set -euo pipefail
version="${{ steps.ver.outputs.arg }}"
version="${version#npm-}"; version="${version#v}"
case "$version" in
*-canary.*) tag=canary ;;
*-*) tag=next ;;
*) tag=latest ;;
esac
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
if [ "$got" = "$version" ]; then
echo "dist-tag $tag -> $got"
exit 0
fi
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
sleep 10
done
echo "::error::npm dist-tag '$tag' never landed on $version"
exit 1
+173
View File
@@ -0,0 +1,173 @@
name: Release
# CLI binary line. Tag namespace `v<semver>` triggers this — it builds the
# release archives + checksums, publishes the GitHub release, and updates the
# Homebrew cask. npm is a SEPARATE line (release-npm.yml, `npm-v*`) so an npm
# prerelease can ship without forcing the stable cask, and vice versa; desktop is
# `desktop-v*` (release-desktop.yml). A prerelease `v*-rc*` still builds archives
# and a prerelease GitHub release, but goreleaser auto-skips the cask upload
# (brew has no prerelease channel — see .goreleaser.yaml).
on:
push:
tags: ['v*']
permissions:
contents: write # create the release and upload archives
jobs:
cache-guard:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
goreleaser:
name: archives + checksums + homebrew tap
needs: cache-guard
runs-on: ubuntu-latest
# CLI stable release (archives + GitHub release + Homebrew cask). It does not
# claim GitHub's repository-wide Latest badge; that belongs to desktop so the
# repository homepage points users at the installable app. Gated on the
# `release` environment so only esengine can approve it going public.
environment: release
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
exit 0
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so read the manifest over the authenticated S3 API instead
# of the public edge.
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL. Desktop releases now
# own that Latest badge, but this check still exercises the public fallback
# path exactly the way those clients fetch it: anonymously, over the public
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
# 403s Actions egress — see the R2 note above), GitHub serves its own
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
# for weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
# (#5822) — every `npm update -g` user was silently downgraded to a
# months-old version, and nothing noticed because the npm line
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
# stable npm tag was simply never pushed. release-npm.yml's own verify
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the runs race: both workflows wait
# on the release environment independently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm run may still be awaiting its own environment approval;
# release-npm.yml's verify step owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
exit 0
;;
esac
version="${TAG#v}"
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
exit 1
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
if [ "$newest" = "$got" ]; then
echo "npm latest -> $got (>= $version) OK"
exit 0
fi
fi
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
sleep 10
done
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
exit 0
@@ -0,0 +1,37 @@
name: Update acknowledgments
on:
workflow_dispatch:
schedule:
- cron: '17 3 * * 1'
permissions:
contents: write
pull-requests: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Update README contributor tables
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/update-acknowledgments.mjs
- name: Open pull request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: chore/update-acknowledgments
delete-branch: true
title: 'Update acknowledgments contributors / 更新致谢贡献者'
commit-message: 'docs: update acknowledgments contributors'
body: |
Refreshes the README acknowledgments tables from the GitHub contributors API.
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1`
- Anonymous contributors are shown without email addresses.
labels: documentation
+64
View File
@@ -0,0 +1,64 @@
# Build artifacts
/bin/
/dist/
/stage/
/npm/.stage/
/reasonix
/reasonix.exe
# Website (Astro) build output
/site/dist/
/site/.astro/
/site/_preview.png
# Go test/build scratch
*.test
*.out
*.coverprofile
coverage.out
cpu.prof
mem.prof
# Wails desktop artifacts
/desktop/build/*
!/desktop/build/appicon.png
!/desktop/build/appicon.svg
/desktop/desktop
/desktop/frontend/dist/*
!/desktop/frontend/dist/.gitkeep
/desktop/frontend/sourcemaps/
/desktop/frontend/wailsjs/
/desktop/frontend/package.json.md5
# Environment / local config (reasonix.example.toml is the shared template)
.env
.env.local
*.local
/reasonix.toml
/desktop/.env
/desktop/reasonix.toml
# Node / frontend dependencies and caches
node_modules/
.pnpm-store/
.npm/
# Project-local Reasonix state; keep the checked-in review command.
/.reasonix/*
!/.reasonix/commands/
!/.reasonix/commands/review.md
# CodeGraph per-project index (rebuilt locally; never committed)
.codegraph/
# Editor / OS
.DS_Store
Thumbs.db
.idea/
.vscode/
/site/public/av/
benchmarks/context-maintenance-e2e/run/
# Local scratch
temp/
+48
View File
@@ -0,0 +1,48 @@
version: "2"
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
settings:
errcheck:
check-type-assertions: false
exclude-functions:
- (*os.File).Close
- (io.Closer).Close
- (*net/http.Response).Body.Close
- golang.org/x/term.Restore
staticcheck:
checks:
- all
- -ST1005 # error strings ending with punctuation — too strict for initial adoption
- -ST1018 # Unicode format characters — intentional in TUI code
- -QF1001 # De Morgan's law — readability preference
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# Setup/teardown in tests routinely ignores cleanup errors.
- path: _test\.go
linters:
- errcheck
# The pinned golangci-lint binary's staticcheck reports SA5011 false
# positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it
# doesn't treat t.Fatal as terminating); the same code is clean under a
# locally-built golangci-lint. Scope the suppression to tests so SA5011
# still guards production code.
- path: _test\.go
linters:
- staticcheck
text: SA5011
issues:
max-issues-per-linter: 0
max-same-issues: 0
+62
View File
@@ -0,0 +1,62 @@
version: 2
project_name: reasonix
before:
hooks:
- go mod download
builds:
- id: reasonix
main: ./cmd/reasonix
binary: reasonix
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Tag }}
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
archives:
- id: reasonix
ids: [reasonix]
name_template: "reasonix-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
checksum:
name_template: SHA256SUMS
algorithm: sha256
homebrew_casks:
- name: reasonix
ids: [reasonix]
# Prereleases (v*-rc*) must not update the stable tap; brew has no separate
# prerelease channel, so a pushed rc cask becomes the default `brew install`.
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
skip_upload: "auto"
repository:
owner: esengine
name: homebrew-reasonix
branch: main
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
description: "Cache-first DeepSeek coding agent for the terminal."
commit_author:
name: reasonix
email: reasonix@deepseek.com
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"]
end
release:
prerelease: auto
make_latest: false
name_template: "Reasonix CLI {{ .Tag }}"
+5
View File
@@ -0,0 +1,5 @@
---
description: Review a file for bugs
argument-hint: [path]
---
Read $1 and list any correctness bugs or risky patterns, with file:line references, most important first. Focus: $ARGUMENTS.
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- GitHub Actions wraps every uploaded artifact in a zip, so the installer arrives
zipped even though it is a single file — match and sign the .exe inside. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file-set>
<include path="*installer*.exe" min-matches="1" max-matches="1" />
<for-each>
<authenticode-sign />
</for-each>
</pe-file-set>
</zip-file>
</artifact-configuration>
+60
View File
@@ -0,0 +1,60 @@
# Changelog
All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
branch.
## Unreleased
### Changed
- Agent runtime defaults now leave both executor and dedicated planner tool-call
rounds unlimited (`max_steps = 0`, `planner_max_steps = 0`). Step limits now
come from the user/global config only; project `reasonix.toml` does not
override them.
## [1.0.0] — 2026-06-03
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
TypeScript line; a new codebase that becomes the default (`main-v2`).
### Highlights
- **Go kernel**: a single static binary (CGO-free), cross-compiled for
darwin/linux/windows on amd64 + arm64. Distributed via npm (the package wraps
the native binary), Homebrew (`esengine/reasonix` tap), and release archives;
no Node runtime needed to run it.
- **Agent core**: the loop, built-in tools (read/write/edit/multi_edit/glob/grep/
ls/bash/web_fetch/todo_write), permission gate, sandboxed bash, and the
DeepSeek prefix-cacheoriented design.
- **Subagents**: `task` plus explore/research/review/security_review skill agents.
- **Skills & hooks**: Claude-Code-style skills (`internal/skill`) and hooks
(`internal/hook`), symlink-aware and slash-integrated.
- **MCP client**: connect external servers over stdio / Streamable HTTP; reads
`[[plugins]]` and a Claude-Code `.mcp.json`.
- **Code intelligence via CodeGraph**: a tree-sitter symbol/call graph
(`codegraph_*` tools) replaces embedding semantic search — no embedding service
or API cost. Fetched into a local cache on first use (or `reasonix codegraph
install`) and indexed in the background, so installs and startup stay fast.
- **Plan mode** with evidence-backed step sign-off (`complete_step`).
- **Memory**: `REASONIX.md` hierarchy + auto-memory, folded into the cache-stable
prefix.
- **ACP** (`reasonix acp`) and an HTTP/SSE server frontend; desktop app (Wails).
### Fixed
- **File encoding support restored** — GBK/GB18030 (and other non-UTF-8) files
can now be read, edited, and grepped correctly. The v2 rewrite had dropped
v1's encoding detection; files in CJK Windows charsets were silently misread
or rejected as binary. The read/edit/write round-trip now preserves the
original file encoding. (#2637)
### Notes
- Versions: the legacy TypeScript line stays in `0.x`; the Go line starts at
`1.0.0`. See [docs/MIGRATING.md](docs/MIGRATING.md).
- Release archives ship a bare binary; CodeGraph is fetched on first use. Windows
support for the fetched runtime is unverified — install `codegraph` on PATH if
the auto-fetch doesn't resolve there.
[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0
+190
View File
@@ -0,0 +1,190 @@
# Contributing to Reasonix
Thank you for your interest in contributing to Reasonix! This guide covers
everything you need to get started.
## Prerequisites
- **Go 1.25+** — the project targets the latest stable Go release
- **Git** — for version control
- **Node.js** (optional) — only if you work on the desktop app (`desktop/`)
## Getting started
```bash
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
go build ./cmd/reasonix # builds the CLI binary
go test ./... # runs the full test suite
```
## Project structure
| Directory | Purpose |
|-----------|---------|
| `cmd/reasonix` | CLI entry point |
| `internal/agent` | Agent loop, session, coordinator |
| `internal/cli` | TUI, subcommands, setup wizard |
| `internal/control` | Transport-agnostic controller |
| `internal/config` | TOML configuration loading |
| `internal/tool/builtin` | Built-in tools (bash, read_file, …) |
| `internal/provider` | Model-backend abstraction |
| `internal/provider/openai` | OpenAI-compatible provider |
| `internal/plugin` | MCP client (stdio + HTTP) |
| `internal/event` | Typed event stream |
| `internal/hook` | Shell hooks (PreToolUse, …) |
| `internal/memory` | REASONIX.md hierarchy + auto-memory |
| `internal/skill` | Skill discovery from Markdown |
| `internal/sandbox` | OS-level sandboxing |
| `internal/serve` | HTTP/SSE server frontend |
| `internal/checkpoint` | Snapshot-based rewind |
| `desktop/` | Wails-based desktop app (separate Go module) |
| `docs/` | Engineering spec, migration guide |
### Dependency direction
```
cli → {agent, plugin, config} → {tool, provider}
```
Built-in subpackages import their parent to self-register via `init()`.
Parents never import children.
## Development workflow
### Building
```bash
make build # go build ./...
make test # go test ./...
make vet # go vet ./...
make fmt # gofmt -w .
make hooks # install git hooks (pre-push: go vet)
make cross # cross-compile for all 6 targets
```
### Isolated development environment
A source-built binary shares no on-disk state with a stable release when launched
with `REASONIX_HOME` set. This gives each build its own self-contained directory
tree — config, credentials, sessions, cache, skills, commands, hooks, and
desktop tab state — so the two builds never interfere:
**CLI**
```bash
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
# or after building:
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
```
**Desktop**
```bash
cd desktop && wails build
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
```
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
Command Prompt; the binary extension is `.exe`.
The directory is empty on first launch; the app behaves exactly like a fresh
install. Every subsequent write — config saves, credential storage, session
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
directory scanning, and all other fallback paths are skipped so no production
data leaks in or out.
### Cache-first review gate
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
touch provider-visible system prompt construction, memory prefix, output styles,
skill index behavior, default tool surfaces, tool schemas, provider request
serialization, compaction, or MCP/tool registration need explicit cache review.
For these changes:
- Keep system prompt changes low-frequency and require explicit review.
- Fill the PR body `Cache-impact:` line with `none`, `low`, `medium`, or `high`
plus the reason.
- Fill the PR body `Cache-guard:` line with the focused guard test/command added
or run, or explain why an existing guard covers the change.
- Fill `System-prompt-review:` when system prompt, memory prefix, output style,
or skill index behavior changes.
- Prefer focused guard tests near the changed surface; `scripts/cache-guard.sh`
remains the broader release-level cache-hit check.
CI enforces this metadata for cache-sensitive paths so prompt/tool prefix churn
is called out before review.
### Running tests
```bash
go test ./... # all tests
go test ./internal/agent/ -v # verbose, one package
go test ./internal/tool/builtin/ -run TestGrep # one test
```
### Code style
- `gofmt` is enforced by CI — format before committing
- Follow existing patterns: wrap errors with `fmt.Errorf("...: %w", err)`
- Library code never calls `os.Exit` or prints to stdout/stderr
- Only `cli/` and `main/` decide exit codes and user-facing messages
- Exported identifiers must have doc comments
### Commit messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(glob): add ** recursive pattern support
fix: replace silent error discards with structured logging
test(event): add comprehensive unit tests for event package
docs: add CONTRIBUTING.md
ci: add golangci-lint and govulncheck
```
## Adding a new built-in tool
1. Create `internal/tool/builtin/mytool.go`
2. Implement the `tool.Tool` interface: `Name()`, `Description()`, `Schema()`, `ReadOnly()`, `Execute()`
3. Register via `func init() { tool.RegisterBuiltin(myTool{}) }`
4. Add tests in `internal/tool/builtin/builtin_test.go` or a separate `mytool_test.go`
5. The tool is automatically available — `main` blank-imports `builtin`
## Adding a new model provider
(For MCP tool servers see `internal/plugin` instead — that's a different layer.)
1. Create `internal/provider/myprovider/`
2. Implement `provider.Provider`: `Name()`, `Stream()`
3. Register via `func init() { provider.Register("mykind", New) }`
4. The provider is available from config with `kind = "mykind"`
## Adding i18n strings
1. Add the field to `internal/i18n/i18n.go` (`Messages` struct)
2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go`
3. The `TestCatalogsComplete` test will fail if you miss a locale
## Submitting changes
1. Fork the repository
2. Create a feature branch from `main-v2`
3. Make your changes with tests
4. Ensure `go test ./...` passes
5. Ensure `gofmt -l .` shows no changes
6. Submit a pull request to `main-v2`
## Reporting issues
Open an issue on GitHub with:
- Steps to reproduce
- Expected vs actual behavior
- Go version and OS
- Relevant logs or error messages
## License
By contributing, you agree that your contributions will be licensed under the
same license as the project.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Reasonix Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOEXE := $(shell go env GOEXE)
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix-plugin-example$(GOEXE) ./cmd/reasonix-plugin-example
vet:
go vet ./...
fmt:
gofmt -w .
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
hooks:
@git config core.hooksPath .githooks
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
cross:
@mkdir -p dist
@for p in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do \
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
echo "build $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o dist/reasonix-$$os-$$arch$$ext ./cmd/reasonix; \
done
clean:
rm -rf bin dist
+209
View File
@@ -0,0 +1,209 @@
<p align="center">
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
<p align="center">
<strong>English</strong>
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.md">Guide</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">Spec</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
> [!IMPORTANT]
> **Reasonix 1.0 is a ground-up rewrite in Go** — this branch (`main-v2`) is the new default and where development happens now.
> The earlier `0.x` TypeScript releases are **legacy**, living on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) branch (maintenance only).
> See the **[migration guide](./docs/MIGRATING.md)**. `npm i -g reasonix` stays the install command — `1.0.0`+ delivers the Go binary, `0.x` is the legacy TS build.
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<br/>
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
<p align="center">A config- and plugin-driven harness — a single static Go binary, tuned around DeepSeek's prefix cache so token costs stay low across long sessions.</p>
<br/>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
<br/>
## Features
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek ships as a preset; any
OpenAI-compatible endpoint is a config entry, not new code. Optionally run
two models together (executor + planner) in separate, cache-stable sessions.
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
(MCP-compatible). Built-in tools self-register at compile time.
- **Cache-aware context maintenance.** Startup injects a small stable environment
summary, stale tool output is snipped/pruned before summary compaction, and the
built-in tool schema contract is documented for regression review.
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
to six targets with one command. The only dependency is a TOML parser.
## Install
```sh
npm i -g reasonix # any OS; pulls the prebuilt native binary
brew install esengine/reasonix/reasonix # macOS
```
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
### Code signing
Windows builds are code-signed with a free certificate provided by the
[SignPath Foundation](https://signpath.org/), with signing through
[SignPath.io](https://signpath.io/).
### Build from source
```sh
make build # -> bin/reasonix(.exe)
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```
## Quick start
```sh
reasonix setup # config wizard → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # or let setup save it to Reasonix home .env
reasonix # then run /init to generate AGENTS.md (project memory)
reasonix run "implement the TODOs in main.go"
reasonix run --model deepseek-pro "add unit tests for this function"
echo "explain this code" | reasonix run
```
## Configuration
A minimal `reasonix.toml` — one provider and a default model — is enough to start:
```toml
default_model = "deepseek-flash"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
```
Resolution order is **flag > `./reasonix.toml` > the user config file >
built-in defaults**; starting with **Reasonix v1.8.1**, the user file lives at
`~/.reasonix/config.toml` on macOS/Linux and
`%AppData%\reasonix\config.toml` on Windows. See
**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details and the
full `config.toml` / `.env` structure. Provider entries name secrets with
`api_key_env`; the secret values themselves live in Reasonix's global
`<Reasonix home>/.env`, shared by CLI and desktop. Project `.env` files are not
provider-key runtime fallbacks, but still feed workspace-scoped, non-provider
`${VAR}` expansion for MCP/plugin settings without importing Reasonix control
variables. Permissions, the sandbox, plugins (MCP), slash
commands, `@` references, and two-model setup are all in the
**[Guide](./docs/GUIDE.md)**.
## Documentation
- **[Guide](./docs/GUIDE.md)** — configuration, permissions & sandbox, plugins
(MCP), slash commands, `@` references, two-model collaboration.
- **[Subagent profiles](./docs/SUBAGENT_PROFILES.md)** — create, share, preview,
run, edit, and safely delete isolated agent profiles from desktop or CLI.
- **[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md)** —
`reasonix doctor capabilities`, desktop Settings → Diagnostics, and the
`/reasonix-guide` skill for skills/hooks/MCP/plugin troubleshooting.
- **[Bot guide](./docs/BOT_GUIDE.md)** — connect Feishu, Lark, and WeChat bots
from the desktop app, then use approvals, YOLO, and commands from IM.
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
data types, and roadmap.
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
complex requests with context, output boundaries, constraints, and when to ask.
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
names, read-only flags, and schema snapshot guard.
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
TypeScript releases to the 1.0 Go rewrite.
- **[Checkpoints & rewind](./docs/CHECKPOINTS.md)** — the snapshot-based edit
safety net (Esc-Esc / `/rewind`).
<br/>
## Star History
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
</picture>
</a>
<br/>
## Support
If Reasonix has been useful and you'd like to say thanks, you can. It stays a coffee, not a contract — donations don't buy feature priority or change how issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="240"/>
</p>
<br/>
## Acknowledgments
A small list of folks whose work has shaped Reasonix the most — the current top
20 contributors by commit count. The full contributor graph is on
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1).
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** (anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** (anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** (anonymous) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
<!-- reasonix-top-contributors:end -->
Also a separate thank-you to [**Bernardxu123**](https://github.com/Bernardxu123)
for designing the project logo, and to
[AIGC Link](https://xhslink.com/m/80ngts127cA) for promoting the project on XiaoHongShu.
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
</p>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`esengine/DeepSeek-Reasonix`
- 原始仓库:https://github.com/esengine/DeepSeek-Reasonix
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+195
View File
@@ -0,0 +1,195 @@
<p align="center">
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.zh-CN.md">指南</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
> [!IMPORTANT]
> **Reasonix 1.0 是用 Go 从零重写的版本** —— 本分支(`main-v2`)已是新的默认分支,后续开发都在这里。
> 早期的 `0.x` TypeScript 版本转为 **legacy**,保留在 [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) 分支(仅维护)。
> 详见**[迁移指南](./docs/MIGRATING.md)**。`npm i -g reasonix` 仍是安装命令——`1.0.0`+ 装的是 Go 二进制,`0.x` 是 legacy TS 版。
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<br/>
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
<p align="center">由配置与插件驱动的极薄 harness——单一静态 Go 二进制,围绕 DeepSeek 的前缀缓存调优,长会话也能把 token 成本压低。</p>
<br/>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
## 特性
- **配置驱动**provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
内置工具在编译期自注册。
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
- **零摩擦分发**`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
唯一依赖是一个 TOML 解析库。
## 安装
```sh
npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制
brew install esengine/reasonix/reasonix # macOS
```
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
### 代码签名
Windows 构建使用 [SignPath 基金会](https://signpath.org/) 提供的免费代码签名证书,
通过 [SignPath.io](https://signpath.io/) 完成签名。
### 从源码构建
```sh
make build # -> bin/reasonix(.exe)
make cross # -> dist/darwin|linux|windows × amd64|arm64
```
## 快速开始
```sh
reasonix setup # 配置向导 → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到 Reasonix 全局 .env
reasonix # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆)
reasonix run "把 main.go 里的 TODO 实现掉"
reasonix run --model deepseek-pro "给这个函数补单元测试"
echo "解释这段代码" | reasonix run
```
## 配置
一个最小的 `reasonix.toml`——一个 provider 加一个默认模型——就够跑起来:
```toml
default_model = "deepseek-flash"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
```
优先级为 **flag > `./reasonix.toml` > 用户配置文件 > 内置默认值**;从
**Reasonix v1.8.1** 开始,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml`
Windows 为 `%AppData%\reasonix\config.toml`。迁移细节见
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**,其中也说明了全局 `config.toml`
`.env` 的完整结构。Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在
CLI 与桌面端共用的 Reasonix 全局 `<Reasonix home>/.env`;项目 `.env` 不再作为
provider key 的运行时 fallback,但仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,不导入 Reasonix 控制变量。权限、沙盒、插件(MCP)、
斜杠命令、`@` 引用与双模型设置,全部在 **[指南](./docs/GUIDE.zh-CN.md)** 里。
## 文档
- **[指南](./docs/GUIDE.zh-CN.md)** —— 配置、权限与沙盒、插件(MCP)、斜杠命令、
`@` 引用、双模型协同。
- **[子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md)** —— 在桌面端或 CLI
创建、共享、预览、运行、编辑和安全删除隔离智能体 Profile。
- **[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md)** ——
`reasonix doctor capabilities`、桌面端 **设置 → 诊断**,以及内置 Skill
`/reasonix-guide`,用于 skills / hooks / MCP / 插件排障。
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
Bot,以及 IM 里的审批、YOLO 和命令交互。
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
read-only 标记和 schema 快照保护。
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
- **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网
(Esc-Esc / `/rewind`)。
<br/>
## Star 趋势
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
</picture>
</a>
<br/>
## 支持本项目
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature 优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="240"/>
</p>
<br/>
## 致谢
下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 当前按 commit 数统计的前 20 名贡献者。
完整贡献者列表在
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1)。
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix**anonymous | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check**anonymous | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan**anonymous |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
<!-- reasonix-top-contributors:end -->
另外特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo
以及 [AIGC Link](https://xhslink.com/m/80ngts127cA) 在小红书上的推广。
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT —— 见 <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
</p>
+73
View File
@@ -0,0 +1,73 @@
# Reasonix project memory
This file is loaded into every session's system prompt (the cache-stable prefix),
so keep it concise and durable — it is the project's standing instructions to the
agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
## Conventions
- Go kernel under `internal/`; each package owns one concern and documents it in a
package comment. Match the surrounding comment density and idiom when editing.
- One transport-agnostic `control.Controller` sits behind every frontend (chat
TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a
frontend, so all three inherit it.
- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay
byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never
mutate it mid-session — ride the turn tail instead (see `control.Compose`).
## Memory
- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md`
(personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any
`REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name.
- `@path` on its own line imports another file's contents.
- `#<note>` in chat quick-adds a line here. The `remember` tool saves durable
facts to the per-project auto-memory store (frontmatter files + `MEMORY.md`
index), which loads into the prefix on the next session.
## Notes
## Pre-push CI simulation
Run these **before every commit** to catch the fastest CI failures locally:
```bash
gofmt -w . # catches gofmt (saves ~13s CI)
go vet ./... # catches vet warnings (saves ~52s CI/lint)
go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks
```
CI runs `golangci-lint` (not locally available), but gofmt + vet already block ~80% of fast-fail scenarios.
## Import cycle rule
Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you:
```
# BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed
```
Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists.
## PR hygiene
- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers.
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## Cache-impact PR metadata
When PR changes touch files under `internal/boot/`, `internal/tool/`, `internal/provider/`, or other cache-sensitive paths (listed in `scripts/check-cache-impact.sh`), the PR body MUST include these lines at the end:
```
Cache-impact: <none|low|medium|high> — <reason>
Cache-guard: <focused guard test/command or existing guard rationale>
```
If the PR also touches files under `internal/config/`, `internal/memory/`, `internal/outputstyle/`, `internal/skill/`, or `internal/boot/`, add:
```
System-prompt-review: <reviewer/approval note>
```
Values `n/a`, `none`, `todo`, `tbd` are rejected — use a descriptive reason instead.
+137
View File
@@ -0,0 +1,137 @@
# Security Policy
## Supported Versions
Reasonix security fixes are prioritized for the currently developed Go rewrite
and the current 1.x release line.
| Version or branch | Security support |
| --- | --- |
| `main-v2` / 1.x releases | Supported |
| `v1` / 0.x legacy branch | Critical fixes only, where practical |
| Older releases, forks, or modified builds | Not covered unless the issue is reproducible upstream |
If you are unsure whether a version is affected, report against the newest
released 1.x version and include the exact version or commit you tested.
## Reporting a Vulnerability
Please report security issues privately. Do not open a public issue with exploit
details, secrets, crash dumps, or proof-of-concept payloads.
Preferred reporting path:
1. Use GitHub private vulnerability reporting for this repository, if available.
2. If private reporting is not available to you, open a minimal public issue
asking for a private maintainer contact path. Do not include exploit details
in that issue.
Please include:
- Affected Reasonix version, commit, operating system, and installation method.
- The feature or surface involved, such as CLI, desktop app, HTTP `serve`, bot
gateway, MCP plugin, built-in tool, updater, or configuration loading.
- Clear reproduction steps using dummy credentials and non-sensitive files.
- The expected impact, such as secret disclosure, arbitrary file access,
command execution, sandbox escape, authentication bypass, or supply-chain risk.
- Any relevant logs with API keys, tokens, local paths, and personal data
redacted.
Do not send real provider API keys, bot credentials, OAuth tokens, private
workspace files, or third-party user data.
## Security Boundaries
Reasonix is a local coding agent. Many features intentionally operate on the
user's local machine and workspace, including file reads, file writes, shell
commands, MCP plugins, language servers, bot sessions, and model-provider
requests. A finding is security-relevant when it crosses a supported boundary or
bypasses an explicit guard.
Supported boundaries include:
- Workspace confinement for file operations that are documented or implemented
as workspace-scoped.
- Permission checks for tool calls, shell commands, file writes, and approvals.
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
The following are normally treated as trusted local/operator-controlled inputs
unless another bug lets an untrusted actor supply them:
- CLI arguments and text typed directly by the local user.
- Project configuration files intentionally loaded from the current workspace.
- Explicit `@path` references supplied by the local user to attach local files.
- MCP servers, language servers, hooks, and slash commands installed or enabled
by the local user.
- Provider base URLs and model names configured by the local user.
The following can be security issues when reachable by an untrusted actor or
when they bypass the intended boundary:
- Reading or writing files outside the configured workspace without explicit
local-user intent.
- Following symlinks or path traversal to escape workspace confinement.
- Running shell commands or external tools without the required permission gate.
- Leaking credentials, environment variables, prompt history, local files, or bot
messages to logs, model providers, MCP servers, crash reports, or telemetry.
- Allowing a website to drive the local HTTP server through CSRF, CORS, or
content-type bypasses.
- Letting a bot user outside the configured allowlist submit prompts, approve
tools, or access a project workspace.
- Trusting unverified update artifacts, plugin definitions, or downloaded
binaries.
## `@` File References
Reasonix supports `@path` references so users can include local files and images
in a prompt. This is intentional local functionality, but implementations must
preserve these invariants:
- In workspace-scoped sessions, relative and absolute paths must resolve under
the active workspace root before file content is read or attached.
- Path traversal such as `..` must not escape the workspace root.
- Symlinks must not be usable to bypass the intended workspace boundary.
- Unscoped local CLI compatibility must not be exposed to remote, bot, or
browser-controlled inputs unless an equivalent workspace boundary is applied.
- File content should be size-limited and binary content should not be dumped as
prompt text.
Static analysis alerts about path expressions should be triaged against these
rules: user-controlled path data is expected, but the access must either stay
inside the configured workspace or be clearly limited to trusted local CLI use.
## Out of Scope
The following reports are usually out of scope unless they demonstrate a bypass
of one of the boundaries above:
- A local user intentionally asks Reasonix to read, edit, or send their own
files to a configured model provider.
- A local user installs or enables a malicious MCP server, hook, slash command,
language server, or shell command and then grants it permission.
- A configured model provider, proxy, or MCP server receives data the user
intentionally sent to it.
- Denial-of-service issues that only affect the local user's own session and do
not corrupt files, leak secrets, or bypass permissions.
- Issues requiring administrator/root access on the user's machine before
interacting with Reasonix.
- Vulnerabilities in third-party services, models, proxies, or plugins that are
not caused by Reasonix behavior.
## Coordinated Disclosure
This is a community-maintained project. Maintainers will make a best-effort
assessment, ask follow-up questions when needed, and coordinate fixes before
public disclosure for confirmed vulnerabilities.
Please give maintainers reasonable time to investigate and release a fix before
publishing exploit details. If you plan to disclose on a timeline, include that
timeline in your initial report.
+255
View File
@@ -0,0 +1,255 @@
// Drives the context-maintenance E2E scenarios against the real DeepSeek API:
// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
)
const (
model = "deepseek-v4-flash"
baseURL = "https://api.deepseek.com"
fatResults = 20
fatBytes = 12_000
)
func prov() provider.Provider {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set")
os.Exit(1)
}
p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return p
}
func fakeGoFile(nonce string, i, size int) string {
var b strings.Builder
fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i)
line := 0
for b.Len() < size {
fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7)
line++
}
return b.String()
}
func seedSession(nonce string) *agent.Session {
s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."})
for i := 0; i < fatResults; i++ {
id := fmt.Sprintf("c%02d", i)
name := fmt.Sprintf("src/file%02d.go", i)
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)})
}
return s
}
// oneShot appends a user message and runs a single completion, returning usage.
func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {
req := provider.Request{
Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),
MaxTokens: 32,
}
ch, err := p.Stream(context.Background(), req)
if err != nil {
return provider.Usage{}, err
}
var u provider.Usage
for c := range ch {
switch c.Type {
case provider.ChunkUsage:
u = *c.Usage
case provider.ChunkError:
return u, c.Err
}
}
return u, nil
}
type meta struct {
SeededAt time.Time `json:"seeded_at"`
Nonces map[string]string `json:"nonces"`
SeedUse map[string]provider.Usage `json:"seed_usage"`
}
func seed(dir string) {
p := prov()
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}
for _, arm := range []string{"pruned", "control"} {
nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000)
s := seedSession(nonce)
u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.")
if err != nil {
fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err)
os.Exit(1)
}
if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m.Nonces[arm] = nonce
m.SeedUse[arm] = u
fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
}
b, _ := json.MarshalIndent(m, "", " ")
if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func resume(dir string) {
p := prov()
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var m meta
if err := json.Unmarshal(raw, &m); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
idle := time.Since(m.SeededAt).Round(time.Minute)
out := map[string]provider.Usage{}
for _, arm := range []string{"pruned", "control"} {
s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
prunes := 0
if arm == "pruned" {
a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
fmt.Fprintln(os.Stderr, "prune:", err)
os.Exit(1)
}
prunes = st.Results
}
u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.")
if err != nil {
fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err)
os.Exit(1)
}
out[arm] = u
fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
}
b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ")
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
c, pr := out["control"], out["pruned"]
if c.CacheMissTokens > 0 {
fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n",
c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))
}
}
// comprehension checks that the agent re-reads a file behind a prune placeholder
// instead of hallucinating: the answer is a number that exists only in the file.
func comprehension(trials int) {
p := prov()
pass := 0
for t := 0; t < trials; t++ {
dir, err := os.MkdirTemp("", "cm-e2e-")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000)
content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400)
if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."})
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."})
for i := 0; i < 4; i++ {
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
}
reg := tool.NewRegistry()
for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") {
reg.Add(tl)
}
a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil || st.Results == 0 {
fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err)
os.Exit(1)
}
err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.")
reRead, answered := false, false
for _, msg := range s.Snapshot() {
if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) {
reRead = true
}
if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {
answered = true
}
}
ok := err == nil && reRead && answered
if ok {
pass++
}
fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err)
os.RemoveAll(dir)
}
fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials)
if pass < trials {
os.Exit(1)
}
}
func main() {
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume")
trials := flag.Int("trials", 5, "comprehension trials")
flag.Parse()
switch flag.Arg(0) {
case "seed":
seed(*dir)
case "resume":
resume(*dir)
case "comprehension":
comprehension(*trials)
default:
fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]")
os.Exit(1)
}
}
@@ -0,0 +1,3 @@
prompt = "Begin by reading the file story/chapter-1.md. Each chapter ends by telling you which chapter to read next; follow that trail one chapter at a time — read a chapter, find where it points you, then read that next chapter — until a chapter says the trail ends. You cannot know the next chapter without reading the current one, so do not guess or read ahead. In chapter 1 a river village is named; in the final chapter on the trail a noble House is named. When the trail ends, write a file answer.txt whose entire contents are the village name from chapter 1, a single hyphen, and the House name from the final chapter — for example Riverton-Stark — and nothing else."
max_steps = 40
timeout_sec = 900
@@ -0,0 +1,4 @@
set -e
got=$(tr -d '[:space:]' < answer.txt | tr '[:upper:]' '[:lower:]')
want="aldermoor-verrin"
[ "$got" = "$want" ] || { echo "answer.txt normalized to '$got', want '$want'"; exit 1; }
@@ -0,0 +1,220 @@
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 4 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 10 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 16 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 22 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 28 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 34 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 40 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 46 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 52 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 58 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 64 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 70 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 76 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 82 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 88 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 94 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 100 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 106 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
It was from Aldermoor, the cluster of cottages where the north and south rivers braid together, that our courier first set out.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 112 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 118 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 124 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 130 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 136 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 142 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 148 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 154 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 160 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 166 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 172 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 178 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 184 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 190 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 196 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 202 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 208 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
To pick up the trail again, the next page you must read is chapter-4.md.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 214 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 220 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
@@ -0,0 +1,220 @@
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 3 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 9 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 15 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 21 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 27 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 33 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 39 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 45 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 51 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 57 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 63 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 69 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 75 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 81 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 87 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 93 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 99 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 105 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 111 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 117 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 123 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 129 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 135 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 141 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 147 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 153 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 159 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 165 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 171 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 177 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 183 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 189 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 195 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 201 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 207 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
To pick up the trail again, the next page you must read is chapter-5.md.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 213 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 219 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
@@ -0,0 +1,220 @@
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 2 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 8 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 14 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 20 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 26 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 32 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 38 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 44 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 50 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 56 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 62 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 68 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 74 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 80 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 86 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 92 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 98 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 104 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 110 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 116 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 122 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 128 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 134 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 140 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 146 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 152 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 158 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 164 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 170 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 176 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 182 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 188 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 194 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 200 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 206 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
To pick up the trail again, the next page you must read is chapter-6.md.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 212 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 218 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
@@ -0,0 +1,220 @@
Line 1 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 7 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 13 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 19 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 25 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 31 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 37 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 43 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 49 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 55 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 61 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 67 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 73 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 79 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 85 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 91 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 97 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 103 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 109 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 115 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 121 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 127 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 133 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 139 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 145 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 151 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 157 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 163 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 169 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 175 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 181 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 187 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 193 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 199 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 205 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
To pick up the trail again, the next page you must read is chapter-2.md.
Line 211 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 217 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
@@ -0,0 +1,220 @@
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 6 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 12 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 18 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 24 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 30 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 36 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 42 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 48 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 54 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 60 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 66 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 72 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 78 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 84 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 90 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 96 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 102 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 108 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 114 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 120 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 126 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 132 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 138 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 144 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 150 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 156 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 162 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 168 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 174 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 180 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 186 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 192 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 198 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 204 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
To pick up the trail again, the next page you must read is chapter-3.md.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 216 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
@@ -0,0 +1,220 @@
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 5 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 11 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 17 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 23 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 29 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 35 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 41 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 47 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 53 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 59 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 65 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 71 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 77 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 83 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 89 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 95 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 101 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 107 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
With that, the ancestral seal of the Verrin was pressed into the warm wax and then, before the whole assembly, ceremonially broken.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 113 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 119 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 125 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 131 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 137 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 143 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 149 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 155 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 161 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 167 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 173 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 179 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 185 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 191 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 197 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 203 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 209 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
Here the trail ends; there is no further page to follow. You now hold both names you were sent to find: the river village of Aldermoor, where the courier began in the first chapter, and the House whose seal was broken above. Your task is complete the moment you write a file named answer.txt whose entire contents are that village name, a single hyphen, and that House name, and nothing else. Write it now.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 215 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
@@ -0,0 +1,3 @@
prompt = "The file calc.py has a bug: add(a, b) returns the wrong result. Fix add so it returns the sum of a and b. Keep the other functions unchanged."
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,7 @@
set -e
python3 - <<'PY'
import calc
assert calc.add(2, 3) == 5, calc.add(2, 3)
assert calc.add(10, 5) == 15, calc.add(10, 5)
assert calc.mul(2, 3) == 6, calc.mul(2, 3)
PY
@@ -0,0 +1,6 @@
def add(a, b):
return a - b
def mul(a, b):
return a * b
+3
View File
@@ -0,0 +1,3 @@
prompt = "Create a file named fizzbuzz.py containing a function fizzbuzz(n) that returns the string 'Fizz' when n is divisible by 3, 'Buzz' when divisible by 5, 'FizzBuzz' when divisible by both 3 and 5, and otherwise the number as a string. Do not print anything at import time."
max_steps = 12
timeout_sec = 180
+8
View File
@@ -0,0 +1,8 @@
set -e
python3 - <<'PY'
import fizzbuzz
assert fizzbuzz.fizzbuzz(3) == "Fizz", fizzbuzz.fizzbuzz(3)
assert fizzbuzz.fizzbuzz(5) == "Buzz", fizzbuzz.fizzbuzz(5)
assert fizzbuzz.fizzbuzz(15) == "FizzBuzz", fizzbuzz.fizzbuzz(15)
assert fizzbuzz.fizzbuzz(7) == "7", fizzbuzz.fizzbuzz(7)
PY
@@ -0,0 +1,3 @@
prompt = "Create a file named palindrome.py with a function is_palindrome(s) that returns True if s reads the same forwards and backwards ignoring case, spaces, and punctuation, and False otherwise. Do not print anything at import time."
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,7 @@
set -e
python3 - <<'PY'
import palindrome
assert palindrome.is_palindrome("Race car") is True
assert palindrome.is_palindrome("A man, a plan, a canal: Panama") is True
assert palindrome.is_palindrome("hello") is False
PY
@@ -0,0 +1,3 @@
prompt = "This directory contains a data/ folder with three files: alpha.txt, beta.txt, and gamma.txt. Each file holds a single line of the form name=number. You MUST delegate the reading to a sub-agent: call the `task` tool exactly once, instructing the sub-agent to read all three files under data/ and report the three numbers back to you. Do NOT read the files yourself with your own tools. After the sub-agent reports the numbers, add them together and write ONLY the resulting integer (no other text, no trailing label or newline-prefixed words) to a file named result.txt in the current directory."
max_steps = 15
timeout_sec = 300
@@ -0,0 +1,12 @@
set -e
# Exercises a fresh `task` sub-agent delegation in the headless `reasonix run`
# path: 17 + 28 + 41 = 86. The numbers are arbitrary so the answer can only be
# produced by actually reading the three seed files (the prompt mandates doing
# that via the `task` tool). Before sub-agents could run without a parent
# session, the `task` call errored here with "parent session is required".
test -f result.txt
got=$(tr -d '[:space:]' < result.txt)
if [ "$got" != "86" ]; then
echo "result.txt = '$got', want 86"
exit 1
fi
@@ -0,0 +1 @@
alpha=17
@@ -0,0 +1 @@
beta=28
@@ -0,0 +1 @@
gamma=41
+658
View File
@@ -0,0 +1,658 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"reasonix/internal/shellparse"
)
type diffOpts struct {
bin, model, repo, base, testCmd, profile string
maxSteps, timeoutSec, attempts int
}
type testRef struct{ name, pkg string }
// pinResult records whether one generated test fails when the PR's source is
// reverted (so it pins the change) and, if so, whether it failed by assertion
// (strong: it checks the new behavior) or only by compile error (weak: it just
// references a symbol the PR added).
type pinResult struct {
testRef
pins bool
byAssertion bool
}
// runDiff asks the agent to write tests covering what the PR changed, grades
// them against the repo's own tests, and — because the agent is stochastic —
// retries up to o.attempts times until a run passes, keeping the best result.
func runDiff(o diffOpts) string {
srcFiles := changedGoFiles(o.repo, o.base, false)
if len(srcFiles) == 0 {
profile := o.profile
if profile == "" {
profile = benchmarkProfileBaseline
}
return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile)
}
pkgs := packagesOf(srcFiles)
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
attempts := o.attempts
if attempts < 1 {
attempts = 1
}
var best diffReport
made := 0
for i := 1; i <= attempts; i++ {
if i > 1 {
resetTree(o.repo)
}
r := runOnce(o, srcFiles, pkgs, prompt)
made = i
if i == 1 || better(r, best) {
best = r
}
if best.passed {
break // stop at the first passing run; attempts is a retry budget
}
}
best.attempt, best.attempts = made, attempts
return renderDiff(best)
}
// runOnce does one agent run + grade: generate tests, check they pass on HEAD,
// differential-check each against the reverted source, measure changed-line
// coverage, and confirm the agent didn't break the build anywhere.
func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
metricsPath := filepath.Join(o.repo, ".e2e-diff-metrics.json")
_ = os.Remove(metricsPath)
defer os.Remove(metricsPath)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
defer cancel()
args := []string{"run", "--metrics", metricsPath, "--max-steps", fmt.Sprint(o.maxSteps)}
if o.model != "" {
args = append(args, "--model", o.model)
}
args = appendBenchmarkProfileArgs(args, o.profile)
args = append(args, prompt)
cmd := exec.CommandContext(ctx, o.bin, args...)
cmd.Dir = o.repo
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout
runErr := cmd.Run()
// The agent's new files are untracked, so `git diff HEAD` would miss them;
// intent-to-add surfaces them as additions without committing.
_ = exec.Command("git", "-C", o.repo, "add", "-AN").Run()
m, _ := readMetrics(metricsPath)
testDiff := gitOut(o.repo, "diff", "HEAD", "--", "*_test.go")
refs := parseNewTests(testDiff)
sourceTouched := len(changedGoFilesWorktree(o.repo, false))
testsPass, testOut := runTests(o.repo, o.testCmd, pkgs)
var pins []pinResult
var mut mutationResult
covered, coverTotal := 0, 0
if len(refs) > 0 && testsPass {
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
mut = runMutation(o.repo, o.base, srcFiles, refs)
}
buildOK, buildOut := goBuildAll(o.repo)
passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
return diffReport{
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
}
}
// better reports whether candidate a is a stronger result than b: a pass beats a
// fail, then more assertion-pins, then more pins, then higher changed-line
// coverage.
func better(a, b diffReport) bool {
if a.passed != b.passed {
return a.passed
}
if x, y := countAssertionPins(a.pins), countAssertionPins(b.pins); x != y {
return x > y
}
if x, y := countPins(a.pins), countPins(b.pins); x != y {
return x > y
}
if a.mut.caught != b.mut.caught {
return a.mut.caught > b.mut.caught
}
return ratio(a.covered, a.coverTotal) > ratio(b.covered, b.coverTotal)
}
func ratio(n, d int) float64 {
if d == 0 {
return 0
}
return float64(n) / float64(d)
}
// resetTree restores the PR-head tree between attempts, dropping the previous
// attempt's generated tests but keeping the provider config the workflow wrote.
func resetTree(repo string) {
_ = exec.Command("git", "-C", repo, "checkout", "--", ".").Run()
_ = exec.Command("git", "-C", repo, "clean", "-fd", "-e", "reasonix.toml").Run()
}
func goBuildAll(repo string) (bool, string) {
cmd := exec.Command("go", "build", "./...")
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
func buildDiffPrompt(srcFiles, pkgs []string, diffText string) string {
var b strings.Builder
b.WriteString("You are in a Go repository. This pull request changed these source files:\n")
for _, f := range srcFiles {
fmt.Fprintf(&b, " - %s\n", f)
}
b.WriteString("\nUnified diff of the change:\n```diff\n")
b.WriteString(diffText)
b.WriteString("\n```\n\n")
b.WriteString("Write focused Go unit tests that exercise the NEW or CHANGED behavior in those files. ")
b.WriteString("Add them to the appropriate *_test.go files in the same packages (")
b.WriteString(strings.Join(pkgs, ", "))
b.WriteString("). Do NOT modify the non-test source files — only add or extend test files. ")
b.WriteString("Prefer small, focused edits and run `gofmt`/`go vet` on the test files as you go to avoid syntax errors. ")
b.WriteString("Then run the package tests and iterate until they pass. When finished, list the test functions you added.")
return b.String()
}
type diffReport struct {
srcFiles, pkgs []string
addedTestLines int
newTests []testRef
sourceTouched int
testsPass bool
pins []pinResult
mut mutationResult
covered, coverTotal int
buildOK bool
buildOut string
failing []string
passed bool
profile string
attempt, attempts int
m runMetrics
runErr error
testOut string
testDiff string
}
func renderDiff(r diffReport) string {
var b strings.Builder
result := "❌ fail"
if r.passed {
result = "✅ pass"
}
profile := r.profile
if profile == "" {
profile = benchmarkProfileBaseline
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile)
fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n")
fmt.Fprintf(&b, "| New test functions added | %d |\n", len(r.newTests))
fmt.Fprintf(&b, "| Test lines added | +%d |\n", r.addedTestLines)
fmt.Fprintf(&b, "| `go test` on affected pkgs | %s |\n", passFail(r.testsPass))
fmt.Fprintf(&b, "| Differential (fail on pre-PR code) | %s |\n", differentialCell(r))
if pinned > 0 {
fmt.Fprintf(&b, "| ↳ pin by assertion / by compile only | %d / %d |\n", byAssert, pinned-byAssert)
}
fmt.Fprintf(&b, "| Changed-line coverage | %s |\n", coverageCell(r))
fmt.Fprintf(&b, "| Mutation (changed funcs caught) | %s |\n", mutationCell(r))
fmt.Fprintf(&b, "| `go build ./...` (regression) | %s |\n", passFail(r.buildOK))
fmt.Fprintf(&b, "| Non-test source touched by agent | %d file(s) |\n", r.sourceTouched)
fmt.Fprintf(&b, "| Cache hit | %s |\n", pct(r.m.CacheHitTokens, r.m.CacheHitTokens+r.m.CacheMissTokens))
fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
}
}
if len(r.failing) > 0 {
fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `"))
}
if r.attempts > 1 {
status := "none passed"
if r.passed {
status = "passed"
}
fmt.Fprintf(&b, "| Attempts | %d of up to %d (%s) |\n", r.attempt, r.attempts, status)
}
fmt.Fprintf(&b, "\n**Packages:** %s\n", strings.Join(r.pkgs, ", "))
if r.attempts <= 1 {
fmt.Fprintf(&b, "\n<sub>Single stochastic run — a green result is one sample, not a guarantee. Comment `/e2e diff x3` to retry up to 3×.</sub>\n")
}
if !r.buildOK && strings.TrimSpace(r.buildOut) != "" {
fmt.Fprintf(&b, "\n<details><summary>go build ./... output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.buildOut, 40))
}
if r.sourceTouched > 0 {
fmt.Fprintf(&b, "\n⚠️ The agent modified %d non-test source file(s); a green run may not reflect the PR's code. Review the diff.\n", r.sourceTouched)
}
if len(r.pins) > 0 {
fmt.Fprintf(&b, "\n<details><summary>Per-test differential</summary>\n\n| Test | Package | Pins the change? |\n|---|---|---|\n")
for _, p := range r.pins {
fmt.Fprintf(&b, "| `%s` | %s | %s |\n", p.name, p.pkg, pinCell(p))
}
fmt.Fprintf(&b, "\n</details>\n")
}
if strings.TrimSpace(r.testDiff) != "" {
fmt.Fprintf(&b, "\n<details><summary>Generated tests (review the assertions)</summary>\n\n```diff\n%s\n```\n</details>\n", truncateFor(r.testDiff, 20000))
}
if !r.testsPass && strings.TrimSpace(r.testOut) != "" {
fmt.Fprintf(&b, "\n<details><summary>go test output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.testOut, 60))
}
if r.runErr != nil {
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
}
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
return b.String()
}
func differentialCell(r diffReport) string {
if !(len(r.newTests) > 0 && r.testsPass) {
return "n/a (tests not green)"
}
return fmt.Sprintf("%d/%d new tests", countPins(r.pins), len(r.pins))
}
func coverageCell(r diffReport) string {
if r.coverTotal == 0 {
return "n/a"
}
return fmt.Sprintf("%s (%d/%d changed lines)", pct(r.covered, r.coverTotal), r.covered, r.coverTotal)
}
func mutationCell(r diffReport) string {
if r.mut.total == 0 {
return "n/a"
}
cell := fmt.Sprintf("%d/%d (%s)", r.mut.caught, r.mut.total, pct(r.mut.caught, r.mut.total))
if len(r.mut.survivors) > 0 {
cell += fmt.Sprintf(" · survived: `%s`", strings.Join(r.mut.survivors, "`, `"))
}
return cell
}
func pinCell(p pinResult) string {
switch {
case p.pins && p.byAssertion:
return "✅ by assertion"
case p.pins:
return "⚠️ by compile only"
default:
return "❌ no (passes on old code)"
}
}
// differentialPerTest reverts the PR's changed source to base (deleting files
// new in the PR), runs each generated test on its own against the old code, and
// restores the source. A test that fails on the old code pins the change.
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
for _, f := range srcFiles {
if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
restored := false
defer func() {
if restored {
return
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
}()
out := make([]pinResult, 0, len(refs))
for _, r := range refs {
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test
raw, err := cmd.CombinedOutput()
out = append(out, pinResult{
testRef: r,
pins: err != nil,
byAssertion: strings.Contains(string(raw), "--- FAIL: "+r.name),
})
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
restored = true
return out
}
// changedLineCoverage runs the affected packages with a coverage profile and
// reports how many of the PR's changed source statement-lines the (new+existing)
// tests actually execute. covered/total are over changed lines that fall inside
// a coverage block; lines that aren't statements are ignored.
func changedLineCoverage(repo, base string, pkgs, srcFiles []string) (covered, total int) {
profile := filepath.Join(repo, ".e2e-cover.out")
defer os.Remove(profile)
args := append([]string{"test", "-covermode=set", "-coverprofile=" + profile, "-coverpkg=" + strings.Join(pkgs, ",")}, pkgs...)
cmd := exec.Command("go", args...)
cmd.Dir = repo
_ = cmd.Run() // a non-zero exit still writes the profile for the tests that ran
blocks := parseCoverProfile(repo, profile)
for file, lines := range changedLineSet(repo, base, srcFiles) {
fileBlocks := blocks[file]
for ln := range lines {
for _, blk := range fileBlocks {
if ln >= blk.start && ln <= blk.end {
total++
if blk.count > 0 {
covered++
}
break
}
}
}
}
return covered, total
}
type coverBlock struct {
start, end, count int
}
// parseCoverProfile reads a Go coverage profile, keyed by repo-relative file path
// (the profile uses module-qualified paths; we match by repo-relative suffix).
func parseCoverProfile(repo, path string) map[string][]coverBlock {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
out := map[string][]coverBlock{}
for _, ln := range strings.Split(string(data), "\n") {
if ln == "" || strings.HasPrefix(ln, "mode:") {
continue
}
colon := strings.LastIndexByte(ln, ':')
if colon < 0 {
continue
}
modPath, rest := ln[:colon], ln[colon+1:]
var sl, sc, el, ec, nstmt, count int
if _, err := fmt.Sscanf(rest, "%d.%d,%d.%d %d %d", &sl, &sc, &el, &ec, &nstmt, &count); err != nil {
continue
}
rel := repoRelFromModulePath(modPath)
out[rel] = append(out[rel], coverBlock{start: sl, end: el, count: count})
}
return out
}
// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into
// "internal/agent/foo.go" by dropping the first path element (the module root).
func repoRelFromModulePath(p string) string {
// Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path.
prefix := "reasonix/"
if strings.HasPrefix(p, prefix) {
return p[len(prefix):]
}
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[i+1:]
}
return p
}
// changedLineSet returns, per repo-relative source file, the set of new line
// numbers the PR added or changed (from a zero-context diff).
func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]bool {
args := append([]string{"diff", "--unified=0", base + "...HEAD", "--"}, srcFiles...)
diff := gitOut(repo, args...)
out := map[string]map[int]bool{}
file := ""
newLine := 0
for _, ln := range strings.Split(diff, "\n") {
// '-' (deletion) lines are intentionally unhandled: they don't advance the
// new-side line counter, so they fall through with no case.
switch {
case strings.HasPrefix(ln, "+++ b/"):
file = strings.TrimPrefix(ln, "+++ b/")
out[file] = map[int]bool{}
case strings.HasPrefix(ln, "@@"):
// @@ -a,b +c,d @@ — start collecting at new-side line c.
// Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
if plus := strings.Index(ln, "+"); plus >= 0 {
num := ln[plus+1:]
end := len(num)
for i := 0; i < len(num); i++ {
if num[i] < '0' || num[i] > '9' {
end = i
break
}
}
_, _ = fmt.Sscanf(num[:end], "%d", &newLine)
}
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"):
if file != "" {
out[file][newLine] = true
}
newLine++
}
}
return out
}
func countPins(ps []pinResult) int {
n := 0
for _, p := range ps {
if p.pins {
n++
}
}
return n
}
func countAssertionPins(ps []pinResult) int {
n := 0
for _, p := range ps {
if p.pins && p.byAssertion {
n++
}
}
return n
}
// parseNewTests reads the working-tree *_test.go diff and returns the Test/Fuzz/
// Benchmark functions the agent added, each tagged with its package directory.
func parseNewTests(diff string) []testRef {
var refs []testRef
pkg := ""
for _, ln := range strings.Split(diff, "\n") {
if strings.HasPrefix(ln, "+++ b/") {
pkg = "./" + filepath.ToSlash(filepath.Dir(strings.TrimPrefix(ln, "+++ b/")))
continue
}
if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") {
continue
}
body := strings.TrimSpace(ln[1:])
if !strings.HasPrefix(body, "func ") {
continue
}
sig := strings.TrimPrefix(body, "func ")
// Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
var name string
if sig[0] == '(' {
close := strings.IndexByte(sig, ')')
if close < 0 {
continue
}
rest := strings.TrimSpace(sig[close+1:])
methodParen := strings.IndexByte(rest, '(')
if methodParen <= 0 {
continue
}
name = rest[:methodParen]
} else {
funcParen := strings.IndexByte(sig, '(')
if funcParen <= 0 {
continue
}
name = sig[:funcParen]
}
if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") {
refs = append(refs, testRef{name: name, pkg: pkg})
}
}
return refs
}
func countAdded(diff string) int {
n := 0
for _, ln := range strings.Split(diff, "\n") {
if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") {
n++
}
}
return n
}
// failingTestNames pulls the names out of `--- FAIL: TestX (…)` lines.
func failingTestNames(out string) []string {
var names []string
seen := map[string]bool{}
for _, ln := range strings.Split(out, "\n") {
ln = strings.TrimSpace(ln)
if !strings.HasPrefix(ln, "--- FAIL:") {
continue
}
rest := strings.Fields(strings.TrimSpace(strings.TrimPrefix(ln, "--- FAIL:")))
if len(rest) > 0 && !seen[rest[0]] {
seen[rest[0]] = true
names = append(names, rest[0])
}
}
return names
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return false, "invalid test command: " + err.Error()
}
fields := test.Argv
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
if len(test.Env) > 0 {
cmd.Env = append(os.Environ(), test.Env...)
}
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
return filterGo(gitOut(repo, "diff", "--name-only", base+"...HEAD", "--", "*.go"), includeTests)
}
func changedGoFilesWorktree(repo string, includeTests bool) []string {
return filterGo(gitOut(repo, "diff", "--name-only", "HEAD", "--", "*.go"), includeTests)
}
func filterGo(out string, includeTests bool) []string {
var keep []string
for _, f := range strings.Fields(strings.ReplaceAll(out, "\n", " ")) {
if strings.HasSuffix(f, "_test.go") && !includeTests {
continue
}
keep = append(keep, f)
}
sort.Strings(keep)
return keep
}
func packagesOf(files []string) []string {
seen := map[string]bool{}
var pkgs []string
for _, f := range files {
dir := "./" + filepath.ToSlash(filepath.Dir(f))
if !seen[dir] {
seen[dir] = true
pkgs = append(pkgs, dir)
}
}
sort.Strings(pkgs)
return pkgs
}
func gitOut(repo string, args ...string) string {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
out, _ := cmd.Output()
return string(out)
}
func truncate(s string) string { return truncateFor(s, 12000) }
func truncateFor(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
// Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune.
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)…"
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
func passFail(ok bool) string {
if ok {
return "pass"
}
return "fail"
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
repo := t.TempDir()
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
import (
"os"
"testing"
)
func TestEnv(t *testing.T) {
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
t.Fatalf("REASONIX_E2E_ENV = %q", got)
}
}
`)
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
if !ok {
t.Fatalf("runTests failed:\n%s", out)
}
}
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
if ok {
t.Fatal("runTests accepted dynamic env assignment")
}
if !strings.Contains(out, "invalid test command: shell expansion") {
t.Fatalf("output = %q, want shell expansion rejection", out)
}
}
func writeE2EFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+430
View File
@@ -0,0 +1,430 @@
// e2ebench runs the committed e2e task suite against a real provider and emits a
// markdown + JSON report (accuracy, cache-hit rate, token use, cost) for a PR.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/fileutil/encoding"
)
type task struct {
ID string
Prompt string `toml:"prompt"`
MaxSteps int `toml:"max_steps"`
TimeoutSec int `toml:"timeout_sec"`
dir string
}
type runMetrics struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheHitTokens int `json:"cache_hit_tokens"`
CacheMissTokens int `json:"cache_miss_tokens"`
Steps int `json:"steps"`
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Compactions int `json:"compactions"`
// Optional Delivery capability counters (omitempty for baseline/old metrics).
ReadinessChecks int `json:"readiness_checks,omitempty"`
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
CapabilityRoutes int `json:"capability_routes,omitempty"`
CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
CapabilityDeclines int `json:"capability_declines,omitempty"`
CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
}
type result struct {
task
runMetrics
Profile string `json:"profile"`
Passed bool
Skipped bool
Note string
}
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
model := flag.String("model", "", "provider/model name (default: config default)")
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
// diff-mode flags
repo := flag.String("repo", ".", "repo root (diff mode)")
base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
testCmd := flag.String("test-cmd", "go test", "grader command run on the affected packages (diff mode)")
maxSteps := flag.Int("max-steps", 80, "agent tool-call cap for the diff task")
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)")
flag.Parse()
profile, err := normalizeBenchmarkProfile(*profileFlag)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if *mode == "diff" {
report := runDiff(diffOpts{
bin: *bin, model: *model, repo: *repo, base: *base,
testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
})
emit(report, *outMD, "")
return
}
tasks, err := loadTasks(*suite)
if err != nil {
fmt.Fprintln(os.Stderr, "load suite:", err)
os.Exit(1)
}
if len(tasks) == 0 {
dir := filepath.Join(*suite, "tasks")
if _, statErr := os.Stat(dir); statErr != nil {
fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr)
} else {
fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir)
}
os.Exit(1)
}
var results []result
total := 0
for _, t := range tasks {
if *budget > 0 && total >= *budget {
results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
continue
}
r := runTask(*bin, *model, profile, t)
total += r.PromptTokens + r.CompletionTokens
results = append(results, r)
}
report := render(results)
if *outMD != "" {
if err := os.WriteFile(*outMD, []byte(report), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
os.Exit(1)
}
} else {
fmt.Print(report)
}
if *outJSON != "" {
b, err := json.MarshalIndent(results, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "marshal json:", err)
os.Exit(1)
}
if err := os.WriteFile(*outJSON, b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write json:", err)
os.Exit(1)
}
}
}
func emit(report, outMD, _ string) {
if outMD != "" {
if err := os.WriteFile(outMD, []byte(report), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
os.Exit(1)
}
return
}
fmt.Print(report)
}
func loadTasks(suite string) ([]task, error) {
tasksDir := filepath.Join(suite, "tasks")
entries, err := os.ReadDir(tasksDir)
if err != nil {
return nil, err
}
var tasks []task
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(tasksDir, e.Name())
var t task
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
if err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
if _, err := toml.Decode(string(data), &t); err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
t.ID = e.Name()
t.dir = dir
if t.TimeoutSec == 0 {
t.TimeoutSec = 240
}
tasks = append(tasks, t)
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
return tasks, nil
}
// runTask copies the task's seed workdir into a temp dir, runs the agent there,
// then drops in verify.sh and runs it as the grader. The grader is added only
// after the run so the agent can't read the answer key.
func runTask(bin, model, profile string, t task) result {
r := result{task: t, Profile: profile}
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
if err != nil {
r.Note = "mktemp: " + err.Error()
return r
}
defer os.RemoveAll(work)
if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) {
if err := copyDir(seed, work); err != nil {
r.Note = "copy seed: " + err.Error()
return r
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
defer cancel()
metricsPath := filepath.Join(work, ".run-metrics.json")
args := []string{"run", "--metrics", metricsPath}
if model != "" {
args = append(args, "--model", model)
}
if t.MaxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
args = append(args, t.Prompt)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = work
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
runErr := cmd.Run()
if m, err := readMetrics(metricsPath); err == nil {
r.runMetrics = m
}
if runErr != nil {
r.Note = "run: " + runErr.Error()
// still grade — a non-zero exit may just be a max-steps notice
}
r.Passed = grade(work, t.dir)
return r
}
func grade(work, taskDir string) bool {
verify := filepath.Join(taskDir, "verify.sh")
if !fileExists(verify) {
return false
}
dst := filepath.Join(work, "verify.sh")
if err := copyFile(verify, dst); err != nil {
return false
}
cmd := exec.Command("bash", "verify.sh")
cmd.Dir = work
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run() == nil
}
func render(results []result) string {
var b strings.Builder
passed, ran := 0, 0
var pTok, cTok, hit, miss, compacts int
var cost float64
currency := ""
for _, r := range results {
if r.Skipped {
continue
}
ran++
if r.Passed {
passed++
}
pTok += r.PromptTokens
cTok += r.CompletionTokens
hit += r.CacheHitTokens
miss += r.CacheMissTokens
compacts += r.Compactions
cost += r.Cost
if r.Currency != "" {
currency = r.Currency
}
}
profile := benchmarkProfileBaseline
if len(results) > 0 && results[0].Profile != "" {
profile = results[0].Profile
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile)
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
passed, ran, pct(passed, ran), pct(hit, hit+miss),
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n")
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n")
for _, r := range results {
switch {
case r.Skipped:
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID)
default:
res := "❌ fail"
if r.Passed {
res = "✅ pass"
}
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n",
r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens),
pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
r.Compactions, currencySym(r.Currency), r.Cost)
}
}
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
notes := false
for _, r := range results {
if r.Note != "" {
if !notes {
fmt.Fprintf(&b, "\n<details><summary>Notes</summary>\n\n")
notes = true
}
fmt.Fprintf(&b, "- `%s`: %s\n", r.ID, r.Note)
}
}
if notes {
fmt.Fprintf(&b, "\n</details>\n")
}
return b.String()
}
func pct(n, d int) string {
if d == 0 {
return "n/a"
}
return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d))
}
func comma(n int) string {
s := fmt.Sprint(n)
if len(s) <= 3 {
return s
}
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
return string(out)
}
func currencySym(c string) string {
if c == "" {
return ""
}
return c + " "
}
func readMetrics(path string) (runMetrics, error) {
var m runMetrics
b, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return m, err
}
return m, json.Unmarshal(b, &m)
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func fileExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && !fi.IsDir()
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip symlinks so a seed link can't leak a file from outside the seed tree.
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
rel, _ := filepath.Rel(src, p)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(p, target)
})
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
// Mirror the source mode so a seed's read-only / exec bit survives the copy.
return os.Chmod(dst, info.Mode().Perm())
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"go/ast"
"go/parser"
"go/printer"
"go/token"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const maxMutants = 15
type mutationResult struct {
caught, total int
survivors []string
}
// runMutation gives a behavioral signal that the differential can't for additive
// PRs: it replaces each changed function's body with a zero-value return (which
// compiles for any signature via *new(T)), runs only the agent's new tests for
// that package, and records whether they catch the mutation. A caught mutant
// means a test actually asserts that function's output; a survivor means the new
// tests don't check it.
func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult {
changed := changedLineSet(repo, base, srcFiles)
byPkg := map[string][]string{}
for _, r := range refs {
byPkg[r.pkg] = append(byPkg[r.pkg], r.name)
}
var res mutationResult
for _, file := range srcFiles {
if res.total >= maxMutants {
break
}
pkg := "./" + filepath.ToSlash(filepath.Dir(file))
tests := byPkg[pkg]
if len(tests) == 0 {
continue // no new tests to attribute a catch to
}
runRe := "^(" + strings.Join(tests, "|") + ")$"
abs := filepath.Join(repo, filepath.FromSlash(file))
srcB, err := os.ReadFile(abs)
if err != nil {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, abs, srcB, 0)
if err != nil {
continue
}
src := string(srcB)
for _, fd := range changedFuncs(fset, f, changed[file]) {
if res.total >= maxMutants {
break
}
res.total++
lb := fset.Position(fd.Body.Lbrace).Offset
rb := fset.Position(fd.Body.Rbrace).Offset
mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:]
if os.WriteFile(abs, []byte(mutated), 0o644) != nil {
res.total--
continue
}
cmd := exec.Command("go", "test", "-run", runRe, pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
// Restore source even on panic; a file left mutated would corrupt the next mutant.
restored := false
defer func() {
if !restored {
_ = os.WriteFile(abs, srcB, 0o644)
}
}()
caught := cmd.Run() != nil
_ = os.WriteFile(abs, srcB, 0o644)
restored = true
if caught {
res.caught++
} else {
res.survivors = append(res.survivors, fd.Name.Name)
}
}
}
return res
}
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
// main/init and nil-named decls are skipped (no meaningful return to mutate).
func changedFuncs(fset *token.FileSet, f *ast.File, lines map[int]bool) []*ast.FuncDecl {
var out []*ast.FuncDecl
for _, decl := range f.Decls {
fd, ok := decl.(*ast.FuncDecl)
if !ok || fd.Body == nil || fd.Name == nil {
continue
}
name := fd.Name.Name
if name == "main" || name == "init" {
continue
}
start := fset.Position(fd.Pos()).Line
end := fset.Position(fd.End()).Line
for ln := range lines {
if ln >= start && ln <= end {
out = append(out, fd)
break
}
}
}
return out
}
// mutantBody returns a replacement body that returns the zero value for each
// result. *new(T) is the zero value of any type T, so this compiles for every
// signature without naming the results or knowing their concrete types.
func mutantBody(fset *token.FileSet, results *ast.FieldList) string {
if results == nil || len(results.List) == 0 {
return "{\n}"
}
var rets []string
for _, field := range results.List {
t := "*new(" + printType(fset, field.Type) + ")"
n := len(field.Names)
if n == 0 {
n = 1
}
for i := 0; i < n; i++ {
rets = append(rets, t)
}
}
return "{\n\treturn " + strings.Join(rets, ", ") + "\n}"
}
func printType(fset *token.FileSet, e ast.Expr) string {
var b strings.Builder
_ = printer.Fprint(&b, fset, e)
return b.String()
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"strings"
)
const (
benchmarkProfileBaseline = "baseline"
benchmarkProfileDelivery = "delivery"
)
func normalizeBenchmarkProfile(profile string) (string, error) {
switch strings.ToLower(strings.TrimSpace(profile)) {
case "", benchmarkProfileBaseline:
return benchmarkProfileBaseline, nil
case benchmarkProfileDelivery:
return benchmarkProfileDelivery, nil
default:
return "", fmt.Errorf("unknown benchmark profile %q (want baseline or delivery)", profile)
}
}
func appendBenchmarkProfileArgs(args []string, profile string) []string {
if profile == benchmarkProfileDelivery {
return append(args, "--profile", "delivery")
}
return args
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"reflect"
"testing"
)
func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) {
args := []string{"run", "fix the bug"}
if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) {
t.Fatalf("baseline args changed: %v", got)
}
}
func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) {
args := []string{"run"}
got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery)
want := []string{"run", "--profile", "delivery"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("delivery args = %v, want %v", got, want)
}
}
func TestNormalizeBenchmarkProfile(t *testing.T) {
for _, input := range []string{"", "baseline", " BASELINE "} {
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
t.Fatalf("normalize(%q) = %q, %v", input, got, err)
}
}
if got, err := normalizeBenchmarkProfile("delivery"); err != nil || got != benchmarkProfileDelivery {
t.Fatalf("normalize(delivery) = %q, %v", got, err)
}
if _, err := normalizeBenchmarkProfile("fast"); err == nil {
t.Fatal("unknown profile should fail")
}
}
+349
View File
@@ -0,0 +1,349 @@
// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio
// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to
// document the contract end-to-end (the protocol the internal/plugin client
// drives) and to give users a working example to copy.
//
// Wire it up in reasonix.toml:
//
// [[plugins]]
// name = "example"
// command = "reasonix-plugin-example"
//
// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount",
// its prompt as the "/mcp__example__review" slash command, and its resource as
// the "@example:doc://style-guide" reference.
//
// Protocol, one JSON object per line:
// - initialize → {protocolVersion, capabilities, serverInfo}
// - notifications/initialized (notification, no id) → ignored
// - tools/list → {tools: [{name, description, inputSchema, annotations}]}
// - tools/call {name, arguments} → {content: [{type:"text", text}], isError}
// - prompts/list → {prompts: [{name, description, arguments}]}
// - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]}
// - resources/list → {resources: [{uri, name, description, mimeType}]}
// - resources/read {uri} → {contents: [{uri, mimeType, text}]}
//
// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is
// reserved for JSON-RPC so it must never carry stray prose.
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"unicode/utf8"
)
// version is overridable via -ldflags "-X main.version=...". Reported in
// initialize's serverInfo so reasonix (and humans) can see which build is running.
var version = "dev"
func main() {
log.SetPrefix("reasonix-plugin-example: ")
log.SetFlags(0)
if err := serve(os.Stdin, os.Stdout); err != nil {
log.Fatal(err)
}
}
// --- JSON-RPC framing ---
type request struct {
JSONRPC string `json:"jsonrpc"`
ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
const (
protocolVersion = "2024-11-05"
codeMethodNotFound = -32601
codeInvalidParams = -32602
)
// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the
// pipe / is shutting down). Each line is one JSON-RPC message.
func serve(in *os.File, out *os.File) error {
r := bufio.NewReader(in)
w := bufio.NewWriter(out)
defer w.Flush()
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
if rerr := handleLine(line, w); rerr != nil {
return rerr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
}
if err != nil {
return nil // EOF or pipe closed: clean shutdown
}
}
}
func handleLine(line []byte, w *bufio.Writer) error {
line = trimSpace(line)
if len(line) == 0 {
return nil
}
var req request
if err := json.Unmarshal(line, &req); err != nil {
log.Printf("skipping unparseable line: %v", err)
return nil
}
if req.ID == nil {
return nil // notification (e.g. notifications/initialized): no reply
}
resp := response{JSONRPC: "2.0", ID: *req.ID}
switch req.Method {
case "initialize":
resp.Result = map[string]any{
"protocolVersion": protocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{},
"prompts": map[string]any{},
"resources": map[string]any{},
},
"serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version},
}
case "tools/list":
resp.Result = map[string]any{"tools": toolList()}
case "tools/call":
resp.Result, resp.Error = callTool(req.Params)
case "prompts/list":
resp.Result = map[string]any{"prompts": promptList()}
case "prompts/get":
resp.Result, resp.Error = getPrompt(req.Params)
case "resources/list":
resp.Result = map[string]any{"resources": resourceList()}
case "resources/read":
resp.Result, resp.Error = readResource(req.Params)
default:
resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method}
}
b, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("marshal response: %w", err)
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
return nil
}
// --- tools ---
// toolDef is one exposed tool: its advertised metadata plus the handler. run
// returns the text result, or an error which becomes an isError tool result the
// model can read and adapt to.
type toolDef struct {
name string
description string
schema map[string]any
readOnly bool
run func(args map[string]any) (string, error)
}
// tools is the registry. Both demo tools are read-only and declare it via the
// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the
// permission layer) auto-allows them without prompting.
var tools = []toolDef{
{
name: "echo",
description: "Echo the given text back. The simplest possible proof the plugin round-trip works.",
readOnly: true,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "Text to echo back"},
},
"required": []string{"text"},
},
run: func(args map[string]any) (string, error) {
text, ok := args["text"].(string)
if !ok {
return "", fmt.Errorf("argument 'text' must be a string")
}
return text, nil
},
},
{
name: "wordcount",
description: "Count the lines, words, and bytes of the given text (like wc).",
readOnly: true,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "Text to measure"},
},
"required": []string{"text"},
},
run: func(args map[string]any) (string, error) {
text, ok := args["text"].(string)
if !ok {
return "", fmt.Errorf("argument 'text' must be a string")
}
return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d",
countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil
},
},
}
func toolList() []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{
"name": t.name,
"description": t.description,
"inputSchema": t.schema,
"annotations": map[string]any{
"readOnlyHint": t.readOnly,
"title": t.name,
},
})
}
return out
}
// callTool dispatches a tools/call. A handler error is reported as an isError
// content result (in-band, so the model sees it) rather than a JSON-RPC error;
// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown
// tool).
func callTool(params json.RawMessage) (any, *rpcError) {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
for _, t := range tools {
if t.name != p.Name {
continue
}
text, err := t.run(p.Arguments)
if err != nil {
return textResult(err.Error(), true), nil
}
return textResult(text, false), nil
}
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name}
}
func textResult(text string, isError bool) map[string]any {
return map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
"isError": isError,
}
}
// --- prompts ---
// promptList advertises the server's prompts. They surface in reasonix as
// /mcp__example__<name> slash commands.
func promptList() []map[string]any {
return []map[string]any{{
"name": "review",
"description": "Draft a focused code-review request for a file.",
"arguments": []map[string]any{
{"name": "path", "description": "File to review", "required": true},
},
}}
}
// getPrompt renders a prompt into MCP messages. The returned text becomes the
// next user turn in reasonix, so it's phrased as an instruction to the model.
func getPrompt(params json.RawMessage) (any, *rpcError) {
var p struct {
Name string `json:"name"`
Arguments map[string]string `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
if p.Name != "review" {
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name}
}
path := p.Arguments["path"]
if path == "" {
path = "the current file"
}
text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path)
return map[string]any{
"description": "Code review request",
"messages": []map[string]any{
{"role": "user", "content": map[string]any{"type": "text", "text": text}},
},
}, nil
}
// --- resources ---
// resourceContents is the demo resource store, keyed by uri.
var resourceContents = map[string]string{
"doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.",
}
func resourceList() []map[string]any {
return []map[string]any{{
"uri": "doc://style-guide",
"name": "Style guide",
"description": "The project's coding style notes.",
"mimeType": "text/plain",
}}
}
func readResource(params json.RawMessage) (any, *rpcError) {
var p struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
text, ok := resourceContents[p.URI]
if !ok {
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI}
}
return map[string]any{
"contents": []map[string]any{
{"uri": p.URI, "mimeType": "text/plain", "text": text},
},
}, nil
}
// countLines counts newline-separated lines, counting a final unterminated line.
func countLines(s string) int {
if s == "" {
return 0
}
n := strings.Count(s, "\n")
if !strings.HasSuffix(s, "\n") {
n++
}
return n
}
// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes.
func trimSpace(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
+20
View File
@@ -0,0 +1,20 @@
// Command reasonix is a config- and plugin-driven coding agent CLI.
package main
import (
"os"
"reasonix/internal/cli"
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/tool/builtin"
)
// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"
func main() {
os.Exit(cli.Run(os.Args[1:], version))
}
+45
View File
@@ -0,0 +1,45 @@
# Wails build assets/output — mostly auto-generated (plists, manifests, compiled
# binary); ignore them but keep the committed designed app icon source and PNG.
/build/*
!/build/appicon.png
!/build/appicon.svg
# Commit only our customized per-user NSIS installer template. wails_tools.nsh and
# everything else under build/ are regenerated by `wails build`, so stay ignored.
# (Each `!` un-ignore needs its parent dir un-ignored first.)
!/build/windows
/build/windows/*
!/build/windows/installer
/build/windows/installer/*
!/build/windows/installer/project.nsi
# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh
# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT
# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first.
!/build/darwin
/build/darwin/*
!/build/darwin/entitlements.plist
!/build/darwin/icon.icns
# Commit the hand-written Linux .deb packaging inputs and app icon assets.
# `wails build` does not generate these, so they must live in git. The .deb is built
# by scripts/desktop-build.sh's linux branch via goreleaser/nfpm. Parent un-ignored first.
!/build/linux
/build/linux/*
!/build/linux/nfpm.yaml
!/build/linux/reasonix.desktop
!/build/linux/icons
!/build/linux/icons/**
# Go test binaries
*.test
# Local config symlinks for `wails dev` (point at the repo-root reasonix.toml/.env
# so the desktop app resolves the same config/keys the CLI does). Never commit.
/reasonix.toml
/.env
# `go build` binary produced in-tree (module reasonix/desktop → "desktop")
/desktop
# Wails dependency-change cache
/frontend/package.json.md5
+283
View File
@@ -0,0 +1,283 @@
# Reasonix Desktop (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.
```
┌─────────────────────────────────────────────────────────────┐
│ 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, …) │
└──────────────────────────────────────────────────────────────┘
```
## 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
`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.
## Prerequisites
- Go (matches the parent module).
- Node + **pnpm** (`npm i -g pnpm`).
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- 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.
## Develop
```sh
cd desktop
wails dev # hot-reloads Go + frontend (Vite dev server)
```
Frontend-only iteration without the Go side:
```sh
cd desktop/frontend
pnpm install
pnpm dev # opens in a plain browser; bridge.ts uses the dev mock
```
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:
```sh
make desktop-test # cd desktop && go test .
make desktop-test-short # skips slow desktop integration/e2e checks
```
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 / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs 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.
### Unsigned builds — first launch
There are no Apple/Windows code-signing certificates yet, so a downloaded build
trips the OS gatekeepers on first run:
- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into
Applications. Gatekeeper may then report the app "is damaged" or is from an
unidentified developer; clear the quarantine attribute and open it:
```sh
xattr -dr com.apple.quarantine /Applications/Reasonix.app
```
- **Windows** — SmartScreen shows "Windows protected your PC". Click *More info →
Run anyway*.
When Developer ID / Authenticode certificates are added, the release workflow's
`HAS_APPLE_CERT` gate flips to the signed path and these steps go away.
### 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 seam (Monaco / CodeMirror)
Code and diff rendering go through two components with stable prop contracts and a
lazy boundary, so a heavy editor stays out of the initial bundle and dropping one
in is a one-line change — no consumer touches:
| Component | Props | Default impl | Upgrade |
|---|---|---|---|
| `components/CodeViewer.tsx` | `EditorProps` | `editors/PlainCode.tsx` (`<pre>`) | swap the lazy import for `editors/MonacoCode` or `editors/CodeMirrorCode` |
| `components/DiffView.tsx` | `DiffProps` | `editors/PlainDiff.tsx` (LCS line 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 point `CodeViewer.tsx`'s `lazy(() => import(...))` at it.
`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.
Markdown itself is currently minimal (fenced code + plain text). Upgrade path:
`pnpm add react-markdown remark-gfm` and render in `components/Markdown.tsx`,
keeping fenced code delegated to `CodeViewer`.
## 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_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or
off.
- **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 install id (generated locally, tied to nothing), app version, OS,
arch, and OS version. It exists solely to count active installs. It never
includes conversations, API keys, file contents, or paths.
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
`telemetry = false` under `[desktop]` in the global config. Dev builds
never ping. Crash and performance-pressure reports are separate and only
ever sent 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 and preference buckets; they never include conversations, prompts, keys,
paths, base URLs, or file contents.
When Memory v5 is enabled, the same aggregate metrics pipeline may include only
content-free count/size buckets such as injection on/off, compiled-token bucket,
IR-overhead bucket, memory-reference count, constraint/risk/step counts, and
memory-graph size buckets. It never uploads memory text, tool outputs, prompts,
file paths, IDs, keys, base URLs, or file contents. The Memory v5 runtime itself
is controlled from Settings > General > "Memory v5" and shares the user/global
`agent.memory_compiler.enabled` setting with the CLI/TUI and `reasonix serve`;
CLI users can also run `/memory-v5 off|observe|compact|on|status` in a session
or `reasonix config memory-v5 off|observe|compact|on|status` from a shell.
+9047
View File
File diff suppressed because it is too large Load Diff
+660
View File
@@ -0,0 +1,660 @@
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 i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
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)
}
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 ...interface{}) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(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
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)
}
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)
}
}
+14
View File
@@ -0,0 +1,14 @@
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)
}
}
+799
View File
@@ -0,0 +1,799 @@
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 i := 0; i < 5; i++ {
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)
}
}
+8718
View File
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
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 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 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
@@ -0,0 +1,427 @@
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)
}
}
+51
View File
@@ -0,0 +1,51 @@
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.
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(first) {
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")
}
}
+707
View File
@@ -0,0 +1,707 @@
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.Err))
}
}
// 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, err error) desktopBridgeNotification {
label := h.tabLabel(tabID)
if 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(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("驱动失败: %v", 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
@@ -0,0 +1,184 @@
package main
import (
"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 {
tab, ctrl, err := a.beginTabTurn(tabID, false)
if err != nil {
if err == control.ErrTurnRunning {
return errDriveBusy
}
return err
}
if tab.sink == nil {
a.finishTabTurnStart(tab, 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 {
tab.sink.cancelTurnStart()
tab.turnStartMu.Unlock()
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 !a.finishTabTurnStart(tab, 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
}
+605
View File
@@ -0,0 +1,605 @@
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 i := 0; i < 2; i++ {
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 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
@@ -0,0 +1,982 @@
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, "POST", 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
@@ -0,0 +1,620 @@
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)
}
+179
View File
@@ -0,0 +1,179 @@
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.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()
})
}
+133
View File
@@ -0,0 +1,133 @@
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")
}
}
+415
View File
@@ -0,0 +1,415 @@
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
@@ -0,0 +1,504 @@
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)
}
}
+135
View File
@@ -0,0 +1,135 @@
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()},
{"AutoResearchList", app.AutoResearchList("missing")},
{"AutoResearchFindings", app.AutoResearchFindings("missing", 10)},
{"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 := 0; i < value.Len(); i++ {
assertRequiredJSONSlicesNonNil(t, path, value.Index(i))
}
case reflect.Struct:
typ := value.Type()
for i := 0; i < value.NumField(); i++ {
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.

After

Width:  |  Height:  |  Size: 61 KiB

+7
View File
@@ -0,0 +1,7 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

+17
View File
@@ -0,0 +1,17 @@
<?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.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Some files were not shown because too many files have changed in this diff Show More