chore: import upstream snapshot with attribution
@@ -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=
|
||||
@@ -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
|
||||
@@ -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 ./...
|
||||
@@ -0,0 +1,3 @@
|
||||
# Every pull request requests review from the maintainers below.
|
||||
# Either maintainer can review and merge any change.
|
||||
* @SivanCola @esengine
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: ["*"]
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 138 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }})."
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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>
|
||||
@@ -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-cache–oriented 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,209 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong>
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="./docs/GUIDE.md">Guide</a>
|
||||
·
|
||||
<a href="./docs/SPEC.md">Spec</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<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>
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`esengine/DeepSeek-Reasonix`
|
||||
- 原始仓库:https://github.com/esengine/DeepSeek-Reasonix
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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>
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="./docs/GUIDE.zh-CN.md">指南</a>
|
||||
·
|
||||
<a href="./docs/SPEC.md">规格</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<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>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 "等待扫码。"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 61 KiB |
@@ -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 |
@@ -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>
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |