diff --git a/.github/workflows/acp-ci.yml b/.github/workflows/acp-ci.yml new file mode 100644 index 000000000..0616e1cca --- /dev/null +++ b/.github/workflows/acp-ci.yml @@ -0,0 +1,147 @@ +name: ACP CI + +# acp/ is a nested Go module, so root CI does not build or test its packages. +on: + push: + branches: [main] + paths: + - 'acp/**' + - '.github/workflows/acp-ci.yml' + - '.gitattributes' + - 'scripts/verify-license.sh' + - 'scripts/verify-english-only.go' + # Integration branch PRs need the same checks before they reach main. + pull_request: + paths: + - 'acp/**' + - '.github/workflows/acp-ci.yml' + - '.gitattributes' + - 'scripts/verify-license.sh' + - 'scripts/verify-english-only.go' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: self-hosted + timeout-minutes: 15 + container: + image: golang:1.26.9 + options: --cpus=2 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Trust workspace + run: git config --global --replace-all safe.directory '*' + + - name: Verify license headers + run: bash scripts/verify-license.sh + + - name: Verify sources contain no unapproved non-English text + working-directory: acp + run: go run ../scripts/verify-english-only.go + + - name: Check formatting + working-directory: acp + run: | + unformatted="$(gofmt -s -l .)" + if [ -n "$unformatted" ]; then + echo "::error::Not gofmt-clean. Run 'gofmt -s -w .' and commit:" + echo "$unformatted" + gofmt -s -d . + exit 1 + fi + + - name: Check line endings + run: | + git add --renormalize . + if ! git diff --cached --quiet; then + echo "::error::Line endings must be LF. Run 'git add --renormalize .' and commit the diff below." + git diff --cached --stat + exit 1 + fi + + # `git status --porcelain` rather than `git diff`: a newly created go.sum + # is untracked, and `git diff` would not report it. + - name: Check go.mod is tidy + working-directory: acp + run: | + go mod tidy + if [ -n "$(git status --porcelain -- go.mod go.sum)" ]; then + echo "::error::go.mod/go.sum are not tidy. Run 'go mod tidy' in acp/ and commit the diff below." + git status --porcelain -- go.mod go.sum + git --no-pager diff -- go.mod go.sum + exit 1 + fi + + - name: Vet + working-directory: acp + run: go vet ./... + + - name: Staticcheck + working-directory: acp + run: | + go install honnef.co/go/tools/cmd/staticcheck@v0.8.1 + staticcheck -version + output="$(staticcheck ./... 2>&1)" || { printf '%s\n' "$output"; exit 1; } + printf '%s\n' "$output" + if printf '%s' "$output" | grep -q 'matched no packages'; then + echo "::error::Staticcheck analyzed no packages." + exit 1 + fi + + - name: Govulncheck + working-directory: acp + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + govulncheck ./... + + - name: Test with coverage + working-directory: acp + run: | + LC_ALL=C go test -v -race -count=1 -coverpkg=./... -coverprofile=coverage.out ./... + go tool cover -func=coverage.out | grep total: | awk '{print $3}' + + - name: Check coverage threshold + working-directory: acp + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total: | awk '{print $3}' | sed 's/%//') + if [ -z "$COVERAGE" ]; then + echo "::error::Could not parse total coverage from coverage.out." + exit 1 + fi + echo "Total coverage: ${COVERAGE}%" + if awk "BEGIN {exit !($COVERAGE < 90)}"; then + echo "FAIL: Coverage ${COVERAGE}% is below 90% threshold" + exit 1 + fi + echo "PASS: Coverage ${COVERAGE}% meets 90% threshold" + + - name: Build + working-directory: acp + run: go build -o ../dist/ocr-acp ./cmd/ocr-acp + + # The Go container need not provide Python. uv provisions an isolated + # Python runtime and the pinned official SDK; no OCR/LLM credentials. + - name: Set up uv for official Python client + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + version: '0.11.1' + enable-cache: true + cache-dependency-glob: acp/testdata/python-smoke-requirements.txt + cache-suffix: python-3.12.13 + + - name: Smoke test + timeout-minutes: 5 + env: + PYTHONUNBUFFERED: '1' + PYTHONOPTIMIZE: '' + run: >- + uv run --no-project --managed-python --python 3.12.13 + --with-requirements acp/testdata/python-smoke-requirements.txt + acp/testdata/python-client-smoke.py diff --git a/.github/workflows/action-contract.yml b/.github/workflows/action-contract.yml index 717f07802..a5f98614f 100644 --- a/.github/workflows/action-contract.yml +++ b/.github/workflows/action-contract.yml @@ -18,6 +18,8 @@ on: - 'scripts/github-actions/**' - 'examples/github_actions/README.md' - '.github/workflows/action-contract.yml' + - '.github/workflows/pr-commit-policy.yml' + - '.github/workflows/release.yml' pull_request: branches: [main] paths: @@ -30,6 +32,8 @@ on: - 'scripts/github-actions/**' - 'examples/github_actions/README.md' - '.github/workflows/action-contract.yml' + - '.github/workflows/pr-commit-policy.yml' + - '.github/workflows/release.yml' permissions: contents: read @@ -54,6 +58,9 @@ jobs: - name: Trust workspace run: git config --global --replace-all safe.directory '*' + - name: Install release contract dependencies + run: apt-get update && apt-get install -y jq + - name: Test GitHub Actions contracts run: npm run test:github-actions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 924ee1255..cd4a2e4a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: runs-on: self-hosted timeout-minutes: 15 container: - image: golang:1.26.6 + image: golang:1.26.9 options: --cpus=2 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -82,6 +82,8 @@ jobs: govulncheck ./... - name: Test with coverage + env: + OCR_DELEGATE_REQUIRE_PTY: '1' run: | go test -v -race -count=1 -coverprofile=coverage.out ./... go tool cover -func=coverage.out | grep total: | awk '{print $3}' @@ -109,6 +111,7 @@ jobs: echo "$HELP" | grep -q "scan" echo "$HELP" | grep -q "delegate" echo "$HELP" | grep -q "config" + echo "$HELP" | grep -q "auth" echo "$HELP" | grep -q "llm" echo "$HELP" | grep -q "viewer" echo "$HELP" | grep -q "session" @@ -119,7 +122,7 @@ jobs: # do: it only proves the windows arms of the build-tag splits compile. GitHub # does not support `container:` on Windows runners # (actions/runner#904), so this job installs Go directly instead of reusing the - # golang:1.26.5 image the other jobs share. + # golang:1.26.9 image the other jobs share. windows: runs-on: windows-latest timeout-minutes: 20 @@ -128,7 +131,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.26.5' + go-version: '1.26.9' cache: true - name: Vet @@ -161,6 +164,7 @@ jobs: echo "$HELP" | grep -q "scan" echo "$HELP" | grep -q "delegate" echo "$HELP" | grep -q "config" + echo "$HELP" | grep -q "auth" echo "$HELP" | grep -q "llm" echo "$HELP" | grep -q "viewer" echo "$HELP" | grep -q "session" @@ -171,7 +175,7 @@ jobs: runs-on: self-hosted timeout-minutes: 10 container: - image: golang:1.26.6 + image: golang:1.26.9 options: --cpus=2 strategy: fail-fast: false diff --git a/.github/workflows/pr-commit-policy.yml b/.github/workflows/pr-commit-policy.yml new file mode 100644 index 000000000..79a9ac67a --- /dev/null +++ b/.github/workflows/pr-commit-policy.yml @@ -0,0 +1,37 @@ +name: PR Commit Policy + +on: + pull_request_target: + branches: [main] + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr-commit-policy: + name: PR Commit Policy + runs-on: [self-hosted, Linux, X64] + timeout-minutes: 5 + container: + image: node:24.18.0 + options: --cpus=1 --memory=1g + steps: + - name: Check out trusted policy script + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Check PR title and commit messages + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + retries: 2 + script: | + const { run } = require('./scripts/github-actions/check-pr-commit-policy.js'); + await run({ github, context, core }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adba83c53..dedb17da6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,16 +3,103 @@ name: Release on: push: tags: ['v*'] + workflow_dispatch: permissions: contents: read jobs: + # Exchanges a GitHub OIDC token for an npm publish token for every package + # without publishing anything, so a trusted publisher misconfiguration is + # caught before a tag is cut. Manual runs stop here. + verify-trusted-publishing: + if: github.event_name == 'workflow_dispatch' + # npm trusted publishing rejects self-hosted runners, so this job and + # npm-publish are cloud-hosted while the rest of the workflow is not. + runs-on: ubuntu-latest + timeout-minutes: 10 + container: + image: node:24 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Trust workspace + run: git config --global --replace-all safe.directory '*' + + - name: Install jq + run: apt-get update && apt-get install -y jq + + - name: Exchange OIDC token for every package + shell: bash + run: | + set -euo pipefail + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "::error::OIDC is unavailable. The job needs 'id-token: write'." + exit 1 + fi + + REGISTRY_HOST=registry.npmjs.org + ID_TOKEN=$(curl -sSf \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + -H 'Accept: application/json' \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:${REGISTRY_HOST}" | jq -r '.value') + + if [ -z "$ID_TOKEN" ] || [ "$ID_TOKEN" = "null" ]; then + echo "::error::Failed to obtain a GitHub OIDC token." + exit 1 + fi + + echo "Presenting claims:" + printf '%s' "$ID_TOKEN" | node -e ' + let raw = "" + process.stdin.on("data", c => raw += c).on("end", () => { + const p = JSON.parse(Buffer.from(raw.trim().split(".")[1], "base64url")) + console.log(JSON.stringify({ + repository: p.repository, + workflow_ref: p.workflow_ref, + environment: p.environment, + repository_visibility: p.repository_visibility, + }, null, 2)) + })' + echo + + # The main package plus every platform package it pulls in. + PACKAGES=$(jq -r '[.name] + (.optionalDependencies // {} | keys) | .[]' package.json) + + failed=0 + for pkg in $PACKAGES; do + escaped=$(printf '%s' "$pkg" | sed 's|/|%2f|') + body=$(curl -s -o exchange.json -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${ID_TOKEN}" \ + -H 'Accept: application/json' \ + "https://${REGISTRY_HOST}/-/npm/v1/oidc/token/exchange/package/${escaped}") + + if [ "$body" = "201" ] && jq -e '.token | strings | select(length > 0)' exchange.json > /dev/null; then + echo "OK ${pkg}" + else + failed=1 + echo "FAILED ${pkg} (HTTP ${body}): $(jq -r '.message // .error // "unknown error"' exchange.json 2>/dev/null)" + fi + rm -f exchange.json + done + + echo + if [ "$failed" -ne 0 ]; then + echo "::error::At least one package is not configured for trusted publishing from this workflow." + exit 1 + fi + echo "All packages accept trusted publishing from this workflow." + build: + if: github.event_name == 'push' runs-on: self-hosted timeout-minutes: 15 container: - image: golang:1.26.6 + image: golang:1.26.9 options: --cpus=2 strategy: matrix: @@ -161,12 +248,15 @@ jobs: npm-publish: needs: release - runs-on: self-hosted + # Must stay cloud-hosted: npm refuses an OIDC exchange from a self-hosted + # runner. The heavy build matrix above is unaffected. + runs-on: ubuntu-latest timeout-minutes: 15 container: image: node:24 permissions: contents: read + id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -176,16 +266,24 @@ jobs: - name: Install jq run: apt-get update && apt-get install -y jq + # Keep a tested npm baseline without downgrading a newer bundled version. + - name: Ensure npm supports trusted publishing + shell: bash + run: | + set -euo pipefail + REQUIRED=11.15.0 + CURRENT=$(npm --version) + if [ "$(printf '%s\n%s\n' "$REQUIRED" "$CURRENT" | sort -V | head -1)" != "$REQUIRED" ]; then + echo "npm ${CURRENT} is below the tested baseline ${REQUIRED}, upgrading" + npm install -g "npm@${REQUIRED}" + fi + npm --version + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: binary-* merge-multiple: true - - name: Configure npm registry - run: echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish platform packages run: | VERSION="${GITHUB_REF_NAME#v}" @@ -214,12 +312,10 @@ jobs: if [ "$already" = "$VERSION" ]; then echo "Skip: ${pkg_name}@${VERSION} already published" else - npm publish "$pkg_dir" --access public + npm publish "$pkg_dir" --access public --provenance echo "Published: ${pkg_name}@${VERSION}" fi done - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Inject version and optionalDependencies run: | @@ -237,8 +333,6 @@ jobs: if [ "$already" = "$VERSION" ]; then echo "Skip: ${pkg_name}@${VERSION} already published" else - npm publish --access public + npm publish --access public --provenance echo "Published: ${pkg_name}@${VERSION}" fi - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/translation-sync.yml b/.github/workflows/translation-sync.yml index 9bc7c4b67..6de78e296 100644 --- a/.github/workflows/translation-sync.yml +++ b/.github/workflows/translation-sync.yml @@ -22,8 +22,7 @@ jobs: runs-on: self-hosted timeout-minutes: 10 container: - # Pinned to an exact 24.x for reproducible builds, matching the - # golang:1.26.6 pin used by the other jobs (avoids the mutable node:24 tag). + # Pin Node to an exact version for reproducible builds. image: node:24.18.0 steps: # fetch-depth: 0 so the non-blocking docs check can diff base...head. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0dd1e1315..482c01995 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ There are many ways to contribute beyond writing code: ### Prerequisites -- [Go 1.25+](https://go.dev/dl/) +- [Go 1.26.9+](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -100,6 +100,14 @@ fix(llm): handle timeout errors in Anthropic API calls docs(README): update configuration examples ``` +`PR Commit Policy` checks the PR title and every commit's full message, including +the body, case-insensitively. Do not include `[skip ci]`, `[ci skip]`, `[no ci]`, +`[skip actions]`, `[actions skip]`, or a `skip-checks: true` / `skip-checks:true` line. + +To fix failures, edit the PR title or rewrite affected commit messages on your +contribution branch. Adding a clean commit does not remove earlier violations. +PR descriptions may discuss these instructions and are not checked. + ### License Headers Every source file (`.go`, `.js`, `.mjs`, `.ts`, `.tsx`, `.kt`, `.kts`, `.sh`, `.py`, `.css`) must include an SPDX license header. After creating new files, run: @@ -128,20 +136,38 @@ make build ### Project Structure ``` +├── acp/ # ACP adapter for the CLI +├── bin/ # NPM wrapper ├── cmd/opencodereview/ # CLI entry point +├── docs/ # Localized README and CONTRIBUTING +├── examples/ # CI/CD integration examples +├── extensions/ # Editor extensions (VS Code and IDEA) +├── imgs/ # Logo, benchmark, and highlight images ├── internal/ │ ├── agent/ # Review agent logic +│ ├── chatgptauth/ # ChatGPT OAuth and credential management │ ├── config/ # Configuration management +│ ├── delegate/ # Delegation-mode review specs (no LLM) │ ├── diff/ # Git diff parsing -│ ├── llm/ # LLM API client (Anthropic & OpenAI) +│ ├── gitcmd/ # Bounded git subprocess runner +│ ├── llm/ # LLM API client (Anthropic, Bedrock & OpenAI) +│ ├── llmloop/ # Shared per-subtask tool-use loop +│ ├── mcp/ # MCP server client and tool adapter │ ├── model/ # Data models +│ ├── pathutil/ # Canonical paths and containment checks +│ ├── release/ # Release asset URL naming checks +│ ├── scan/ # Full-file code review (ocr scan) │ ├── session/ # Review session management -│ ├── tool/ # Built-in tools (file_read, code_search, etc.) +│ ├── stdout/ # Swappable stdout writer +│ ├── suggestdiff/ # Line-level diffs for suggestion rendering │ ├── telemetry/ # OpenTelemetry integration +│ ├── tool/ # Built-in tools (file_read, code_search, etc.) │ └── viewer/ # WebUI session viewer -├── pages/ # WebUI frontend -├── scripts/ # Build & install scripts -└── bin/ # NPM wrapper +├── npm/ # Per-platform optional binary packages +├── pages/ # Landing page +├── plugins/ # Coding agent plugins +├── scripts/ # Install, publish, and check scripts +└── skills/ # Agent skill manifests ``` ## AI-Assisted Development diff --git a/README.md b/README.md index 45618b0b4..96b4e2312 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ npm Build status License - Ask DeepWiki + Ask DeepWiki OpenSSF Best Practices

diff --git a/ROADMAP.md b/ROADMAP.md index 2dcd7deb3..6bdda03c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,6 +27,15 @@ OpenCodeReview currently provides: ## Planned — H2 2026 +### ACP Server Adapter + +- **Experimental: local source builds only** — Expose OCR as a dedicated review + agent through an independent ACP v1 stdio adapter, keeping protocol handling + outside the core review engine. Review/scan, command discovery, progress, + findings and cancellation are implemented. + See the [adapter documentation](acp/README.md) for configuration and support + boundaries. + ### IDE Plugins - **JetBrains plugin** — Bring AI code review to IntelliJ IDEA, GoLand, diff --git a/acp/.gitignore b/acp/.gitignore new file mode 100644 index 000000000..963100e12 --- /dev/null +++ b/acp/.gitignore @@ -0,0 +1,14 @@ +# Compiled mock binary from the manual build in README.md. +# Anchored to this exact path: a bare "mock-ocr" pattern would also match the +# testdata/mock-ocr/ SOURCE directory, silently ignoring any new scenario file +# added there. +/testdata/mock-ocr/mock-ocr +/testdata/mock-ocr/mock-ocr.exe + +/coverage.out + +# A bare `go build ./testdata/mock-ocr/` drops the binary in the module +# root; the README's -o form puts it under testdata instead. Both are +# build artifacts. +/mock-ocr +/mock-ocr.exe diff --git a/acp/Makefile b/acp/Makefile new file mode 100644 index 000000000..d5a5570d1 --- /dev/null +++ b/acp/Makefile @@ -0,0 +1,93 @@ +.PHONY: build test check coverage license-check english-check clean + +# Minimum total statement coverage enforced by `make coverage`. +# Mirrors COVERAGE_THRESHOLD in the repository root Makefile. +COVERAGE_MIN ?= 90 + +# Windows GitHub-hosted and native desktops often lack a C toolchain. The race +# detector needs cgo there, so Unix keeps -race and Windows matches root CI. +ifeq ($(OS),Windows_NT) +ACP_BIN ?= ../dist/ocr-acp.exe +GOTESTFLAGS ?= -v -count=1 +GOCOVERFLAGS ?= -count=1 +# Unix-only tests are omitted by build tags, so native Windows coverage is +# informational. Linux ACP CI remains the 90% gate. +COVERAGE_GATE ?= 0 +else +ACP_BIN ?= ../dist/ocr-acp +GOTESTFLAGS ?= -v -race -count=1 +GOCOVERFLAGS ?= -race -count=1 +COVERAGE_GATE ?= 1 +endif + +build: + go build -o $(ACP_BIN) ./cmd/ocr-acp + +test: + LC_ALL=C go test $(GOTESTFLAGS) ./... + +# Unlike the root `check`, which formats in place with `gofmt -s -w .`, this +# one fails on unformatted files instead of fixing them, so it can run as a +# gate. `gofmt -s` matches the flag CI uses. +check: license-check english-check + @unformatted=$$(gofmt -s -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "gofmt: the following files need formatting (run 'gofmt -s -w .'):"; \ + echo "$$unformatted"; \ + exit 1; \ + fi + LC_ALL=C go vet ./... + @sc=$$(command -v staticcheck || echo "$$(go env GOPATH)/bin/staticcheck"); \ + if [ ! -x "$$sc" ]; then \ + echo "staticcheck: not installed, SKIPPED (CI installs it; see .github/workflows/acp-ci.yml)"; \ + exit 0; \ + fi; \ + out=$$("$$sc" ./... 2>&1); \ + status=$$?; \ + printf '%s\n' "$$out"; \ + if printf '%s' "$$out" | grep -q 'matched no packages'; then \ + echo "staticcheck: analyzed no packages; refusing to report a pass."; \ + echo "staticcheck 0.8.x requires a Go >= 1.26 toolchain, but this module declares go 1.23."; \ + exit 1; \ + fi; \ + exit $$status + +coverage: + # Include dependencies exercised by cross-package integration tests. + LC_ALL=C go test $(GOCOVERFLAGS) -coverpkg=./... -coverprofile=coverage.out ./... + @actual=$$(go tool cover -func=coverage.out | awk '/^total:/ {gsub(/%/, "", $$3); print $$3}'); \ + awk -v actual="$$actual" -v minimum="$(COVERAGE_MIN)" -v gate="$(COVERAGE_GATE)" 'BEGIN { \ + if (actual == "") { \ + print "FAIL: could not parse total coverage from coverage.out"; \ + exit 1; \ + } \ + printf "total coverage: %s%% (minimum: %s%%)\n", actual, minimum; \ + if (gate != 0 && actual + 0 < minimum + 0) { \ + print "FAIL: coverage is below the minimum"; \ + exit 1; \ + } \ + if (gate == 0 && actual + 0 < minimum + 0) { \ + print "coverage gate skipped on this OS (Unix-only tests omitted)"; \ + } \ + }' + +# verify-license.sh takes no path argument and always scans the whole repository +# via `git ls-files`, so this checks more than just the acp module. +license-check: + @bash ../scripts/verify-license.sh + +# Running from this module scopes git ls-files to its files. A missing script is a +# hard failure: this module lives inside the OCR repository, so the script is +# always expected to be there, and silently skipping would let `make check` +# exit 0 without having verified anything. +english-check: + @if [ ! -f ../scripts/verify-english-only.go ]; then \ + echo "english-check: ../scripts/verify-english-only.go is missing"; \ + exit 1; \ + fi + go run ../scripts/verify-english-only.go + +clean: + rm -f coverage.out + rm -f testdata/mock-ocr/mock-ocr testdata/mock-ocr/mock-ocr.exe + rm -f ../dist/ocr-acp ../dist/ocr-acp.exe diff --git a/acp/README.md b/acp/README.md new file mode 100644 index 000000000..00285131b --- /dev/null +++ b/acp/README.md @@ -0,0 +1,491 @@ +# ocr-acp: ACP Adapter for OpenCodeReview + +Independent ACP (Agent Client Protocol) adapter for OpenCodeReview CLI. + +## Status + +The ACP adapter is experimental and currently unstable. It is available only +for local compilation from source. To use it, build with +`make -C acp build` from the repository root and configure your client to launch +the resulting binary. To update it, update your source checkout and rebuild. + +The adapter supports ACP v1 stdio, command discovery, review/scan, optional +natural-language parsing, cancellation, bounded logs, formatted findings and +safe file navigation. It uses the existing OCR CLI without modifying its review +engine, prompts or progress output. + +macOS and Linux use process groups; Windows uses Job Objects and cancellable +stdio. Linux CI runs the full automated suite. + +## Project Structure + +``` +acp/ +├── cmd/ocr-acp/ # Independent stdio server and shutdown owner +├── internal/ +│ ├── adapter/ # ACP methods, sessions and prompt orchestration +│ │ ├── presentation/ # Reports, progress cards and Markdown rendering +│ │ ├── transport/ # SDK connection, dispatch and command discovery +│ │ └── workspace/ # Result roots, safe file locations and file URLs +│ ├── contract/ # CLI contract and intent structures (phase 3) +│ ├── intent/ # prompt -> intent / clarify / reject (phase 4) +│ │ └── llm/ # Intent parsing model configuration and protocol client +│ ├── orchestrator/ # OCR process lifecycle and result decoding (phase 5) +│ │ └── process/ # Unix process groups and Windows Job Objects +│ └── testutil/ # Cross-platform subprocess test helpers +├── testdata/ +│ ├── mock-ocr/ # CLI contract test double +│ └── ocr-stub/ # Configurable process and lifecycle test double +├── Makefile # Build, test, and quality checks +└── go.mod # Independent Go module +``` + +`cmd/ocr-acp` starts the independent ACP server. It exposes `/review` and +`/scan` through ACP `available_commands_update` and emits findings once in the +final message. Safe locations become Markdown file links, resolved against the +Git root for review and the session cwd for scan; other locations remain text. + +The adapter owns session state, turn cancellation and shutdown. Its transport +sub-package depends on the SDK agent interface and an atomic command-discovery +callback, without accessing session maps or locks. Presentation receives a runner +and a notification function, and shares file validation with prompt resources +through `adapter/workspace`, which has no ACP SDK dependency. The orchestrator retains +execution outcome decisions and stream cleanup; its `process` sub-package only +owns platform process-tree operations. Unit tests follow those boundaries, while +wire-protocol and prompt integration tests remain in the adapter. + +## Quick Start + +The Test, Quality Checks and Build commands below run from `acp/`. +From the repository root, use `make -C acp `. + +### Test + +```bash +make test +make coverage +``` + +`make coverage` fails if total statement coverage drops below `COVERAGE_MIN` +(default 90%) on Unix. It instruments all packages in this module, including +dependencies exercised by cross-package integration tests. Windows defaults to informational coverage because +Unix-only tests are excluded, and omits the race detector to avoid requiring a +C toolchain. Override the threshold with `make coverage COVERAGE_MIN=95` and +enable the Windows gate explicitly with `COVERAGE_GATE=1`. + +### Quality Checks + +```bash +make check +make english-check +``` + +`make check` fails on unformatted files, on `go vet` findings, and on +`staticcheck` findings. It looks for `staticcheck` on `PATH` first and then in +`$(go env GOPATH)/bin`, where `go install` puts it, so a local install is +found without editing `PATH`. When the binary is genuinely absent it prints a +notice and skips it — it never reports a pass for a check it did not run. + +A present-but-useless `staticcheck` is also a failure, not a pass: if it +reports `matched no packages` it analyzed nothing, which usually happens when +the binary is newer than the active Go toolchain. `staticcheck` 0.8.x needs a +Go >= 1.26 toolchain while this module declares `go 1.23`, so CI pins a +`golang:1.26.9` container for exactly that reason. + +### CI + +`acp/` is a nested Go module, and the root `ci.yml` resolves its package list +with `go list ./...`, which does not descend into a nested module. The root CLI CI job therefore does not build or test this +module. + +`.github/workflows/acp-ci.yml` covers it instead — a dedicated workflow +for the nested module, the same idea as `pages-ci.yml` for `pages/`. +Linux CI runs the license, English-only, formatting, line-ending, `go.mod` +tidy, vet, staticcheck, coverage and govulncheck gates in a `golang:1.26.9` +container, builds the adapter and runs the pinned official Python SDK smoke +test with mock OCR. The workflow has only the `test` job; there are no +cross-platform build jobs. +No real review-provider credentials are required. +Its `pull_request` trigger deliberately has no `branches:` filter, so it also +runs for pull requests into an integration branch rather than only `main`. + +The workflow installs and invokes staticcheck directly, and fails if it +reports `matched no packages`. Local `make check` rejects the same empty-analysis +result, but can skip staticcheck when the executable is absent. + +### Build + +```bash +make build +./../dist/ocr-acp --ocr-binary /path/to/ocr +``` + +The server speaks ACP v1 over stdio. Configure natural-language parsing with +`--parser-provider`, `--parser-model`, `--parser-base-url` and the +`OCR_ACP_PARSER_API_KEY` environment variable. `--turn-timeout` bounds a +complete turn. Windows uses Job Objects and cancellable stdio. + +### ACP Client Configuration and Upgrades + +Build with `make -C acp build` from the repository root. Register a custom +agent named `OpenCodeReview` in an ACP v1 stdio client with these launch values +(replace both absolute paths). This is a launch configuration example, not a +complete settings file: field names and nesting depend on the client. + +```json +{ + "command": "/absolute/path/open-code-review/dist/ocr-acp", + "args": ["--ocr-binary", "/absolute/path/ocr"], + "env": {} +} +``` + +On Windows the adapter binary is `dist/ocr-acp.exe` and the OCR path typically +ends in `.exe`. In JSON configuration, escape backslashes or use forward slashes; +keep each path as one string without adding shell quotes inside it. `file:` resource links use +the `file:///C:/...` form. + +This minimal configuration supports slash commands without a parser model. +For natural language, supply the separate parser configuration described below +to the agent process through flags or its environment. A graphical app may not +inherit your terminal environment. Do not put API keys in CLI arguments or +commit credentials in shared settings. Configure the review model through OCR's +own configuration; the client's model selector does not configure the parser +or OCR. Select your project's working directory in the client and follow its +ACP configuration guide for the enclosing settings structure. + +After updating the source and rebuilding, ensure the configured command points to the +new binary, then start a **new OpenCodeReview session**. Existing processes and +old messages do not reload automatically. If the client reuses an older agent process, +restart the agent/client after allowing active work to finish or cancelling it. +Session restoration is not supported; a new session has no pending clarification +state from the previous one. + +### Current Conversation Display + +The adapter uses the existing OCR CLI progress logs and final JSON output; +it does not require a modified CLI or a custom progress protocol. During a run, +progress reflects the latest reported activity and locally measured total time. +Per-group phase durations, model-response wait times and live tool-request rounds +are not available from this interface. + +- One generic ACP tool entry, **OCR review** or **OCR scan**, shows the latest + progress activity and locally measured elapsed time. Diagnostic lines remain + in the details without replacing the activity title. The final title shows + the outcome after process cleanup. Folding and expansion state belong to the client. +- Expand the entry for the **Command:** code block, working directory and + plain-text process logs. The command uses the actual OCR binary and shell-quoted + argv; the runner executes argv directly, not through a shell. Copying it requires + the displayed working directory and appropriate local OCR configuration. +- Log updates replace a bounded tail (up to 32 KiB, with an omission notice). + Neither the command nor raw logs are repeated in the conversation body. + Final details also show tool usage totals and input/output/cache token counts + when reported by OCR. These are aggregate statistics, not individual tool cards. +- The final report leads with the completion state, file/finding counts and a + severity breakdown. Numbered finding headings include severity and a file + location; category and explanation follow. Valid in-root locations retain + links, while invalid locations remain text. Existing and suggested code use + language-tagged blocks; suggestions are explicitly marked as not applied. +- Findings appear only in the final message. Partial/failure diagnostics and + budget-limit notices stay visible. A compact footer retains cumulative token + usage and OCR-reported elapsed time, which can differ from the adapter's live + execution timer. Missing token breakdowns are omitted rather than invented. + Markdown, syntax highlighting and link rendering depend on the client version. + +For a faster development check, use `/review --effort low` (at most one review round). +`medium` allows up to two rounds and `high` up to three. A group stops early +when a round adds no new findings. Omitting the flag preserves OCR's +configuration, whose fallback is `medium`; the adapter does not lower it. +Lower effort may miss findings and does not guarantee a proportional speedup. + +### Platform Support + +| Platform/client | Implementation | Validation/support boundary | +| --- | --- | --- | +| macOS arm64 | Process groups and cancellable stdio | Local automated tests and SDK smoke passed | +| Linux x86_64 | Unix process-group/stdio implementation | Linux CI runs tests, race-enabled coverage and Python SDK smoke | +| Linux riscv64 (RISC-V) | Experimental source-build support using the Unix implementation | Docker/QEMU build, startup and core tests without race passed | +| Windows amd64 / arm64 | Job Objects (`KILL_ON_JOB_CLOSE`) and deadline-capable stdio | Built on a Windows machine and works in normal use | +| Zed / VSCode ACP extension | Standard ACP stdio interface | Manual acceptance confirmed on tested Unix installations | + +## CLI Contract + +`internal/contract` builds OCR CLI argument vectors and models the JSON the +CLI writes back. + +### Intent to arguments + +`BuildReviewArgs` and `BuildScanArgs` turn an intent into an argv slice. They +always append `--format json --audience human --color never` last, so the +integration contract holds regardless of what a caller passes in `Extra`. + +### Extra flag whitelist + +`Extra` is not a free-form passthrough. Every element is validated against a +per-command whitelist derived from the OCR CLI's own flag registrations: + +| Command | Forwardable via `Extra` | +| --- | --- | +| `review` | `--effort`, `--no-filter`, `--background`, `--background-file` | +| `scan` | `--batch`, `--no-plan`, `--no-dedup`, `--no-summary`, `--background` | + +The lists are asymmetric because the CLI registers different flags on each +command — `--effort` is rejected on `scan`, `--batch` on `review`. Enum flags +(`--effort`, `--batch`) also have their values checked, because the CLI +silently falls back to a default for unknown values rather than erroring. + +Everything else is rejected, including the integration flags the adapter owns +(`--format`, `--audience`, `--color`, `--output`, `--repo`, `--rule`, +`--tools`, `--resume`), the runtime overrides (`--model`, `--provider`), +`--preview`, and any bare word that is not a flag. + +### Result types + +`ReviewResult`, `ScanResult`, `Summary`, `Comment` and `Manifest` mirror the +real `ocr --format json` envelope (`jsonOutput` plus `model.LlmComment`): +`summary` is an object, a finding carries `content`/`start_line`/`end_line`, +and `start_line == end_line == 0` means a file-level comment. Per-comment +`thinking` is deliberately not modeled, so reasoning cannot leak by accident. +`Comment.Severity` and the `status` string are open sets: the observed values +are declared as `Status*` constants, but consumers must tolerate unknown ones. + +## Parsing and Clarification + +`internal/intent` turns one `session/prompt` text into exactly one of three +outcomes, and never starts a process: + +- a runnable `contract.ReviewIntent` / `contract.ScanIntent`; +- a clarification question (one pending request with multiple known fields); +- a rejection with an actionable hint. + +Routing is deliberate: + +- A leading `/` selects the deterministic parser. `/review`, `/scan` and + their flags are an exact grammar, so they keep working while the parsing LLM + is down. Unknown commands (`/foo`) and out-of-scope flags (`--staged`, + `--repo`, `--format`, `--ocr-binary`) are rejected, never forwarded to + the model. +- Everything else goes to the LLM through a single `submit_intent` tool call. + There is no keyword table, regex or template for natural language, and no + deterministic fallback: a failed, timed-out or unsupported call becomes a + clarification or a rejection. +- Every proposed value is validated before it becomes argv. Refs are checked + with `git rev-parse --verify ^{commit}`; paths and extra flags go through + the phase 3 `BuildReviewArgs` / `BuildScanArgs`, which own the whitelist. + Adapter-owned flags are absent from the tool schema, so prompt injection + cannot reach them. + +Compatible fields survive successive clarifications; explicitly supplied values +override earlier ones. Switching review type or between review and scan does +not carry incompatible fields forward. Completion or cancellation clears the +pending request. This is short-lived clarification state, not conversation memory. + +The parsing timeout defaults to 15s and is cancellable through the context. +It applies before OCR starts. Increasing `--turn-timeout` does not extend this +separate parser deadline; there is no parser-timeout startup flag. On failure, +inspect the reported request phase, elapsed time, HTTP status and provider +response. A phase identifies where the request stopped, not necessarily the +root cause; a timeout alone does not prove a network failure. Use a slash +command to bypass parsing, for example `/review --commit HEAD` to review the +latest commit in the current checkout. + +The parsing model is instructed to write `question`, `reason` and `hint` in the +current user's language, honoring an explicit response-language request. +Commands, flags, refs, paths and protocol fields are not translated. This is a +model instruction, not a deterministic translation guarantee: tests cover the +instruction and preservation of localized responses, not every provider's +language adherence. Fixed configuration/validation/timeout messages and report +labels remain English. Anthropic intent parsing disables Thinking to support +the forced `submit_intent` call; OCR's review-model Thinking is unchanged. + +### Parsing LLM configuration + +The parsing LLM is configured independently from the review LLM, through +`--parser-provider`, `--parser-model` and `--parser-base-url` (or +`OCR_ACP_PARSER_PROVIDER`, `OCR_ACP_PARSER_MODEL`, `OCR_ACP_PARSER_BASE_URL`); +the API key is environment-only (`OCR_ACP_PARSER_API_KEY`). Flags win over +the environment. + +Base URL conventions depend on the protocol: + +| Protocol | Example base URL | Request endpoint | +| --- | --- | --- | +| `openai` | `https://gateway.example/v1` | `https://gateway.example/v1/chat/completions` | +| `anthropic` | `https://gateway.example` | `https://gateway.example/v1/messages` | + +A full URL ending in `/chat/completions` or `/v1/messages`, respectively, is +also accepted without appending that suffix again. An Anthropic base ending +only in `/v1` would produce `/v1/v1/messages`; use the gateway's correct base +or full endpoint. These are placeholder URLs, not provider recommendations. + +With no parser configuration, slash commands remain usable and natural +language requests receive an actionable refusal. Partial or invalid parser +configuration fails startup explicitly; it never silently disables the parser. + +## Protocol and Resource Boundaries + +The adapter uses ACP Go SDK v0.13.5 for JSON-RPC framing, validation and types. +SDK imports stay in `internal/adapter`; `contract`, `intent` and `orchestrator` +remain protocol-independent. This compact boundary combines the protocol, +server and mapping directories proposed by the design, without extra layers. +The connection uses the SDK's base dispatcher so a busy session can reject a +second prompt without the SDK automatically cancelling the first. + +- `session/new` requires an existing absolute cwd and announces `review` and + `scan` through standard `available_commands_update` with argument hints. + The connection writes the session response before publishing its command + notification, without requiring a first prompt or a fixed delay. The pinned + SDK's serialized frame writer preserves this order; clients still own their + session-registration and native completion UI scheduling. Descriptions include + examples; argument hints do not provide dynamic branch or path completion. +- Review resolves locations against the nearest Git root; non-Git review is + refused. Scan resolves locations against the session cwd. Additional roots + are not advertised; prompts cannot override `--repo`. +- Existing in-root regular files receive Markdown file links in the final message. + Line ranges are checked against the file; `0/0` means file-level. Missing, + escaping, invalid and symlink-outside paths retain text without navigation. +- Text blocks are separated by newlines. Local `file:` ResourceLinks may + select files for `/scan` when no explicit `--path` is present. Links with + review or explicit scan paths are refused instead of silently changing + scope. Link-only prompts ask for `/scan`; resend the links with that command. + Remote URLs, directories, outside files and unsupported content are refused. + Resources are not fetched or read into LLM context. Prompt content is bounded + to 128 blocks and 64 KiB, with an 8 KiB URI limit. +- Results retain severity, category, suggestions, statistics and partial-review + diagnostics. The final message presents the summary before findings, with no + separate finding tool cards or navigation buttons; `thinking` is never + modeled or transmitted. +- Cancel is idempotent and clears clarification state. EOF, SIGINT and SIGTERM + cancel tasks and wait for managed process cleanup. On Windows, SIGTERM is not + delivered; CTRL_BREAK (mapped to `os.Interrupt` in Go) is the cooperative + signal, with Job Object termination as the guaranteed path. A console-less + agent process (common when an editor spawns it with `CREATE_NO_WINDOW`) + cannot deliver CTRL_BREAK, so cancellation then relies on Job Object + termination alone. Stalled stdio writes use + pollable OS pipes with a two-second deadline, rather than relying on context + cancellation to interrupt synchronous writes. Inherited non-overlapped Windows + pipes are bridged onto overlapped pipes so that deadline and Close can cancel I/O. +- Timeout sends a visible retry message and returns `end_turn` with + `_meta.ocr.kind=timed_out`. ACP v1 has no dedicated timeout stop reason. +- Greetings, unsupported requests and invalid options send explanatory text + and finish with `end_turn` plus `_meta.ocr.kind=rejected`, without starting + OCR. They do not use the `refusal` stop reason: Zed removes the user prompt + and subsequent response on that reason, hiding the guidance. + +HTTP transport, persistent sessions, additional directories, client-managed terminal APIs, +file mutation and image/audio/embedded-resource prompts are not supported. + +### Official Python Client Smoke Test + +Run from the repository root (requires `uv`, Git and Go): + +```bash +make -C acp build +uv run --no-project --managed-python --python 3.12.13 --with-requirements acp/testdata/python-smoke-requirements.txt acp/testdata/python-client-smoke.py +``` + +Python is pinned to 3.12.13. The generated requirements file pins all SDK +dependencies and records distribution hashes; its header contains the +regeneration command. Update the `.in` file when changing the SDK version. +The script refuses optimized Python execution because its checks use assertions. +CI keys the uv cache on the Python version and requirements file and allows +five minutes for interpreter provisioning, dependency installation and testing. + +The official Python ACP SDK starts the independent adapter over OS stdio +pipes. The test verifies initialization, session creation, command discovery, +review and scan findings from a nested directory, severity/suggestions, +ResourceLink target selection, commands inside execution details, live activity +before completion, plain-text logs confined to tool details, repeated +cancellation and graceful EOF exit. Go wire tests separately assert the +session-response-before-command-notification ordering. +OCR itself is a deterministic subprocess fixture: this proves cross-SDK +protocol interoperability, not real OCR/LLM output quality or graphical-client +rendering. The current smoke test uses ordinary CLI logs and JSON, including +no-findings completion, partial coverage, budget failures and cancellation. + +`internal/intent/llm` deliberately does not read the OCR config file, +`OCR_LLM_*`/`ANTHROPIC_*`, shell rc files or `api_key_cmd`. The review LLM +stays owned by the OCR CLI, so an OCR upgrade never forces the adapter to copy +or drift with OCR's provider system. Only `anthropic` and `openai` are +supported; `openai-responses` and `anthropic-bedrock` fail with an +actionable error instead of silently degrading. `Endpoint.Summary()` names +the source, protocol, model and URL for reproducible logs, and never prints the +token. + +## Mock OCR + +The `testdata/mock-ocr` program simulates OCR CLI behavior for testing without +real LLM calls. + +### Build Mock + +```bash +go build -o testdata/mock-ocr/mock-ocr ./testdata/mock-ocr/ +``` + +### Scenarios + +`-scenario` defaults to `success-review`, so running the binary bare works. + +All scenarios emit the real CLI JSON envelope (`status`, `message`, `summary` +object, `comments` with `content`/`start_line`/`end_line`, `manifest`), not the +adapter's DTO. + +- `success-review`: Normal review with findings (default) +- `success-scan`: Normal scan with findings (status `success`, no manifest) +- `partial`: Partial results with a failed coverage entry (timeout simulation) +- `empty-comments`: No findings; emits `"comments":[]` +- `stderr-pollution`: Noise and a second JSON document on stderr alongside a + valid stdout document +- `invalid-json`: Malformed JSON output +- `non-zero-exit`: `status: failed`, `"comments":null`, a failed manifest and + exit code 1 +- `block-for-cancel`: Prints `READY` to stderr, blocks, then on SIGINT/SIGTERM + emits a failed document whose `manifest.run_failure.classification` and + `coverage.failed[].classification` are `cancelled`, and exits 1 to mirror the + real CLI (which has no dedicated cancellation exit code) +- `spawn-child`: forks a blocking child in the same process group, writes its + PID to `-child-pid-file`, and waits until that child is ready. It is used to + verify cancellation reaps managed descendants without timing-based tests + +`-delay` pauses before producing output. If a signal arrives during the delay +the mock exits 130 without writing a result. + +### Example + +```bash +# Normal review +./testdata/mock-ocr/mock-ocr -scenario success-review + +# With delay +./testdata/mock-ocr/mock-ocr -scenario success-review -delay 500ms + +# Test cancellation +./testdata/mock-ocr/mock-ocr -scenario block-for-cancel +``` + +### Contract tests + +`internal/contract/mock_contract_test.go` builds the mock and decodes its real +stdout into the contract types, asserting the decoded field values (content, +line numbers, summary, manifest classifications) rather than just counts. + +`internal/contract/real_cli_test.go` adds two more layers: + +- deterministic fixtures of the real CLI shape (summary object, unknown + fields, `thinking`) that fail if the DTO drifts from the documented output; +- `TestRealOCRBinaryScanMultiplePaths`, an opt-in integration test. Set + `OCR_BINARY` to run the adapter's own argv against a real binary: + `OCR_BINARY=../dist/opencodereview make test` from `acp/`. It pins the + `--path` comma-joining rule end to end (a repeated `--path` would silently + scan only the last path). + +## Dependencies + +- Go 1.23+ to build this module +- OCR CLI +- ACP Go SDK v0.13.5 + +## License + +Apache-2.0 (same as parent OCR project) diff --git a/acp/cmd/ocr-acp/adapter_signal_unix_test.go b/acp/cmd/ocr-acp/adapter_signal_unix_test.go new file mode 100644 index 000000000..d46d7440d --- /dev/null +++ b/acp/cmd/ocr-acp/adapter_signal_unix_test.go @@ -0,0 +1,17 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "os/exec" + "syscall" +) + +func prepareAdapterCmd(cmd *exec.Cmd) {} + +func interruptAdapter(cmd *exec.Cmd) error { + return cmd.Process.Signal(syscall.SIGTERM) +} diff --git a/acp/cmd/ocr-acp/adapter_signal_windows_test.go b/acp/cmd/ocr-acp/adapter_signal_windows_test.go new file mode 100644 index 000000000..4f53e966b --- /dev/null +++ b/acp/cmd/ocr-acp/adapter_signal_windows_test.go @@ -0,0 +1,26 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "os/exec" + "syscall" +) + +func prepareAdapterCmd(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } +} + +func interruptAdapter(cmd *exec.Cmd) error { + r1, _, e := procGenerateConsoleCtrlEvent.Call(1, uintptr(uint32(cmd.Process.Pid))) + if r1 == 0 { + return e + } + return nil +} diff --git a/acp/cmd/ocr-acp/main.go b/acp/cmd/ocr-acp/main.go new file mode 100644 index 000000000..1bf240fc8 --- /dev/null +++ b/acp/cmd/ocr-acp/main.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/alibaba/open-code-review/acp/internal/adapter" + "github.com/alibaba/open-code-review/acp/internal/intent" + "github.com/alibaba/open-code-review/acp/internal/intent/llm" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" +) + +func main() { + binary := flag.String("ocr-binary", "", "OCR executable path") + provider := flag.String("parser-provider", "", "Parser LLM provider") + model := flag.String("parser-model", "", "Parser LLM model") + baseURL := flag.String("parser-base-url", "", "Parser LLM base URL") + turnTimeout := flag.Duration("turn-timeout", 0, "Maximum duration of a turn including parsing (zero disables)") + flag.Parse() + if *turnTimeout < 0 { + fmt.Fprintln(os.Stderr, "turn-timeout must not be negative") + os.Exit(1) + } + resolved, err := orchestrator.ResolveBinary(orchestrator.ResolveOptions{Explicit: *binary}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + parser, err := configuredParser(llm.Options{Provider: *provider, Model: *model, BaseURL: *baseURL}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + agent := adapter.NewAgent(resolved.Path, orchestrator.NewRunner(resolved.Path), parser) + agent.TurnTimeout = *turnTimeout + output, err := inheritedPipe(os.Stdout, true) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer output.Close() + input, err := inheritedPipe(os.Stdin, false) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer input.Close() + conn := adapter.NewConnection(agent, output, input) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + select { + case <-conn.Done(): + case <-ctx.Done(): + stop() + } + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := agent.Shutdown(cleanup); err != nil { + fmt.Fprintln(os.Stderr, "ACP shutdown timed out") + os.Exit(1) + } +} + +func configuredParser(opts llm.Options) (*intent.Parser, error) { + getenv := opts.Getenv + if getenv == nil { + getenv = os.Getenv + } + configured := opts.Provider != "" || opts.Model != "" || opts.BaseURL != "" || opts.APIKey != "" + for _, key := range []string{llm.EnvProvider, llm.EnvModel, llm.EnvBaseURL, llm.EnvAPIKey} { + configured = configured || getenv(key) != "" + } + if !configured { + return nil, nil + } + ep, err := llm.Resolve(opts) + if err != nil { + return nil, fmt.Errorf("invalid parser configuration: check parser-provider (openai or anthropic), parser-model, parser-base-url and OCR_ACP_PARSER_API_KEY: %w", err) + } + client, err := llm.NewClient(ep) + if err != nil { + return nil, fmt.Errorf("unable to initialize configured parser client: %w", err) + } + return intent.NewParser(client, nil), nil +} diff --git a/acp/cmd/ocr-acp/main_test.go b/acp/cmd/ocr-acp/main_test.go new file mode 100644 index 000000000..8abd3a5b6 --- /dev/null +++ b/acp/cmd/ocr-acp/main_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent/llm" + "github.com/alibaba/open-code-review/acp/internal/testutil" + acp "github.com/coder/acp-go-sdk" +) + +func TestParserConfigurationIsExplicit(t *testing.T) { + for _, tc := range []struct { + name string + opts llm.Options + wantError, wantParser bool + }{ + {name: "absent"}, + {name: "partial", opts: llm.Options{Provider: "openai"}, wantError: true}, + {name: "invalid", opts: llm.Options{Provider: "bad", Model: "m", APIKey: "secret"}, wantError: true}, + {name: "valid", opts: llm.Options{Provider: "openai", Model: "m", APIKey: "secret"}, wantParser: true}, + } { + t.Run(tc.name, func(t *testing.T) { + tc.opts.Getenv = func(string) string { return "" } + p, err := configuredParser(tc.opts) + if (err != nil) != tc.wantError || (p != nil) != tc.wantParser { + t.Fatalf("parser=%v error=%v", p != nil, err) + } + if err != nil && strings.Contains(err.Error(), "secret") { + t.Fatal("credential leaked") + } + }) + } +} + +func TestACPProcessHelper(t *testing.T) { + if os.Getenv("OCR_ACP_TEST_HELPER") != "1" { + return + } + flag.CommandLine = flag.NewFlagSet("ocr-acp", flag.ExitOnError) + os.Args = []string{"ocr-acp", "--ocr-binary", os.Getenv("OCR_ACP_TEST_BINARY")} + main() + os.Exit(0) +} + +func TestDisconnectReapsOCRBeforeAdapterExit(t *testing.T) { + for _, mode := range []string{"EOF", "signal"} { + t.Run(mode, func(t *testing.T) { testAdapterCleanup(t, mode) }) + } +} + +func testAdapterCleanup(t *testing.T, mode string) { + dir := t.TempDir() + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + cleaned := filepath.Join(dir, "cleaned") + binary := testutil.Install(t, dir, "ocr", &testutil.Config{Block: true, CleanupFile: cleaned}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestACPProcessHelper$") + prepareAdapterCmd(cmd) + cmd.Env = append(os.Environ(), "OCR_ACP_TEST_HELPER=1", "OCR_ACP_TEST_BINARY="+binary) + var logs bytes.Buffer + cmd.Stderr = &logs + input, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + ready := make(chan struct{}, 1) + client := acp.NewConnection(func(_ context.Context, method string, raw json.RawMessage) (any, *acp.RequestError) { + if method == acp.ClientMethodSessionUpdate && strings.Contains(string(raw), "READY") { + select { + case ready <- struct{}{}: + default: + } + } + return nil, nil + }, input, output) + init, err := acp.SendRequest[acp.InitializeResponse](client, ctx, acp.AgentMethodInitialize, acp.InitializeRequest{ProtocolVersion: 1}) + if err != nil || init.ProtocolVersion != 1 { + t.Fatalf("initialize: %v %+v", err, init) + } + session, err := acp.SendRequest[acp.NewSessionResponse](client, ctx, acp.AgentMethodSessionNew, acp.NewSessionRequest{Cwd: dir, McpServers: []acp.McpServer{}}) + if err != nil { + t.Fatal(err) + } + go func() { + _, _ = acp.SendRequest[acp.PromptResponse](client, ctx, acp.AgentMethodSessionPrompt, acp.PromptRequest{SessionId: session.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + }() + select { + case <-ready: + case <-ctx.Done(): + t.Fatal("OCR readiness timeout") + } + if mode == "signal" { + err = interruptAdapter(cmd) + } else { + err = input.Close() + } + if err != nil { + t.Fatal(err) + } + if err = cmd.Wait(); err != nil { + t.Fatalf("adapter exit: %v; %s", err, logs.String()) + } + if _, err = os.Stat(cleaned); err != nil { + t.Fatalf("adapter exited before OCR cleanup: %v; %s", err, logs.String()) + } +} diff --git a/acp/cmd/ocr-acp/stdio.go b/acp/cmd/ocr-acp/stdio.go new file mode 100644 index 000000000..562025974 --- /dev/null +++ b/acp/cmd/ocr-acp/stdio.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "errors" + "io" + "os" + "sync" + "time" +) + +// stdioHandle is a deadline-capable stdio endpoint. cleanup runs once on Close +// so a Windows copy goroutine blocked on an inherited pipe can be cancelled. +type stdioHandle struct { + file *os.File + cleanup func() error + once sync.Once +} + +func (h *stdioHandle) Read(p []byte) (int, error) { return h.file.Read(p) } + +func (h *stdioHandle) Write(p []byte) (int, error) { return h.file.Write(p) } + +func (h *stdioHandle) SetReadDeadline(t time.Time) error { return h.file.SetReadDeadline(t) } + +func (h *stdioHandle) SetWriteDeadline(t time.Time) error { return h.file.SetWriteDeadline(t) } + +func (h *stdioHandle) Close() error { + var err error + h.once.Do(func() { + err = h.file.Close() + if h.cleanup != nil { + err = errors.Join(err, h.cleanup()) + } + }) + return err +} + +var _ io.ReadWriteCloser = (*stdioHandle)(nil) diff --git a/acp/cmd/ocr-acp/stdio_test.go b/acp/cmd/ocr-acp/stdio_test.go new file mode 100644 index 000000000..0f132846c --- /dev/null +++ b/acp/cmd/ocr-acp/stdio_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestRealBinaryUnreadStdoutExits(t *testing.T) { + dir := t.TempDir() + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + adapter := filepath.Join(dir, testutil.ExeName("ocr-acp")) + args := []string{"build", "-o", adapter, "."} + if runtime.GOOS != "windows" { + args = []string{"build", "-race", "-o", adapter, "."} + } + build := exec.Command("go", args...) + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, out) + } + ocr := testutil.Install(t, dir, "ocr", &testutil.Config{HugeMessage: 1 << 20}) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, adapter, "--ocr-binary", ocr) + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "OCR_ACP_PARSER_") { + cmd.Env = append(cmd.Env, e) + } + } + var logs bytes.Buffer + cmd.Stderr = &logs + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = in.Close(); _ = out.Close() }) + enc, dec := json.NewEncoder(in), json.NewDecoder(out) + request := func(id int, method string, params any) map[string]any { + t.Helper() + if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil { + t.Fatal(err) + } + var response map[string]any + for { + if err := dec.Decode(&response); err != nil { + t.Fatal(err) + } + if response["id"] != nil { + break + } + response = nil + } + if response["error"] != nil { + t.Fatalf("request failed: %+v", response) + } + return response + } + request(1, "initialize", map[string]any{"protocolVersion": 1}) + s := request(2, "session/new", map[string]any{"cwd": dir, "mcpServers": []any{}}) + id := s["result"].(map[string]any)["sessionId"] + start := time.Now() + if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": 3, "method": "session/prompt", "params": map[string]any{"sessionId": id, "prompt": []any{map[string]any{"type": "text", "text": "/review"}}}}); err != nil { + t.Fatal(err) + } + // Keep stdin and stdout open, and never read stdout again. Neither EOF nor + // restoring a reader may be needed to make the process exit. + if err := cmd.Wait(); err != nil { + t.Fatalf("exit: %v; %s", err, logs.String()) + } + if elapsed := time.Since(start); elapsed > 6*time.Second { + t.Fatalf("blocked for %s", elapsed) + } +} diff --git a/acp/cmd/ocr-acp/stdio_unix.go b/acp/cmd/ocr-acp/stdio_unix.go new file mode 100644 index 000000000..428c08822 --- /dev/null +++ b/acp/cmd/ocr-acp/stdio_unix.go @@ -0,0 +1,39 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "fmt" + "io" + "os" + "syscall" + "time" +) + +// inheritedPipe registers a nonblocking duplicate with Go's poller. Closing an +// inherited blocking os.Stdout cannot reliably interrupt a system write. +func inheritedPipe(source *os.File, output bool) (io.ReadWriteCloser, error) { + fd, err := syscall.Dup(int(source.Fd())) + if err != nil { + return nil, err + } + syscall.CloseOnExec(fd) + if err = syscall.SetNonblock(fd, true); err != nil { + _ = syscall.Close(fd) + return nil, err + } + f := os.NewFile(uintptr(fd), "acp-stdio") + if output { + err = f.SetWriteDeadline(time.Time{}) + } else { + err = f.SetReadDeadline(time.Time{}) + } + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("ACP stdio requires a pollable pipe: %w", err) + } + return f, nil +} diff --git a/acp/cmd/ocr-acp/stdio_windows.go b/acp/cmd/ocr-acp/stdio_windows.go new file mode 100644 index 000000000..cd51f456f --- /dev/null +++ b/acp/cmd/ocr-acp/stdio_windows.go @@ -0,0 +1,281 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "errors" + "fmt" + "io" + "os" + "runtime" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procCancelSynchronousIo = modkernel32.NewProc("CancelSynchronousIo") + procGetCurrentThread = modkernel32.NewProc("GetCurrentThread") + procGenerateConsoleCtrlEvent = modkernel32.NewProc("GenerateConsoleCtrlEvent") + procCreateNamedPipeW = modkernel32.NewProc("CreateNamedPipeW") + pipeSeq atomic.Uint64 +) + +const ( + pipeAccessInbound = 0x00000001 + fileFlagOverlapped = 0x40000000 + fileFlagFirstPipeInstance = 0x00080000 + pipeTypeByte = 0x00000000 + pipeRejectRemoteClients = 0x00000008 + pipeWait = 0x00000000 +) + +func inheritedPipe(source *os.File, output bool) (io.ReadWriteCloser, error) { + raw, err := duplicateFile(source) + if err != nil { + return nil, err + } + var probe error + if output { + probe = raw.SetWriteDeadline(time.Time{}) + } else { + probe = raw.SetReadDeadline(time.Time{}) + } + if probe == nil { + return &stdioHandle{file: raw}, nil + } + bridged, err := bridgeNonpollable(raw, output) + if err != nil { + _ = raw.Close() + return nil, err + } + return bridged, nil +} + +func duplicateFile(source *os.File) (*os.File, error) { + current, err := syscall.GetCurrentProcess() + if err != nil { + return nil, err + } + var dup syscall.Handle + err = syscall.DuplicateHandle(current, syscall.Handle(source.Fd()), current, &dup, 0, false, syscall.DUPLICATE_SAME_ACCESS) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(dup), "acp-stdio"), nil +} + +func bridgeNonpollable(raw *os.File, output bool) (*stdioHandle, error) { + r, w, err := overlappedPipe() + if err != nil { + return nil, err + } + var copier *cancellableCopy + if output { + copier, err = startCancellableCopy(raw, r, duplicateCurrentThread) + } else { + copier, err = startCancellableCopy(w, raw, duplicateCurrentThread) + } + if err != nil { + _ = r.Close() + _ = w.Close() + return nil, fmt.Errorf("initialize stdio copier: %w", err) + } + if output { + return &stdioHandle{file: w, cleanup: copier.drain}, nil + } + return &stdioHandle{file: r, cleanup: copier.abort}, nil +} + +// overlappedPipe is the Windows counterpart to a pollable os.Pipe. Anonymous +// pipes created by syscall.Pipe do not support deadlines; named pipes opened +// with FILE_FLAG_OVERLAPPED do, so Close and SetDeadline can cancel I/O. +func overlappedPipe() (r, w *os.File, err error) { + name := fmt.Sprintf(`\\.\pipe\ocr-acp-%d-%d`, os.Getpid(), pipeSeq.Add(1)) + uname, err := syscall.UTF16PtrFromString(name) + if err != nil { + return nil, nil, err + } + server, _, e := procCreateNamedPipeW.Call( + uintptr(unsafe.Pointer(uname)), + uintptr(pipeAccessInbound|fileFlagOverlapped|fileFlagFirstPipeInstance), + uintptr(pipeTypeByte|pipeWait|pipeRejectRemoteClients), + 1, + 65536, + 65536, + 0, + 0, + ) + if server == 0 || server == uintptr(syscall.InvalidHandle) { + return nil, nil, fmt.Errorf("CreateNamedPipe: %v", e) + } + client, err := syscall.CreateFile( + uname, + syscall.GENERIC_WRITE, + 0, + nil, + syscall.OPEN_EXISTING, + syscall.FILE_FLAG_OVERLAPPED, + 0, + ) + if err != nil { + _ = syscall.CloseHandle(syscall.Handle(server)) + return nil, nil, fmt.Errorf("CreateFile named pipe: %w", err) + } + reader := os.NewFile(server, "ocr-acp-pipe-r") + writer := os.NewFile(uintptr(client), "ocr-acp-pipe-w") + if err := reader.SetReadDeadline(time.Time{}); err != nil { + _ = reader.Close() + _ = writer.Close() + return nil, nil, fmt.Errorf("ACP stdio requires a pollable pipe: %w", err) + } + if err := writer.SetWriteDeadline(time.Time{}); err != nil { + _ = reader.Close() + _ = writer.Close() + return nil, nil, fmt.Errorf("ACP stdio requires a pollable pipe: %w", err) + } + return reader, writer, nil +} + +const stdioCopyTimeout = 2 * time.Second + +type cancellableCopy struct { + dst, src *os.File + thread syscall.Handle + done chan struct{} + mu sync.Mutex + stopping bool + closeOnce sync.Once + copyErr error // Published when done closes. +} + +func startCancellableCopy(dst, src *os.File, acquireThread func() (syscall.Handle, error)) (*cancellableCopy, error) { + c := &cancellableCopy{dst: dst, src: src, done: make(chan struct{})} + ready := make(chan error, 1) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer close(c.done) + thread, err := acquireThread() + if err != nil { + ready <- err + return + } + c.mu.Lock() + c.thread = thread + c.mu.Unlock() + // Synchronize handle retirement with cancellation before this OS thread + // can return to the runtime pool. A done-channel check alone would race. + defer func() { + c.mu.Lock() + _ = syscall.CloseHandle(c.thread) + c.thread = 0 + c.mu.Unlock() + }() + ready <- nil + buf := make([]byte, 32*1024) + for !c.stopped() { + n, rerr := src.Read(buf) + if n > 0 && !c.stopped() { + if _, werr := dst.Write(buf[:n]); werr != nil { + c.copyErr = werr + break + } + } + if rerr != nil { + if !errors.Is(rerr, io.EOF) { + c.copyErr = rerr + } + break + } + } + _ = dst.Close() + _ = src.Close() + }() + if err := <-ready; err != nil { + <-c.done + return nil, err + } + return c, nil +} + +func (c *cancellableCopy) stopped() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.stopping +} + +// The output endpoint has already been closed, so EOF follows any queued +// bytes. Input cleanup cannot drain: its external writer may remain open. +func (c *cancellableCopy) drain() error { + timer := time.NewTimer(stdioCopyTimeout) + defer timer.Stop() + select { + case <-c.done: + return c.copyErr + case <-timer.C: + return errors.Join(fmt.Errorf("stdio output did not drain within %s: %w", stdioCopyTimeout, os.ErrDeadlineExceeded), c.abort()) + } +} + +func (c *cancellableCopy) abort() error { + c.mu.Lock() + c.stopping = true + c.mu.Unlock() + // Closing synchronous files may itself wait for an outstanding OS call. + // Keep Close outside the caller and continue cancellation until it returns. + c.closeOnce.Do(func() { + go func() { + _ = c.src.Close() + _ = c.dst.Close() + }() + }) + timer := time.NewTimer(stdioCopyTimeout) + defer timer.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + c.mu.Lock() + if c.thread != 0 { + _ = cancelSynchronousIO(c.thread) + } + c.mu.Unlock() + select { + case <-c.done: + return nil + case <-timer.C: + return fmt.Errorf("stdio copy did not stop within %s", stdioCopyTimeout) + case <-ticker.C: + // A one-shot cancellation can precede the syscall after stopped() was + // checked. Retry while the copier still owns this locked OS thread. + } + } +} + +func duplicateCurrentThread() (syscall.Handle, error) { + current, err := syscall.GetCurrentProcess() + if err != nil { + return 0, err + } + pseudo, _, _ := procGetCurrentThread.Call() + var dup syscall.Handle + err = syscall.DuplicateHandle(current, syscall.Handle(pseudo), current, &dup, 0, false, syscall.DUPLICATE_SAME_ACCESS) + return dup, err +} + +func cancelSynchronousIO(thread syscall.Handle) error { + r1, _, e := procCancelSynchronousIo.Call(uintptr(thread)) + if r1 == 0 { + if errno, ok := e.(syscall.Errno); ok && (errno == 0 || uint32(errno) == 1168) { + return nil + } + return e + } + return nil +} diff --git a/acp/cmd/ocr-acp/stdio_windows_test.go b/acp/cmd/ocr-acp/stdio_windows_test.go new file mode 100644 index 000000000..d07b556d3 --- /dev/null +++ b/acp/cmd/ocr-acp/stdio_windows_test.go @@ -0,0 +1,257 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "bytes" + "errors" + "io" + "os" + "syscall" + "testing" + "time" +) + +func TestInheritedPipeAcceptsGoPipes(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + out, err := inheritedPipe(w, true) + if err != nil { + t.Fatal(err) + } + defer out.Close() + in, err := inheritedPipe(r, false) + if err != nil { + t.Fatal(err) + } + defer in.Close() + writeDL, ok := out.(interface{ SetWriteDeadline(time.Time) error }) + if !ok { + t.Fatal("output is not deadline-capable") + } + readDL, ok := in.(interface{ SetReadDeadline(time.Time) error }) + if !ok { + t.Fatal("input is not deadline-capable") + } + if err := writeDL.SetWriteDeadline(time.Now().Add(50 * time.Millisecond)); err != nil { + t.Fatalf("write deadline: %v", err) + } + if err := readDL.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil { + t.Fatalf("read deadline: %v", err) + } + buf := make([]byte, 1) + if _, err := in.Read(buf); err == nil { + t.Fatal("expected read deadline") + } +} + +func TestInheritedPipeCloseCancelsBlockedWrite(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + out, err := inheritedPipe(w, true) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + buf := bytes.Repeat([]byte("x"), 1<<20) + for { + if _, err := out.Write(buf); err != nil { + done <- err + return + } + } + }() + time.Sleep(50 * time.Millisecond) + start := time.Now() + if err := out.Close(); err != nil && !errors.Is(err, os.ErrClosed) && !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("close: %v", err) + } + select { + case <-done: + if time.Since(start) > 3*time.Second { + t.Fatal("write close did not unblock promptly") + } + case <-time.After(3 * time.Second): + t.Fatal("blocked write was not cancelled") + } +} + +// A successful bridge Write only reaches its intermediate pipe. Close must +// let the copier deliver those bytes before cancelling it. +func TestOutputBridgeCloseDrainsBufferedBytes(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + out, err := bridgeNonpollable(w, true) + if err != nil { + t.Fatal(err) + } + payload := bytes.Repeat([]byte("final response\n"), 2048) + if _, err := out.Write(payload); err != nil { + t.Fatal(err) + } + closed := make(chan error, 1) + go func() { closed <- out.Close() }() + time.Sleep(50 * time.Millisecond) + received := make(chan []byte, 1) + go func() { data, _ := io.ReadAll(r); received <- data }() + select { + case data := <-received: + if !bytes.Equal(data, payload) { + t.Fatalf("drain delivered %d of %d bytes", len(data), len(payload)) + } + case <-time.After(3 * time.Second): + t.Fatal("output did not drain") + } + if err := <-closed; err != nil { + t.Fatal(err) + } +} + +func TestCopyRejectsThreadInitializationFailure(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + failure := errors.New("thread duplication failed") + copier, err := startCancellableCopy(w, r, func() (syscall.Handle, error) { return 0, failure }) + if copier != nil || !errors.Is(err, failure) { + t.Fatalf("copier=%v error=%v", copier, err) + } +} + +func TestCompletedCopyReleasesThreadBeforeAbort(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + dst, err := os.CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + copier, err := startCancellableCopy(dst, r, duplicateCurrentThread) + if err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + select { + case <-copier.done: + case <-time.After(3 * time.Second): + t.Fatal("copy did not finish") + } + copier.mu.Lock() + thread := copier.thread + copier.mu.Unlock() + if thread != 0 { + t.Fatalf("completed copier retained thread handle %v", thread) + } + if err := copier.abort(); err != nil { + t.Fatal(err) + } +} + +func TestCopyAbortCancelsIdleSynchronousRead(t *testing.T) { + for range 20 { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + dst, err := os.CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + copier, err := startCancellableCopy(dst, r, duplicateCurrentThread) + if err != nil { + t.Fatal(err) + } + // Abort immediately to exercise cancellation racing the first Read. + if err := copier.abort(); err != nil { + t.Fatal(err) + } + _ = w.Close() + } +} + +func TestOutputBridgeCloseReportsReceiverFailure(t *testing.T) { + r, w := smallSynchronousPipe(t) + defer r.Close() + out, err := bridgeNonpollable(w, true) + if err != nil { + t.Fatal(err) + } + defer out.Close() + if err := out.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if _, err := out.Write(bytes.Repeat([]byte("x"), 32*1024)); err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + closed := make(chan error, 1) + go func() { closed <- out.Close() }() + select { + case err := <-closed: + if err == nil { + t.Fatal("Close hid receiver failure") + } + case <-time.After(5 * time.Second): + t.Fatal("Close did not return after receiver failure") + } +} + +func TestOutputBridgeCloseReportsDrainTimeout(t *testing.T) { + r, w := smallSynchronousPipe(t) + defer r.Close() + out, err := bridgeNonpollable(w, true) + if err != nil { + t.Fatal(err) + } + defer out.Close() + if err := out.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if _, err := out.Write(bytes.Repeat([]byte("x"), 32*1024)); err != nil { + t.Fatal(err) + } + closed := make(chan error, 1) + go func() { closed <- out.Close() }() + select { + case err := <-closed: + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("Close = %v, want drain timeout", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Close did not cancel blocked output") + } +} + +// Keep the receiver buffer below the test payload so an unread write blocks. +func smallSynchronousPipe(t *testing.T) (*os.File, *os.File) { + t.Helper() + var r, w syscall.Handle + if err := syscall.CreatePipe(&r, &w, nil, 4096); err != nil { + t.Fatal(err) + } + reader, writer := os.NewFile(uintptr(r), "test-pipe-r"), os.NewFile(uintptr(w), "test-pipe-w") + t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() }) + return reader, writer +} diff --git a/acp/cmd/ocr-acp/stub_main_test.go b/acp/cmd/ocr-acp/stub_main_test.go new file mode 100644 index 000000000..ae69553a6 --- /dev/null +++ b/acp/cmd/ocr-acp/stub_main_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "fmt" + "os" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestMain(m *testing.M) { + code := m.Run() + if err := testutil.Cleanup(); err != nil { + fmt.Fprintln(os.Stderr, "clean up OCR stub:", err) + code = 1 + } + os.Exit(code) +} diff --git a/acp/go.mod b/acp/go.mod new file mode 100644 index 000000000..f62ce7b02 --- /dev/null +++ b/acp/go.mod @@ -0,0 +1,5 @@ +module github.com/alibaba/open-code-review/acp + +go 1.23 + +require github.com/coder/acp-go-sdk v0.13.5 diff --git a/acp/go.sum b/acp/go.sum new file mode 100644 index 000000000..04e8dab77 --- /dev/null +++ b/acp/go.sum @@ -0,0 +1,2 @@ +github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= +github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= diff --git a/acp/internal/adapter/agent.go b/acp/internal/adapter/agent.go new file mode 100644 index 000000000..16a4dd451 --- /dev/null +++ b/acp/internal/adapter/agent.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "sync" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +type Agent struct { + // TurnTimeout bounds parsing and OCR execution together. Zero disables it. + TurnTimeout time.Duration + parser *intent.Parser + binary string + runner orchestrator.Runner + conn interface { + SessionUpdate(context.Context, acp.SessionNotification) error + } + mu sync.Mutex + sessions map[acp.SessionId]*session + nextID uint64 + closed bool +} + +func NewAgent(binary string, runner orchestrator.Runner, parsers ...*intent.Parser) *Agent { + parser := intent.NewParser(nil, nil) + if len(parsers) > 0 && parsers[0] != nil { + parser = parsers[0] + } + return &Agent{binary: binary, parser: parser, runner: runner, sessions: map[acp.SessionId]*session{}} +} + +func (a *Agent) SetAgentConnection(c interface { + SessionUpdate(context.Context, acp.SessionNotification) error +}) { + a.mu.Lock() + defer a.mu.Unlock() + a.conn = c +} + +func (a *Agent) Initialize(context.Context, acp.InitializeRequest) (acp.InitializeResponse, error) { + return acp.InitializeResponse{ProtocolVersion: acp.ProtocolVersionNumber, AgentInfo: &acp.Implementation{Name: "ocr-acp", Version: "dev"}, AgentCapabilities: acp.AgentCapabilities{PromptCapabilities: acp.PromptCapabilities{}, SessionCapabilities: acp.SessionCapabilities{Close: &acp.SessionCloseCapabilities{}}}}, nil +} + +func (a *Agent) Authenticate(context.Context, acp.AuthenticateRequest) (acp.AuthenticateResponse, error) { + return acp.AuthenticateResponse{}, nil +} + +func (a *Agent) Logout(context.Context, acp.LogoutRequest) (acp.LogoutResponse, error) { + return acp.LogoutResponse{}, nil +} + +func (a *Agent) update(ctx context.Context, id acp.SessionId, msg string) error { + return a.notify(ctx, id, acp.UpdateAgentMessageText(msg)) +} + +func (a *Agent) notify(ctx context.Context, id acp.SessionId, update acp.SessionUpdate) error { + a.mu.Lock() + conn := a.conn + a.mu.Unlock() + if conn == nil { + return nil + } + return conn.SessionUpdate(ctx, acp.SessionNotification{SessionId: id, Update: update}) +} + +func (a *Agent) SetSessionMode(context.Context, acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) { + return acp.SetSessionModeResponse{}, nil +} + +func (a *Agent) SetSessionConfigOption(context.Context, acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) { + return acp.SetSessionConfigOptionResponse{}, nil +} + +func (a *Agent) ResumeSession(context.Context, acp.ResumeSessionRequest) (acp.ResumeSessionResponse, error) { + return acp.ResumeSessionResponse{}, acp.NewMethodNotFound(acp.AgentMethodSessionResume) +} + +func (a *Agent) ListSessions(context.Context, acp.ListSessionsRequest) (acp.ListSessionsResponse, error) { + return acp.ListSessionsResponse{}, acp.NewMethodNotFound(acp.AgentMethodSessionList) +} + +var _ acp.Agent = (*Agent)(nil) diff --git a/acp/internal/adapter/connection.go b/acp/internal/adapter/connection.go new file mode 100644 index 000000000..593898f96 --- /dev/null +++ b/acp/internal/adapter/connection.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "io" + + "github.com/alibaba/open-code-review/acp/internal/adapter/transport" + acp "github.com/coder/acp-go-sdk" +) + +// Connection preserves the adapter entry point while transport owns the wire protocol. +type Connection = transport.Connection + +// NewConnection binds notifications before any request can acquire the agent lock. +func NewConnection(agent *Agent, output io.Writer, input io.Reader) *Connection { + agent.mu.Lock() + defer agent.mu.Unlock() + conn := transport.NewConnection(agent, output, input, agent.claimCommandDiscovery) + agent.conn = conn + return conn +} + +// claimCommandDiscovery keeps session lifecycle and discovery state under one lock. +func (a *Agent) claimCommandDiscovery(id acp.SessionId) bool { + a.mu.Lock() + defer a.mu.Unlock() + s := a.sessions[id] + if s == nil || s.closing || a.closed || s.commandsSent { + return false + } + s.commandsSent = true + return true +} diff --git a/acp/internal/adapter/connection_test.go b/acp/internal/adapter/connection_test.go new file mode 100644 index 000000000..f420ad6b6 --- /dev/null +++ b/acp/internal/adapter/connection_test.go @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "encoding/json" + "io" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +type greetingParser struct{} + +func (greetingParser) CallTool(context.Context, intent.LLMRequest) (intent.ToolCall, error) { + return intent.ToolCall{Name: "submit_intent", Arguments: []byte(`{"action":"reject","reason":"Hello! I can help review code.","hint":"Send /review or /scan."}`)}, nil +} + +func TestWireRejectionPreservesVisibleGuidance(t *testing.T) { + for _, tc := range []struct { + name string + text string + parser intent.LLMClient + }{ + {name: "greeting", text: "hello", parser: greetingParser{}}, + {name: "unconfigured parser", text: "review my changes"}, + {name: "invalid command", text: "/unknown"}, + } { + t.Run(tc.name, func(t *testing.T) { + runner := &captureRunner{} + a := NewAgent("ocr", runner, intent.NewParser(tc.parser, nil)) + serverRead, clientWrite := io.Pipe() + clientRead, serverWrite := io.Pipe() + defer serverRead.Close() + defer serverWrite.Close() + defer clientRead.Close() + defer clientWrite.Close() + s, err := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + server := NewConnection(a, serverWrite, serverRead) + defer func() { clientWrite.Close(); <-server.Done() }() + if err := json.NewEncoder(clientWrite).Encode(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "session/prompt", + "params": acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock(tc.text)}}, + }); err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(clientRead) + var update struct { + Method string `json:"method"` + Params acp.SessionNotification `json:"params"` + } + if err := decoder.Decode(&update); err != nil { + t.Fatal(err) + } + if update.Method != "session/update" || update.Params.SessionId != s.SessionId || update.Params.Update.AgentMessageChunk == nil || update.Params.Update.AgentMessageChunk.Content.Text.Text == "" { + t.Fatalf("missing visible guidance before completion: %+v", update) + } + var response struct { + Result acp.PromptResponse `json:"result"` + } + if err := decoder.Decode(&response); err != nil { + t.Fatal(err) + } + // Zed removes this turn's messages on refusal. Application guidance + // must complete normally while preserving the non-execution cause. + if response.Result.StopReason != acp.StopReasonEndTurn { + t.Fatalf("guidance would be discarded: %+v", response.Result) + } + ocr, _ := response.Result.Meta["ocr"].(map[string]any) + if ocr["kind"] != "rejected" || len(runner.requests) != 0 { + t.Fatalf("rejection metadata or no-execution guarantee lost: %+v", response.Result) + } + }) + } +} + +type wireRunner struct { + started chan struct{} + cancelled chan struct{} + release chan struct{} +} + +func (r *wireRunner) Run(ctx context.Context, _ orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + events := make(chan orchestrator.Event) + outcomes := make(chan orchestrator.Outcome, 1) + close(r.started) + go func() { + kind := orchestrator.OutcomeCompleted + select { + case <-ctx.Done(): + close(r.cancelled) + kind = orchestrator.OutcomeCancelled + <-r.release + case <-r.release: + } + close(events) + outcomes <- orchestrator.Outcome{Kind: kind} + close(outcomes) + }() + return events, outcomes +} + +func wirePair(t *testing.T, a *Agent) (*Connection, *acp.Connection, func()) { + t.Helper() + serverRead, clientWrite := io.Pipe() + clientRead, serverWrite := io.Pipe() + server := NewConnection(a, serverWrite, serverRead) + client := acp.NewConnection(func(context.Context, string, json.RawMessage) (any, *acp.RequestError) { return nil, nil }, clientWrite, clientRead) + disconnect := func() { _ = clientWrite.Close() } + t.Cleanup(func() { + _ = clientWrite.Close() + _ = serverWrite.Close() + _ = serverRead.Close() + _ = clientRead.Close() + }) + return server, client, disconnect +} + +func TestWireBusyDoesNotCancelRunningPrompt(t *testing.T) { + r := &wireRunner{make(chan struct{}), make(chan struct{}), make(chan struct{})} + a := NewAgent("ocr", r) + _, client, _ := wirePair(t, a) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s, err := acp.SendRequest[acp.NewSessionResponse](client, ctx, acp.AgentMethodSessionNew, acp.NewSessionRequest{Cwd: testGitDir(t), McpServers: []acp.McpServer{}}) + if err != nil { + t.Fatal(err) + } + p := acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}} + done := make(chan error, 1) + go func() { + resp, err := acp.SendRequest[acp.PromptResponse](client, ctx, acp.AgentMethodSessionPrompt, p) + if err == nil && resp.StopReason != acp.StopReasonEndTurn { + err = acp.NewInternalError(string(resp.StopReason)) + } + done <- err + }() + select { + case <-r.started: + case <-ctx.Done(): + t.Fatal("prompt did not start") + } + _, err = acp.SendRequest[acp.PromptResponse](client, ctx, acp.AgentMethodSessionPrompt, p) + if err == nil || !strings.Contains(err.Error(), "session_busy") { + t.Fatalf("busy error: %v", err) + } + select { + case <-r.cancelled: + t.Fatal("busy request cancelled existing prompt") + default: + } + close(r.release) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestWireDisconnectShutdownWaitsForTaskCleanup(t *testing.T) { + r := &wireRunner{make(chan struct{}), make(chan struct{}), make(chan struct{})} + a := NewAgent("ocr", r) + server, client, disconnect := wirePair(t, a) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s, err := acp.SendRequest[acp.NewSessionResponse](client, ctx, acp.AgentMethodSessionNew, acp.NewSessionRequest{Cwd: testGitDir(t), McpServers: []acp.McpServer{}}) + if err != nil { + t.Fatal(err) + } + go func() { + _, _ = acp.SendRequest[acp.PromptResponse](client, ctx, acp.AgentMethodSessionPrompt, acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + }() + select { + case <-r.started: + case <-ctx.Done(): + t.Fatal("prompt did not start") + } + disconnect() + select { + case <-server.Done(): + case <-ctx.Done(): + t.Fatal("connection did not close") + } + done := make(chan error, 1) + go func() { done <- a.Shutdown(ctx) }() + select { + case <-r.cancelled: + case <-ctx.Done(): + t.Fatal("task was not cancelled") + } + select { + case err := <-done: + t.Fatalf("shutdown returned before cleanup: %v", err) + default: + } + close(r.release) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestWireRejectsInvalidPromptSchema(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + _, client, _ := wirePair(t, a) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for _, params := range []any{map[string]any{"sessionId": "s-1", "prompt": "wrong"}, map[string]any{"sessionId": "s-1"}} { + _, err := acp.SendRequest[acp.PromptResponse](client, ctx, acp.AgentMethodSessionPrompt, params) + if err == nil || !strings.Contains(err.Error(), "-32602") { + t.Fatalf("schema error = %v", err) + } + } +} + +func TestWireInitializeAndUnsupportedMethods(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + _, client, _ := wirePair(t, a) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + init, err := acp.SendRequest[acp.InitializeResponse](client, ctx, acp.AgentMethodInitialize, acp.InitializeRequest{ProtocolVersion: acp.ProtocolVersionNumber}) + if err != nil || init.ProtocolVersion != acp.ProtocolVersionNumber { + t.Fatalf("initialize: %+v %v", init, err) + } + for _, test := range []struct { + method string + params any + }{ + {acp.AgentMethodSessionResume, map[string]any{"sessionId": "missing", "cwd": testGitDir(t), "mcpServers": []any{}}}, + {acp.AgentMethodSessionList, map[string]any{}}, + {"unsupported", map[string]any{}}, + } { + _, err := acp.SendRequest[json.RawMessage](client, ctx, test.method, test.params) + if err == nil || !strings.Contains(err.Error(), "-32601") { + t.Fatalf("%s: %v", test.method, err) + } + } +} + +func TestWireCancelNotificationEndsPrompt(t *testing.T) { + r := &wireRunner{make(chan struct{}), make(chan struct{}), make(chan struct{})} + a := NewAgent("ocr", r) + _, client, _ := wirePair(t, a) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s, err := acp.SendRequest[acp.NewSessionResponse](client, ctx, acp.AgentMethodSessionNew, acp.NewSessionRequest{Cwd: testGitDir(t), McpServers: []acp.McpServer{}}) + if err != nil { + t.Fatal(err) + } + done := make(chan acp.PromptResponse, 1) + go func() { + resp, _ := acp.SendRequest[acp.PromptResponse](client, ctx, acp.AgentMethodSessionPrompt, acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + done <- resp + }() + select { + case <-r.started: + case <-ctx.Done(): + t.Fatal("prompt did not start") + } + for range 2 { + if err := client.SendNotification(ctx, acp.AgentMethodSessionCancel, acp.CancelNotification{SessionId: s.SessionId}); err != nil { + t.Fatal(err) + } + } + select { + case <-r.cancelled: + case <-ctx.Done(): + t.Fatal("cancel not delivered") + } + close(r.release) + if resp := <-done; resp.StopReason != acp.StopReasonCancelled { + t.Fatalf("stop reason: %s", resp.StopReason) + } + if _, err := acp.SendRequest[acp.CloseSessionResponse](client, ctx, acp.AgentMethodSessionClose, acp.CloseSessionRequest{SessionId: s.SessionId}); err != nil { + t.Fatal(err) + } +} diff --git a/acp/internal/adapter/discovery_test.go b/acp/internal/adapter/discovery_test.go new file mode 100644 index 000000000..cd9b9abf3 --- /dev/null +++ b/acp/internal/adapter/discovery_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "encoding/json" + "io" + "testing" + "time" + + acp "github.com/coder/acp-go-sdk" +) + +func TestCommandDiscoveryClaimsLiveSessionOnce(t *testing.T) { + for _, name := range []string{"live", "closing", "shutdown"} { + t.Run(name, func(t *testing.T) { + a := NewAgent("unused", &captureRunner{}) + s, err := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if a.claimCommandDiscovery("missing") { + t.Fatal("unknown session claimed discovery") + } + a.sessions[s.SessionId].closing = name == "closing" + a.closed = name == "shutdown" + const attempts = 32 + claimed := make(chan bool, attempts) + for range attempts { + go func() { claimed <- a.claimCommandDiscovery(s.SessionId) }() + } + count := 0 + for range attempts { + if <-claimed { + count++ + } + } + want := 0 + if name == "live" { + want = 1 + } + if count != want { + t.Fatalf("discovery claims = %d, want %d", count, want) + } + }) + } +} + +func TestWireCommandsFollowSessionResponse(t *testing.T) { + input, clientWrite := io.Pipe() + clientRead, output := io.Pipe() + defer input.Close() + defer clientWrite.Close() + defer clientRead.Close() + defer output.Close() + runner := &captureRunner{} + a := NewAgent("unused", runner) + conn := NewConnection(a, output, input) + defer func() { clientWrite.Close(); <-conn.Done() }() + encoder, decoder := json.NewEncoder(clientWrite), json.NewDecoder(clientRead) + done := make(chan struct{}) + go func() { + defer close(done) + if err := encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": map[string]any{"protocolVersion": 1}}); err != nil { + t.Error(err) + return + } + var initialized map[string]any + if err := decoder.Decode(&initialized); err != nil { + t.Error(err) + return + } + seen := map[acp.SessionId]bool{} + for i := 1; i <= 3; i++ { + if err := encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": i, "method": "session/new", "params": acp.NewSessionRequest{Cwd: t.TempDir(), McpServers: []acp.McpServer{}}}); err != nil { + t.Error(err) + return + } + var response struct { + ID int `json:"id"` + Result acp.NewSessionResponse `json:"result"` + Method string `json:"method"` + } + if err := decoder.Decode(&response); err != nil { + t.Error(err) + return + } + if response.Method != "" || response.ID != i || response.Result.SessionId == "" { + t.Errorf("notification preceded registration: %+v", response) + return + } + id := response.Result.SessionId + if seen[id] { + t.Error("duplicate session") + return + } + seen[id] = true + var notification struct { + Method string `json:"method"` + Params acp.SessionNotification `json:"params"` + } + if err := decoder.Decode(¬ification); err != nil { + t.Error(err) + return + } + commands := notification.Params.Update.AvailableCommandsUpdate + if notification.Method != "session/update" || notification.Params.SessionId != id || commands == nil { + t.Errorf("incorrect notification: %+v", notification) + return + } + if len(commands.AvailableCommands) != 2 { + t.Error("unexpected command count") + return + } + for index, name := range []string{"review", "scan"} { + command := commands.AvailableCommands[index] + if command.Name != name || command.Description == "" || command.Input == nil || command.Input.Unstructured == nil || command.Input.Unstructured.Hint == "" { + t.Errorf("invalid command: %+v", command) + return + } + } + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("session discovery blocked") + } + if len(runner.requests) != 0 { + t.Fatal("discovery started OCR") + } + if err := a.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/acp/internal/adapter/lifecycle_test.go b/acp/internal/adapter/lifecycle_test.go new file mode 100644 index 000000000..ec4fb7b05 --- /dev/null +++ b/acp/internal/adapter/lifecycle_test.go @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +type waitingParser struct{ ready chan struct{} } + +func TestTurnDeadlineIncludesParsing(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}, intent.NewParser(waitingParser{make(chan struct{})}, nil)) + a.TurnTimeout = 20 * time.Millisecond + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + r, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("review please")}}) + if err != nil { + t.Fatal(err) + } + if r.StopReason != acp.StopReasonEndTurn || r.Meta["ocr"].(map[string]any)["kind"] != "timed_out" { + t.Fatalf("timeout response: %+v", r) + } +} + +func TestInterruptedCloseRemainsTrackedByShutdown(t *testing.T) { + r := cleanupRunner{make(chan struct{}), make(chan struct{}), make(chan struct{})} + a := NewAgent("ocr", r) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + p := acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}} + go func() { _, _ = a.Prompt(context.Background(), p) }() + <-r.ready + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := a.CloseSession(ctx, acp.CloseSessionRequest{SessionId: s.SessionId}); !errors.Is(err, context.Canceled) { + t.Fatalf("close: %v", err) + } + if _, err := a.Prompt(context.Background(), p); err == nil { + t.Fatal("closing session accepted work") + } + if err := a.Shutdown(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("shutdown lost running task: %v", err) + } + close(r.release) + cleanup, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + if err := a.Shutdown(cleanup); err != nil { + t.Fatal(err) + } +} + +func (p waitingParser) CallTool(ctx context.Context, _ intent.LLMRequest) (intent.ToolCall, error) { + close(p.ready) + <-ctx.Done() + return intent.ToolCall{}, ctx.Err() +} + +func TestShutdownDeadlineAndCloseWait(t *testing.T) { + r := cleanupRunner{make(chan struct{}), make(chan struct{}), make(chan struct{})} + a := NewAgent("ocr", r) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + go func() { + _, _ = a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + }() + <-r.ready + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := a.Shutdown(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("shutdown: %v", err) + } + <-r.cancelled + closed := make(chan error, 1) + go func() { + _, err := a.CloseSession(context.Background(), acp.CloseSessionRequest{SessionId: s.SessionId}) + closed <- err + }() + close(r.release) + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("close did not finish") + } +} + +func TestCancelDuringParsing(t *testing.T) { + ready := make(chan struct{}) + a := NewAgent("ocr", fakeRunner{}, intent.NewParser(waitingParser{ready}, nil)) + s, err := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + if err != nil { + t.Fatal(err) + } + result := make(chan acp.PromptResponse, 1) + go func() { + r, _ := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("review please")}}) + result <- r + }() + <-ready + if err := a.Cancel(context.Background(), acp.CancelNotification{SessionId: s.SessionId}); err != nil { + t.Fatal(err) + } + select { + case r := <-result: + if r.StopReason != acp.StopReasonCancelled { + t.Fatalf("stop: %s", r.StopReason) + } + case <-time.After(time.Second): + t.Fatal("parser did not cancel") + } +} + +func TestSessionAcceptsPromptAfterCancellation(t *testing.T) { + ready := make(chan struct{}) + a := NewAgent("ocr", fakeRunner{}, intent.NewParser(waitingParser{ready}, nil)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s, err := a.NewSession(ctx, acp.NewSessionRequest{Cwd: testGitDir(t)}) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + response, err := a.Prompt(ctx, acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("review please")}}) + if err == nil && response.StopReason != acp.StopReasonCancelled { + err = errors.New("first prompt did not return cancelled") + } + done <- err + }() + select { + case <-ready: + case <-ctx.Done(): + t.Fatal("parser did not start") + } + if err := a.Cancel(ctx, acp.CancelNotification{SessionId: s.SessionId}); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("cancelled prompt did not finish") + } + response, err := a.Prompt(ctx, acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + if err != nil || response.StopReason != acp.StopReasonEndTurn { + t.Fatalf("next prompt: response=%+v error=%v", response, err) + } +} + +func TestSessionIDsSurviveDeletion(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + one, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + two, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + original := a.sessions[two.SessionId] + if _, err := a.CloseSession(context.Background(), acp.CloseSessionRequest{SessionId: one.SessionId}); err != nil { + t.Fatal(err) + } + three, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + if three.SessionId == two.SessionId || a.sessions[two.SessionId] != original { + t.Fatal("existing session overwritten") + } +} + +func TestCancelClearsIdleClarification(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + _, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review --from main")}}) + if err != nil { + t.Fatal(err) + } + if a.sessions[s.SessionId].state.Pending() == nil { + t.Fatal("expected pending clarification") + } + _ = a.Cancel(context.Background(), acp.CancelNotification{SessionId: s.SessionId}) + if a.sessions[s.SessionId].state.Pending() != nil { + t.Fatal("cancel retained clarification") + } +} + +type cleanupRunner struct{ ready, cancelled, release chan struct{} } + +func (r cleanupRunner) Run(ctx context.Context, _ orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + events := make(chan orchestrator.Event) + outcomes := make(chan orchestrator.Outcome, 1) + go func() { + close(r.ready) + <-ctx.Done() + close(r.cancelled) + <-r.release + close(events) + outcomes <- orchestrator.Outcome{Kind: orchestrator.OutcomeCancelled} + close(outcomes) + }() + return events, outcomes +} + +func TestShutdownWaitsForCleanup(t *testing.T) { + r := cleanupRunner{make(chan struct{}), make(chan struct{}), make(chan struct{})} + a := NewAgent("ocr", r) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: testGitDir(t)}) + go func() { + _, _ = a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + }() + <-r.ready + finished := make(chan error, 1) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { finished <- a.Shutdown(ctx) }() + <-r.cancelled + select { + case <-finished: + t.Fatal("shutdown returned before cleanup") + default: + } + close(r.release) + if err := <-finished; err != nil { + t.Fatal(err) + } + if _, err := a.NewSession(ctx, acp.NewSessionRequest{Cwd: testGitDir(t)}); err == nil { + t.Fatal("shutdown accepted new session") + } +} diff --git a/acp/internal/adapter/locations_test.go b/acp/internal/adapter/locations_test.go new file mode 100644 index 000000000..f2ca64aac --- /dev/null +++ b/acp/internal/adapter/locations_test.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/adapter/presentation" + "github.com/alibaba/open-code-review/acp/internal/adapter/workspace" + "github.com/alibaba/open-code-review/acp/internal/contract" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +func testGitDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + return dir +} + +func unreadableLocalFile(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + path := filepath.Join(root, "unreadable.go") + if err := os.WriteFile(path, []byte("package sample\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0000); err != nil { + t.Skipf("cannot remove file read permissions: %v", err) + } + t.Cleanup(func() { + if err := os.Chmod(path, 0600); err != nil { + t.Errorf("restore file permissions: %v", err) + } + }) + f, err := os.Open(path) + if err == nil { + f.Close() + t.Skip("current process can read files without read permission") + } + if !os.IsPermission(err) { + t.Fatalf("expected permission denial, got %v", err) + } + return root, path +} + +func TestUnreadableFindingKeepsTextWithoutNavigation(t *testing.T) { + root, path := unreadableLocalFile(t) + for _, line := range []int{0, 1} { + comment := contract.Comment{Path: path, StartLine: line, EndLine: line, Content: "Unreadable file finding"} + if location := workspace.ResolveLocation(root, comment.Path, comment.StartLine, comment.EndLine); location != nil { + t.Fatalf("unreadable file has location: %+v", location) + } + result := &orchestrator.Result{Review: &contract.ReviewResult{Comments: []contract.Comment{comment}}} + text := presentation.FormatResult(result, root) + if !strings.Contains(text, comment.Content) || strings.Contains(text, "(", ">", "\r", " ", "\n", " ").Replace(text) +} + +func writeCodeBlock(b *strings.Builder, path, code string) { + language := map[string]string{ + ".go": "go", ".py": "python", ".js": "javascript", ".mjs": "javascript", ".jsx": "jsx", + ".ts": "typescript", ".tsx": "tsx", ".rs": "rust", ".java": "java", ".c": "c", ".h": "c", + ".cpp": "cpp", ".cs": "csharp", ".sh": "bash", ".json": "json", ".yaml": "yaml", ".yml": "yaml", + ".toml": "toml", ".html": "html", ".css": "css", ".sql": "sql", ".md": "markdown", + }[strings.ToLower(filepath.Ext(path))] + writeFencedBlock(b, language, code) +} + +func writeFencedBlock(b *strings.Builder, language, text string) { + // The delimiter must be longer than any run in the quoted source. + longest, run := 2, 0 + for _, char := range text { + if char == '`' { + run++ + if run > longest { + longest = run + } + } else { + run = 0 + } + } + fence := strings.Repeat("`", longest+1) + fmt.Fprintf(b, "%s%s\n%s", fence, language, text) + if !strings.HasSuffix(text, "\n") { + b.WriteByte('\n') + } + fmt.Fprintf(b, "%s\n", fence) +} diff --git a/acp/internal/adapter/presentation/markdown_test.go b/acp/internal/adapter/presentation/markdown_test.go new file mode 100644 index 000000000..409a11964 --- /dev/null +++ b/acp/internal/adapter/presentation/markdown_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package presentation + +import ( + "strings" + "testing" +) + +func TestCodeAndLogMarkdownPreserveFencesAndNewlines(t *testing.T) { + for _, tc := range []struct { + name, text, code, log string + }{ + {"empty", "", "```go\n\n```\n", "```text\n\n```\n"}, + {"no final newline", "value", "```go\nvalue\n```\n", "```text\nvalue\n```\n"}, + {"final newline", "value\n", "```go\nvalue\n```\n", "```text\nvalue\n```\n"}, + {"blank final line", "value\n\n", "```go\nvalue\n\n```\n", "```text\nvalue\n\n```\n"}, + {"multiple backtick runs", "`one`\n```two`````three``", "``````go\n`one`\n```two`````three``\n``````\n", "``````text\n`one`\n```two`````three``\n``````\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + var b strings.Builder + writeCodeBlock(&b, "source.GO", tc.text) + if got := b.String(); got != tc.code { + t.Errorf("code block = %q, want %q", got, tc.code) + } + log := progressLog{text: tc.text} + if got := log.markdown(); got != tc.log { + t.Errorf("log block = %q, want %q", got, tc.log) + } + }) + } +} diff --git a/acp/internal/adapter/presentation/progress.go b/acp/internal/adapter/presentation/progress.go new file mode 100644 index 000000000..35a1ba5bf --- /dev/null +++ b/acp/internal/adapter/presentation/progress.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package presentation + +import ( + "context" + "strings" + "time" + + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +const progressInterval = 500 * time.Millisecond + +// Progress presents one OCR execution through ACP tool updates. +// Notify and Runner are required; it does not own session state or transport locks. +type Progress struct { + Binary string + Runner orchestrator.Runner + Notify func(context.Context, acp.SessionId, acp.SessionUpdate) error +} + +// Collect uses one native tool call for the entire OCR process. Log +// snapshots replace prior content at a bounded rate, not once per stderr line. +func (p Progress) Collect(ctx context.Context, cancel context.CancelFunc, sessionID acp.SessionId, request orchestrator.Request) (orchestrator.Outcome, error) { + id := nextToolCallID("ocr-execution") + // Generic tools have a collapsible header in Zed. Execute titles instead + // render as command previews, without the same activity-label interaction. + operation := "OCR review" + if len(request.Args) > 0 && request.Args[0] == "scan" { + operation = "OCR scan" + } + started := time.Now() + activity := "" + title := progressTitle(operation, activity, 0) + var details strings.Builder + details.WriteString(executionCommand(p.Binary, request)) + details.WriteString("Working directory:\n\n") + writeCodeBlock(&details, "", request.CWD) + executionDetails := acp.ToolContent(acp.TextBlock(details.String())) + content := func(log *progressLog) []acp.ToolCallContent { + blocks := []acp.ToolCallContent{executionDetails} + if log.text != "" || log.truncated { + blocks = append(blocks, log.content()...) + } + return blocks + } + if err := p.Notify(ctx, sessionID, acp.StartToolCall(id, title, acp.WithStartKind(acp.ToolKindOther), acp.WithStartStatus(acp.ToolCallStatusInProgress), acp.WithStartContent([]acp.ToolCallContent{executionDetails}))); err != nil { + return orchestrator.Outcome{}, err + } + events, outcomes := p.Runner.Run(ctx, request) + ticker := time.NewTicker(progressInterval) + defer ticker.Stop() + var log progressLog + first := true + var sendErr error + flush := func() { + if sendErr != nil || ctx.Err() != nil { + return + } + nextTitle := progressTitle(operation, activity, time.Since(started)) + if !log.dirty && nextTitle == title { + return + } + options := []acp.ToolCallUpdateOpt{acp.WithUpdateTitle(nextTitle)} + if log.dirty { + options = append(options, acp.WithUpdateContent(content(&log))) + } + sendErr = p.Notify(ctx, sessionID, acp.UpdateToolCall(id, options...)) + title = nextTitle + log.dirty = false + if sendErr != nil { + cancel() + } + } + for events != nil { + select { + case event, open := <-events: + if !open { + events = nil + continue + } + log.append(event.Message) + log.truncated = log.truncated || event.Truncated + if event.Kind == orchestrator.EventProgress && strings.TrimSpace(event.Message) != "" { + activity = event.Message + } + if first && log.dirty { + first = false + flush() + } + case <-ticker.C: + flush() + } + } + flush() + outcome := <-outcomes + // The bounded stderr tail is authoritative when best-effort events dropped. + if outcome.Diagnostics != "" { + log.text = "" + log.append(outcome.Diagnostics) + } + for _, warning := range outcome.Warnings { + log.append(warning.Message) + log.truncated = log.truncated || warning.Truncated + } + status := acp.ToolCallStatusCompleted + kind := outcome.Kind + if ctx.Err() == context.DeadlineExceeded { + kind = orchestrator.OutcomeTimedOut + } else if ctx.Err() != nil { + kind = orchestrator.OutcomeCancelled + } + if outcome.Err != nil && kind == orchestrator.OutcomeCompleted { + kind = orchestrator.OutcomeFailed + } + if sendErr != nil { + kind = orchestrator.OutcomeFailed + log.append("OCR progress delivery failed: " + sendErr.Error()) + } + if kind != orchestrator.OutcomeCompleted || outcome.Err != nil { + status = acp.ToolCallStatusFailed + } + if kind != orchestrator.OutcomeCompleted { + log.append("OCR execution: " + string(kind)) + } + if outcome.Err != nil { + log.append(outcome.Err.Error()) + } + // After cleanup, make one bounded terminal attempt even if progress failed. + // A disconnected client may never receive it; preserve the original failure. + finish, stop := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer stop() + finalContent := content(&log) + if stats := executionStats(outcome.Result); stats != "" { + finalContent = append(finalContent, acp.ToolContent(acp.TextBlock(stats))) + } + finalTitle := progressTitle(operation, executionResultLabel(kind, outcome.Result), time.Since(started)) + if err := p.Notify(finish, sessionID, acp.UpdateToolCall(id, acp.WithUpdateTitle(finalTitle), acp.WithUpdateStatus(status), acp.WithUpdateContent(finalContent))); sendErr == nil { + sendErr = err + } + return outcome, sendErr +} diff --git a/acp/internal/adapter/presentation/progress_format.go b/acp/internal/adapter/presentation/progress_format.go new file mode 100644 index 000000000..4b3606f52 --- /dev/null +++ b/acp/internal/adapter/presentation/progress_format.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package presentation + +import ( + "fmt" + "sort" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/alibaba/open-code-review/acp/internal/contract" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +const progressLogLimit = 32 << 10 + +type progressLog struct { + text string + truncated bool + dirty bool +} + +func (l *progressLog) append(message string) { + message = strings.TrimRight(message, "\r\n") + if message == "" { + return + } + l.text += strings.ToValidUTF8(message, "?") + "\n" + if len(l.text) > progressLogLimit { + start := len(l.text) - progressLogLimit + for start < len(l.text) && !utf8.RuneStart(l.text[start]) { + start++ + } + l.text = strings.Clone(l.text[start:]) + l.truncated = true + } + l.dirty = true +} + +func (l *progressLog) content() []acp.ToolCallContent { + return []acp.ToolCallContent{acp.ToolContent(acp.TextBlock(l.markdown()))} +} + +func (l *progressLog) markdown() string { + var b strings.Builder + if l.truncated { + b.WriteString("Earlier OCR log output was truncated.\n\n") + } + // Explicit plain text prevents client language guessing for process logs. + writeFencedBlock(&b, "text", l.text) + return b.String() +} + +// A short escaped activity label remains visible while the log is collapsed. +func progressTitle(operation, message string, elapsed time.Duration) string { + message = strings.ToValidUTF8(message, "?") + lines := strings.Split(strings.TrimSpace(message), "\n") + message = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[len(lines)-1]), "[ocr]")) + var b strings.Builder + for _, r := range message { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' { + r = ' ' + } + if b.Len()+utf8.RuneLen(r) > 160 { + b.WriteString("...") + break + } + b.WriteRune(r) + } + if b.Len() == 0 { + return operation + " · Running · " + elapsed.Truncate(time.Second).String() + } + return operation + " · " + markdownLabel(b.String()) + " · " + elapsed.Truncate(time.Second).String() +} + +// shellArgument formats an argv element for copying into a POSIX shell. The +// runner still executes the original argv directly, without a shell. +func shellArgument(value string) string { + if value != "" && strings.IndexFunc(value, func(r rune) bool { + return !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-", r) + }) == -1 { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func executionCommandLine(binary string, request orchestrator.Request) string { + parts := []string{shellArgument(binary)} + for _, arg := range request.Args { + parts = append(parts, shellArgument(arg)) + } + return strings.Join(parts, " ") +} + +func executionCommand(binary string, request orchestrator.Request) string { + var b strings.Builder + b.WriteString("Command:\n\nPOSIX shell syntax:\n\n") + writeCodeBlock(&b, "command.sh", executionCommandLine(binary, request)) + b.WriteByte('\n') + return b.String() +} + +// Execution failures take precedence over any usable result returned by OCR. +func executionResultLabel(kind orchestrator.OutcomeKind, result *orchestrator.Result) string { + if kind == orchestrator.OutcomeCompleted && result != nil { + var status string + if result.Review != nil { + status = reviewStatus(result.Review.Status, result.Review.Manifest) + } else if result.Scan != nil { + status = result.Scan.Status + } + if status != "" && status != contract.StatusSuccess && status != contract.StatusComplete { + return resultStatus(status) + } + } + return outcomeLabel(kind) +} + +func outcomeLabel(kind orchestrator.OutcomeKind) string { + switch kind { + case orchestrator.OutcomeCompleted: + return "Completed" + case orchestrator.OutcomeCancelled: + return "Cancelled" + case orchestrator.OutcomeTimedOut: + return "Timed out" + default: + return "Failed" + } +} + +// executionStats shows aggregate CLI facts, not inferred per-invocation tool calls. +func executionStats(result *orchestrator.Result) string { + if result == nil { + return "" + } + var summary *contract.Summary + var calls *contract.ToolCalls + if result.Review != nil { + summary, calls = result.Review.Summary, result.Review.ToolCalls + } else if result.Scan != nil { + summary, calls = result.Scan.Summary, result.Scan.ToolCalls + } + var b strings.Builder + if calls != nil { + fmt.Fprintf(&b, "**Tool usage:** %d calls", calls.Total) + names := make([]string, 0, len(calls.ByTool)) + for name := range calls.ByTool { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fmt.Fprintf(&b, "\n\n%s × %d", markdownLabel(name), calls.ByTool[name]) + } + } + if summary != nil { + var usage []string + if summary.TotalTokens > 0 { + usage = append(usage, fmt.Sprintf("%d total", summary.TotalTokens)) + } + if summary.InputTokens != nil { + usage = append(usage, fmt.Sprintf("%d input", *summary.InputTokens)) + } + if summary.OutputTokens != nil { + usage = append(usage, fmt.Sprintf("%d output", *summary.OutputTokens)) + } + if summary.CacheReadTokens > 0 { + usage = append(usage, fmt.Sprintf("%d cache read", summary.CacheReadTokens)) + } + if summary.CacheWriteTokens > 0 { + usage = append(usage, fmt.Sprintf("%d cache write", summary.CacheWriteTokens)) + } + if len(usage) > 0 { + if b.Len() > 0 { + b.WriteString("\n\n") + } + b.WriteString("**Tokens (cumulative):** " + strings.Join(usage, " · ")) + } + } + return b.String() +} diff --git a/acp/internal/adapter/presentation/progress_format_test.go b/acp/internal/adapter/presentation/progress_format_test.go new file mode 100644 index 000000000..33cf009f6 --- /dev/null +++ b/acp/internal/adapter/presentation/progress_format_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package presentation + +import ( + "encoding/json" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/alibaba/open-code-review/acp/internal/contract" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" +) + +func TestProgressLogPreservesLinesAndBounds(t *testing.T) { + var log progressLog + log.append("") + log.append("[ocr] first\r\n") + log.append("[ocr] second") + if log.text != "[ocr] first\n[ocr] second\n" { + t.Fatalf("lines joined: %q", log.text) + } + data, _ := json.Marshal(log.content()) + if !strings.Contains(string(data), `[ocr] first\n[ocr] second\n`) { + t.Fatalf("logs not rendered as code: %s", data) + } + log.append(strings.Repeat("\u20ac", progressLogLimit)) + if len(log.text) > progressLogLimit || !utf8.ValidString(log.text) || !log.truncated { + t.Fatal("log tail is not bounded valid UTF-8") + } + log.append("invalid\xff") + data, _ = json.Marshal(log.content()) + if !strings.Contains(string(data), "truncated") || !strings.Contains(log.text, "invalid?") { + t.Fatalf("missing truncation or encoding repair: %s", data) + } +} + +func TestExecutionCommandQuotesArguments(t *testing.T) { + for _, tc := range []struct{ value, want string }{ + {"ocr", "ocr"}, {"--format=json", "--format=json"}, {"", "''"}, + {"two words", "'two words'"}, {"a'b", "'a'\"'\"'b'"}, + {"$(echo unsafe);*", "'$(echo unsafe);*'"}, {"a\nb", "'a\nb'"}, + } { + if got := shellArgument(tc.value); got != tc.want { + t.Errorf("quote %q = %q, want %q", tc.value, got, tc.want) + } + } +} + +func TestProgressTitleIsSingleLineBoundedAndEscaped(t *testing.T) { + if got := progressTitle("OCR review", "", 0); got != "OCR review · Running · 0s" { + t.Fatalf("unexpected empty title: %q", got) + } + if got := progressTitle("OCR scan", "previous line\n[ocr] reading [file](url)", 2*time.Second); got != "OCR scan · "+markdownLabel("reading [file](url)")+" · 2s" { + t.Fatalf("latest activity not escaped: %q", got) + } + for _, message := range []string{ + "[ocr] a\tb\r c\u0085d\u202ee\u2028f\u2029", + strings.Repeat("\u754c", 200), strings.Repeat("*", 200), "invalid\xff", + } { + title := progressTitle("OCR review", message, time.Minute) + if !utf8.ValidString(title) || strings.ContainsAny(title, "\r\n\t\u0085\u202e\u2028\u2029") || len(title) > 370 { + t.Fatalf("unsafe or unbounded title: %q", title) + } + } +} + +func TestExecutionStatsFromCLIJSON(t *testing.T) { + for _, operation := range []string{"review", "scan"} { + t.Run(operation, func(t *testing.T) { + const payload = `{"summary":{"total_tokens":14,"input_tokens":10,"output_tokens":0,"cache_read_tokens":3,"cache_write_tokens":1},"tool_calls":{"total":3,"by_tool":{"read_file":2,"code_search":1}}}` + result := &orchestrator.Result{} + if operation == "review" { + result.Review = &contract.ReviewResult{} + if err := json.Unmarshal([]byte(payload), result.Review); err != nil { + t.Fatal(err) + } + } else { + result.Scan = &contract.ScanResult{} + if err := json.Unmarshal([]byte(payload), result.Scan); err != nil { + t.Fatal(err) + } + } + got := executionStats(result) + for _, want := range []string{"3 calls", "code\\_search × 1", "read\\_file × 2", "14 total", "10 input", "0 output", "3 cache read", "1 cache write"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q: %s", want, got) + } + } + if strings.Index(got, "code") > strings.Index(got, "read") { + t.Errorf("tool counts are not deterministic: %s", got) + } + }) + } + for _, result := range []*orchestrator.Result{nil, {}, {Review: &contract.ReviewResult{Summary: &contract.Summary{}}}} { + if got := executionStats(result); got != "" { + t.Errorf("missing statistics invented: %s", got) + } + } +} diff --git a/acp/internal/adapter/presentation/progress_test.go b/acp/internal/adapter/presentation/progress_test.go new file mode 100644 index 000000000..5c776b1f1 --- /dev/null +++ b/acp/internal/adapter/presentation/progress_test.go @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package presentation + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/contract" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +func TestDiagnosticsDoNotReplaceProgressActivity(t *testing.T) { + runner := progressRunner(func(_ context.Context, _ orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + events := make(chan orchestrator.Event, 2) + events <- orchestrator.Event{Kind: orchestrator.EventProgress, Message: "[ocr] Reviewing files"} + events <- orchestrator.Event{Kind: orchestrator.EventDiagnostic, Message: "TraceID: diagnostic-only"} + close(events) + outcomes := make(chan orchestrator.Outcome, 1) + outcomes <- orchestrator.Outcome{Kind: orchestrator.OutcomeCompleted} + close(outcomes) + return events, outcomes + }) + a := Progress{Binary: "ocr", Runner: runner} + seenActivity, seenDiagnostic := false, false + a.Notify = notifySink(progressSink(func(_ context.Context, n acp.SessionNotification) error { + if update := n.Update.ToolCallUpdate; update != nil { + if update.Title != nil && strings.Contains(*update.Title, "diagnostic-only") { + t.Fatal("diagnostic became activity") + } + if update.Title != nil && strings.Contains(*update.Title, "Reviewing files") { + seenActivity = true + } + data, _ := json.Marshal(update.Content) + seenDiagnostic = seenDiagnostic || strings.Contains(string(data), "diagnostic-only") + } + return nil + })) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if _, err := a.Collect(ctx, cancel, "s", orchestrator.Request{Args: []string{"scan"}}); err != nil { + t.Fatal(err) + } + if !seenActivity || !seenDiagnostic { + t.Fatal("activity or diagnostic details lost") + } +} + +func TestProgressStartFailureDoesNotRunOCR(t *testing.T) { + r := &captureRunner{} + a := Progress{Binary: "ocr", Runner: r} + want := errors.New("output unavailable") + attempts := 0 + a.Notify = notifySink(progressSink(func(_ context.Context, n acp.SessionNotification) error { + attempts++ + if n.Update.ToolCall == nil { + t.Error("expected execution entry") + } + return want + })) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _, err := a.Collect(ctx, cancel, "s", orchestrator.Request{Args: []string{"review"}}) + if !errors.Is(err, want) || len(r.requests) != 0 || attempts != 1 { + t.Fatalf("OCR started after output failure: %v", err) + } +} + +func TestProgressDetailsArriveBeforeRunnerCompletes(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + visible := make(chan struct{}) + runner := progressRunner(func(ctx context.Context, _ orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + events := make(chan orchestrator.Event, 1) + outcomes := make(chan orchestrator.Outcome, 1) + go func() { + defer close(outcomes) + events <- orchestrator.Event{Kind: orchestrator.EventProgress, Message: "READY"} + select { + case <-visible: + case <-ctx.Done(): + } + events <- orchestrator.Event{Message: strings.Repeat("\u20ac", progressLogLimit)} + close(events) + outcomes <- orchestrator.Outcome{Kind: orchestrator.OutcomeCompleted} + }() + return events, outcomes + }) + a := Progress{Binary: "ocr", Runner: runner} + seen := false + var final acp.SessionNotification + a.Notify = notifySink(progressSink(func(_ context.Context, n acp.SessionNotification) error { + if chat := n.Update.AgentMessageChunk; chat != nil { + t.Error("raw logs leaked into chat") + } + if update := n.Update.ToolCallUpdate; update != nil { + data, _ := json.Marshal(update.Content) + if update.Status == nil && strings.Contains(string(data), "READY") && !seen { + if update.Title == nil || !strings.Contains(*update.Title, "READY") { + t.Error("collapsed title does not show current activity") + } + seen = true + close(visible) + } + final = n + } + return nil + })) + _, err := a.Collect(ctx, cancel, "s", orchestrator.Request{Args: []string{"review"}}) + if err != nil || ctx.Err() != nil || !seen { + t.Fatalf("live progress stalled: %v / %v / seen=%v", err, ctx.Err(), seen) + } + update := final.Update.ToolCallUpdate + if update == nil || len(update.Content) != 2 { + t.Fatalf("missing final command and details: %+v", update) + } + data, _ := json.Marshal(update.Content[1]) + if len(data) > progressLogLimit+512 || !strings.Contains(string(data), "truncated") { + t.Fatalf("final details are not a bounded tail: %d bytes", len(data)) + } +} + +func TestProgressOutputFailureWaitsForCleanup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cancelled, release := make(chan struct{}), make(chan struct{}) + runner := progressRunner(func(ctx context.Context, _ orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + events := make(chan orchestrator.Event, 1) + events <- orchestrator.Event{Kind: orchestrator.EventProgress, Message: "READY"} + outcomes := make(chan orchestrator.Outcome, 1) + go func() { + <-ctx.Done() + close(cancelled) + <-release + close(events) + outcomes <- orchestrator.Outcome{Kind: orchestrator.OutcomeCancelled} + close(outcomes) + }() + return events, outcomes + }) + a := Progress{Binary: "ocr", Runner: runner} + want := errors.New("write failed") + a.Notify = notifySink(progressSink(func(_ context.Context, n acp.SessionNotification) error { + if n.Update.ToolCallUpdate != nil { + return want + } + return nil + })) + done := make(chan error, 1) + go func() { + _, err := a.Collect(ctx, cancel, "s", orchestrator.Request{Args: []string{"review"}}) + done <- err + }() + select { + case <-cancelled: + case <-time.After(5 * time.Second): + t.Fatal("output failure did not cancel OCR") + } + select { + case <-done: + t.Fatal("returned before cleanup") + default: + } + close(release) + if err := <-done; !errors.Is(err, want) { + t.Fatalf("lost write error: %v", err) + } +} + +func TestProgressTerminalStates(t *testing.T) { + for _, kind := range []orchestrator.OutcomeKind{orchestrator.OutcomeFailed, orchestrator.OutcomeCancelled, orchestrator.OutcomeTimedOut} { + t.Run(string(kind), func(t *testing.T) { + a := Progress{Binary: "ocr", Runner: errorRunner{kind: kind}} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var final acp.SessionNotification + a.Notify = notifySink(progressSink(func(ctx context.Context, n acp.SessionNotification) error { + if n.Update.ToolCallUpdate != nil { + if ctx.Err() != nil { + t.Fatal("terminal update uses cancelled context") + } + final = n + } + return nil + })) + _, err := a.Collect(ctx, cancel, "s", orchestrator.Request{Args: []string{"review"}}) + if err != nil { + t.Fatal(err) + } + update := final.Update.ToolCallUpdate + data, _ := json.Marshal(update) + if update == nil || update.Status == nil || *update.Status != acp.ToolCallStatusFailed || !strings.HasPrefix(*update.Title, "OCR review · "+outcomeLabel(kind)+" · ") || !strings.Contains(string(data), string(kind)) { + t.Fatalf("missing failed state: %+v", update) + } + }) + } +} + +func TestProgressCancellationFinishesToolAfterCleanup(t *testing.T) { + for _, timeout := range []bool{false, true} { + t.Run(map[bool]string{false: "cancel", true: "timeout"}[timeout], func(t *testing.T) { + parent, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + ctx, cancel := context.WithCancel(parent) + defer cancel() + if timeout { + var expire context.CancelFunc + ctx, expire = context.WithDeadline(parent, time.Now().Add(-time.Second)) + defer expire() + } + r := cleanupRunner{make(chan struct{}), make(chan struct{}), make(chan struct{})} + a := Progress{Binary: "ocr", Runner: r} + var final acp.SessionNotification + a.Notify = notifySink(progressSink(func(ctx context.Context, n acp.SessionNotification) error { + if n.Update.ToolCallUpdate != nil { + if ctx.Err() != nil { + t.Error("terminal update context expired") + } + final = n + } + return nil + })) + done := make(chan error, 1) + go func() { + _, err := a.Collect(ctx, cancel, "s", orchestrator.Request{Args: []string{"review"}}) + done <- err + }() + <-r.ready + cancel() + <-r.cancelled + select { + case <-done: + t.Fatal("returned before cleanup") + default: + } + close(r.release) + if err := <-done; err != nil { + t.Fatal(err) + } + want := "cancelled" + if timeout { + want = "timed_out" + } + update := final.Update.ToolCallUpdate + data, _ := json.Marshal(update) + if update == nil || *update.Status != acp.ToolCallStatusFailed || !strings.HasPrefix(*update.Title, "OCR review · "+outcomeLabel(orchestrator.OutcomeKind(want))+" · ") || !strings.Contains(string(data), want) { + t.Fatalf("incorrect terminal state: %+v", update) + } + }) + } +} + +func TestIdleProgressUpdatesElapsedWithoutRepeatingContent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + visible := make(chan struct{}) + runner := progressRunner(func(context.Context, orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + events := make(chan orchestrator.Event) + outcomes := make(chan orchestrator.Outcome, 1) + go func() { + select { + case <-visible: + case <-ctx.Done(): + } + close(events) + outcomes <- orchestrator.Outcome{Kind: orchestrator.OutcomeCompleted} + close(outcomes) + }() + return events, outcomes + }) + seen := false + a := Progress{Binary: "ocr", Runner: runner} + a.Notify = notifySink(progressSink(func(_ context.Context, n acp.SessionNotification) error { + if update := n.Update.ToolCallUpdate; update != nil && update.Status == nil && !seen { + if update.Title == nil || strings.HasSuffix(*update.Title, " · 0s") { + t.Error("elapsed time did not advance") + } + if len(update.Content) != 0 { + t.Error("unchanged log content was resent") + } + seen = true + close(visible) + } + return nil + })) + if _, err := a.Collect(ctx, cancel, "s", orchestrator.Request{Args: []string{"review"}}); err != nil { + t.Fatal(err) + } + if !seen || ctx.Err() != nil { + t.Fatal("idle elapsed-time update never arrived") + } +} + +func TestTerminalProgressUsesResultStatus(t *testing.T) { + for _, tc := range []struct { + name, status, manifest, want string + kind orchestrator.OutcomeKind + scan bool + }{ + {"partial", "partial", "partial", "Partial", orchestrator.OutcomeCompleted, false}, + {"manifest", "success", "partial", "Partial", orchestrator.OutcomeCompleted, false}, + {"warnings", "completed_with_warnings", "", "Completed with warnings", orchestrator.OutcomeCompleted, true}, + {"errors", "completed_with_errors", "", "Completed with errors", orchestrator.OutcomeCompleted, false}, + {"skipped", "skipped", "", "Skipped", orchestrator.OutcomeCompleted, false}, + {"cancelled", "partial", "partial", "Cancelled", orchestrator.OutcomeCancelled, false}, + {"failed", "partial", "partial", "Failed", orchestrator.OutcomeFailed, false}, + } { + t.Run(tc.name, func(t *testing.T) { + result := &orchestrator.Result{Review: &contract.ReviewResult{Status: tc.status, Manifest: &contract.Manifest{TerminalState: tc.manifest}}} + if tc.scan { + result = &orchestrator.Result{Scan: &contract.ScanResult{Status: tc.status}} + } + runner := progressRunner(func(context.Context, orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + events := make(chan orchestrator.Event) + close(events) + outcomes := make(chan orchestrator.Outcome, 1) + outcomes <- orchestrator.Outcome{Kind: tc.kind, Result: result} + close(outcomes) + return events, outcomes + }) + a := Progress{Binary: "ocr", Runner: runner} + var title string + a.Notify = notifySink(progressSink(func(_ context.Context, n acp.SessionNotification) error { + if u := n.Update.ToolCallUpdate; u != nil && u.Title != nil { + title = *u.Title + } + return nil + })) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _, err := a.Collect(ctx, cancel, "test", orchestrator.Request{Args: []string{"review"}}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(title, "OCR review · "+tc.want+" · ") { + t.Fatalf("misleading final title: %s", title) + } + }) + } +} diff --git a/acp/internal/adapter/presentation/report.go b/acp/internal/adapter/presentation/report.go new file mode 100644 index 000000000..b5e9b1581 --- /dev/null +++ b/acp/internal/adapter/presentation/report.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package presentation renders OCR reports and progress as ACP client updates. +package presentation + +import ( + "fmt" + "sort" + "strings" + + "github.com/alibaba/open-code-review/acp/internal/adapter/workspace" + "github.com/alibaba/open-code-review/acp/internal/contract" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" +) + +// FormatResult renders OCR findings with safe local navigation when root is set. +func FormatResult(r *orchestrator.Result, root string) string { + if r == nil || r.Review == nil && r.Scan == nil { + return "OCR returned no result." + } + var b strings.Builder + if r.Review != nil { + review := r.Review + writeReport(&b, root, "review", review.Status, review.Message, review.Summary, review.Comments, review.Manifest) + } + if r.Scan != nil { + scan := r.Scan + if b.Len() > 0 { + b.WriteString("\n\n") + } + writeReport(&b, root, "scan", scan.Status, scan.Message, scan.Summary, scan.Comments, nil) + } + return b.String() +} + +func resultStatus(status string) string { + switch status { + case contract.StatusSuccess, contract.StatusComplete: + return "Complete" + case contract.StatusPartial: + return "Partial" + case contract.StatusFailed: + return "Failed" + case contract.StatusSkipped: + return "Skipped" + case contract.StatusCompletedWithWarnings: + return "Completed with warnings" + case contract.StatusCompletedWithErrors: + return "Completed with errors" + case "": + return "Status unavailable" + default: + return markdownLabel(status) + } +} + +// reviewStatus uses the manifest's terminal state when the CLI provides one. +func reviewStatus(status string, manifest *contract.Manifest) string { + if manifest != nil && manifest.TerminalState != "" { + return manifest.TerminalState + } + return status +} + +func writeReport(b *strings.Builder, root, operation, status, message string, summary *contract.Summary, comments []contract.Comment, manifest *contract.Manifest) { + fmt.Fprintf(b, "## OCR %s · %s\n\n", operation, resultStatus(reviewStatus(status, manifest))) + count := int64(len(comments)) + if manifest != nil && manifest.Coverage != nil && manifest.Coverage.Selected != nil { + coverage := manifest.Coverage + fmt.Fprintf(b, "%d/%d files completed · ", len(coverage.Completed), len(coverage.Selected)) + for _, group := range []struct { + count int + label string + }{ + {len(coverage.Reused), "reused"}, {len(coverage.Failed), "failed"}, {len(coverage.Waived), "waived"}, + } { + if group.count > 0 { + fmt.Fprintf(b, "%d %s · ", group.count, group.label) + } + } + } else if summary != nil { + fileNoun := "files" + if summary.FilesReviewed == 1 { + fileNoun = "file" + } + verb := "processed" + if state := reviewStatus(status, manifest); state == contract.StatusSuccess || state == contract.StatusComplete { + verb = "reviewed" + } + fmt.Fprintf(b, "%d %s %s · ", summary.FilesReviewed, fileNoun, verb) + } + noun := "findings" + if count == 1 { + noun = "finding" + } + fmt.Fprintf(b, "%d %s", count, noun) + writeSeverityCounts(b, comments) + if message != "" { + fmt.Fprintf(b, "\n\n%s", message) + } + if summary != nil && summary.BudgetExceeded { + b.WriteString("\n\n**Token budget exceeded.** Coverage may be incomplete.") + } + if manifest != nil { + if manifest.TerminalState != "" && resultStatus(manifest.TerminalState) != resultStatus(status) { + fmt.Fprintf(b, "\n\nReported status: %s", resultStatus(status)) + } + if manifest.RunFailure != nil { + fmt.Fprintf(b, "\n\nRun failure [%s]: %s", manifest.RunFailure.Classification, manifest.RunFailure.Reason) + } + if manifest.Coverage != nil { + for _, failure := range manifest.Coverage.Failed { + fmt.Fprintf(b, "\n\nFailed file %s [%s]: %s", markdownLabel(failure.Path), failure.Classification, failure.Reason) + } + } + } + for index, comment := range comments { + writeFinding(b, index+1, comment, workspace.ResolveLocation(root, comment.Path, comment.StartLine, comment.EndLine)) + } + if summary != nil { + var usage []string + if summary.TotalTokens > 0 { + usage = append(usage, fmt.Sprintf("%d tokens", summary.TotalTokens)) + } + if summary.Elapsed != "" { + usage = append(usage, markdownLabel(summary.Elapsed)) + } + if len(usage) > 0 { + fmt.Fprintf(b, "\n\nUsage: %s", strings.Join(usage, " · ")) + } + } +} + +func severityLabel(severity string) string { + switch severity { + case "critical": + return "Critical" + case "high": + return "High" + case "medium": + return "Medium" + case "low": + return "Low" + case "": + return "Unspecified" + default: + return markdownLabel(severity) + } +} + +func writeSeverityCounts(b *strings.Builder, comments []contract.Comment) { + if len(comments) == 0 { + return + } + counts := make(map[string]int) + for _, comment := range comments { + counts[comment.Severity]++ + } + labels := make([]string, 0, len(counts)) + for _, severity := range []string{"critical", "high", "medium", "low"} { + if count := counts[severity]; count > 0 { + labels = append(labels, fmt.Sprintf("%s %d", severityLabel(severity), count)) + delete(counts, severity) + } + } + unknown := make([]string, 0, len(counts)) + for severity := range counts { + if severity != "" { + unknown = append(unknown, severity) + } + } + sort.Strings(unknown) + for _, severity := range unknown { + labels = append(labels, fmt.Sprintf("%s %d", severityLabel(severity), counts[severity])) + } + if counts[""] > 0 { + labels = append(labels, fmt.Sprintf("Unspecified %d", counts[""])) + } + fmt.Fprintf(b, "\n\n%s", strings.Join(labels, " · ")) +} + +func writeFinding(b *strings.Builder, number int, c contract.Comment, location *workspace.Location) { + fmt.Fprintf(b, "\n\n### %d. %s", number, severityLabel(c.Severity)) + label := c.Path + if c.StartLine > 0 { + label += fmt.Sprintf(":%d", c.StartLine) + if c.EndLine > c.StartLine { + label += fmt.Sprintf("-%d", c.EndLine) + } + } + if label != "" { + b.WriteString(" · ") + if location != nil { + fmt.Fprintf(b, "[%s](<%s>)", markdownLabel(label), workspace.FileURLString(location.Path, location.Line)) + } else { + b.WriteString(markdownLabel(label)) + } + } + if c.Category != "" { + fmt.Fprintf(b, "\n\n**Category:** %s", markdownLabel(c.Category)) + } + fmt.Fprintf(b, "\n\n%s", c.Content) + if c.ExistingCode != "" { + b.WriteString("\n\n**Existing code:**\n\n") + writeCodeBlock(b, c.Path, c.ExistingCode) + } + if c.SuggestionCode != "" { + b.WriteString("\n\n**Suggested code (not applied):**\n\n") + writeCodeBlock(b, c.Path, c.SuggestionCode) + } +} diff --git a/acp/internal/adapter/presentation/report_test.go b/acp/internal/adapter/presentation/report_test.go new file mode 100644 index 000000000..003bc3d8d --- /dev/null +++ b/acp/internal/adapter/presentation/report_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package presentation + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/adapter/workspace" + "github.com/alibaba/open-code-review/acp/internal/contract" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" +) + +func TestPartialResultRetainsDiagnosticsAndSuggestions(t *testing.T) { + r := &orchestrator.Result{Review: &contract.ReviewResult{Status: "partial", Message: "Incomplete review", Summary: &contract.Summary{FilesReviewed: 3, Comments: 1}, Comments: []contract.Comment{{Path: "a.go", Severity: "critical", Category: "bug", Content: "unsafe", SuggestionCode: "fixed()"}}, Manifest: &contract.Manifest{TerminalState: "partial", Coverage: &contract.Coverage{Failed: []contract.CoverageItem{{Path: "failed.go", Classification: "timeout", Reason: "provider stalled"}}}}}} + got := formatReport(r) + for _, want := range []string{"Partial", "Critical", "bug", "3 files processed", "1 finding", "fixed()", "failed.go", "provider stalled"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } +} + +func TestFindingMarkdownFormatting(t *testing.T) { + root := t.TempDir() + path := "a [test](one)#.go" + if err := os.WriteFile(filepath.Join(root, path), []byte("first\nsecond\n"), 0600); err != nil { + t.Fatal(err) + } + comment := contract.Comment{Path: path, StartLine: 1, EndLine: 2, Severity: "critical", Category: "bug", Content: "Explanation.", ExistingCode: "// ```\nold()", SuggestionCode: "new()"} + result := &orchestrator.Result{Review: &contract.ReviewResult{Status: "partial", Comments: []contract.Comment{comment}}} + got := FormatResult(result, root) + location := workspace.ResolveLocation(root, comment.Path, comment.StartLine, comment.EndLine) + uri := workspace.FileURLString(location.Path, location.Line) + for _, want := range []string{"## OCR review", "\n\n### 1. Critical · ", "[a \\[test\\]\\(one\\)\\#.go:1-2](<" + uri + ">)", "\n\nExplanation.\n\n", "**Existing code:**\n\n````go\n// ```\nold()\n````", "**Suggested code (not applied):**\n\n```go\nnew()\n```"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } + if strings.Contains(formatReport(result), "file://") { + t.Fatal("no-root formatting must not invent navigation") + } +} + +func TestFindingMarkdownFallbackAndLanguages(t *testing.T) { + for _, path := range []string{"../outside.go", "missing.go", "[link](https://example.com)", "bad\n# title"} { + var b strings.Builder + writeFinding(&b, 1, contract.Comment{Path: path, Content: "Kept"}, nil) + if strings.Contains(b.String(), "](https:") || strings.Contains(b.String(), "file://") || strings.Contains(b.String(), "\n# title") || !strings.Contains(b.String(), "Kept") { + t.Fatalf("unsafe or missing fallback: %q", b.String()) + } + } + for _, tc := range []struct{ path, language string }{{"a.go", "go"}, {"a.py", "python"}, {"a.ts", "typescript"}, {"a.unknown", ""}} { + var b strings.Builder + writeFinding(&b, 1, contract.Comment{Path: tc.path, ExistingCode: "example"}, nil) + if !strings.Contains(b.String(), "```"+tc.language+"\nexample\n```") { + t.Fatalf("language for %s: %q", tc.path, b.String()) + } + } +} + +func TestReportStatusAndMissingUsage(t *testing.T) { + for _, tc := range []struct{ status, label string }{ + {"success", "Complete"}, {"complete", "Complete"}, {"partial", "Partial"}, + {"failed", "Failed"}, {"skipped", "Skipped"}, + {"completed_with_warnings", "Completed with warnings"}, {"completed_with_errors", "Completed with errors"}, + {"future_status", "future\\_status"}, {"", "Status unavailable"}, + } { + t.Run(tc.status, func(t *testing.T) { + for _, scan := range []bool{false, true} { + result := &orchestrator.Result{Review: &contract.ReviewResult{Status: tc.status, Message: "Result detail"}} + operation := "review" + if scan { + result = &orchestrator.Result{Scan: &contract.ScanResult{Status: tc.status, Message: "Result detail"}} + operation = "scan" + } + got := formatReport(result) + if !strings.HasPrefix(got, "## OCR "+operation+" · "+tc.label+"\n\n") || !strings.Contains(got, "Result detail") || !strings.Contains(got, "0 findings") { + t.Fatalf("lost status or result: %s", got) + } + for _, invented := range []string{"Status:", "Completion:", "Usage:", "files reviewed", "No issues", "No findings"} { + if strings.Contains(got, invented) { + t.Fatalf("unexpected %q: %s", invented, got) + } + } + } + }) + } + for _, result := range []*orchestrator.Result{nil, {}} { + if got := formatReport(result); got != "OCR returned no result." { + t.Fatalf("missing result: %s", got) + } + } +} + +func TestReportSummarySeverityOrderAndFooter(t *testing.T) { + got := formatReport(&orchestrator.Result{Review: &contract.ReviewResult{ + Status: "complete", Summary: &contract.Summary{FilesReviewed: 12, Comments: 7, TotalTokens: 18420, Elapsed: "42s", BudgetExceeded: true}, + Comments: []contract.Comment{{Severity: "low"}, {Severity: "high"}, {Severity: "critical"}, {Severity: "medium"}, {Severity: "high"}, {Severity: "future"}, {}}, + }}) + for _, want := range []string{"12 files reviewed · 7 findings", "Critical 1 · High 2 · Medium 1 · Low 1 · future 1 · Unspecified 1", "**Token budget exceeded.**", "### 7. Unspecified", "Usage: 18420 tokens · 42s"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q: %s", want, got) + } + } + if !strings.HasSuffix(got, "Usage: 18420 tokens · 42s") { + t.Fatalf("usage is not footer: %s", got) + } + for _, tc := range []struct { + summary contract.Summary + footer string + }{ + {contract.Summary{TotalTokens: 2}, "Usage: 2 tokens"}, + {contract.Summary{Elapsed: "1s"}, "Usage: 1s"}, + {contract.Summary{}, ""}, + } { + got := formatReport(&orchestrator.Result{Scan: &contract.ScanResult{Status: "success", Summary: &tc.summary}}) + if tc.footer == "" && strings.Contains(got, "Usage:") || tc.footer != "" && !strings.HasSuffix(got, tc.footer) { + t.Fatalf("optional footer: %s", got) + } + } +} + +func TestReportRetainsManifestDiagnostics(t *testing.T) { + for _, status := range []string{"partial", "failed"} { + got := formatReport(&orchestrator.Result{Review: &contract.ReviewResult{Status: status, Manifest: &contract.Manifest{TerminalState: status, + RunFailure: &contract.RunFailure{Classification: "provider_error", Reason: "Provider unavailable"}, + Coverage: &contract.Coverage{Failed: []contract.CoverageItem{{Path: "failed.go", Classification: "timeout", Reason: "Deadline exceeded"}}}, + }}}) + for _, want := range []string{"provider_error", "Provider unavailable", "failed.go", "timeout", "Deadline exceeded"} { + if !strings.Contains(got, want) { + t.Fatalf("lost diagnostic %q: %s", want, got) + } + } + if strings.Contains(got, "Completion:") || strings.Contains(got, "Manifest status:") { + t.Fatalf("duplicate status: %s", got) + } + } + got := formatReport(&orchestrator.Result{Review: &contract.ReviewResult{Status: "success", Manifest: &contract.Manifest{TerminalState: "partial"}}}) + if !strings.HasPrefix(got, "## OCR review · Partial") || !strings.Contains(got, "Reported status: Complete") { + t.Fatalf("conflicting status dropped: %s", got) + } +} + +func TestReportHeadingEscapesMetadata(t *testing.T) { + got := formatReport(&orchestrator.Result{Scan: &contract.ScanResult{Status: "future\n# heading", Comments: []contract.Comment{{Severity: "[urgent](https://example.com)\n# heading", Category: "**category**", Path: "bad\n# heading", Content: "Body"}}}}) + if strings.Contains(got, "\n# heading") || strings.Contains(got, "](https://") || strings.Contains(got, "**category**") { + t.Fatalf("unsafe headings: %s", got) + } + if !strings.Contains(got, "Body") { + t.Fatalf("lost content: %s", got) + } +} + +func TestReportCoverageUsesManifest(t *testing.T) { + for _, tc := range []struct{ name, coverage, want string }{ + {"partial", `{"selected":[{},{},{},{}],"completed":[{},{},{}],"failed":[{"path":"server.go","classification":"budget","reason":"round limit"}]}`, "3/4 files completed · 1 failed · 0 findings"}, + {"resumed", `{"selected":[{},{},{},{}],"completed":[{}],"reused":[{}],"failed":[{}],"waived":[{}]}`, "1/4 files completed · 1 reused · 1 failed · 1 waived · 0 findings"}, + {"empty", `{"selected":[],"completed":[],"failed":[]}`, "0/0 files completed · 0 findings"}, + } { + t.Run(tc.name, func(t *testing.T) { + var review contract.ReviewResult + if err := json.Unmarshal([]byte(`{"status":"partial","summary":{"files_reviewed":4},"manifest":{"terminal_state":"partial","coverage":`+tc.coverage+`}}`), &review); err != nil { + t.Fatal(err) + } + got := formatReport(&orchestrator.Result{Review: &review}) + if !strings.Contains(got, tc.want) || strings.Contains(got, "files reviewed") { + t.Fatalf("misleading coverage: %s", got) + } + }) + } +} + +func TestReportFindingsMatchRenderedList(t *testing.T) { + for _, count := range []int64{0, 5} { + got := formatReport(&orchestrator.Result{Review: &contract.ReviewResult{Status: "complete", Summary: &contract.Summary{Comments: count}, Comments: []contract.Comment{{Severity: "low", Content: "Actual finding"}}}}) + if !strings.Contains(got, "1 finding\n\nLow 1") || !strings.Contains(got, "### 1. Low") { + t.Fatalf("inconsistent findings: %s", got) + } + } + got := formatReport(&orchestrator.Result{Scan: &contract.ScanResult{Status: "completed_with_warnings", Summary: &contract.Summary{BudgetExceeded: true}}}) + if strings.Contains(got, "Review coverage") || !strings.Contains(got, "Coverage may be incomplete") { + t.Fatalf("wrong operation: %s", got) + } +} +func TestFormatResult(t *testing.T) { + got := formatReport(&orchestrator.Result{Review: &contract.ReviewResult{Message: "summary", Comments: []contract.Comment{{Path: "x.go", StartLine: 2, EndLine: 3, Content: "issue"}}}}) + if got == "" { + t.Fatal("empty result") + } +} + +func TestFormatResultVariants(t *testing.T) { + if formatReport(nil) != "OCR returned no result." { + t.Fatal("nil result") + } + if formatReport(&orchestrator.Result{Scan: &contract.ScanResult{Message: "scan", Comments: []contract.Comment{{Path: "a", Content: "x"}}}}) == "" { + t.Fatal("scan result") + } +} + +func TestFormattedFindingsKeepSafeNavigationAndText(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.go"), []byte("first\nlast"), 0600); err != nil { + t.Fatal(err) + } + result := &orchestrator.Result{Review: &contract.ReviewResult{Comments: []contract.Comment{ + {Path: "a.go", StartLine: 2, EndLine: 2, Severity: "critical", Content: "broken", SuggestionCode: "fixed"}, + {Path: "a.go", Content: "file finding"}, + {Path: "../outside", Content: "unlocatable"}, + }}} + text := FormatResult(result, root) + if !strings.Contains(text, "```go\nfixed\n```") || !strings.Contains(text, "#L2") { + t.Fatalf("finding lost formatted source or navigation: %s", text) + } + if !strings.Contains(text, "[a.go:2]( 0 && strings.TrimSpace(text) == "" { + s.state.Clear() + return a.sendTerminal(task, p.SessionId, "Send /scan with these resources to scan the selected files.", acp.StopReasonEndTurn) + } + r, e := a.parser.WithRepo(intent.GitRepo{Dir: s.cwd}).Parse(task, text, s.state) + if task.Err() != nil { + if errors.Is(task.Err(), context.DeadlineExceeded) { + return a.reportTimeout(ctx, p.SessionId) + } + return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil + } + if e != nil { + return a.sendRejection(task, p.SessionId, "Invalid scan or review options: "+e.Error()) + } + if r.Kind == intent.KindClarify { + return a.sendTerminal(task, p.SessionId, r.Clarify.Question, acp.StopReasonEndTurn) + } + if r.Kind == intent.KindReject { + return a.sendRejection(task, p.SessionId, r.Reject.Reason+" "+r.Reject.Hint) + } + if len(paths) > 0 { + if r.Scan == nil || len(r.Scan.Paths) > 0 { + return a.sendRejection(task, p.SessionId, "Resource links select files only with /scan without --path. Remove the links for repository review or explicit --path scans.") + } + r.Scan.Paths = paths + } + var args []string + if r.Review != nil { + args, e = contract.BuildReviewArgs(r.Review) + } else { + args, e = contract.BuildScanArgs(r.Scan) + } + if e != nil { + return a.sendRejection(task, p.SessionId, "Invalid scan or review options: "+e.Error()) + } + root, e := workspace.ResultRoot(task, s.cwd, args) + if e != nil { + if task.Err() != nil { + return a.finishSend(task, p.SessionId, task.Err(), acp.StopReasonEndTurn) + } + return a.sendRejection(task, p.SessionId, "Cannot determine the OCR target directory. Review requires a Git repository; check the session directory or use /scan for a non-Git directory.") + } + // Output failures stop OCR without marking the whole turn as user-cancelled. + runTask, stopRunner := context.WithCancel(task) + defer stopRunner() + presenter := presentation.Progress{Binary: a.binary, Runner: a.runner, Notify: a.notify} + o, progressErr := presenter.Collect(runTask, stopRunner, p.SessionId, orchestrator.Request{CWD: s.cwd, Args: args}) + if o.Kind == orchestrator.OutcomeTimedOut || errors.Is(task.Err(), context.DeadlineExceeded) { + return a.reportTimeout(ctx, p.SessionId) + } + if progressErr != nil { + return a.finishSend(task, p.SessionId, progressErr, acp.StopReasonEndTurn) + } + if o.Kind == orchestrator.OutcomeCancelled || task.Err() != nil { + return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil + } + if o.Result != nil { + text := presentation.FormatResult(o.Result, root) + if o.Err != nil { + text += "\nOCR failed: " + o.Err.Error() + } + resp, sendErr := a.sendTerminal(task, p.SessionId, text, acp.StopReasonEndTurn) + if sendErr != nil { + return resp, sendErr + } + if o.Err != nil { + return acp.PromptResponse{}, o.Err + } + return resp, nil + } + if o.Err != nil { + return acp.PromptResponse{}, o.Err + } + return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil +} + +// Application-level rejections are completed guidance, not model refusals. +// Clients such as Zed discard the turn's messages on the refusal stop reason. +func (a *Agent) sendRejection(ctx context.Context, id acp.SessionId, text string) (acp.PromptResponse, error) { + response, err := a.sendTerminal(ctx, id, text, acp.StopReasonEndTurn) + if err == nil && response.StopReason == acp.StopReasonEndTurn && response.Meta == nil && ctx.Err() == nil { + response.Meta = map[string]any{"ocr": map[string]any{"kind": "rejected"}} + } + return response, err +} + +func (a *Agent) sendTerminal(ctx context.Context, id acp.SessionId, text string, reason acp.StopReason) (acp.PromptResponse, error) { + err := a.update(ctx, id, text) + return a.finishSend(ctx, id, err, reason) +} + +func (a *Agent) finishSend(ctx context.Context, id acp.SessionId, err error, reason acp.StopReason) (acp.PromptResponse, error) { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return a.reportTimeout(ctx, id) + } + if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) { + return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil + } + if err != nil { + return acp.PromptResponse{}, err + } + return acp.PromptResponse{StopReason: reason}, nil +} + +// ACP v1 has no timeout stop reason. Preserve the machine-readable cause in +// metadata instead of using max_turn_requests, which means a different limit. +func timeoutResponse() acp.PromptResponse { + return acp.PromptResponse{StopReason: acp.StopReasonEndTurn, Meta: map[string]any{"ocr": map[string]any{"kind": "timed_out", "retryable": true}}} +} + +func (a *Agent) reportTimeout(ctx context.Context, id acp.SessionId) (acp.PromptResponse, error) { + // The task deadline has expired, but its connection may still be usable. + notice, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + // A best-effort notice must not replace the machine-readable turn outcome. + // A broken transport still closes normally and may prevent response delivery. + _ = a.update(notice, id, "OCR timed out before this turn completed. Retry or increase --turn-timeout.") + return timeoutResponse(), nil +} diff --git a/acp/internal/adapter/prompt_test.go b/acp/internal/adapter/prompt_test.go new file mode 100644 index 000000000..0576add87 --- /dev/null +++ b/acp/internal/adapter/prompt_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent" + acp "github.com/coder/acp-go-sdk" +) + +func TestRejectedGuidancePreservesTerminalFailures(t *testing.T) { + a := NewAgent("ocr", &captureRunner{}) + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + resp, err := a.sendRejection(cancelled, "s", "Use /review.") + if err != nil || resp.StopReason != acp.StopReasonCancelled || resp.Meta != nil { + t.Fatalf("cancellation overwritten: %+v, %v", resp, err) + } + expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + resp, err = a.sendRejection(expired, "s", "Use /review.") + ocr, _ := resp.Meta["ocr"].(map[string]any) + if err != nil || ocr["kind"] != "timed_out" { + t.Fatalf("timeout overwritten: %+v, %v", resp, err) + } + sink := &discoveryRecorder{err: context.Canceled} + a.SetAgentConnection(sink) + resp, err = a.sendRejection(context.Background(), "s", "Use /review.") + if err != nil || resp.StopReason != acp.StopReasonCancelled || resp.Meta != nil { + t.Fatalf("transport cancellation overwritten: %+v, %v", resp, err) + } + sink.err = errors.New("disconnected") + if _, err = a.sendRejection(context.Background(), "s", "Use /review."); !errors.Is(err, sink.err) { + t.Fatalf("send error lost: %v", err) + } +} + +type noticeRecorder struct { + text string + expired bool +} + +func (r *noticeRecorder) SessionUpdate(ctx context.Context, n acp.SessionNotification) error { + r.expired = ctx.Err() != nil + if n.Update.AgentMessageChunk != nil { + r.text += n.Update.AgentMessageChunk.Content.Text.Text + } + return nil +} + +func TestTimeoutHasVisibleNotice(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}, intent.NewParser(waitingParser{make(chan struct{})}, nil)) + a.TurnTimeout = 10 * time.Millisecond + recorder := ¬iceRecorder{} + a.SetAgentConnection(recorder) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: t.TempDir()}) + _, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("review please")}}) + if err != nil { + t.Fatal(err) + } + if recorder.expired || !strings.Contains(recorder.text, "timed out") || !strings.Contains(recorder.text, "Retry") { + t.Fatalf("notice: %+v", recorder) + } +} + +func TestSessionRejectsUnknownCommit(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + recorder := ¬iceRecorder{} + a.SetAgentConnection(recorder) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: t.TempDir()}) + r, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review --commit missing-ref")}}) + if err != nil { + t.Fatal(err) + } + if r.StopReason != acp.StopReasonEndTurn || !strings.Contains(recorder.text, "could not resolve that ref") || a.sessions[s.SessionId].state.Pending() == nil { + t.Fatalf("invalid repository ref accepted: %+v", r) + } +} diff --git a/acp/internal/adapter/resources.go b/acp/internal/adapter/resources.go new file mode 100644 index 000000000..9351ad92c --- /dev/null +++ b/acp/internal/adapter/resources.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "fmt" + "net/url" + "path/filepath" + "strings" + + "github.com/alibaba/open-code-review/acp/internal/adapter/workspace" + acp "github.com/coder/acp-go-sdk" +) + +// Resources select local scan targets; they are never fetched or interpreted +// as instructions, and never silently narrow a repository-wide review. +func promptContent(cwd string, blocks []acp.ContentBlock) (string, []string, error) { + if len(blocks) > 128 { + return "", nil, fmt.Errorf("too many prompt blocks (maximum 128)") + } + var texts, paths []string + size := 0 + for _, block := range blocks { + switch { + case block.Text != nil: + size += len(block.Text.Text) + texts = append(texts, block.Text.Text) + case block.ResourceLink != nil: + uri := block.ResourceLink.Uri + size += len(uri) + if len(uri) > 8192 { + return "", nil, fmt.Errorf("resource URI is too long") + } + u, err := url.Parse(uri) + if err != nil || u.Scheme != "file" || (u.Host != "" && u.Host != "localhost") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", nil, fmt.Errorf("resources must be local absolute file URIs without query or fragment") + } + native := workspace.NativePathFromFileURL(u) + if !filepath.IsAbs(native) { + return "", nil, fmt.Errorf("resources must be local absolute file URIs without query or fragment") + } + if base := filepath.Base(native); base != strings.TrimSpace(base) { + return "", nil, fmt.Errorf("resource path has leading or trailing whitespace") + } + path, f, err := workspace.OpenLocalFile(cwd, native) + if err != nil { + return "", nil, fmt.Errorf("resource must be an existing file inside the session directory") + } + f.Close() + root, err := filepath.EvalSymlinks(cwd) + if err != nil { + return "", nil, fmt.Errorf("session directory is unavailable") + } + path, err = filepath.Rel(root, path) + if err != nil { + return "", nil, fmt.Errorf("resource cannot be resolved relative to the session directory") + } + // OCR normalizes leading/trailing whitespace in --path values. + // Reject such names instead of allowing the CLI to select a + // different file than the client requested. + if path != strings.TrimSpace(path) { + return "", nil, fmt.Errorf("resource path has leading or trailing whitespace") + } + paths = append(paths, filepath.ToSlash(path)) + default: + return "", nil, fmt.Errorf("unsupported content: send text or a local file resource link") + } + if size > 64*1024 { + return "", nil, fmt.Errorf("prompt exceeds 64 KiB") + } + } + return strings.Join(texts, "\n"), paths, nil +} diff --git a/acp/internal/adapter/resources_test.go b/acp/internal/adapter/resources_test.go new file mode 100644 index 000000000..36f1b795f --- /dev/null +++ b/acp/internal/adapter/resources_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/adapter/workspace" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +func TestPromptContentResources(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file with space.go") + if err := os.WriteFile(path, []byte("package sample\n"), 0600); err != nil { + t.Fatal(err) + } + for _, name := range []string{" leading.go", "trailing.go "} { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("package sample\n"), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := promptContent(dir, []acp.ContentBlock{acp.ResourceLinkBlock(name, workspace.FileURLString(p, nil))}); err == nil { + t.Fatalf("accepted whitespace path %q", name) + } + } + uri := workspace.FileURLString(path, nil) + text, paths, err := promptContent(dir, []acp.ContentBlock{acp.TextBlock("/scan"), acp.TextBlock("--no-plan"), acp.ResourceLinkBlock("untrusted name", uri)}) + if err != nil || text != "/scan\n--no-plan" || len(paths) != 1 || paths[0] != "file with space.go" { + t.Fatalf("%q %v %v", text, paths, err) + } + for _, bad := range []string{"https://example.com/file", "file://remote/file", "file:///missing", uri + "?query", uri + "#fragment", "file:///bad%zz", "file:relative", "file://user@localhost/file", "file:///" + strings.Repeat("a", 8192)} { + if _, _, err := promptContent(dir, []acp.ContentBlock{acp.ResourceLinkBlock("x", bad)}); err == nil { + t.Fatalf("accepted %s", bad) + } + } + for _, blocks := range [][]acp.ContentBlock{{{}}, {acp.TextBlock(strings.Repeat("a", 65537))}, make([]acp.ContentBlock, 129)} { + if _, _, err := promptContent(dir, blocks); err == nil { + t.Fatal("accepted unsupported or oversized input") + } + } + outside := filepath.Join(t.TempDir(), "outside.go") + if err := os.WriteFile(outside, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link.go") + if err := os.Symlink(outside, link); err != nil { + return + } + if _, _, err := promptContent(dir, []acp.ContentBlock{acp.ResourceLinkBlock("link", workspace.FileURLString(link, nil))}); err == nil { + t.Fatal("accepted symlink escape") + } +} + +func TestPromptContentRejectsUnavailableResources(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.go") + if err := os.WriteFile(outside, []byte("package sample\n"), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + path string + }{ + {"directory", root}, + {"missing", filepath.Join(root, "missing.go")}, + {"outside", outside}, + } { + t.Run(tc.name, func(t *testing.T) { + text, paths, err := promptContent(root, []acp.ContentBlock{acp.ResourceLinkBlock("file", workspace.FileURLString(tc.path, nil))}) + if err == nil || err.Error() != "resource must be an existing file inside the session directory" || text != "" || paths != nil { + t.Fatalf("resource rejection = %q, %v, %v", text, paths, err) + } + }) + } +} + +func TestPromptContentRejectsUnreadableResource(t *testing.T) { + root, path := unreadableLocalFile(t) + text, paths, err := promptContent(root, []acp.ContentBlock{acp.ResourceLinkBlock("file", workspace.FileURLString(path, nil))}) + if err == nil || err.Error() != "resource must be an existing file inside the session directory" || text != "" || paths != nil { + t.Fatalf("unreadable resource rejection = %q, %v, %v", text, paths, err) + } +} + +func TestPromptContentCanonicalResourcePath(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "nested") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "source.go") + if err := os.WriteFile(path, []byte("package sample\n"), 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias.go") + if err := os.Symlink(path, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + text, paths, err := promptContent(root, []acp.ContentBlock{acp.TextBlock("/scan"), acp.ResourceLinkBlock("alias", workspace.FileURLString(alias, nil))}) + if err != nil || text != "/scan" || len(paths) != 1 || paths[0] != "nested/source.go" { + t.Fatalf("canonical resource = %q, %v, %v", text, paths, err) + } +} + +type captureRunner struct{ requests []orchestrator.Request } + +func (r *captureRunner) Run(ctx context.Context, request orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + r.requests = append(r.requests, request) + return fakeRunner{}.Run(ctx, request) +} + +func TestResourceRouting(t *testing.T) { + dir := testGitDir(t) + path := filepath.Join(dir, "file.go") + if err := os.WriteFile(path, []byte("package sample\n"), 0600); err != nil { + t.Fatal(err) + } + link := acp.ResourceLinkBlock("sample", workspace.FileURLString(path, nil)) + runner := &captureRunner{} + agent := NewAgent("ocr", runner) + session, err := agent.NewSession(context.Background(), acp.NewSessionRequest{Cwd: dir}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + text string + stop acp.StopReason + runs int + }{ + {"", acp.StopReasonEndTurn, 0}, + {"/review", acp.StopReasonEndTurn, 0}, + {"/scan --path file.go", acp.StopReasonEndTurn, 0}, + {"/scan", acp.StopReasonEndTurn, 1}, + } { + response, err := agent.Prompt(context.Background(), acp.PromptRequest{SessionId: session.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock(tc.text), link}}) + if err != nil || response.StopReason != tc.stop || len(runner.requests) != tc.runs { + t.Fatalf("%q: %+v %v runs=%d", tc.text, response, err, len(runner.requests)) + } + } + if !strings.Contains(strings.Join(runner.requests[0].Args, " "), "--path file.go") { + t.Fatal(runner.requests) + } + r, err := agent.Prompt(context.Background(), acp.PromptRequest{SessionId: session.SessionId, Prompt: []acp.ContentBlock{{}}}) + if err != nil || r.StopReason != acp.StopReasonEndTurn { + t.Fatalf("unsupported: %+v %v", r, err) + } +} + +func TestCommaResourcePathGetsVisibleRejectionWithoutRunningOCR(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a,b.go") + if err := os.WriteFile(path, []byte("package sample\n"), 0600); err != nil { + t.Fatal(err) + } + runner := &captureRunner{} + agent := NewAgent("ocr", runner) + session, err := agent.NewSession(context.Background(), acp.NewSessionRequest{Cwd: dir}) + if err != nil { + t.Fatal(err) + } + sink := &discoveryRecorder{} + agent.SetAgentConnection(sink) + response, err := agent.Prompt(context.Background(), acp.PromptRequest{ + SessionId: session.SessionId, + Prompt: []acp.ContentBlock{acp.TextBlock("/scan"), acp.ResourceLinkBlock("sample", workspace.FileURLString(path, nil))}, + }) + if len(runner.requests) != 0 { + t.Fatal("invalid resource started OCR") + } + if err != nil || response.StopReason != acp.StopReasonEndTurn { + t.Fatalf("response=%+v error=%v", response, err) + } + meta, _ := response.Meta["ocr"].(map[string]any) + if meta["kind"] != "rejected" { + t.Fatalf("missing rejection metadata: %+v", response) + } + if len(sink.updates) != 1 || sink.updates[0].SessionId != session.SessionId { + t.Fatalf("updates=%+v", sink.updates) + } + chunk := sink.updates[0].Update.AgentMessageChunk + if chunk == nil || chunk.Content.Text == nil || !strings.Contains(chunk.Content.Text.Text, "path must not contain a comma") { + t.Fatalf("missing actionable guidance: %+v", chunk) + } +} + +type discoveryRecorder struct { + updates []acp.SessionNotification + err error +} + +func (r *discoveryRecorder) SessionUpdate(_ context.Context, n acp.SessionNotification) error { + r.updates = append(r.updates, n) + return r.err +} + +func TestNewSessionDoesNotNotifyBeforeResponse(t *testing.T) { + agent := NewAgent("ocr", fakeRunner{}) + sink := &discoveryRecorder{err: errors.New("must not send before response")} + agent.SetAgentConnection(sink) + s, err := agent.NewSession(context.Background(), acp.NewSessionRequest{Cwd: t.TempDir()}) + if err != nil || s.SessionId == "" || len(sink.updates) != 0 { + t.Fatalf("premature discovery: session=%+v updates=%+v error=%v", s, sink.updates, err) + } +} diff --git a/acp/internal/adapter/server_test.go b/acp/internal/adapter/server_test.go new file mode 100644 index 000000000..22742c62b --- /dev/null +++ b/acp/internal/adapter/server_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/contract" + "github.com/alibaba/open-code-review/acp/internal/orchestrator" + acp "github.com/coder/acp-go-sdk" +) + +type fakeRunner struct{} + +func (fakeRunner) Run(context.Context, orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + e := make(chan orchestrator.Event) + close(e) + o := make(chan orchestrator.Outcome, 1) + o <- orchestrator.Outcome{Kind: orchestrator.OutcomeCompleted, Result: &orchestrator.Result{Review: &contract.ReviewResult{Message: "done"}}} + close(o) + return e, o +} + +type errorRunner struct{ kind orchestrator.OutcomeKind } + +func (r errorRunner) Run(context.Context, orchestrator.Request) (<-chan orchestrator.Event, <-chan orchestrator.Outcome) { + e := make(chan orchestrator.Event, 1) + e <- orchestrator.Event{Kind: orchestrator.EventWarning, Message: "warn"} + close(e) + o := make(chan orchestrator.Outcome, 1) + o <- orchestrator.Outcome{Kind: r.kind, Err: fmt.Errorf("failure")} + close(o) + return e, o +} + +func TestAgentSessionAndPrompt(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + dir := testGitDir(t) + got, err := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: filepath.Clean(dir), McpServers: []acp.McpServer{}}) + if err != nil { + t.Fatal(err) + } + resp, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: got.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + if err != nil { + t.Fatal(err) + } + if resp.StopReason != acp.StopReasonEndTurn { + t.Fatalf("stop=%s", resp.StopReason) + } + if _, err := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: "relative", McpServers: []acp.McpServer{}}); err == nil { + t.Fatal("expected absolute cwd validation") + } + _ = os.ErrNotExist +} + +func TestAgentLifecycleMethods(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + if _, err := a.Initialize(context.Background(), acp.InitializeRequest{}); err != nil { + t.Fatal(err) + } + if _, err := a.Authenticate(context.Background(), acp.AuthenticateRequest{}); err != nil { + t.Fatal(err) + } + if _, err := a.Logout(context.Background(), acp.LogoutRequest{}); err != nil { + t.Fatal(err) + } + if _, err := a.SetSessionMode(context.Background(), acp.SetSessionModeRequest{}); err != nil { + t.Fatal(err) + } + if _, err := a.SetSessionConfigOption(context.Background(), acp.SetSessionConfigOptionRequest{}); err != nil { + t.Fatal(err) + } + if _, err := a.ResumeSession(context.Background(), acp.ResumeSessionRequest{}); err == nil { + t.Fatal("expected unsupported resume") + } + if _, err := a.ListSessions(context.Background(), acp.ListSessionsRequest{}); err == nil { + t.Fatal("expected unsupported list") + } + d := testGitDir(t) + s, err := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: d, McpServers: []acp.McpServer{}}) + if err != nil { + t.Fatal(err) + } + if err := a.Cancel(context.Background(), acp.CancelNotification{SessionId: s.SessionId}); err != nil { + t.Fatal(err) + } + if _, err := a.CloseSession(context.Background(), acp.CloseSessionRequest{SessionId: s.SessionId}); err != nil { + t.Fatal(err) + } +} + +func TestSetConnection(t *testing.T) { a := NewAgent("ocr", fakeRunner{}); a.SetAgentConnection(nil) } + +func TestPromptRejectAndClarify(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + d := testGitDir(t) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: d, McpServers: []acp.McpServer{}}) + if r, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/unknown")}}); err != nil || r.StopReason != acp.StopReasonEndTurn { + t.Fatalf("reject: %v %+v", err, r) + } + if r, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review --from main")}}); err != nil || r.StopReason != acp.StopReasonEndTurn { + t.Fatalf("clarify: %v %+v", err, r) + } +} + +func TestPromptOutcomeBranches(t *testing.T) { + d := testGitDir(t) + for _, kind := range []orchestrator.OutcomeKind{orchestrator.OutcomeFailed, orchestrator.OutcomeTimedOut} { + a := NewAgent("ocr", errorRunner{kind: kind}) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: d, McpServers: []acp.McpServer{}}) + _, _ = a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}) + } +} + +func TestPromptErrorsAndCancel(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + if _, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: "missing"}); err == nil { + t.Fatal("expected unknown session") + } + if err := a.Cancel(context.Background(), acp.CancelNotification{SessionId: "missing"}); err != nil { + t.Fatal(err) + } + d := testGitDir(t) + _, _ = a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: d, McpServers: []acp.McpServer{}}) + if _, err := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: filepath.Join(d, "missing"), McpServers: []acp.McpServer{}}); err == nil { + t.Fatal("expected invalid cwd") + } + _ = os.ErrNotExist +} + +func TestPromptScanAndTimeout(t *testing.T) { + a := NewAgent("ocr", fakeRunner{}) + d := testGitDir(t) + s, _ := a.NewSession(context.Background(), acp.NewSessionRequest{Cwd: d, McpServers: []acp.McpServer{}}) + if _, err := a.Prompt(context.Background(), acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/scan")}}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := a.Prompt(ctx, acp.PromptRequest{SessionId: s.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("/review")}}); err == nil { /* parser may reject before cancellation */ + } +} diff --git a/acp/internal/adapter/session.go b/acp/internal/adapter/session.go new file mode 100644 index 000000000..96b308c18 --- /dev/null +++ b/acp/internal/adapter/session.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package adapter + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/alibaba/open-code-review/acp/internal/intent" + acp "github.com/coder/acp-go-sdk" +) + +type session struct { + commandsSent bool + closing bool + cwd string + state *intent.State + cancel context.CancelFunc + busy bool + done chan struct{} +} + +func (a *Agent) NewSession(_ context.Context, p acp.NewSessionRequest) (acp.NewSessionResponse, error) { + if !filepath.IsAbs(p.Cwd) { + return acp.NewSessionResponse{}, fmt.Errorf("cwd must be absolute") + } + st, e := os.Stat(p.Cwd) + if e != nil || !st.IsDir() { + return acp.NewSessionResponse{}, fmt.Errorf("invalid cwd") + } + a.mu.Lock() + if a.closed { + a.mu.Unlock() + return acp.NewSessionResponse{}, fmt.Errorf("server_closed") + } + a.nextID++ + id := acp.SessionId(fmt.Sprintf("s-%d", a.nextID)) + a.sessions[id] = &session{cwd: p.Cwd, state: &intent.State{}} + a.mu.Unlock() + return acp.NewSessionResponse{SessionId: id}, nil +} + +func (a *Agent) Cancel(_ context.Context, p acp.CancelNotification) error { + a.mu.Lock() + defer a.mu.Unlock() + if s := a.sessions[p.SessionId]; s != nil { + s.state.Clear() + if s.cancel != nil { + s.cancel() + } + } + return nil +} + +func (a *Agent) CloseSession(ctx context.Context, p acp.CloseSessionRequest) (acp.CloseSessionResponse, error) { + a.mu.Lock() + s := a.sessions[p.SessionId] + var done <-chan struct{} + if s != nil { + s.closing = true + s.state.Clear() + if s.cancel != nil { + s.cancel() + } + done = s.done + } + a.mu.Unlock() + if done != nil { + select { + case <-done: + case <-ctx.Done(): + return acp.CloseSessionResponse{}, ctx.Err() + } + } + a.mu.Lock() + delete(a.sessions, p.SessionId) + a.mu.Unlock() + return acp.CloseSessionResponse{}, nil +} + +// Shutdown rejects new work, cancels active turns and waits for reader cleanup. +func (a *Agent) Shutdown(ctx context.Context) error { + a.mu.Lock() + a.closed = true + var pending []<-chan struct{} + for _, s := range a.sessions { + s.state.Clear() + if s.cancel != nil { + s.cancel() + } + if s.done != nil { + pending = append(pending, s.done) + } + } + a.mu.Unlock() + for _, done := range pending { + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +// beginTurn marks the session busy and installs its cancellation context under +// the agent lock. Every successful call must be paired with endTurn. +func (a *Agent) beginTurn(ctx context.Context, id acp.SessionId) (*session, context.Context, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed { + return nil, nil, fmt.Errorf("server_closed") + } + s := a.sessions[id] + if s == nil { + return nil, nil, fmt.Errorf("unknown session") + } + if s.closing { + return nil, nil, fmt.Errorf("session_closed") + } + if s.busy { + return nil, nil, fmt.Errorf("session_busy") + } + s.busy = true + s.done = make(chan struct{}) + task, cancel := context.WithCancel(ctx) + if a.TurnTimeout > 0 { + cancel() + task, cancel = context.WithTimeout(ctx, a.TurnTimeout) + } + s.cancel = cancel + return s, task, nil +} + +// endTurn releases the session only after its prompt and runner cleanup finish. +// The busy flag keeps s.cancel owned by this turn until it is cleared here. +func (a *Agent) endTurn(ctx context.Context, id acp.SessionId, s *session) { + a.mu.Lock() + defer a.mu.Unlock() + if ctx.Err() != nil { + s.state.Clear() + } + s.cancel() + s.busy = false + s.cancel = nil + close(s.done) + if s.closing { + delete(a.sessions, id) + } +} diff --git a/acp/internal/adapter/transport/connection.go b/acp/internal/adapter/transport/connection.go new file mode 100644 index 000000000..a95411593 --- /dev/null +++ b/acp/internal/adapter/transport/connection.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package transport implements ACP dispatch and ordered notifications over the SDK connection. +package transport + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sync" + "time" + + acp "github.com/coder/acp-go-sdk" +) + +// Connection uses the SDK framing and schema validation while leaving prompt +// cancellation to Agent. AgentSideConnection v0.13.5 cancels an existing prompt +// before the agent can reject a new one as busy. +type Connection struct{ *acp.Connection } + +// timedWriter closes a stalled transport, including the read side, so the SDK +// releases its write lock and reports connection loss to the shutdown owner. +type timedWriter struct { + output io.Writer + input io.Reader + timeout time.Duration + once sync.Once +} + +type writeDeadliner interface { + io.Writer + SetWriteDeadline(time.Time) error +} + +func (w *timedWriter) abort() { + w.once.Do(func() { + if c, ok := w.output.(io.Closer); ok { + _ = c.Close() + } + if c, ok := w.input.(io.Closer); ok { + _ = c.Close() + } + }) +} + +func (w *timedWriter) Write(p []byte) (int, error) { + if file, ok := w.output.(writeDeadliner); ok { + // Reject non-pollable files before entering a potentially blocking write. + if err := file.SetWriteDeadline(time.Now().Add(w.timeout)); err != nil { + w.abort() + return 0, err + } + n, err := file.Write(p) + if err != nil { + w.abort() + } + return n, err + } + if _, ok := w.output.(io.Closer); !ok { + return 0, fmt.Errorf("ACP output must support Close to interrupt blocked writes") + } + timer := time.AfterFunc(w.timeout, w.abort) + defer timer.Stop() + return w.output.Write(p) +} + +// NewConnection serves the SDK protocol without changing Agent cancellation ownership. +// claimDiscovery atomically claims command discovery for a live session. +func NewConnection(agent acp.Agent, output io.Writer, input io.Reader, claimDiscovery func(acp.SessionId) bool) *Connection { + w := &timedWriter{output: output, input: input, timeout: 2 * time.Second} + handler := func(ctx context.Context, method string, params json.RawMessage) (any, *acp.RequestError) { + return dispatch(ctx, agent, method, params) + } + return &Connection{acp.NewConnection(handler, &discoveryWriter{output: w, claim: claimDiscovery}, input)} +} + +func (c *Connection) SessionUpdate(ctx context.Context, p acp.SessionNotification) error { + if err := p.Validate(); err != nil { + return err + } + return c.SendNotification(ctx, acp.ClientMethodSessionUpdate, p) +} diff --git a/acp/internal/adapter/transport/discovery.go b/acp/internal/adapter/transport/discovery.go new file mode 100644 index 000000000..571711ffd --- /dev/null +++ b/acp/internal/adapter/transport/discovery.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package transport + +import ( + "encoding/json" + "io" + + acp "github.com/coder/acp-go-sdk" +) + +// The pinned SDK writes each complete JSON-RPC frame under its write mutex, +// but exposes no after-response callback. Publish discovery here only after +// the new-session response has been written successfully, under that same +// mutex. Calling SendNotification here would recursively acquire the mutex. +// This guarantees wire order, not a client-side registration acknowledgement. +type discoveryWriter struct { + output *timedWriter + claim func(acp.SessionId) bool +} + +func (w *discoveryWriter) Write(frame []byte) (int, error) { + n, err := w.output.Write(frame) + if err != nil || n != len(frame) { + if err == nil { + err = io.ErrShortWrite + } + w.output.abort() + return n, err + } + var response struct { + ID json.RawMessage `json:"id"` + Error json.RawMessage `json:"error"` + Result struct { + SessionId acp.SessionId `json:"sessionId"` + } `json:"result"` + } + if json.Unmarshal(frame, &response) != nil || len(response.ID) == 0 || len(response.Error) > 0 || response.Result.SessionId == "" { + return n, nil + } + if !w.claim(response.Result.SessionId) { + return n, nil + } + notification := acp.SessionNotification{SessionId: response.Result.SessionId, Update: commandUpdate()} + payload, err := json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params acp.SessionNotification `json:"params"` + }{"2.0", acp.ClientMethodSessionUpdate, notification}) + if err == nil { + payload = append(payload, '\n') + var written int + written, err = w.output.Write(payload) + if err == nil && written != len(payload) { + err = io.ErrShortWrite + } + } + if err != nil { + w.output.abort() + } + return n, err +} + +func commandUpdate() acp.SessionUpdate { + return acp.SessionUpdate{AvailableCommandsUpdate: &acp.SessionAvailableCommandsUpdate{ + SessionUpdate: "available_commands_update", + AvailableCommands: []acp.AvailableCommand{ + {Name: "review", Description: "Review current Git changes by default. For a commit use /review --commit ; for a range use /review --from main --to HEAD.", Input: &acp.AvailableCommandInput{Unstructured: &acp.UnstructuredCommandInput{Hint: "[--commit | --from --to ] [--effort low|medium|high]"}}}, + {Name: "scan", Description: "Scan the session directory, including non-Git files. Select files or directories with /scan --path src or /scan --path a.go,b.go.", Input: &acp.AvailableCommandInput{Unstructured: &acp.UnstructuredCommandInput{Hint: "[--path ] [--batch none|by-language|by-directory]"}}}, + }, + }} +} diff --git a/acp/internal/adapter/transport/discovery_test.go b/acp/internal/adapter/transport/discovery_test.go new file mode 100644 index 000000000..d37f9940d --- /dev/null +++ b/acp/internal/adapter/transport/discovery_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package transport + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + "time" + + acp "github.com/coder/acp-go-sdk" +) + +type discoveryOutput struct { + bytes.Buffer + calls int + failAt int + short bool + closed bool +} + +func (w *discoveryOutput) Write(p []byte) (int, error) { + w.calls++ + if w.calls == w.failAt { + if w.short { + return 0, nil + } + return 0, errors.New("disconnected") + } + return w.Buffer.Write(p) +} +func (w *discoveryOutput) Close() error { w.closed = true; return nil } + +func TestDiscoveryWriterFailureAndDuplicateBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + failAt int + short, closing, shutdown bool + }{ + {name: "normal"}, {name: "response error", failAt: 1}, {name: "response short", failAt: 1, short: true}, + {name: "notification error", failAt: 2}, {name: "notification short", failAt: 2, short: true}, + {name: "closing", closing: true}, {name: "shutdown", shutdown: true}, + } { + t.Run(tc.name, func(t *testing.T) { + id := acp.SessionId("s-1") + claimed := false + claim := func(sessionID acp.SessionId) bool { + if sessionID != id || tc.closing || tc.shutdown || claimed { + return false + } + claimed = true + return true + } + out := &discoveryOutput{failAt: tc.failAt, short: tc.short} + w := &discoveryWriter{output: &timedWriter{output: out, timeout: time.Second}, claim: claim} + frame, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "result": acp.NewSessionResponse{SessionId: id}}) + if err != nil { + t.Fatal(err) + } + _, err = w.Write(append(frame, '\n')) + if tc.failAt != 0 { + if err == nil || !out.closed { + t.Fatalf("failure not aborting: %v closed=%v", err, out.closed) + } + return + } + if err != nil { + t.Fatal(err) + } + want := 2 + if tc.closing || tc.shutdown { + want = 1 + } + if out.calls != want { + t.Fatalf("writes=%d want=%d", out.calls, want) + } + if _, err = w.Write(frame); err != nil { + t.Fatal(err) + } + if out.calls != want+1 { + t.Fatal("duplicate discovery") + } + for _, ignored := range []string{`{"jsonrpc":"2.0","id":2,"result":{"sessionId":"missing"}}`, `{"jsonrpc":"2.0","id":3,"error":{"code":-1}}`, `not json`} { + if _, err = w.Write([]byte(ignored)); err != nil { + t.Fatal(err) + } + } + }) + } +} diff --git a/acp/internal/adapter/transport/dispatch.go b/acp/internal/adapter/transport/dispatch.go new file mode 100644 index 000000000..6d27cfd52 --- /dev/null +++ b/acp/internal/adapter/transport/dispatch.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package transport + +import ( + "context" + "encoding/json" + "errors" + + acp "github.com/coder/acp-go-sdk" +) + +func dispatch(ctx context.Context, a acp.Agent, method string, params json.RawMessage) (any, *acp.RequestError) { + switch method { + case acp.AgentMethodInitialize: + return dispatchRequest(ctx, params, a.Initialize) + case acp.AgentMethodAuthenticate: + return dispatchRequest(ctx, params, a.Authenticate) + case acp.AgentMethodLogout: + return dispatchRequest(ctx, params, a.Logout) + case acp.AgentMethodSessionNew: + return dispatchRequest(ctx, params, a.NewSession) + case acp.AgentMethodSessionPrompt: + return dispatchRequest(ctx, params, a.Prompt) + case acp.AgentMethodSessionCancel: + return dispatchRequest(ctx, params, func(ctx context.Context, p acp.CancelNotification) (any, error) { + return nil, a.Cancel(ctx, p) + }) + case acp.AgentMethodSessionClose: + return dispatchRequest(ctx, params, a.CloseSession) + case acp.AgentMethodSessionSetMode: + return dispatchRequest(ctx, params, a.SetSessionMode) + case acp.AgentMethodSessionSetConfigOption: + return dispatchRequest(ctx, params, a.SetSessionConfigOption) + case acp.AgentMethodSessionResume: + return dispatchRequest(ctx, params, a.ResumeSession) + case acp.AgentMethodSessionList: + return dispatchRequest(ctx, params, a.ListSessions) + default: + return nil, acp.NewMethodNotFound(method) + } +} + +func dispatchRequest[P any, R any, V interface { + *P + Validate() error +}](ctx context.Context, raw json.RawMessage, call func(context.Context, P) (R, error)) (any, *acp.RequestError) { + var p P + if err := json.Unmarshal(raw, &p); err != nil { + return nil, acp.NewInvalidParams(map[string]any{"error": err.Error()}) + } + if err := V(&p).Validate(); err != nil { + return nil, acp.NewInvalidParams(map[string]any{"error": err.Error()}) + } + r, err := call(ctx, p) + if err == nil { + return r, nil + } + var rpcErr *acp.RequestError + if errors.As(err, &rpcErr) { + return nil, rpcErr + } + if errors.Is(err, context.Canceled) { + return nil, acp.NewRequestCancelled(nil) + } + return nil, acp.NewInternalError(map[string]any{"error": err.Error()}) +} diff --git a/acp/internal/adapter/workspace/files.go b/acp/internal/adapter/workspace/files.go new file mode 100644 index 000000000..e13183055 --- /dev/null +++ b/acp/internal/adapter/workspace/files.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package workspace + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// Location is a validated local file and optional one-based line number. +type Location struct { + Path string + Line *int +} + +// OpenLocalFile opens an existing regular file contained within root and returns +// its canonical path. The caller must close the file on success. +func OpenLocalFile(root, path string) (string, *os.File, error) { + if !filepath.IsAbs(root) || path == "" || strings.ContainsAny(path, "\x00\r\n") { + return "", nil, fmt.Errorf("invalid local file path") + } + if !filepath.IsAbs(path) { + if strings.ContainsAny(path, `\:`) { + return "", nil, fmt.Errorf("invalid local file path") + } + for _, part := range strings.Split(path, "/") { + if part == ".." { + return "", nil, fmt.Errorf("invalid local file path") + } + } + } + base, err := filepath.EvalSymlinks(root) + if err != nil { + return "", nil, err + } + if !filepath.IsAbs(path) { + path = filepath.Join(base, path) + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return "", nil, err + } + rel, err := filepath.Rel(base, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", nil, fmt.Errorf("local file is outside root") + } + info, err := os.Stat(path) + if err != nil { + return "", nil, err + } + if !info.Mode().IsRegular() { + return "", nil, fmt.Errorf("path is not a regular file") + } + f, err := os.Open(path) + if err != nil { + return "", nil, err + } + info, err = f.Stat() + if err != nil { + f.Close() + return "", nil, err + } + if !info.Mode().IsRegular() { + f.Close() + return "", nil, fmt.Errorf("path is not a regular file") + } + return path, f, nil +} + +// ResolveLocation only adds navigation when the existing file and the full +// reported line range are valid. Callers must retain finding text on failure. +func ResolveLocation(root, path string, startLine, endLine int) *Location { + canonical, f, err := OpenLocalFile(root, path) + if err != nil { + return nil + } + defer f.Close() + location := &Location{Path: canonical} + if startLine == 0 && endLine == 0 { + return location + } + if startLine < 1 || endLine < startLine || !hasLine(f, endLine) { + return nil + } + line := startLine + location.Line = &line + return location +} + +// hasLine reads bounded fragments even for an arbitrarily long source line. +// A trailing newline terminates its line; it does not create another one. +func hasLine(r io.Reader, wanted int) bool { + reader := bufio.NewReader(r) + line := 1 + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 && line == wanted { + return true + } + if err == bufio.ErrBufferFull { + continue + } + if err != nil { + return false + } + line++ + } +} diff --git a/acp/internal/adapter/workspace/files_test.go b/acp/internal/adapter/workspace/files_test.go new file mode 100644 index 000000000..c58610c2e --- /dev/null +++ b/acp/internal/adapter/workspace/files_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFindingLocationBoundaries(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "file.go") + if err := os.WriteFile(file, []byte("one\ntwo\n"), 0600); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "outside.go") + if err := os.WriteFile(outside, []byte("outside\n"), 0600); err != nil { + t.Fatal(err) + } + hasSymlink := true + if err := os.Symlink(outside, filepath.Join(root, "escape.go")); err != nil { + hasSymlink = false + } else if err := os.Symlink(file, filepath.Join(root, "inside.go")); err != nil { + hasSymlink = false + } + for _, tc := range []struct { + name string + path string + start, end int + valid bool + }{ + {"line range", "file.go", 1, 2, true}, + {"absolute inside", file, 2, 2, true}, + {"file level", "file.go", 0, 0, true}, + {"internal symlink", "inside.go", 1, 1, true}, + {"symlink escape", "escape.go", 1, 1, false}, + {"absolute outside", outside, 1, 1, false}, + {"traversal", "../file.go", 1, 1, false}, + {"embedded traversal", "x/../file.go", 1, 1, false}, + {"missing", "missing.go", 1, 1, false}, + {"directory", ".", 0, 0, false}, + {"uri", "file:///file.go", 1, 1, false}, + {"windows path", "C:\\file.go", 1, 1, false}, + {"backslash relative", ".\\file.go", 1, 1, false}, + {"empty", "", 0, 0, false}, + {"newline", "file.go\n", 1, 1, false}, + {"beyond eof", "file.go", 3, 3, false}, + {"end beyond eof", "file.go", 1, 3, false}, + {"reversed", "file.go", 2, 1, false}, + {"negative", "file.go", -1, -1, false}, + {"missing end", "file.go", 1, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if !hasSymlink && (tc.name == "internal symlink" || tc.name == "symlink escape") { + t.Skip("symlinks unavailable") + } + got := ResolveLocation(root, tc.path, tc.start, tc.end) + if (got != nil) != tc.valid { + t.Fatalf("location = %#v; valid = %v", got, tc.valid) + } + if got != nil { + if !filepath.IsAbs(got.Path) { + t.Fatal("location not absolute") + } + if tc.start == 0 && got.Line != nil { + t.Fatal("invented file-level line") + } + if tc.start > 0 && (got.Line == nil || *got.Line != tc.start) { + t.Fatal("incorrect line") + } + } + }) + } +} + +func TestHasLineBounds(t *testing.T) { + for _, tc := range []struct { + text string + line int + want bool + }{ + {"", 1, false}, + {"one", 1, true}, + {"one\n", 2, false}, + {"one\ntwo", 2, true}, + {"\n\n", 2, true}, + {strings.Repeat("x", 32768) + "\nend", 2, true}, + {strings.Repeat("x", 32768), 2, false}, + } { + if got := hasLine(strings.NewReader(tc.text), tc.line); got != tc.want { + t.Fatalf("line %d, input length %d: got %v, want %v", tc.line, len(tc.text), got, tc.want) + } + } +} diff --git a/acp/internal/adapter/workspace/fileurl.go b/acp/internal/adapter/workspace/fileurl.go new file mode 100644 index 000000000..f81c8bc8b --- /dev/null +++ b/acp/internal/adapter/workspace/fileurl.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package workspace + +import ( + "fmt" + "net/url" + "path/filepath" + "runtime" + "strings" +) + +// NativePathFromFileURL converts a local file: URL path into an OS path. +// Callers must already have rejected non-file schemes, hosts, queries and fragments. +func NativePathFromFileURL(u *url.URL) string { + path := u.Path + if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' { + path = path[1:] + } + return filepath.FromSlash(path) +} + +// FileURLString formats a local path and optional line as a file URL. +func FileURLString(path string, line *int) string { + slash := filepath.ToSlash(path) + if runtime.GOOS == "windows" && len(slash) >= 2 && slash[1] == ':' && !strings.HasPrefix(slash, "/") { + slash = "/" + slash + } + u := url.URL{Scheme: "file", Path: slash} + if line != nil { + u.Fragment = fmt.Sprintf("L%d", *line) + } + return u.String() +} diff --git a/acp/internal/adapter/workspace/fileurl_test.go b/acp/internal/adapter/workspace/fileurl_test.go new file mode 100644 index 000000000..6e995b4de --- /dev/null +++ b/acp/internal/adapter/workspace/fileurl_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package workspace + +import ( + "net/url" + "path/filepath" + "runtime" + "testing" +) + +func TestFileURLRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file with space.go") + uri := FileURLString(path, nil) + parsed, err := url.Parse(uri) + if err != nil || parsed.Scheme != "file" { + t.Fatalf("uri=%q err=%v", uri, err) + } + native := NativePathFromFileURL(parsed) + if filepath.Clean(native) != filepath.Clean(path) { + t.Fatalf("native=%q path=%q uri=%q", native, path, uri) + } + if runtime.GOOS == "windows" { + if parsed.Host != "" || parsed.Path == "" || parsed.Path[0] != '/' { + t.Fatalf("windows file URI should be file:///C:/... got %q", uri) + } + } + line := 4 + withLine := FileURLString(path, &line) + if !filepath.IsAbs(path) { + t.Fatal(path) + } + parsed, err = url.Parse(withLine) + if err != nil || parsed.Fragment != "L4" { + t.Fatalf("line uri=%q err=%v", withLine, err) + } +} + +func TestFileURLRoundTripNonASCII(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "评审 file.go") // allow-non-english: Windows file URI fixture + uri := FileURLString(path, nil) + parsed, err := url.Parse(uri) + if err != nil { + t.Fatal(err) + } + native := NativePathFromFileURL(parsed) + if filepath.Clean(native) != filepath.Clean(path) { + t.Fatalf("native=%q path=%q uri=%q", native, path, uri) + } +} diff --git a/acp/internal/adapter/workspace/root.go b/acp/internal/adapter/workspace/root.go new file mode 100644 index 000000000..4f1f4786c --- /dev/null +++ b/acp/internal/adapter/workspace/root.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package workspace resolves OCR result roots and validates local file navigation. +package workspace + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// ResultRoot follows the CLI contract: review paths are relative to the Git +// root; scan paths are relative to --repo, or the process cwd when omitted. +func ResultRoot(ctx context.Context, cwd string, args []string) (string, error) { + if !filepath.IsAbs(cwd) || len(args) == 0 { + return "", fmt.Errorf("invalid result directory or operation") + } + root := cwd + switch args[0] { + case "review": + probe, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + cmd := exec.CommandContext(probe, "git", "-C", root, "rev-parse", "--show-toplevel") + cmd.WaitDelay = time.Second + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("resolve review root: %w", err) + } + root = filepath.Clean(strings.TrimSpace(string(out))) + case "scan": + default: + return "", fmt.Errorf("unsupported result operation") + } + canonical, err := filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + info, err := os.Stat(canonical) + if err != nil || !info.IsDir() { + return "", fmt.Errorf("result root is not a directory") + } + return canonical, nil +} diff --git a/acp/internal/adapter/workspace/root_test.go b/acp/internal/adapter/workspace/root_test.go new file mode 100644 index 000000000..e324c40c2 --- /dev/null +++ b/acp/internal/adapter/workspace/root_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package workspace + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestResultRootMatchesCLIOperation(t *testing.T) { + root := t.TempDir() + if out, err := exec.Command("git", "init", root).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + sub := filepath.Join(root, "sub") + if err := os.Mkdir(sub, 0700); err != nil { + t.Fatal(err) + } + canonical, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + args []string + want string + }{ + {"review walks to git root", []string{"review"}, canonical}, + {"scan stays in cwd", []string{"scan"}, filepath.Join(canonical, "sub")}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ResultRoot(context.Background(), sub, tc.args) + if err != nil || got != tc.want { + t.Fatalf("root = %q, %v; want %q", got, err, tc.want) + } + }) + } + for _, args := range [][]string{nil, {"unknown"}} { + if _, err := ResultRoot(context.Background(), root, args); err == nil { + t.Fatalf("expected invalid root for %v", args) + } + } + if _, err := ResultRoot(context.Background(), ".", []string{"scan"}); err == nil { + t.Fatal("relative cwd accepted") + } + if _, err := ResultRoot(context.Background(), t.TempDir(), []string{"review"}); err == nil { + t.Fatal("nonrepository review accepted") + } +} diff --git a/acp/internal/contract/intent.go b/acp/internal/contract/intent.go new file mode 100644 index 000000000..dd52a20a0 --- /dev/null +++ b/acp/internal/contract/intent.go @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package contract + +import ( + "fmt" + "path/filepath" + "slices" + "strconv" + "strings" +) + +// ReviewType represents the type of review operation. +type ReviewType string + +const ( + ReviewTypeWorkspace ReviewType = "workspace" + ReviewTypeRange ReviewType = "range" + ReviewTypeCommit ReviewType = "commit" +) + +// fixedArgs are the integration flags the adapter owns. They are appended last +// because flag parsing is last-wins, so a flag that slipped past the whitelist +// still could not override them. +var fixedArgs = []string{"--format", "json", "--audience", "human", "--color", "never"} + +// ReviewIntent represents a structured review request. +type ReviewIntent struct { + Type ReviewType + From string // Required for range + To string // Required for range + Commit string // Required for commit + + // Extra holds optional OCR CLI flags forwarded verbatim to the child + // process. Every element is validated against reviewExtraFlags: unknown + // flags, missing values, and enum values outside the allowed set are all + // rejected. Only the flags listed there can be forwarded; integration + // flags such as --format, --audience, --output and --repo are fixed by + // the adapters above and cannot be overridden from here. + Extra []string +} + +// ScanIntent represents a structured scan request. +type ScanIntent struct { + Paths []string // Relative paths to scan; empty means scan root + + // Extra mirrors ReviewIntent.Extra but is validated against + // scanExtraFlags, which is a different set: the OCR CLI registers + // different flags on `scan` than on `review`. + Extra []string +} + +// extraFlagSpec describes one flag that callers may forward through Extra. +type extraFlagSpec struct { + takesValue bool + // enum, when non-empty, lists the only values the flag accepts. The OCR + // CLI silently falls back to a default for unknown values on some flags + // (e.g. --batch), so the adapter rejects them instead of guessing. + enum []string +} + +// reviewExtraFlags is the whitelist for ReviewIntent.Extra. It matches the +// flags registered by registerReviewFlags in cmd/opencodereview/shared_flags.go. +var reviewExtraFlags = map[string]extraFlagSpec{ + "--effort": {takesValue: true, enum: []string{"low", "medium", "high"}}, + "--no-filter": {}, + "--background": {takesValue: true}, + "--background-file": {takesValue: true}, +} + +// scanExtraFlags is the whitelist for ScanIntent.Extra. It matches the flags +// registered by registerScanFlags in cmd/opencodereview/shared_flags.go. +// Note that `scan` has no --effort and `review` has no --batch, so a flag +// valid on one command is rejected on the other. +var scanExtraFlags = map[string]extraFlagSpec{ + "--batch": {takesValue: true, enum: []string{"none", "by-language", "by-directory"}}, + "--no-plan": {}, + "--no-dedup": {}, + "--no-summary": {}, + "--background": {takesValue: true}, +} + +// BuildReviewArgs constructs OCR CLI arguments from ReviewIntent. +// Returns error if intent is invalid, required fields are missing, or Extra +// contains a flag outside reviewExtraFlags. +func BuildReviewArgs(intent *ReviewIntent) ([]string, error) { + if intent == nil { + return nil, fmt.Errorf("review intent is nil") + } + + args := []string{"review"} + + switch intent.Type { + case ReviewTypeWorkspace: + // Workspace is the default mode; the range and commit fields are + // mutually exclusive with it, so a caller that sets them has a bug + // rather than a request to silently widen the scope. + if intent.From != "" || intent.To != "" || intent.Commit != "" { + return nil, fmt.Errorf("workspace review must not set from, to or commit") + } + case ReviewTypeRange: + if intent.Commit != "" { + return nil, fmt.Errorf("range review must not set commit") + } + if intent.From == "" && intent.To == "" { + return nil, fmt.Errorf("range review requires both --from and --to") + } else if intent.From == "" { + return nil, fmt.Errorf("range review requires --from") + } else if intent.To == "" { + return nil, fmt.Errorf("range review requires --to") + } + args = append(args, "--from", intent.From, "--to", intent.To) + case ReviewTypeCommit: + if intent.From != "" || intent.To != "" { + return nil, fmt.Errorf("commit review must not set from or to") + } + if intent.Commit == "" { + return nil, fmt.Errorf("commit review requires --commit") + } + args = append(args, "--commit", intent.Commit) + default: + return nil, fmt.Errorf("unknown review type: %s", intent.Type) + } + + if err := validateExtra(intent.Extra, reviewExtraFlags); err != nil { + return nil, err + } + args = append(args, intent.Extra...) + args = append(args, fixedArgs...) + + return args, nil +} + +// BuildScanArgs constructs OCR CLI arguments from ScanIntent. +// Returns error if a path escapes the scan root or Extra contains a flag +// outside scanExtraFlags. +func BuildScanArgs(intent *ScanIntent) ([]string, error) { + if intent == nil { + return nil, fmt.Errorf("scan intent is nil") + } + + args := []string{"scan"} + + if len(intent.Paths) > 0 { + cleaned := make([]string, 0, len(intent.Paths)) + for _, p := range intent.Paths { + c, err := normalizePath(p) + if err != nil { + return nil, err + } + cleaned = append(cleaned, c) + } + // The OCR CLI's --path is a single comma-separated string, not a + // repeatable flag: pflag keeps only the last occurrence of a scalar + // flag, so one --path per element would silently drop every path + // except the last. Joining is safe only because normalizePath + // rejects any element containing a comma; removing that check would + // let two paths merge into one. + args = append(args, "--path", strings.Join(cleaned, ",")) + } + + if err := validateExtra(intent.Extra, scanExtraFlags); err != nil { + return nil, err + } + args = append(args, intent.Extra...) + args = append(args, fixedArgs...) + + return args, nil +} + +// validateExtra rejects any element of extra that is not a whitelisted flag, +// is a whitelisted flag missing its value, or carries a value outside the +// flag's enum. Both "--flag value" and "--flag=value" forms are accepted. +// +// A separated value that itself looks like a flag is rejected rather than +// consumed, so ["--effort", "--no-filter"] reports a missing value instead of +// silently reading "--no-filter" as the effort. The same applies to an inline +// value: "--effort=--no-filter" is rejected. +// +// An empty inline value ("--background=") is accepted for flags without an +// enum, because the OCR CLI treats it the same as omitting the flag. Enum +// flags reject it, since "" is not one of their allowed values. +func validateExtra(extra []string, allowed map[string]extraFlagSpec) error { + for i := 0; i < len(extra); i++ { + arg := extra[i] + + name := arg + value := "" + hasInlineValue := false + if idx := strings.IndexByte(arg, '='); idx >= 0 { + name, value, hasInlineValue = arg[:idx], arg[idx+1:], true + } + + spec, ok := allowed[name] + if !ok { + return fmt.Errorf("flag not allowed: %s", arg) + } + + if !spec.takesValue { + // Boolean flags accept a bare form or an explicit --flag=. + // pflag parses that value with strconv.ParseBool (see pflag's + // boolConv), so validate with the same function to match the CLI + // exactly rather than guessing a narrower grammar. + if hasInlineValue { + if _, err := strconv.ParseBool(value); err != nil { + return fmt.Errorf("flag %s: invalid boolean value %q", name, value) + } + } + continue + } + + if !hasInlineValue { + i++ + if i >= len(extra) { + return fmt.Errorf("flag %s requires a value", name) + } + value = extra[i] + } + + if strings.HasPrefix(value, "-") { + return fmt.Errorf("flag %s requires a value, got %q which looks like a flag", name, value) + } + + if len(spec.enum) > 0 && !slices.Contains(spec.enum, strings.ToLower(value)) { + return fmt.Errorf("flag %s: invalid value %q (allowed: %s)", + name, value, strings.Join(spec.enum, ", ")) + } + } + return nil +} + +// normalizePath cleans a user-supplied relative path and rejects anything +// that escapes the target root. filepath.Clean collapses "." and "..", but +// it only treats the host separator as a separator: on Unix, "..\\..\\file" +// survives Clean unchanged. We therefore treat both "/" and "\\" as +// separators before cleaning, so a path written for either platform is +// judged the same way on every platform. +func normalizePath(p string) (string, error) { + if filepath.IsAbs(p) { + return "", fmt.Errorf("absolute path not allowed: %s", p) + } + // filepath.IsAbs does not recognise Windows drive letters on Unix. + if len(p) >= 2 && p[1] == ':' && isASCIILetter(p[0]) { + return "", fmt.Errorf("absolute path not allowed: %s", p) + } + if strings.Contains(p, ",") { + return "", fmt.Errorf("path must not contain a comma (--path is comma-separated): %s", p) + } + + unified := strings.ReplaceAll(p, `\`, "/") + // Unix absolute paths and Windows drive-root paths (\Windows) become a + // leading slash after unification. filepath.IsAbs does not treat them as + // absolute on the opposite OS, so reject the prefix explicitly. + if strings.HasPrefix(unified, "/") { + return "", fmt.Errorf("absolute path not allowed: %s", p) + } + cleaned := filepath.Clean(filepath.FromSlash(unified)) + + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes root: %s", p) + } + // Clean can return an absolute path on Windows after unification. + if filepath.IsAbs(cleaned) { + return "", fmt.Errorf("absolute path not allowed: %s", p) + } + // The CLI trims --path elements and drops empty ones. Reject padding so + // the requested target cannot change or silently widen to the scan root. + if cleaned != strings.TrimSpace(cleaned) { + return "", fmt.Errorf("path has leading or trailing whitespace: %s", p) + } + return filepath.ToSlash(cleaned), nil +} + +func isASCIILetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} diff --git a/acp/internal/contract/intent_test.go b/acp/internal/contract/intent_test.go new file mode 100644 index 000000000..5ef6585d4 --- /dev/null +++ b/acp/internal/contract/intent_test.go @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package contract + +import ( + "reflect" + "testing" +) + +func TestBuildReviewArgs(t *testing.T) { + tests := []struct { + name string + intent ReviewIntent + want []string + wantErr bool + }{ + { + name: "workspace review", + intent: ReviewIntent{Type: ReviewTypeWorkspace}, + want: []string{"review", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "range review", + intent: ReviewIntent{ + Type: ReviewTypeRange, + From: "main", + To: "feature", + }, + want: []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "commit review", + intent: ReviewIntent{ + Type: ReviewTypeCommit, + Commit: "abc123", + }, + want: []string{"review", "--commit", "abc123", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "range review missing from", + intent: ReviewIntent{ + Type: ReviewTypeRange, + To: "feature", + }, + wantErr: true, + }, + { + name: "commit review missing commit", + intent: ReviewIntent{ + Type: ReviewTypeCommit, + }, + wantErr: true, + }, + { + name: "workspace review with extra flags", + intent: ReviewIntent{ + Type: ReviewTypeWorkspace, + Extra: []string{"--effort", "high"}, + }, + want: []string{"review", "--effort", "high", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "unknown review type", + intent: ReviewIntent{ + Type: ReviewType("bogus"), + }, + wantErr: true, + }, + { + name: "range review missing to only", + intent: ReviewIntent{ + Type: ReviewTypeRange, + From: "main", + }, + wantErr: true, + }, + { + name: "range review missing from only", + intent: ReviewIntent{ + Type: ReviewTypeRange, + To: "feature", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BuildReviewArgs(&tt.intent) + if (err != nil) != tt.wantErr { + t.Errorf("BuildReviewArgs() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("BuildReviewArgs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBuildScanArgs(t *testing.T) { + tests := []struct { + name string + intent ScanIntent + want []string + wantErr bool + }{ + { + name: "scan root (no paths)", + intent: ScanIntent{}, + want: []string{"scan", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "scan single path", + intent: ScanIntent{ + Paths: []string{"internal/agent"}, + }, + want: []string{"scan", "--path", "internal/agent", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "scan multiple paths are comma-joined into one --path", + intent: ScanIntent{ + Paths: []string{"cmd", "internal"}, + }, + want: []string{"scan", "--path", "cmd,internal", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "reject absolute path", + intent: ScanIntent{ + Paths: []string{"/absolute/path"}, + }, + wantErr: true, + }, + { + name: "reject parent traversal", + intent: ScanIntent{ + Paths: []string{"../outside"}, + }, + wantErr: true, + }, + { + name: "clean path with dot", + intent: ScanIntent{ + Paths: []string{"./internal/agent"}, + }, + want: []string{"scan", "--path", "internal/agent", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + name: "reject bare parent", + intent: ScanIntent{ + Paths: []string{".."}, + }, + wantErr: true, + }, + { + name: "reject parent traversal after clean", + intent: ScanIntent{ + Paths: []string{"foo/../../bar"}, + }, + wantErr: true, + }, + { + name: "reject backslash traversal", + intent: ScanIntent{ + Paths: []string{`..\..\file`}, + }, + wantErr: true, + }, + { + name: "reject windows absolute path", + intent: ScanIntent{ + Paths: []string{`C:\Windows\System32`}, + }, + wantErr: true, + }, + { + name: "reject windows drive absolute with forward slash", + intent: ScanIntent{ + Paths: []string{"C:/Windows"}, + }, + wantErr: true, + }, + { + name: "reject comma in path", + intent: ScanIntent{ + Paths: []string{"a,b"}, + }, + wantErr: true, + }, + { + name: "scan with extra flags", + intent: ScanIntent{ + Extra: []string{"--batch", "by-language"}, + }, + want: []string{"scan", "--batch", "by-language", "--format", "json", "--audience", "human", "--color", "never"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BuildScanArgs(&tt.intent) + if (err != nil) != tt.wantErr { + t.Errorf("BuildScanArgs() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("BuildScanArgs() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestBuildReviewArgsExtraWhitelist covers the flags that must not be +// forwardable from a caller, plus the value validation on the ones that may. +func TestBuildReviewArgsExtraWhitelist(t *testing.T) { + tests := []struct { + name string + extra []string + wantErr bool + }{ + {"effort with valid value", []string{"--effort", "low"}, false}, + {"effort with invalid value", []string{"--effort", "extreme"}, true}, + {"effort with inline value", []string{"--effort=medium"}, false}, + {"effort missing value", []string{"--effort"}, true}, + {"boolean no-filter", []string{"--no-filter"}, false}, + {"boolean no-filter inline", []string{"--no-filter=true"}, false}, + {"boolean no-filter invalid inline", []string{"--no-filter=maybe"}, true}, + {"background with value", []string{"--background", "review the auth path"}, false}, + {"background-file with value", []string{"--background-file", "spec.md"}, false}, + // A value that looks like a flag is rejected rather than consumed, so a + // typo'd invocation cannot silently swallow the following flag. + {"effort value looks like flag", []string{"--effort", "--no-filter"}, true}, + {"inner flag name as value", []string{"--background", "--effort"}, true}, + {"effort inline value looks like flag", []string{"--effort=--no-filter"}, true}, + {"background inline value looks like flag", []string{"--background=--format"}, true}, + {"bare dash as value", []string{"--effort", "-"}, true}, + // Empty inline values: allowed for flags without an enum, where the OCR + // CLI treats "" the same as omitting the flag, and rejected for enum + // flags, where "" is simply not a valid value. + {"empty inline background", []string{"--background="}, false}, + {"empty inline effort", []string{"--effort="}, true}, + // Integration flags are fixed by the adapter and must not be overridable. + {"output path injection", []string{"--output", "/tmp/leak"}, true}, + {"format override", []string{"--format", "yaml"}, true}, + {"audience override", []string{"--audience", "agent"}, true}, + {"color override", []string{"--color", "always"}, true}, + {"repo override", []string{"--repo", "/etc"}, true}, + {"rule override", []string{"--rule", "evil.json"}, true}, + {"tools override", []string{"--tools", "evil.json"}, true}, + {"resume override", []string{"--resume", "other-session"}, true}, + {"model override", []string{"--model", "attacker-model"}, true}, + {"provider override", []string{"--provider", "evil"}, true}, + {"preview changes semantics", []string{"--preview"}, true}, + // scan-only flags are not registered on review. + {"scan-only batch on review", []string{"--batch", "none"}, true}, + {"scan-only no-plan on review", []string{"--no-plan"}, true}, + {"scan-only no-dedup on review", []string{"--no-dedup"}, true}, + {"scan-only no-summary on review", []string{"--no-summary"}, true}, + // A bare word is not a flag and must not be forwarded. + {"stray value", []string{"evil"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + intent := ReviewIntent{Type: ReviewTypeWorkspace, Extra: tt.extra} + _, err := BuildReviewArgs(&intent) + if (err != nil) != tt.wantErr { + t.Errorf("BuildReviewArgs(Extra=%v) error = %v, wantErr %v", tt.extra, err, tt.wantErr) + } + }) + } +} + +// TestBuildScanArgsExtraWhitelist is the scan counterpart. The whitelists +// differ between commands, so --effort is rejected here and accepted on review. +func TestBuildScanArgsExtraWhitelist(t *testing.T) { + tests := []struct { + name string + extra []string + wantErr bool + }{ + {"batch with valid value", []string{"--batch", "by-directory"}, false}, + {"batch with invalid value", []string{"--batch", "by-nonsense"}, true}, + {"batch with inline value", []string{"--batch=none"}, false}, + {"batch missing value", []string{"--batch"}, true}, + {"boolean no-plan", []string{"--no-plan"}, false}, + {"boolean no-dedup", []string{"--no-dedup"}, false}, + {"boolean no-summary", []string{"--no-summary"}, false}, + {"background with value", []string{"--background", "scan context"}, false}, + // Same flag-as-value guard as on review. + {"batch value looks like flag", []string{"--batch", "--no-dedup"}, true}, + {"batch inline value looks like flag", []string{"--batch=--no-summary"}, true}, + {"empty inline background", []string{"--background="}, false}, + {"empty inline batch", []string{"--batch="}, true}, + // Integration flags are fixed by the adapter and must not be overridable. + {"output path injection", []string{"--output", "/tmp/leak"}, true}, + {"format override", []string{"--format", "sarif"}, true}, + {"audience override", []string{"--audience", "agent"}, true}, + {"color override", []string{"--color", "always"}, true}, + {"repo override", []string{"--repo", "/etc"}, true}, + {"rule override", []string{"--rule", "evil.json"}, true}, + // review-only flags are not registered on scan. + {"review-only effort on scan", []string{"--effort", "high"}, true}, + {"review-only no-filter on scan", []string{"--no-filter"}, true}, + {"review-only background-file on scan", []string{"--background-file", "spec.md"}, true}, + {"stray value", []string{"evil"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + intent := ScanIntent{Extra: tt.extra} + _, err := BuildScanArgs(&intent) + if (err != nil) != tt.wantErr { + t.Errorf("BuildScanArgs(Extra=%v) error = %v, wantErr %v", tt.extra, err, tt.wantErr) + } + }) + } +} + +// TestBuildReviewArgsRejectsNilAndContradictory pins the mode exclusivity: a +// workspace intent must not carry range/commit fields, and vice versa. Silent +// tolerance here would hide a caller bug that changes the review scope. +func TestBuildReviewArgsRejectsNilAndContradictory(t *testing.T) { + if _, err := BuildReviewArgs(nil); err == nil { + t.Error("BuildReviewArgs(nil) = nil error, want error") + } + + tests := []struct { + name string + intent ReviewIntent + }{ + {"workspace with commit", ReviewIntent{Type: ReviewTypeWorkspace, Commit: "abc"}}, + {"workspace with from", ReviewIntent{Type: ReviewTypeWorkspace, From: "main"}}, + {"range with commit", ReviewIntent{Type: ReviewTypeRange, From: "main", To: "feature", Commit: "abc"}}, + {"commit with from", ReviewIntent{Type: ReviewTypeCommit, Commit: "abc", From: "main"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := BuildReviewArgs(&tt.intent); err == nil { + t.Errorf("BuildReviewArgs(%+v) = nil error, want error", tt.intent) + } + }) + } +} + +func TestBuildScanArgsRejectsNil(t *testing.T) { + if _, err := BuildScanArgs(nil); err == nil { + t.Error("BuildScanArgs(nil) = nil error, want error") + } +} + +// TestValidateExtraRejectsInvalidBooleanValues checks that an inline boolean +// value is only accepted when strconv.ParseBool accepts it, mirroring pflag. +func TestValidateExtraRejectsInvalidBooleanValues(t *testing.T) { + if _, err := BuildReviewArgs(&ReviewIntent{Type: ReviewTypeWorkspace, Extra: []string{"--no-filter=not-a-bool"}}); err == nil { + t.Error("review --no-filter=not-a-bool accepted, want error") + } + if _, err := BuildScanArgs(&ScanIntent{Extra: []string{"--no-plan=maybe"}}); err == nil { + t.Error("scan --no-plan=maybe accepted, want error") + } + for _, ok := range []string{"--no-filter=true", "--no-filter=false", "--no-filter=1", "--no-filter=0"} { + if _, err := BuildReviewArgs(&ReviewIntent{Type: ReviewTypeWorkspace, Extra: []string{ok}}); err != nil { + t.Errorf("review %s rejected: %v", ok, err) + } + } +} + +func TestExtraEnumCaseCompatibility(t *testing.T) { + for _, extra := range [][]string{{"--effort", "High"}, {"--effort=MEDIUM"}} { + args, err := BuildReviewArgs(&ReviewIntent{Type: ReviewTypeWorkspace, Extra: extra}) + if err != nil { + t.Errorf("review %q: %v", extra, err) + continue + } + if !reflect.DeepEqual(args[1:1+len(extra)], extra) { + t.Errorf("forwarded flags changed: %q", args) + } + } + for _, extra := range [][]string{{"--batch", "By-Language"}, {"--batch=BY-DIRECTORY"}} { + args, err := BuildScanArgs(&ScanIntent{Extra: extra}) + if err != nil { + t.Errorf("scan %q: %v", extra, err) + continue + } + if !reflect.DeepEqual(args[1:1+len(extra)], extra) { + t.Errorf("forwarded flags changed: %q", args) + } + } +} + +func TestScanRejectsWhitespacePaddedPaths(t *testing.T) { + for _, path := range []string{" ", " src", "src ", "./ src", "src/ ", "\tsrc", "src\n"} { + if args, err := BuildScanArgs(&ScanIntent{Paths: []string{path}}); err == nil { + t.Errorf("path %q accepted: %q", path, args) + } + } + for _, path := range []string{"src/my file.go", "src/ inner/file.go", "."} { + if _, err := BuildScanArgs(&ScanIntent{Paths: []string{path}}); err != nil { + t.Errorf("path %q rejected: %v", path, err) + } + } +} diff --git a/acp/internal/contract/interrupt_unix_test.go b/acp/internal/contract/interrupt_unix_test.go new file mode 100644 index 000000000..55eaf1fc5 --- /dev/null +++ b/acp/internal/contract/interrupt_unix_test.go @@ -0,0 +1,17 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package contract_test + +import ( + "os/exec" + "syscall" +) + +func prepareInterruptible(cmd *exec.Cmd) {} + +func interruptProcess(cmd *exec.Cmd) error { + return cmd.Process.Signal(syscall.SIGINT) +} diff --git a/acp/internal/contract/interrupt_windows_test.go b/acp/internal/contract/interrupt_windows_test.go new file mode 100644 index 000000000..dbacbb170 --- /dev/null +++ b/acp/internal/contract/interrupt_windows_test.go @@ -0,0 +1,28 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package contract_test + +import ( + "os/exec" + "syscall" +) + +func prepareInterruptible(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } +} + +func interruptProcess(cmd *exec.Cmd) error { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + proc := kernel32.NewProc("GenerateConsoleCtrlEvent") + r1, _, e := proc.Call(1, uintptr(uint32(cmd.Process.Pid))) + if r1 == 0 { + return e + } + return nil +} diff --git a/acp/internal/contract/mock_contract_test.go b/acp/internal/contract/mock_contract_test.go new file mode 100644 index 000000000..9c6a61547 --- /dev/null +++ b/acp/internal/contract/mock_contract_test.go @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// These tests run the mock OCR binary and decode its real stdout into the +// contract types. The mock mirrors the real OCR CLI JSON envelope, so a DTO +// that drifts from the CLI fails here. Assertions cover the field values that +// matter (content/start_line/end_line/summary/manifest), not just counts. +package contract_test + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/contract" +) + +// mockBin is the compiled test double, built once per test binary run. +var mockBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "ocr-acp-mock") + if err != nil { + fmt.Fprintf(os.Stderr, "creating temp dir: %v\n", err) + os.Exit(1) + } + + mockBin = filepath.Join(dir, "mock-ocr") + if runtime.GOOS == "windows" { + mockBin += ".exe" + } + build := exec.Command("go", "build", "-o", mockBin, "../../testdata/mock-ocr") + if out, err := build.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "building mock-ocr: %v\n%s", err, out) + _ = os.RemoveAll(dir) + os.Exit(1) + } + + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// runMock executes the test double, returning stdout and the exit code. +// stderr is discarded: these tests assert on the stdout contract only. +func runMock(t *testing.T, args ...string) (string, int) { + t.Helper() + + cmd := exec.Command(mockBin, args...) + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = io.Discard + + err := cmd.Run() + if err == nil { + return stdout.String(), 0 + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("running mock-ocr %v: %v", args, err) + } + return stdout.String(), exitErr.ExitCode() +} + +func decodeReview(t *testing.T, stdout string) contract.ReviewResult { + t.Helper() + var result contract.ReviewResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("mock stdout does not decode into contract.ReviewResult: %v\nstdout: %q", err, stdout) + } + return result +} + +func TestMockSuccessReviewDecodesEveryField(t *testing.T) { + stdout, code := runMock(t, "-scenario", "success-review") + if code != 0 { + t.Fatalf("mock-ocr -scenario success-review exited %d, want 0", code) + } + + result := decodeReview(t, stdout) + if result.Status != contract.StatusComplete { + t.Errorf("Status = %q, want %q", result.Status, contract.StatusComplete) + } + if result.Summary == nil { + t.Fatal("Summary = nil, want the decoded summary object") + } + if result.Summary.FilesReviewed != 1 || result.Summary.Comments != 2 || result.Summary.TotalTokens != 18746 { + t.Errorf("Summary = %+v, want files_reviewed=1 comments=2 total_tokens=18746", *result.Summary) + } + if len(result.Comments) != 2 { + t.Fatalf("len(Comments) = %d, want 2", len(result.Comments)) + } + + first := result.Comments[0] + if first.Path != "internal/agent/agent.go" || first.Content != "Potential nil dereference on the error path." { + t.Errorf("first comment = %+v", first) + } + if first.StartLine != 42 || first.EndLine != 42 { + t.Errorf("first comment lines = %d-%d, want 42-42", first.StartLine, first.EndLine) + } + if first.Category != "bug" || first.Severity != "warning" { + t.Errorf("first comment category/severity = %q/%q", first.Category, first.Severity) + } + + second := result.Comments[1] + if second.StartLine != 0 || second.EndLine != 0 { + t.Errorf("file-level comment lines = %d-%d, want 0-0", second.StartLine, second.EndLine) + } + if second.SuggestionCode != "+return err" || second.ExistingCode != "return nil" { + t.Errorf("second comment suggestion/existing = %q/%q", second.SuggestionCode, second.ExistingCode) + } + + if result.Manifest == nil || result.Manifest.TerminalState != "complete" { + t.Errorf("Manifest = %+v, want terminal_state=complete", result.Manifest) + } +} + +func TestMockPartialCarriesCoverageFailure(t *testing.T) { + stdout, code := runMock(t, "-scenario", "partial") + if code != 0 { + t.Fatalf("mock-ocr -scenario partial exited %d, want 0", code) + } + result := decodeReview(t, stdout) + if result.Status != contract.StatusPartial { + t.Errorf("Status = %q, want %q", result.Status, contract.StatusPartial) + } + if result.Manifest == nil || result.Manifest.Coverage == nil || len(result.Manifest.Coverage.Failed) != 1 { + t.Fatalf("Manifest = %+v, want one failed coverage item", result.Manifest) + } + if got := result.Manifest.Coverage.Failed[0].Classification; got != "timeout" { + t.Errorf("coverage failure classification = %q, want timeout", got) + } +} + +func TestMockEmptyCommentsEmitsArray(t *testing.T) { + stdout, code := runMock(t, "-scenario", "empty-comments") + if code != 0 { + t.Fatalf("mock-ocr -scenario empty-comments exited %d, want 0", code) + } + if !strings.Contains(stdout, `"comments":[]`) { + t.Errorf("stdout should contain an empty comments array, got %q", stdout) + } + result := decodeReview(t, stdout) + if result.Status != contract.StatusComplete || len(result.Comments) != 0 { + t.Errorf("result = %+v, want complete with no comments", result) + } +} + +func TestMockStderrPollutionDoesNotLeak(t *testing.T) { + stdout, code := runMock(t, "-scenario", "stderr-pollution") + if code != 0 { + t.Fatalf("mock-ocr -scenario stderr-pollution exited %d, want 0", code) + } + result := decodeReview(t, stdout) + if result.Status != contract.StatusComplete || len(result.Comments) != 1 { + t.Errorf("result = %+v, want complete with 1 comment", result) + } +} + +func TestMockScanScenarioMatchesContract(t *testing.T) { + stdout, code := runMock(t, "-scenario", "success-scan") + if code != 0 { + t.Fatalf("mock-ocr -scenario success-scan exited %d, want 0", code) + } + + var result contract.ScanResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("mock stdout does not decode into contract.ScanResult: %v\nstdout: %q", err, stdout) + } + if result.Status != contract.StatusSuccess { + t.Errorf("Status = %q, want %q", result.Status, contract.StatusSuccess) + } + if result.Summary == nil || result.Summary.FilesReviewed != 2 { + t.Errorf("Summary = %+v, want files_reviewed=2", result.Summary) + } + if len(result.Comments) != 1 { + t.Fatalf("len(Comments) = %d, want 1", len(result.Comments)) + } + if c := result.Comments[0]; c.Path != "cmd/main.go" || c.Content != "Unused import." || c.StartLine != 10 || c.Severity != "info" { + t.Errorf("comment = %+v", c) + } +} + +// TestMockDefaultScenarioSucceeds pins the default -scenario value: running +// the binary bare is how a developer first tries it, so it must not error. +func TestMockDefaultScenarioSucceeds(t *testing.T) { + stdout, code := runMock(t) + if code != 0 { + t.Fatalf("mock-ocr with no arguments exited %d, want 0", code) + } + if result := decodeReview(t, stdout); result.Status != contract.StatusComplete { + t.Errorf("Status = %q, want %q", result.Status, contract.StatusComplete) + } +} + +// TestMockNonZeroExitStillDecodes documents that a failed run reports its +// status through the JSON document, not only through the exit code. +func TestMockNonZeroExitStillDecodes(t *testing.T) { + stdout, code := runMock(t, "-scenario", "non-zero-exit") + if code == 0 { + t.Fatal("mock-ocr -scenario non-zero-exit exited 0, want non-zero") + } + result := decodeReview(t, stdout) + if result.Status != contract.StatusFailed { + t.Errorf("Status = %q, want %q", result.Status, contract.StatusFailed) + } + if result.Comments != nil { + t.Errorf("Comments = %v, want nil (null in JSON)", result.Comments) + } +} + +// TestMockInvalidJSONDoesNotDecode confirms the malformed-output scenario +// actually produces output the adapter must reject. +func TestMockInvalidJSONDoesNotDecode(t *testing.T) { + stdout, code := runMock(t, "-scenario", "invalid-json") + if code == 0 { + t.Fatal("mock-ocr -scenario invalid-json exited 0, want non-zero") + } + + var result contract.ReviewResult + if err := json.Unmarshal([]byte(stdout), &result); err == nil { + t.Fatalf("malformed stdout decoded without error: %q", stdout) + } +} + +// TestMockCancelReportsCancelledManifest drives the cancellation path: wait for +// the READY marker on stderr, send SIGINT, then check the real cancellation +// contract. The real CLI has no dedicated cancel exit code, so the adapter +// must rely on the manifest classifications rather than the exit status. +func TestMockCancelReportsCancelledManifest(t *testing.T) { + cmd := exec.Command(mockBin, "-scenario", "block-for-cancel") + prepareInterruptible(cmd) + + stderr, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("stderr pipe: %v", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + t.Fatalf("starting mock-ocr: %v", err) + } + + ready := make(chan struct{}) + var once sync.Once + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + if strings.Contains(scanner.Text(), "READY") { + once.Do(func() { close(ready) }) + } + } + }() + + select { + case <-ready: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatal("mock-ocr never reported READY") + } + + if err := interruptProcess(cmd); err != nil { + t.Fatalf("sending interrupt: %v", err) + } + + // Drain stdout before Wait so the child cannot block on a full pipe. + out, readErr := io.ReadAll(stdout) + waitErr := cmd.Wait() + + if readErr != nil { + t.Fatalf("reading stdout: %v", readErr) + } + // Cancellation exits non-zero in the real CLI; the adapter must not treat + // that alone as failure, which is why the manifest is asserted below. + if waitErr == nil { + t.Error("cancelled mock exited 0, want non-zero (mirrors the real CLI)") + } + + result := decodeReview(t, string(out)) + if result.Status != contract.StatusFailed { + t.Errorf("Status after cancel = %q, want %q", result.Status, contract.StatusFailed) + } + if result.Manifest == nil || result.Manifest.RunFailure == nil { + t.Fatalf("Manifest = %+v, want a run_failure", result.Manifest) + } + if got := result.Manifest.RunFailure.Classification; got != "cancelled" { + t.Errorf("run_failure.classification = %q, want cancelled", got) + } + if result.Manifest.Coverage == nil || len(result.Manifest.Coverage.Failed) != 1 { + t.Fatalf("coverage = %+v, want one failed item", result.Manifest.Coverage) + } + if got := result.Manifest.Coverage.Failed[0].Classification; got != "cancelled" { + t.Errorf("coverage.failed[0].classification = %q, want cancelled", got) + } +} diff --git a/acp/internal/contract/real_cli_test.go b/acp/internal/contract/real_cli_test.go new file mode 100644 index 000000000..4438991f2 --- /dev/null +++ b/acp/internal/contract/real_cli_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// This file guards the contract against drift from the real OCR CLI. +// +// TestRealOCRReviewShapeDecodes and TestRealOCRScanShapeDecodes are +// deterministic fixtures taken from the phase 1 CLI Contract. They run +// everywhere and fail if the DTO stops matching the documented real output +// shape, including the summary object and the content/start_line fields. +// +// TestRealOCRBinaryScanMultiplePaths is opt-in: set OCR_BINARY to a real ocr +// binary (for example ../dist/opencodereview) to run it. It exercises the +// adapter's own argv against the real CLI, which is how the --path +// comma-joining rule is verified end to end. +package contract_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/contract" +) + +// realReviewJSON is the shape documented in the CLI Contract (jsonOutput plus +// model.LlmComment), including fields the adapter does not model (llm, +// tool_calls, groups, retry_report, per-comment thinking) so that tolerance of +// unknown fields is exercised too. The inner quotes are avoided on purpose so +// the document stays a plain raw string. +const realReviewJSON = `{ + "status": "complete", + "llm": {"model": "deepseek-flash"}, + "trace_id": "trace-1", + "message": "Review complete: 1 finding(s) across 1 selected item(s).", + "summary": { + "files_reviewed": 1, + "comments": 1, + "total_tokens": 18746, + "input_tokens": 17952, + "output_tokens": 794, + "cache_read_tokens": 14592, + "elapsed": "7s" + }, + "tool_calls": {"total": 1, "by_tool": {"code_comment": 1}}, + "comments": [ + { + "path": "main.go", + "content": "Type mismatch: assigning a string literal to an int variable does not compile.", + "suggestion_code": "+var x int = 0", + "existing_code": "+var x int = zero", + "start_line": 3, + "end_line": 3, + "thinking": "long internal reasoning that must not surface", + "category": "bug", + "severity": "critical" + } + ], + "groups": [{"label": "main.go", "files": ["main.go"]}], + "session_id": "843e2022-8130-416f-bb95-ef76ab43ef9d", + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "run_id": "843e2022", + "operation": "review", + "terminal_state": "complete", + "coverage": {"selected": [{"path": "main.go"}], "completed": [{"path": "main.go"}], "failed": []}, + "elapsed_ms": 7232 + }, + "retry_report": {"schema_version": "ocr.llm-retry-report/v1"} +}` + +func TestRealOCRReviewShapeDecodes(t *testing.T) { + var result contract.ReviewResult + if err := json.Unmarshal([]byte(realReviewJSON), &result); err != nil { + t.Fatalf("real review JSON does not decode: %v", err) + } + if result.Status != contract.StatusComplete { + t.Errorf("Status = %q, want %q", result.Status, contract.StatusComplete) + } + if result.Message == "" { + t.Error("Message is empty") + } + if result.Summary == nil || result.Summary.FilesReviewed != 1 || result.Summary.Comments != 1 { + t.Fatalf("Summary = %+v, want files_reviewed=1 comments=1", result.Summary) + } + if len(result.Comments) != 1 { + t.Fatalf("len(Comments) = %d, want 1", len(result.Comments)) + } + c := result.Comments[0] + if c.Path != "main.go" || c.Content == "" { + t.Errorf("comment path/content = %q/%q", c.Path, c.Content) + } + if c.StartLine != 3 || c.EndLine != 3 || c.Category != "bug" || c.Severity != "critical" { + t.Errorf("comment = %+v, want 3-3 bug/critical", c) + } + if c.SuggestionCode == "" || c.ExistingCode == "" { + t.Errorf("suggestion/existing code not decoded: %+v", c) + } + if result.Manifest == nil || result.Manifest.TerminalState != "complete" { + t.Errorf("Manifest = %+v, want terminal_state=complete", result.Manifest) + } +} + +const realScanJSON = `{"status":"success","message":"Scan complete.","summary":{"files_reviewed":2,"comments":0,"elapsed":"1s"},"comments":null}` + +func TestRealOCRScanShapeDecodes(t *testing.T) { + var result contract.ScanResult + if err := json.Unmarshal([]byte(realScanJSON), &result); err != nil { + t.Fatalf("real scan JSON does not decode: %v", err) + } + if result.Status != contract.StatusSuccess { + t.Errorf("Status = %q, want %q", result.Status, contract.StatusSuccess) + } + if result.Comments != nil { + t.Errorf("Comments = %v, want nil (null)", result.Comments) + } + if result.Summary == nil || result.Summary.FilesReviewed != 2 { + t.Errorf("Summary = %+v, want files_reviewed=2", result.Summary) + } +} + +func TestRealOCRBinaryScanMultiplePaths(t *testing.T) { + bin := os.Getenv("OCR_BINARY") + if bin == "" { + t.Skip("set OCR_BINARY to a real ocr binary to run this integration test") + } + if _, err := os.Stat(bin); err != nil { + t.Fatalf("OCR_BINARY=%q is not usable: %v", bin, err) + } + + root := t.TempDir() + for _, dir := range []string{"a", "b"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatal(err) + } + } + writeFile(t, filepath.Join(root, "a", "one.go"), "package a\n\nfunc A() {}\n") + writeFile(t, filepath.Join(root, "b", "two.go"), "package b\n\nfunc B() {}\n") + + args, err := contract.BuildScanArgs(&contract.ScanIntent{Paths: []string{"a", "b"}}) + if err != nil { + t.Fatalf("BuildScanArgs: %v", err) + } + // The adapter runs the child with its cwd set to the scan root; --preview + // keeps the call LLM-free. + args = append(args, "--preview") + + cmd := exec.Command(bin, args...) + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + t.Fatalf("running %s %v: %v", bin, args, err) + } + + var preview struct { + Files []struct { + Path string `json:"path"` + WillReview bool `json:"will_review"` + } `json:"files"` + } + if err := json.Unmarshal(out, &preview); err != nil { + t.Fatalf("decoding preview: %v\n%s", err, out) + } + + seen := map[string]bool{} + for _, f := range preview.Files { + seen[f.Path] = true + } + if !seen["a/one.go"] || !seen["b/two.go"] { + t.Errorf("preview files = %v, want both a/one.go and b/two.go (repeated --path would drop one)", seen) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/acp/internal/contract/result.go b/acp/internal/contract/result.go new file mode 100644 index 000000000..993a2712a --- /dev/null +++ b/acp/internal/contract/result.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package contract + +// This file models the subset of the OCR CLI JSON result envelope that the +// adapter needs. Field names and types mirror the real CLI output +// (jsonOutput in cmd/opencodereview/output.go and model.LlmComment in +// internal/model/review.go) so that decoding real `ocr --format json` +// output works. Unknown fields are ignored. +// +// `thinking` is deliberately not modeled. It is per-comment model reasoning +// that must never reach the user, and a field that does not exist on the DTO +// cannot leak by accident. +// +// Status values are an open set. Review and scan report different names, and +// a newer CLI may add more, so consumers must tolerate unknown values rather +// than fail on them. + +// Known status values reported by the OCR CLI. Review uses the manifest +// terminal state (complete/partial/failed/skipped/completed_with_warnings/ +// completed_with_errors) with `success` as the no-manifest fallback; scan has +// no manifest and uses success/completed_with_warnings/completed_with_errors/ +// skipped. +const ( + StatusSuccess = "success" + StatusComplete = "complete" + StatusPartial = "partial" + StatusFailed = "failed" + StatusSkipped = "skipped" + StatusCompletedWithWarnings = "completed_with_warnings" + StatusCompletedWithErrors = "completed_with_errors" +) + +// ReviewResult mirrors the JSON document `ocr review --format json` writes to +// stdout on a successful run. It is not a full mirror: only the fields the +// adapter consumes are modeled. +type ReviewResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Summary *Summary `json:"summary,omitempty"` + Comments []Comment `json:"comments"` // may be null or [] + ToolCalls *ToolCalls `json:"tool_calls,omitempty"` + // Manifest is present for review but not for scan. The adapter reads + // run_failure/coverage classifications from it to cross-check a local + // cancel, because the CLI has no dedicated cancellation exit code. + Manifest *Manifest `json:"manifest,omitempty"` +} + +// ScanResult mirrors the JSON document `ocr scan --format json` writes to +// stdout. A scan has no run manifest, so its status is limited to the +// top-level fallback values; an empty or skipped result is still valid. +type ScanResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Summary *Summary `json:"summary,omitempty"` + Comments []Comment `json:"comments"` // may be null or [] + ToolCalls *ToolCalls `json:"tool_calls,omitempty"` +} + +// Summary mirrors jsonSummary usage, timing, and budget metadata. Human-readable +// result text lives in ReviewResult.Message. Optional token pointers distinguish +// older CLI output without token breakdowns from reported zero usage. +type Summary struct { + FilesReviewed int64 `json:"files_reviewed"` + Comments int64 `json:"comments"` + TotalTokens int64 `json:"total_tokens,omitempty"` + InputTokens *int64 `json:"input_tokens,omitempty"` + OutputTokens *int64 `json:"output_tokens,omitempty"` + CacheReadTokens int64 `json:"cache_read_tokens,omitempty"` + CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"` + BudgetExceeded bool `json:"budget_exceeded,omitempty"` + Elapsed string `json:"elapsed,omitempty"` +} + +// ToolCalls contains aggregate tool usage reported by the OCR CLI. +type ToolCalls struct { + Total int64 `json:"total"` + ByTool map[string]int64 `json:"by_tool"` +} + +// Comment mirrors model.LlmComment. StartLine and EndLine are 1-indexed; when +// both are 0 the finding is file-level and the adapter must not invent a line +// number. Severity is an open set (critical, high, medium, low have been +// observed, but consumers must tolerate unknown values). +type Comment struct { + Path string `json:"path"` + Content string `json:"content"` // always emitted; mirrors model.LlmComment's tag + SuggestionCode string `json:"suggestion_code,omitempty"` + ExistingCode string `json:"existing_code,omitempty"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Category string `json:"category,omitempty"` + Severity string `json:"severity,omitempty"` +} + +// Manifest mirrors the review run manifest (ocr.run-manifest/v1). Only the +// fields the adapter consults are modeled. +type Manifest struct { + SchemaVersion string `json:"schema_version,omitempty"` + Operation string `json:"operation,omitempty"` + TerminalState string `json:"terminal_state,omitempty"` + RunFailure *RunFailure `json:"run_failure,omitempty"` + Coverage *Coverage `json:"coverage,omitempty"` +} + +// RunFailure is the run-level failure classification in a manifest. +type RunFailure struct { + Classification string `json:"classification,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// Coverage is the manifest per-item coverage report. +type Coverage struct { + Selected []CoverageItem `json:"selected,omitempty"` + Completed []CoverageItem `json:"completed,omitempty"` + Reused []CoverageItem `json:"reused,omitempty"` + Failed []CoverageItem `json:"failed,omitempty"` + Waived []CoverageItem `json:"waived,omitempty"` +} + +// CoverageItem is one entry of manifest.coverage. Classification is the field +// that marks a cancelled item. +type CoverageItem struct { + Path string `json:"path,omitempty"` + Classification string `json:"classification,omitempty"` + Reason string `json:"reason,omitempty"` +} diff --git a/acp/internal/contract/result_test.go b/acp/internal/contract/result_test.go new file mode 100644 index 000000000..ef1102561 --- /dev/null +++ b/acp/internal/contract/result_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package contract + +import ( + "encoding/json" + "testing" +) + +func TestOptionalResultUsage(t *testing.T) { + for _, raw := range []string{ + `{"status":"success","summary":{"files_reviewed":2,"comments":0},"comments":[]}`, + `{"status":"success","summary":{"files_reviewed":2,"comments":0,"input_tokens":0,"output_tokens":3,"cache_read_tokens":4,"cache_write_tokens":5,"budget_exceeded":true},"tool_calls":{"total":2,"by_tool":{"read":2}},"comments":[]}`, + } { + var review ReviewResult + var scan ScanResult + if err := json.Unmarshal([]byte(raw), &review); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(raw), &scan); err != nil { + t.Fatal(err) + } + for _, summary := range []*Summary{review.Summary, scan.Summary} { + if review.ToolCalls == nil { + if summary.InputTokens != nil || summary.OutputTokens != nil || summary.BudgetExceeded { + t.Fatalf("invented legacy usage: %+v", summary) + } + } else if summary.InputTokens == nil || *summary.InputTokens != 0 || summary.OutputTokens == nil || *summary.OutputTokens != 3 || summary.CacheReadTokens != 4 || summary.CacheWriteTokens != 5 || !summary.BudgetExceeded { + t.Fatalf("lost usage: %+v", summary) + } + } + if review.ToolCalls != nil && (scan.ToolCalls == nil || review.ToolCalls.Total != 2 || scan.ToolCalls.ByTool["read"] != 2) { + t.Fatalf("lost tool calls: %+v %+v", review.ToolCalls, scan.ToolCalls) + } + } +} diff --git a/acp/internal/intent/fake_test.go b/acp/internal/intent/fake_test.go new file mode 100644 index 000000000..e8ec893cb --- /dev/null +++ b/acp/internal/intent/fake_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/contract" +) + +// fakeRepo is an offline RepoContext. Refs listed in bad fail to resolve. +type fakeRepo struct { + bad map[string]bool + calls []string +} + +func (f *fakeRepo) ResolveCommit(_ context.Context, ref string) error { + f.calls = append(f.calls, ref) + if f.bad[ref] { + return errors.New("unknown revision") + } + return nil +} + +// fakeLLM is a scripted LLMClient: it returns one canned call or a canned error. +type fakeLLM struct { + call ToolCall + err error + calls int + lastReq LLMRequest + block bool +} + +func (f *fakeLLM) CallTool(ctx context.Context, req LLMRequest) (ToolCall, error) { + f.calls++ + f.lastReq = req + if f.block { + <-ctx.Done() + return ToolCall{}, ctx.Err() + } + return f.call, f.err +} + +// workspaceCallJSON is the common canned reply: a workspace review. +const workspaceCallJSON = `{"action":"review","review":{"type":"workspace"}}` + +// intentCall marshals a raw JSON intent object into a submit_intent ToolCall. +func intentCall(t *testing.T, raw string) ToolCall { + t.Helper() + var probe map[string]any + if err := json.Unmarshal([]byte(raw), &probe); err != nil { + t.Fatalf("test intent is not valid JSON: %v", err) + } + return ToolCall{Name: submitIntentToolName, Arguments: []byte(raw)} +} + +// newTestParser builds a parser with a permissive repo and the given LLM. +func newTestParser(llm LLMClient, repo RepoContext) *Parser { + if repo == nil { + repo = &fakeRepo{} + } + return NewParser(llm, repo) +} + +// requireIntent fails unless r is a runnable intent and returns its argv. +func requireIntent(t *testing.T, r Result) []string { + t.Helper() + if r.Kind != KindIntent { + t.Fatalf("Kind = %v, want KindIntent (clarify=%+v reject=%+v)", r.Kind, r.Clarify, r.Reject) + } + switch { + case r.Review != nil: + args, err := contract.BuildReviewArgs(r.Review) + if err != nil { + t.Fatalf("BuildReviewArgs: %v", err) + } + return args + case r.Scan != nil: + args, err := contract.BuildScanArgs(r.Scan) + if err != nil { + t.Fatalf("BuildScanArgs: %v", err) + } + return args + default: + t.Fatal("intent result has neither Review nor Scan") + return nil + } +} + +func requireClarify(t *testing.T, r Result, wantMissing ...string) *Clarify { + t.Helper() + if r.Kind != KindClarify { + t.Fatalf("Kind = %v, want KindClarify (reject=%+v)", r.Kind, r.Reject) + } + if r.Clarify == nil || r.Clarify.Question == "" { + t.Fatal("clarify result has no question") + } + if len(wantMissing) > 0 { + got := map[string]bool{} + for _, m := range r.Clarify.Missing { + got[m] = true + } + for _, m := range wantMissing { + if !got[m] { + t.Fatalf("clarify missing = %v, want it to contain %q", r.Clarify.Missing, m) + } + } + } + return r.Clarify +} + +func requireReject(t *testing.T, r Result) *Reject { + t.Helper() + if r.Kind != KindReject { + t.Fatalf("Kind = %v, want KindReject (clarify=%+v)", r.Kind, r.Clarify) + } + if r.Reject == nil || r.Reject.Reason == "" { + t.Fatal("reject result has no reason") + } + return r.Reject +} + +func equalArgs(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/acp/internal/intent/llm/client.go b/acp/internal/intent/llm/client.go new file mode 100644 index 000000000..6cc6f7043 --- /dev/null +++ b/acp/internal/intent/llm/client.go @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptrace" + "net/url" + "strings" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent" +) + +const ( + anthropicVersion = "2023-06-01" + anthropicMaxTokens = 2048 + defaultHTTPTimeout = 5 * time.Minute + maxErrorBody = 500 +) + +// Client is the phase 4 parsing client for the openai and anthropic protocols. +// Either protocol failing to accept a forced tool call is a hard error; the +// client never falls back to parsing free text. +type Client struct { + ep Endpoint + http *http.Client +} + +var _ intent.LLMClient = (*Client)(nil) + +// NewClient validates that the resolved endpoint can be served and returns the +// client. Unsupported protocols are an explicit, actionable error. +func NewClient(ep Endpoint) (*Client, error) { + switch ep.Protocol { + case ProtocolOpenAI, ProtocolAnthropic: + case ProtocolOpenAIResponses, ProtocolAnthropicBedrock: + return nil, fmt.Errorf("the parsing client does not support protocol %q yet; configure a provider that speaks %q or %q instead", ep.Protocol, ProtocolOpenAI, ProtocolAnthropic) + default: + return nil, fmt.Errorf("the parsing client does not know protocol %q", ep.Protocol) + } + if strings.TrimSpace(ep.Model) == "" { + return nil, fmt.Errorf("the parsing endpoint has no model configured") + } + if strings.TrimSpace(ep.URL) == "" || strings.TrimSpace(ep.Token) == "" { + return nil, fmt.Errorf("the parsing endpoint is missing a url or a token") + } + hc := &http.Client{Timeout: defaultHTTPTimeout} + if ep.Timeout > 0 { + hc.Timeout = ep.Timeout + } + return &Client{ep: ep, http: hc}, nil +} + +// Endpoint returns the resolved endpoint the client is using. +func (c *Client) Endpoint() Endpoint { return c.ep } + +// CallTool implements intent.LLMClient. +func (c *Client) CallTool(ctx context.Context, req intent.LLMRequest) (intent.ToolCall, error) { + switch c.ep.Protocol { + case ProtocolOpenAI: + return c.callOpenAI(ctx, req) + case ProtocolAnthropic: + return c.callAnthropic(ctx, req) + default: + return intent.ToolCall{}, fmt.Errorf("unsupported protocol %q", c.ep.Protocol) + } +} + +type openAIResponse struct { + Choices []struct { + Message struct { + ToolCalls []struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (c *Client) callOpenAI(ctx context.Context, req intent.LLMRequest) (intent.ToolCall, error) { + body := map[string]any{ + "model": c.ep.Model, + "messages": []map[string]string{ + {"role": "system", "content": req.System}, + {"role": "user", "content": req.User}, + }, + "tools": []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": req.Tool.Name, + "description": req.Tool.Description, + "parameters": json.RawMessage(req.Tool.Parameters), + }, + }}, + "tool_choice": map[string]any{ + "type": "function", + "function": map[string]string{"name": req.Tool.Name}, + }, + } + for k, v := range c.ep.ExtraBody { + body[k] = v + } + + data, err := c.post(ctx, openAIChatURL(c.ep.URL), body, openAIHeaders(c.ep)) + if err != nil { + return intent.ToolCall{}, err + } + + var resp openAIResponse + if err := json.Unmarshal(data, &resp); err != nil { + return intent.ToolCall{}, fmt.Errorf("openai response was not valid JSON: %w", err) + } + if resp.Error != nil && resp.Error.Message != "" { + return intent.ToolCall{}, fmt.Errorf("openai endpoint error: %s", c.diagnosticText(resp.Error.Message)) + } + if len(resp.Choices) == 0 { + return intent.ToolCall{}, fmt.Errorf("openai response contained no choices") + } + calls := resp.Choices[0].Message.ToolCalls + if len(calls) != 1 { + return intent.ToolCall{}, fmt.Errorf("openai response contained %d tool calls, want exactly 1; the endpoint may not support forced tool calls", len(calls)) + } + return intent.ToolCall{Name: calls[0].Function.Name, Arguments: []byte(calls[0].Function.Arguments)}, nil +} + +type anthropicResponse struct { + Content []struct { + Type string `json:"type"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + } `json:"content"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (c *Client) callAnthropic(ctx context.Context, req intent.LLMRequest) (intent.ToolCall, error) { + body := map[string]any{ + "model": c.ep.Model, + "max_tokens": anthropicMaxTokens, + "system": req.System, + "messages": []map[string]string{ + {"role": "user", "content": req.User}, + }, + "tools": []map[string]any{{ + "name": req.Tool.Name, + "description": req.Tool.Description, + "input_schema": json.RawMessage(req.Tool.Parameters), + }}, + "tool_choice": map[string]string{"type": "tool", "name": req.Tool.Name}, + // Forced tool selection is incompatible with Thinking. This setting + // applies only to the ACP intent parser, not the OCR review model. + "thinking": map[string]string{"type": "disabled"}, + } + for k, v := range c.ep.ExtraBody { + // Preserve the parser's forced-tool compatibility contract even when + // an endpoint supplies conflicting request-body defaults. + switch k { + case "thinking", "tool_choice", "tools": + continue + } + body[k] = v + } + + data, err := c.post(ctx, anthropicMessagesURL(c.ep.URL), body, anthropicHeaders(c.ep)) + if err != nil { + return intent.ToolCall{}, err + } + + var resp anthropicResponse + if err := json.Unmarshal(data, &resp); err != nil { + return intent.ToolCall{}, fmt.Errorf("anthropic response was not valid JSON: %w", err) + } + if resp.Error != nil && resp.Error.Message != "" { + return intent.ToolCall{}, fmt.Errorf("anthropic endpoint error: %s", c.diagnosticText(resp.Error.Message)) + } + var found []intent.ToolCall + for _, block := range resp.Content { + if block.Type != "tool_use" { + continue + } + found = append(found, intent.ToolCall{Name: block.Name, Arguments: block.Input}) + } + if len(found) != 1 { + return intent.ToolCall{}, fmt.Errorf("anthropic response contained %d tool calls, want exactly 1", len(found)) + } + return found[0], nil +} + +func (c *Client) post(ctx context.Context, rawURL string, body any, headers map[string]string) ([]byte, error) { + encoded, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode request: %w", err) + } + trace := &requestTrace{stage: "building request", start: time.Now()} + httpReq, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace.hooks()), http.MethodPost, rawURL, bytes.NewReader(encoded)) + if err != nil { + return nil, c.requestFailure(ctx, trace, rawURL, 0, err, "") + } + for k, v := range headers { + httpReq.Header.Set(k, v) + } + trace.set("acquiring connection") + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, c.requestFailure(ctx, trace, rawURL, 0, err, "") + } + defer resp.Body.Close() + trace.set("reading response body") + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, c.requestFailure(ctx, trace, rawURL, resp.StatusCode, err, "") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + trace.set("received HTTP error response") + return nil, c.requestFailure(ctx, trace, rawURL, resp.StatusCode, nil, strings.TrimSpace(string(data))) + } + return data, nil +} + +func openAIChatURL(rawURL string) string { + return endpointURL(rawURL, "/chat/completions") +} + +func anthropicMessagesURL(rawURL string) string { + return endpointURL(rawURL, "/v1/messages") +} + +func endpointURL(rawURL, suffix string) string { + u, err := url.Parse(rawURL) + if err != nil { + // Let request construction report the invalid URL using safe diagnostics. + return rawURL + } + path := strings.TrimRight(u.EscapedPath(), "/") + if !strings.HasSuffix(path, suffix) { + path += suffix + } + u.Path, _ = url.PathUnescape(path) + u.RawPath = path + return u.String() +} + +func openAIHeaders(ep Endpoint) map[string]string { + h := map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + ep.Token, + } + applyExtraHeaders(h, ep.ExtraHeaders) + return h +} + +func anthropicHeaders(ep Endpoint) map[string]string { + h := map[string]string{ + "Content-Type": "application/json", + "anthropic-version": anthropicVersion, + } + if ep.AuthHeader == "x-api-key" { + h["x-api-key"] = ep.Token + } else { + h["Authorization"] = "Bearer " + ep.Token + } + applyExtraHeaders(h, ep.ExtraHeaders) + return h +} + +// applyExtraHeaders copies configured headers but never lets them clobber the +// auth or content headers the client manages. +func applyExtraHeaders(dst map[string]string, extra map[string]string) { + for k, v := range extra { + switch strings.ToLower(k) { + case "authorization", "x-api-key", "content-type", "user-agent": + continue + } + dst[k] = v + } +} diff --git a/acp/internal/intent/llm/client_test.go b/acp/internal/intent/llm/client_test.go new file mode 100644 index 000000000..defaca6c0 --- /dev/null +++ b/acp/internal/intent/llm/client_test.go @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/intent" +) + +func testRequest() intent.LLMRequest { + return intent.LLMRequest{ + System: "system prompt", + User: "user prompt", + Tool: intent.ToolSchema{ + Name: "submit_intent", + Description: "submit", + Parameters: []byte(`{"type":"object"}`), + }, + } +} + +func TestCallToolPreservesEndpointQueryAndEscapedPath(t *testing.T) { + for _, protocol := range []string{ProtocolOpenAI, ProtocolAnthropic} { + for _, fullPath := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/full=%v", protocol, fullPath), func(t *testing.T) { + suffix := "/chat/completions" + if protocol == ProtocolAnthropic { + suffix = "/v1/messages" + } + const query = "api-version=2024-02-01&key=a%2fb&key=c+d" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/gateway%2Ftenant"+suffix || r.URL.RawQuery != query { + t.Errorf("request URI = %q", r.RequestURI) + } + if protocol == ProtocolOpenAI { + _, _ = io.WriteString(w, `{"choices":[{"message":{"tool_calls":[{"function":{"name":"submit_intent","arguments":"{}"}}]}}]}`) + } else { + _, _ = io.WriteString(w, `{"content":[{"type":"tool_use","name":"submit_intent","input":{}}]}`) + } + })) + defer srv.Close() + base := srv.URL + "/gateway%2Ftenant" + if fullPath { + base += suffix + } + client, err := NewClient(Endpoint{URL: base + "/?" + query + "#fragment", Token: "key", Model: "m", Protocol: protocol}) + if err != nil { + t.Fatal(err) + } + if _, err := client.CallTool(context.Background(), testRequest()); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestNewClient(t *testing.T) { + valid := Endpoint{URL: "http://x", Token: "k", Model: "m", Protocol: ProtocolOpenAI} + if _, err := NewClient(valid); err != nil { + t.Fatalf("openai: %v", err) + } + valid.Protocol = ProtocolAnthropic + if _, err := NewClient(valid); err != nil { + t.Fatalf("anthropic: %v", err) + } + + bad := []Endpoint{ + {URL: "http://x", Token: "k", Model: "m", Protocol: ProtocolOpenAIResponses}, + {URL: "http://x", Token: "k", Model: "m", Protocol: ProtocolAnthropicBedrock}, + {URL: "http://x", Token: "k", Model: "m", Protocol: "bogus"}, + {URL: "http://x", Token: "k", Protocol: ProtocolOpenAI}, + {URL: "http://x", Model: "m", Protocol: ProtocolOpenAI}, + {Token: "k", Model: "m", Protocol: ProtocolOpenAI}, + } + for i, ep := range bad { + if _, err := NewClient(ep); err == nil { + t.Fatalf("case %d: expected an error", i) + } + } + + c, err := NewClient(Endpoint{URL: "http://x", Token: "k", Model: "m", Protocol: ProtocolOpenAI}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if c.Endpoint().Model != "m" { + t.Fatalf("Endpoint() = %+v", c.Endpoint()) + } +} + +func TestOpenAICallTool(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("path = %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer key" { + t.Errorf("authorization = %q", got) + } + body, _ := io.ReadAll(r.Body) + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Errorf("request body: %v", err) + } + if decoded["model"] != "m" { + t.Errorf("model = %v", decoded["model"]) + } + if decoded["tool_choice"] == nil { + t.Error("tool_choice missing") + } + tools, _ := decoded["tools"].([]any) + if len(tools) != 1 { + t.Errorf("tools = %v", decoded["tools"]) + } + io.WriteString(w, `{"choices":[{"message":{"tool_calls":[{"type":"function","function":{"name":"submit_intent","arguments":"{\"action\":\"scan\"}"}}]}}]}`) + })) + defer srv.Close() + + c, err := NewClient(Endpoint{URL: srv.URL + "/v1", Token: "key", Model: "m", Protocol: ProtocolOpenAI}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + call, err := c.CallTool(context.Background(), testRequest()) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if call.Name != "submit_intent" || !strings.Contains(string(call.Arguments), "scan") { + t.Fatalf("call = %+v %s", call.Name, call.Arguments) + } +} + +func TestOpenAIErrors(t *testing.T) { + tests := []struct { + name string + body string + code int + }{ + {"no choices", `{"choices":[]}`, 200}, + {"no tool calls", `{"choices":[{"message":{}}]}`, 200}, + {"two tool calls", `{"choices":[{"message":{"tool_calls":[{"function":{"name":"a"}},{"function":{"name":"b"}}]}}]}`, 200}, + {"endpoint error", `{"error":{"message":"bad key"}}`, 200}, + {"invalid json", `not json`, 200}, + {"http error", `{"error":"boom"}`, 500}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.code) + io.WriteString(w, tt.body) + })) + defer srv.Close() + c, err := NewClient(Endpoint{URL: srv.URL, Token: "k", Model: "m", Protocol: ProtocolOpenAI}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if _, err := c.CallTool(context.Background(), testRequest()); err == nil { + t.Fatal("expected an error") + } + }) + } +} + +func TestAnthropicCallTool(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + t.Errorf("path = %q", r.URL.Path) + } + if got := r.Header.Get("x-api-key"); got != "key" { + t.Errorf("x-api-key = %q", got) + } + if got := r.Header.Get("anthropic-version"); got != anthropicVersion { + t.Errorf("anthropic-version = %q", got) + } + body, _ := io.ReadAll(r.Body) + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Errorf("request body: %v", err) + } + if decoded["max_tokens"] == nil || decoded["system"] == nil { + t.Errorf("missing fields: %v", decoded) + } + io.WriteString(w, `{"content":[{"type":"text","text":"hi"},{"type":"tool_use","name":"submit_intent","input":{"action":"review","review":{"type":"workspace"}}}]}`) + })) + defer srv.Close() + + c, err := NewClient(Endpoint{URL: srv.URL, Token: "key", Model: "m", Protocol: ProtocolAnthropic, AuthHeader: "x-api-key"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + call, err := c.CallTool(context.Background(), testRequest()) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if call.Name != "submit_intent" || !strings.Contains(string(call.Arguments), "workspace") { + t.Fatalf("call = %+v %s", call.Name, call.Arguments) + } +} + +func TestAnthropicForcedToolDisablesThinking(t *testing.T) { + for _, override := range []bool{false, true} { + t.Run(fmt.Sprintf("override=%v", override), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Thinking map[string]any `json:"thinking"` + Choice map[string]string `json:"tool_choice"` + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + Temperature *float64 `json:"temperature"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + w.WriteHeader(400) + return + } + if body.Thinking["type"] != "disabled" || len(body.Thinking) != 1 { + t.Errorf("thinking = %v", body.Thinking) + http.Error(w, "Thinking mode does not support this tool_choice", 400) + return + } + if body.Choice["type"] != "tool" || body.Choice["name"] != "submit_intent" || len(body.Choice) != 2 { + t.Errorf("tool_choice = %v", body.Choice) + } + if len(body.Tools) != 1 || body.Tools[0].Name != "submit_intent" { + t.Errorf("tools = %v", body.Tools) + } + if override && (body.Temperature == nil || *body.Temperature != 0) { + t.Error("extra temperature not preserved") + } + io.WriteString(w, `{"content":[{"type":"tool_use","name":"submit_intent","input":{"action":"review","review":{"type":"workspace"}}}]}`) + })) + defer srv.Close() + ep := Endpoint{URL: srv.URL, Token: "test-key", Model: "test-model", Protocol: ProtocolAnthropic} + if override { + ep.ExtraBody = map[string]any{ + "thinking": map[string]any{"type": "enabled", "budget_tokens": 1024}, + "tool_choice": map[string]string{"type": "auto"}, + "tools": []any{}, "temperature": 0, + } + } + c, err := NewClient(ep) + if err != nil { + t.Fatal(err) + } + call, err := c.CallTool(context.Background(), testRequest()) + if err != nil || call.Name != "submit_intent" { + t.Fatalf("call=%+v error=%v", call, err) + } + }) + } +} + +func TestAnthropicAuthorizationHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer key" { + t.Errorf("authorization = %q", got) + } + if r.Header.Get("x-api-key") != "" { + t.Error("x-api-key should not be set") + } + io.WriteString(w, `{"content":[{"type":"tool_use","name":"submit_intent","input":{"action":"review","review":{"type":"workspace"}}}]}`) + })) + defer srv.Close() + c, _ := NewClient(Endpoint{URL: srv.URL, Token: "key", Model: "m", Protocol: ProtocolAnthropic, AuthHeader: "authorization"}) + if _, err := c.CallTool(context.Background(), testRequest()); err != nil { + t.Fatalf("CallTool: %v", err) + } +} + +func TestAnthropicErrors(t *testing.T) { + tests := []struct { + name string + body string + code int + }{ + {"no tool calls", `{"content":[{"type":"text","text":"hi"}]}`, 200}, + {"two tool calls", `{"content":[{"type":"tool_use","name":"a"},{"type":"tool_use","name":"b"}]}`, 200}, + {"endpoint error", `{"error":{"message":"bad"}}`, 200}, + {"invalid json", `nope`, 200}, + {"http error", `{}`, 500}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.code) + io.WriteString(w, tt.body) + })) + defer srv.Close() + c, _ := NewClient(Endpoint{URL: srv.URL, Token: "k", Model: "m", Protocol: ProtocolAnthropic, AuthHeader: "x-api-key"}) + if _, err := c.CallTool(context.Background(), testRequest()); err == nil { + t.Fatal("expected an error") + } + }) + } +} + +func TestExtraHeadersAndBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Trace"); got != "1" { + t.Errorf("X-Trace = %q", got) + } + if got := r.Header.Get("User-Agent"); got == "evil" { + t.Error("reserved User-Agent was overridden") + } + if got := r.Header.Get("Authorization"); got != "Bearer k" { + t.Errorf("authorization = %q", got) + } + body, _ := io.ReadAll(r.Body) + var decoded map[string]any + json.Unmarshal(body, &decoded) + if decoded["temperature"] != float64(0) { + t.Errorf("extra body not merged: %v", decoded) + } + io.WriteString(w, `{"choices":[{"message":{"tool_calls":[{"function":{"name":"submit_intent","arguments":"{}"}}]}}]}`) + })) + defer srv.Close() + c, _ := NewClient(Endpoint{ + URL: srv.URL, Token: "k", Model: "m", Protocol: ProtocolOpenAI, + ExtraHeaders: map[string]string{"X-Trace": "1", "User-Agent": "evil", "Authorization": "evil"}, + ExtraBody: map[string]any{"temperature": 0}, + }) + if _, err := c.CallTool(context.Background(), testRequest()); err != nil { + t.Fatalf("CallTool: %v", err) + } +} + +func TestCallToolUnsupportedProtocol(t *testing.T) { + c := &Client{ep: Endpoint{Protocol: "bogus"}} + if _, err := c.CallTool(context.Background(), testRequest()); err == nil { + t.Fatal("expected an error") + } +} + +func TestPostErrors(t *testing.T) { + c := &Client{http: &http.Client{}} + if _, err := c.post(context.Background(), "http://x", func() {}, nil); err == nil { + t.Fatal("unmarshalable body should error") + } + if _, err := c.post(context.Background(), "://bad", map[string]any{}, nil); err == nil { + t.Fatal("bad url should error") + } + if _, err := c.post(context.Background(), "http://127.0.0.1:1/", map[string]any{}, nil); err == nil { + t.Log("unreachable endpoint unexpectedly reachable") + } +} + +func TestURLHelpers(t *testing.T) { + if got := openAIChatURL("http://x/v1"); got != "http://x/v1/chat/completions" { + t.Fatalf("openAIChatURL = %q", got) + } + if got := openAIChatURL("http://x/v1/chat/completions"); got != "http://x/v1/chat/completions" { + t.Fatalf("openAIChatURL = %q", got) + } + if got := anthropicMessagesURL("http://x"); got != "http://x/v1/messages" { + t.Fatalf("anthropicMessagesURL = %q", got) + } + if got := anthropicMessagesURL("http://x/v1/messages"); got != "http://x/v1/messages" { + t.Fatalf("anthropicMessagesURL = %q", got) + } +} + +func TestApplyExtraHeaders(t *testing.T) { + dst := map[string]string{"Authorization": "keep"} + applyExtraHeaders(dst, map[string]string{"Authorization": "drop", "X-Trace": "1", "content-type": "drop"}) + if dst["Authorization"] != "keep" || dst["X-Trace"] != "1" { + t.Fatalf("headers = %v", dst) + } + if _, ok := dst["content-type"]; ok { + t.Fatalf("reserved header leaked: %v", dst) + } +} diff --git a/acp/internal/intent/llm/diagnostics.go b/acp/internal/intent/llm/diagnostics.go new file mode 100644 index 000000000..acd6a84e3 --- /dev/null +++ b/acp/internal/intent/llm/diagnostics.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http/httptrace" + "net/url" + "sort" + "strings" + "sync" + "syscall" + "time" +) + +// Trace callbacks can run on transport goroutines, even after Do returns. +// Keep only the latest observed phase, never headers or request contents. +type requestTrace struct { + mu sync.Mutex + stage string + start time.Time +} + +func (t *requestTrace) set(stage string) { + t.mu.Lock() + defer t.mu.Unlock() + t.stage = stage +} + +func (t *requestTrace) hooks() *httptrace.ClientTrace { + return &httptrace.ClientTrace{ + DNSStart: func(httptrace.DNSStartInfo) { t.set("resolving DNS") }, + ConnectStart: func(string, string) { t.set("connecting to endpoint or proxy") }, + TLSHandshakeStart: func() { t.set("TLS handshake") }, + TLSHandshakeDone: func(_ tls.ConnectionState, err error) { + if err == nil { + t.set("acquiring connection") + } + }, + GotConn: func(httptrace.GotConnInfo) { t.set("sending request") }, + WroteRequest: func(info httptrace.WroteRequestInfo) { + if info.Err == nil { + t.set("waiting for response headers") + } + }, + GotFirstResponseByte: func() { t.set("reading response headers") }, + } +} + +type requestError struct { + message string + cause error +} + +func (e *requestError) Error() string { return e.message } +func (e *requestError) Unwrap() error { return e.cause } + +func (c *Client) requestFailure(ctx context.Context, trace *requestTrace, rawURL string, status int, cause error, detail string) error { + trace.mu.Lock() + stage := trace.stage + trace.mu.Unlock() + reason := "network or transport failure" + hint := "Check connectivity, proxy settings and the gateway logs." + var networkError net.Error + var dnsError *net.DNSError + var certError *tls.CertificateVerificationError + switch { + case stage == "building request": + reason = "invalid parser endpoint URL" + hint = "Request was not sent. Check parser-base-url." + case errors.Is(ctx.Err(), context.Canceled): + reason = "request cancelled by caller" + case errors.Is(ctx.Err(), context.DeadlineExceeded): + reason = "caller deadline exceeded" + if !errors.Is(context.Cause(ctx), context.DeadlineExceeded) { + // The owning layer explains custom deadlines. Arbitrary cause text + // may contain private data and must not enter diagnostics here. + reason = "request deadline exceeded" + } + case errors.As(cause, &networkError) && networkError.Timeout(): + reason = "HTTP client or transport timeout" + case errors.As(cause, &dnsError): + reason = "DNS lookup failed" + case errors.As(cause, &certError): + reason = "TLS certificate verification failed" + case errors.Is(cause, syscall.ECONNREFUSED): + reason = "connection refused" + case errors.Is(cause, io.ErrUnexpectedEOF): + reason = "connection closed before response completed" + case status < 200 && status != 0 || status >= 300: + reason = fmt.Sprintf("HTTP %d", status) + } + if stage == "waiting for response headers" || stage == "reading response headers" { + hint = "The request was sent, but no complete HTTP response headers arrived. Gateway queueing, provider latency or the network may be responsible; check gateway/provider logs." + } else if stage == "reading response body" { + hint = "HTTP headers arrived, but the response body could not be read completely. Check gateway/provider streaming and network logs." + } + if detail != "" { + hint = "Provider response: " + c.diagnosticText(detail) + } + endpoint := "" + if u, err := url.Parse(rawURL); err == nil { + u.User, u.RawQuery, u.Fragment = nil, "", "" + endpoint = u.String() + } + message := fmt.Sprintf("Parsing LLM request failed: %s.\nStage: %s; elapsed: %s; HTTP status: %s.\nProtocol: %s; model: %s; endpoint: %s.\n%s", + reason, stage, time.Since(trace.start).Round(time.Millisecond), responseStatus(status), + quoteDiagnostic(c.ep.Protocol), quoteDiagnostic(c.ep.Model), quoteDiagnostic(endpoint), hint) + return &requestError{message: message, cause: cause} +} + +func responseStatus(status int) string { + if status == 0 { + return "not received" + } + return fmt.Sprint(status) +} + +// Redact provider-controlled text, where credentials might be echoed. Metadata +// fields are quoted separately; URLs lose userinfo, query and fragment first. +func (c *Client) diagnosticText(text string) string { + secrets := []string{c.ep.Token} + for name, value := range c.ep.ExtraHeaders { + if !publicDiagnosticField(name) { + secrets = append(secrets, value) + } + } + if u, err := url.Parse(c.ep.URL); err == nil { + if u.User != nil { + password, _ := u.User.Password() + secrets = append(secrets, password) + if password == "" { + username := u.User.Username() + secrets = append(secrets, username, url.User(username).String()) + } + // Preserve the original userinfo spelling, including lowercase percent + // escapes, when a provider echoes the configured URL verbatim. + _, authority, _ := strings.Cut(c.ep.URL, "://") + if end := strings.IndexAny(authority, "/?#"); end >= 0 { + authority = authority[:end] + } + if at := strings.LastIndexByte(authority, '@'); at >= 0 { + secrets = append(secrets, authority[:at]) + } + } + for _, pair := range strings.Split(u.RawQuery, "&") { + rawName, rawValue, hasValue := strings.Cut(pair, "=") + name, _ := url.QueryUnescape(rawName) + if !hasValue || publicDiagnosticField(name) { + continue + } + // Preserve original escapes: QueryEscape normalizes spaces and hex + // case, while providers may echo the URL exactly as configured. + secrets = append(secrets, rawValue) + if value, err := url.QueryUnescape(rawValue); err == nil { + secrets = append(secrets, value, url.QueryEscape(value)) + } + } + } + // Match complete encoded values before shorter decoded prefixes. Replace + // once so later secrets cannot alter an already inserted redaction marker. + sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) }) + var replacements []string + for _, secret := range secrets { + if secret != "" { + replacements = append(replacements, secret, "[REDACTED]") + } + } + return quoteDiagnostic(strings.NewReplacer(replacements...).Replace(text)) +} + +// Exempt only ordinary tracing, content-negotiation and version metadata. +// Unknown fields stay conservative, including custom authentication headers. +// Credentials have no minimum length and remain redacted in provider text. +func publicDiagnosticField(name string) bool { + switch strings.ToLower(name) { + case "x-trace", "x-request-id", "traceparent", "tracestate", "content-type", "accept", "user-agent", "api-version", "anthropic-version": + return true + default: + return false + } +} + +func quoteDiagnostic(text string) string { + runes := []rune(text) + if len(runes) > maxErrorBody { + text = string(runes[:maxErrorBody]) + "..." + } + // Quote controls so an endpoint cannot inject diagnostic lines. + return fmt.Sprintf("%q", text) +} diff --git a/acp/internal/intent/llm/diagnostics_test.go b/acp/internal/intent/llm/diagnostics_test.go new file mode 100644 index 000000000..dc2beb9e2 --- /dev/null +++ b/acp/internal/intent/llm/diagnostics_test.go @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/http/httptrace" + "net/url" + "strings" + "syscall" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent" +) + +func TestRequestTimeoutReportsObservedPhase(t *testing.T) { + for _, body := range []bool{false, true} { + name := "headers" + if body { + name = "body" + } + t.Run(name, func(t *testing.T) { + received := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + if body { + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + } + close(received) + <-r.Context().Done() + })) + defer srv.Close() + client, _ := NewClient(Endpoint{URL: srv.URL, Token: "test-secret", Model: "test-model", Protocol: ProtocolAnthropic}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := client.CallTool(ctx, testRequest()) + select { + case <-received: + default: + t.Fatal("request never reached server") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline error, got %v", err) + } + stage, status := "waiting for response headers", "not received" + if body { + stage, status = "reading response body", "200" + } + for _, want := range []string{"caller deadline exceeded", "Stage: " + stage, "elapsed:", "HTTP status: " + status, "test-model", "anthropic"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missing %q in %v", want, err) + } + } + }) + } +} + +type diagnosticTransport func(*http.Request) (*http.Response, error) + +func (f diagnosticTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestRequestFailureClassificationAndRedaction(t *testing.T) { + for _, test := range []struct { + name string + trace func(*httptrace.ClientTrace) + err error + want string + }{ + {"dns", func(tr *httptrace.ClientTrace) { tr.DNSStart(httptrace.DNSStartInfo{}) }, &net.DNSError{Err: "not found", Name: "secret-host"}, "DNS lookup failed"}, + {"connect", func(tr *httptrace.ClientTrace) { tr.ConnectStart("tcp", "secret-host") }, errors.New("fake-token-secret"), "connecting to endpoint or proxy"}, + {"refused", func(tr *httptrace.ClientTrace) { tr.ConnectStart("tcp", "secret-host") }, syscall.ECONNREFUSED, "connection refused"}, + {"tls", func(tr *httptrace.ClientTrace) { tr.TLSHandshakeStart() }, &tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{}}, "TLS certificate verification failed"}, + {"tls failure", func(tr *httptrace.ClientTrace) { + tr.TLSHandshakeStart() + tr.TLSHandshakeDone(tls.ConnectionState{}, io.EOF) + }, io.EOF, "TLS handshake"}, + {"timeout", func(tr *httptrace.ClientTrace) { tr.GotConn(httptrace.GotConnInfo{}) }, &net.DNSError{IsTimeout: true}, "HTTP client or transport timeout"}, + {"write", func(tr *httptrace.ClientTrace) { + tr.GotConn(httptrace.GotConnInfo{}) + tr.WroteRequest(httptrace.WroteRequestInfo{Err: io.ErrUnexpectedEOF}) + }, io.ErrUnexpectedEOF, "sending request"}, + {"partial headers", func(tr *httptrace.ClientTrace) { + tr.TLSHandshakeDone(tls.ConnectionState{}, nil) + tr.WroteRequest(httptrace.WroteRequestInfo{}) + tr.GotFirstResponseByte() + }, io.ErrUnexpectedEOF, "reading response headers"}, + } { + t.Run(test.name, func(t *testing.T) { + client, _ := NewClient(Endpoint{URL: "http://example.invalid", Token: "fake-token-secret", Model: "test-model", Protocol: ProtocolOpenAI}) + client.http.Transport = diagnosticTransport(func(r *http.Request) (*http.Response, error) { + trace := httptrace.ContextClientTrace(r.Context()) + if trace == nil { + t.Fatal("HTTP request has no diagnostic trace") + } + test.trace(trace) + return nil, &url.Error{Op: "Post", URL: "http://user:password@example.invalid?key=fake-token-secret", Err: test.err} + }) + _, err := client.CallTool(context.Background(), testRequest()) + if err == nil || !strings.Contains(err.Error(), test.want) || !errors.Is(err, test.err) { + t.Fatalf("got %v, want %s and original cause", err, test.want) + } + for _, secret := range []string{"fake-token-secret", "password", "secret-host"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("credential or raw transport details exposed: %v", err) + } + } + }) + } +} + +func TestRequestCancellationIsNotReportedAsTimeout(t *testing.T) { + client, _ := NewClient(Endpoint{URL: "http://example.invalid", Token: "test-secret", Model: "test-model", Protocol: ProtocolAnthropic}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + client.http.Transport = diagnosticTransport(func(r *http.Request) (*http.Response, error) { return nil, r.Context().Err() }) + _, err := client.CallTool(ctx, testRequest()) + if !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "cancelled by caller") || strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("cancellation diagnostic = %v", err) + } +} + +func TestParserRejectionPreservesRequestDiagnostics(t *testing.T) { + client, _ := NewClient(Endpoint{URL: "http://example.invalid", Token: "test-secret", Model: "test-model", Protocol: ProtocolAnthropic}) + client.http.Transport = diagnosticTransport(func(r *http.Request) (*http.Response, error) { + trace := httptrace.ContextClientTrace(r.Context()) + trace.GotConn(httptrace.GotConnInfo{}) + trace.WroteRequest(httptrace.WroteRequestInfo{}) + <-r.Context().Done() + return nil, r.Context().Err() + }) + parser := intent.NewParser(client, nil).WithTimeout(20 * time.Millisecond) + result, err := parser.Parse(context.Background(), "review current changes", intent.NewState()) + if err != nil || result.Kind != intent.KindReject { + t.Fatalf("parse result=%+v error=%v", result, err) + } + for _, want := range []string{"parser timeout of 20ms", "request deadline exceeded", "OCR has not started", "waiting for response headers", "test-model", "no complete HTTP response headers"} { + if !strings.Contains(result.Reject.Reason, want) { + t.Errorf("missing %q in %s", want, result.Reject.Reason) + } + } + if strings.Contains(result.Reject.Reason, "caller deadline exceeded") { + t.Errorf("parser timeout attributed to caller: %s", result.Reject.Reason) + } +} + +func TestCustomDeadlineCauseIsNotExposed(t *testing.T) { + client, err := NewClient(Endpoint{URL: "http://example.invalid", Token: "test-secret", Model: "test-model", Protocol: ProtocolAnthropic}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithDeadlineCause(context.Background(), time.Now().Add(-time.Second), errors.New("private-custom-cause")) + defer cancel() + client.http.Transport = diagnosticTransport(func(r *http.Request) (*http.Response, error) { + return nil, r.Context().Err() + }) + _, err = client.CallTool(ctx, testRequest()) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected original deadline error, got %v", err) + } + if !strings.Contains(err.Error(), "request deadline exceeded") || strings.Contains(err.Error(), "caller deadline exceeded") || strings.Contains(err.Error(), "private-custom-cause") { + t.Fatalf("unsafe or misattributed deadline diagnostic: %v", err) + } +} + +func TestHTTPFailureRedactsEncodedQueryCredentials(t *testing.T) { + for _, test := range []struct { + name string + query string + echo string + }{ + {"plus as space", "key=a+b", "a+b|a b"}, + {"percent space", "key=a%20b", "a%20b|a b|a+b"}, + {"literal plus", "key=a%2Bb", "a%2Bb|a+b"}, + {"lowercase slash", "key=a%2fb", "a%2fb|a/b|a%2Fb"}, + {"equals", "key=a%3Db", "a%3Db|a=b"}, + {"overlapping percent", "key=a%25", "a%25|a%"}, + {"short percent", "key=%25", "%25|%"}, + {"repeated encoded key", "%6bey=a%20b&key=c%2fd", "a%20b|a b|a+b|c%2fd|c/d|c%2Fd"}, + } { + t.Run(test.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != test.query { + t.Errorf("request query changed: %q", r.URL.RawQuery) + } + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, "Echo: "+test.echo) + })) + defer srv.Close() + client, err := NewClient(Endpoint{URL: srv.URL + "?" + test.query, Token: "test-secret", Model: "test-model", Protocol: ProtocolAnthropic}) + if err != nil { + t.Fatal(err) + } + _, err = client.post(context.Background(), client.ep.URL, map[string]string{}, nil) + if err == nil { + t.Fatal("expected HTTP error") + } + redacted := strings.TrimSuffix(strings.Repeat("[REDACTED]|", strings.Count(test.echo, "|")+1), "|") + if !strings.Contains(err.Error(), `Provider response: "Echo: `+redacted+`"`) { + t.Fatalf("encoded credential not fully redacted: %v", err) + } + }) + } +} + +func TestDiagnosticsBoundProviderOutput(t *testing.T) { + client := &Client{ep: Endpoint{Token: "test-secret"}} + value := client.diagnosticText(strings.Repeat("x", 1000) + "test-secret") + if len(value) > maxErrorBody+5 || !strings.HasSuffix(value, "...\"") || strings.Contains(value, "test-secret") { + t.Fatalf("unbounded diagnostic: %d bytes", len(value)) + } +} + +func TestDiagnosticsRedactUsernameCredentials(t *testing.T) { + for _, tc := range []struct{ userinfo, decoded string }{ + {"username-secret", "username-secret"}, + {"username-secret:", "username-secret"}, + {"username%2fsecret", "username/secret"}, + } { + for _, scheme := range []string{"https", "HTTPS"} { + endpoint := scheme + "://" + tc.userinfo + "@gateway/v1" + client := &Client{ep: Endpoint{URL: endpoint, Token: "different-secret"}} + got := client.diagnosticText("Echo " + endpoint + " " + tc.decoded) + if strings.Contains(got, tc.userinfo) || strings.Contains(got, tc.decoded) { + t.Fatalf("credential leaked: %s", got) + } + } + } +} + +func TestHTTPFailureIncludesSafeProviderMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"error":{"message":"No provider channel; fake-token-secret header-secret query-secret password-secret\nforged line"}}`) + })) + defer srv.Close() + client, _ := NewClient(Endpoint{URL: srv.URL, Token: "fake-token-secret", Model: "test-model", Protocol: ProtocolAnthropic, + ExtraHeaders: map[string]string{"X-Access-Token": "header-secret"}}) + // Exercise diagnostic redaction independently of the provider URL builder. + u, _ := url.Parse(srv.URL) + u.User = url.UserPassword("fake-user", "password-secret") + u.RawQuery = "key=query-secret" + client.ep.URL = u.String() + _, err := client.post(context.Background(), u.String(), map[string]string{"prompt": "private-prompt"}, nil) + if err == nil || !strings.Contains(err.Error(), "HTTP 503") || !strings.Contains(err.Error(), "No provider channel") { + t.Fatalf("missing provider diagnostic: %v", err) + } + for _, secret := range []string{"fake-token-secret", "header-secret", "query-secret", "password-secret", "fake-user", "private-prompt", "\nforged"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("secret/control exposed: %v", err) + } + } +} + +func TestDiagnosticMetadataSurvivesShortConfigurationValues(t *testing.T) { + client, _ := NewClient(Endpoint{ + URL: "http://api:p5@10.0.0.1:8080/v1/messages?api-version=2024-01-01&key=q7#private", + Token: "q7", Model: "gpt-4.1", Protocol: ProtocolAnthropic, + ExtraHeaders: map[string]string{"X-Trace": "1", "Content-Type": "json", "X-Access-Token": "z9"}, + }) + client.http.Transport = diagnosticTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader("api version 2024-01-01, trace 1, json; credentials: q7 p5 z9")), Header: make(http.Header)}, nil + }) + _, err := client.post(context.Background(), client.ep.URL, map[string]string{}, nil) + if err == nil { + t.Fatal("expected HTTP error") + } + for _, want := range []string{`Protocol: "anthropic"`, `model: "gpt-4.1"`, `endpoint: "http://10.0.0.1:8080/v1/messages"`, "api version 2024-01-01, trace 1, json", "HTTP 503", "[REDACTED]"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missing %q in %v", want, err) + } + } + for _, secret := range []string{"q7", "p5", "z9", "api:p5@", "?api-version", "#private"} { + if strings.Contains(err.Error(), secret) { + t.Errorf("credential/URL decoration exposed: %v", err) + } + } +} + +func TestInvalidURLFailsBeforeTransport(t *testing.T) { + for _, rawURL := range []string{"http://example.invalid/%zz", "http://example.invalid/\nprivate"} { + client := &Client{http: &http.Client{Transport: diagnosticTransport(func(*http.Request) (*http.Response, error) { + t.Error("invalid URL reached transport") + return nil, errors.New("unexpected transport call") + })}} + _, err := client.post(context.Background(), rawURL, map[string]string{}, nil) + if err == nil { + t.Fatal("invalid URL accepted") + } + for _, want := range []string{"invalid parser endpoint URL", "Stage: building request", "Request was not sent", "HTTP status: not received"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missing %q in %v", want, err) + } + } + if strings.Contains(err.Error(), "network or transport failure") || strings.Contains(err.Error(), "acquiring connection") { + t.Errorf("local build failure misclassified: %v", err) + } + } +} diff --git a/acp/internal/intent/llm/real_llm_test.go b/acp/internal/intent/llm/real_llm_test.go new file mode 100644 index 000000000..7147df114 --- /dev/null +++ b/acp/internal/intent/llm/real_llm_test.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "os" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/intent" +) + +// TestRealEndpointToolCall is opt-in because it calls a real, configured +// endpoint. The core regression never depends on it. +func TestRealEndpointToolCall(t *testing.T) { + if os.Getenv("OCR_ACP_REAL_LLM") == "" { + t.Skip("set OCR_ACP_REAL_LLM=1 to call the configured endpoint") + } + ep, err := Resolve(Options{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + c, err := NewClient(ep) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Log(ep.Summary()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + call, err := c.CallTool(ctx, intent.LLMRequest{ + System: "You convert one review request into exactly one submit_intent tool call.", + User: "Review my current changes.", + Tool: intent.SubmitIntentTool(), + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if call.Name != "submit_intent" { + t.Fatalf("tool = %q", call.Name) + } +} + +// This optional semantic check sends only synthetic requests through the real +// parser prompt. It neither sends repository content nor starts an OCR review. +func TestRealLatestCommitIntent(t *testing.T) { + if os.Getenv("OCR_ACP_REAL_LLM") == "" { + t.Skip("set OCR_ACP_REAL_LLM=1 to call the configured endpoint") + } + ep, err := Resolve(Options{}) + if err != nil { + t.Fatal("real parser configuration is unavailable") + } + client, err := NewClient(ep) + if err != nil { + t.Fatal("real parser client could not be constructed") + } + for _, input := range []string{ + "\u5ba1\u67e5\u4e00\u4e0b\u6700\u65b0\u63d0\u4ea4\u7684commit", + "\u5ba1\u67e5\u4e00\u4e0b\u6700\u8fd1\u7684\u4e00\u6b21\u63d0\u4ea4", + } { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + result, err := intent.NewParser(client, nil).Parse(ctx, input, intent.NewState()) + cancel() + if err != nil || result.Kind != intent.KindIntent || result.Review == nil || result.Review.Commit != "HEAD" { + t.Fatalf("latest commit was not mapped to a HEAD review (kind=%v)", result.Kind) + } + } +} diff --git a/acp/internal/intent/llm/resolve.go b/acp/internal/intent/llm/resolve.go new file mode 100644 index 000000000..652e81123 --- /dev/null +++ b/acp/internal/intent/llm/resolve.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package llm resolves the parsing LLM endpoint from the adapter's own +// minimal configuration, and speaks the two protocols the adapter supports. +// +// It deliberately does not read the OCR config file, the OCR_LLM_* or +// ANTHROPIC_* environment, any shell rc file, or run api_key_cmd. The parsing +// LLM is configured independently from the review LLM, so an OCR upgrade never +// forces the adapter to copy or drift with OCR's provider system. +package llm + +import ( + "fmt" + "net/url" + "os" + "strings" + "time" +) + +// Canonical protocol names, matching the OCR values. +const ( + ProtocolAnthropic = "anthropic" + ProtocolOpenAI = "openai" + ProtocolOpenAIResponses = "openai-responses" + ProtocolAnthropicBedrock = "anthropic-bedrock" +) + +// Parsing configuration environment variables. The matching startup flags +// (--parser-provider, --parser-model, --parser-base-url) win over these; the +// API key has no flag and is environment-only. +const ( + EnvProvider = "OCR_ACP_PARSER_PROVIDER" + EnvModel = "OCR_ACP_PARSER_MODEL" + EnvBaseURL = "OCR_ACP_PARSER_BASE_URL" + EnvAPIKey = "OCR_ACP_PARSER_API_KEY" +) + +// Endpoint is a resolved LLM endpoint. Token is never written to logs; use +// Summary for the reproducible evidence line. +type Endpoint struct { + URL string + Token string + Model string + Provider string + Protocol string + AuthHeader string + Source string + Timeout time.Duration + ExtraHeaders map[string]string + ExtraBody map[string]any +} + +// Options carries the parsing startup flags and injects the environment for +// tests. APIKey has no corresponding flag: the real CLI only ever sets it from +// OCR_ACP_PARSER_API_KEY, and this field exists so tests and a future secret +// source can supply it directly. +type Options struct { + Provider string + Model string + BaseURL string + APIKey string + Getenv func(string) string +} + +// defaultBaseURLs are the only two endpoints the adapter will guess. Any other +// provider must set --parser-base-url / OCR_ACP_PARSER_BASE_URL explicitly. +var defaultBaseURLs = map[string]string{ + ProtocolAnthropic: "https://api.anthropic.com", + ProtocolOpenAI: "https://api.openai.com/v1", +} + +// Resolve builds the parsing endpoint from the startup flags and the +// OCR_ACP_PARSER_* environment variables, with the flags taking precedence. +func Resolve(opts Options) (Endpoint, error) { + getenv := opts.Getenv + if getenv == nil { + getenv = os.Getenv + } + + proto, err := normalizeProtocol(firstNonEmpty(opts.Provider, getenv(EnvProvider))) + if err != nil { + return Endpoint{}, err + } + + model := strings.TrimSpace(firstNonEmpty(opts.Model, getenv(EnvModel))) + if model == "" { + return Endpoint{}, fmt.Errorf("no parsing model configured: set --parser-model or %s", EnvModel) + } + + baseURL := strings.TrimSpace(firstNonEmpty(opts.BaseURL, getenv(EnvBaseURL))) + if baseURL == "" { + baseURL = defaultBaseURLs[proto] + } + if err := validateBaseURL(baseURL); err != nil { + return Endpoint{}, err + } + + token := strings.TrimSpace(firstNonEmpty(opts.APIKey, getenv(EnvAPIKey))) + if token == "" { + return Endpoint{}, fmt.Errorf("no parsing API key configured: set %s", EnvAPIKey) + } + + source := "environment" + if strings.TrimSpace(opts.Provider) != "" || strings.TrimSpace(opts.Model) != "" || strings.TrimSpace(opts.BaseURL) != "" { + source = "startup flags" + } + + return Endpoint{ + URL: baseURL, + Token: token, + Model: model, + Provider: proto, + Protocol: proto, + AuthHeader: defaultAuthHeader(proto), + Source: source, + }, nil +} + +func normalizeProtocol(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case ProtocolOpenAI: + return ProtocolOpenAI, nil + case ProtocolAnthropic: + return ProtocolAnthropic, nil + case "": + return "", fmt.Errorf("no parsing provider configured: set --parser-provider or %s to %q or %q", EnvProvider, ProtocolAnthropic, ProtocolOpenAI) + case ProtocolOpenAIResponses, ProtocolAnthropicBedrock: + return "", fmt.Errorf("the parsing client does not support protocol %q yet; use %q or %q", raw, ProtocolAnthropic, ProtocolOpenAI) + default: + return "", fmt.Errorf("unsupported parsing provider %q; only %q and %q are supported", raw, ProtocolAnthropic, ProtocolOpenAI) + } +} + +func defaultAuthHeader(proto string) string { + if proto == ProtocolAnthropic { + return "x-api-key" + } + return "" +} + +func validateBaseURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parsing base URL %q is not a valid URL: %w", raw, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("parsing base URL %q must start with http:// or https://", raw) + } + if u.Host == "" { + return fmt.Errorf("parsing base URL %q has no host", raw) + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// Summary returns a reproducible one-line description of the endpoint that +// never contains the token. +func (e Endpoint) Summary() string { + return fmt.Sprintf("parsing LLM: source=%s protocol=%s model=%s url=%s", e.Source, e.Protocol, e.Model, redactURL(e.URL)) +} + +func redactURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + u.User = nil + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/acp/internal/intent/llm/resolve_test.go b/acp/internal/intent/llm/resolve_test.go new file mode 100644 index 000000000..1a604deee --- /dev/null +++ b/acp/internal/intent/llm/resolve_test.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "strings" + "testing" +) + +func envFunc(values map[string]string) func(string) string { + return func(key string) string { return values[key] } +} + +func TestResolveDefaultsForEachProvider(t *testing.T) { + cases := []struct { + provider string + protocol string + url string + auth string + }{ + {ProtocolAnthropic, ProtocolAnthropic, "https://api.anthropic.com", "x-api-key"}, + {ProtocolOpenAI, ProtocolOpenAI, "https://api.openai.com/v1", ""}, + } + for _, c := range cases { + t.Run(c.provider, func(t *testing.T) { + ep, err := Resolve(Options{Provider: c.provider, Model: "m", APIKey: "k"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ep.Protocol != c.protocol { + t.Errorf("protocol = %q, want %q", ep.Protocol, c.protocol) + } + if ep.Provider != c.provider { + t.Errorf("provider = %q, want %q", ep.Provider, c.provider) + } + if ep.URL != c.url { + t.Errorf("url = %q, want %q", ep.URL, c.url) + } + if ep.AuthHeader != c.auth { + t.Errorf("auth header = %q, want %q", ep.AuthHeader, c.auth) + } + if ep.Model != "m" || ep.Token != "k" { + t.Errorf("model/token = %q/%q, want m/k", ep.Model, ep.Token) + } + if ep.Source != "startup flags" { + t.Errorf("source = %q, want startup flags", ep.Source) + } + }) + } +} + +func TestResolveFromEnvironment(t *testing.T) { + ep, err := Resolve(Options{Getenv: envFunc(map[string]string{ + EnvProvider: "anthropic", + EnvModel: "claude-3-5-sonnet", + EnvBaseURL: "https://proxy.example/v1", + EnvAPIKey: "sekret", + })}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ep.Source != "environment" { + t.Errorf("source = %q, want environment", ep.Source) + } + if ep.URL != "https://proxy.example/v1" || ep.Model != "claude-3-5-sonnet" || ep.Token != "sekret" { + t.Errorf("unexpected endpoint: %+v", ep) + } +} + +func TestResolveFlagsWinOverEnvironment(t *testing.T) { + ep, err := Resolve(Options{ + Provider: "openai", + Model: "flag-model", + Getenv: envFunc(map[string]string{ + EnvProvider: "anthropic", + EnvModel: "env-model", + EnvAPIKey: "env-key", + }), + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ep.Protocol != ProtocolOpenAI || ep.Model != "flag-model" { + t.Errorf("flags did not win: %+v", ep) + } + if ep.Token != "env-key" { + t.Errorf("token = %q, want env-key", ep.Token) + } +} + +func TestResolveProviderCaseInsensitive(t *testing.T) { + ep, err := Resolve(Options{Provider: " OpenAI ", Model: "m", APIKey: "k"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ep.Protocol != ProtocolOpenAI { + t.Errorf("protocol = %q, want openai", ep.Protocol) + } +} + +func TestResolveErrors(t *testing.T) { + cases := []struct { + name string + opts Options + getenv map[string]string + want string + }{ + {"no provider", Options{Model: "m", APIKey: "k"}, nil, "no parsing provider"}, + {"unsupported provider", Options{Provider: "gemini", Model: "m", APIKey: "k"}, nil, "only"}, + {"responses unsupported", Options{Provider: "openai-responses", Model: "m", APIKey: "k"}, nil, "does not support protocol"}, + {"bedrock unsupported", Options{Provider: "anthropic-bedrock", Model: "m", APIKey: "k"}, nil, "does not support protocol"}, + {"no model", Options{Provider: "openai", APIKey: "k"}, nil, "no parsing model"}, + {"no key", Options{Provider: "openai", Model: "m"}, nil, "no parsing API key"}, + {"bad scheme", Options{Provider: "openai", Model: "m", BaseURL: "ftp://example.com", APIKey: "k"}, nil, "http://"}, + {"no host", Options{Provider: "openai", Model: "m", BaseURL: "https://", APIKey: "k"}, nil, "host"}, + {"provider from env", Options{Getenv: envFunc(map[string]string{EnvProvider: "anthropic", EnvModel: "m"})}, nil, "no parsing API key"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.opts.Getenv == nil { + c.opts.Getenv = envFunc(c.getenv) + } + _, err := Resolve(c.opts) + if err == nil { + t.Fatalf("Resolve succeeded, want error containing %q", c.want) + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("error = %q, want it to contain %q", err, c.want) + } + }) + } +} + +func TestSummaryRedactsToken(t *testing.T) { + ep, err := Resolve(Options{Provider: "anthropic", Model: "claude-3", APIKey: "super-secret-token"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + summary := ep.Summary() + if strings.Contains(summary, "super-secret-token") { + t.Fatalf("Summary leaked the token: %s", summary) + } + for _, want := range []string{"anthropic", "claude-3", "api.anthropic.com"} { + if !strings.Contains(summary, want) { + t.Errorf("Summary %q does not mention %q", summary, want) + } + } +} + +func TestRedactURLDropsUserInfoAndQuery(t *testing.T) { + got := redactURL("https://user:pass@example.com/v1?api_key=leak#frag") + if strings.Contains(got, "user") || strings.Contains(got, "pass") || strings.Contains(got, "api_key") || strings.Contains(got, "frag") { + t.Errorf("redactURL left secrets in %q", got) + } + if got != "https://example.com/v1" { + t.Errorf("redactURL = %q, want https://example.com/v1", got) + } + if redactURL("://not a url") != "://not a url" { + t.Errorf("redactURL should return the raw value when parsing fails") + } +} + +func TestFirstNonEmpty(t *testing.T) { + if got := firstNonEmpty("", " ", "b", "c"); got != "b" { + t.Errorf("firstNonEmpty = %q, want b", got) + } + if got := firstNonEmpty(" ", ""); got != "" { + t.Errorf("firstNonEmpty = %q, want empty", got) + } +} diff --git a/acp/internal/intent/natural.go b/acp/internal/intent/natural.go new file mode 100644 index 000000000..b8d18987c --- /dev/null +++ b/acp/internal/intent/natural.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +var errParserTimeout = errors.New("parser timeout") + +// parseNatural turns free text into an intent through one LLM tool call. There +// is no deterministic fallback: a failed call becomes a clarification or a +// rejection, never a keyword match. +func (p *Parser) parseNatural(ctx context.Context, text string, st *State) (Result, error) { + if p.llm == nil { + return RejectResult("natural-language parsing is unavailable because no parsing LLM is configured", + "Use /review or /scan."), nil + } + + req := LLMRequest{ + System: systemPrompt, + User: buildUserPrompt(text, st), + Tool: SubmitIntentTool(), + } + + callCtx := ctx + if p.timeout > 0 { + var cancel context.CancelFunc + callCtx, cancel = context.WithTimeoutCause(ctx, p.timeout, errParserTimeout) + defer cancel() + } + + call, err := p.llm.CallTool(callCtx, req) + if err != nil { + if errors.Is(context.Cause(callCtx), errParserTimeout) { + return RejectResult(fmt.Sprintf("natural-language parsing exceeded the configured parser timeout of %s; OCR has not started: %s", p.timeout, err), + "Use /review or /scan to bypass the parsing model, or try again."), nil + } + return RejectResult("could not parse the request with the LLM: "+err.Error(), + "Use /review or /scan, or try again."), nil + } + if call.Name != submitIntentToolName { + return RejectResult("the LLM returned an unexpected tool: "+call.Name, + "Try again, or use /review or /scan."), nil + } + + var raw rawIntent + dec := json.NewDecoder(bytes.NewReader(call.Arguments)) + dec.DisallowUnknownFields() + if err := dec.Decode(&raw); err != nil { + return RejectResult("the LLM returned a malformed intent: "+err.Error(), + "Try again, or use /review or /scan."), nil + } + if err := ensureEOF(dec); err != nil { + return RejectResult("the LLM returned trailing data after the intent: "+err.Error(), + "Try again, or use /review or /scan."), nil + } + + return p.applyRaw(ctx, st, &raw) +} + +func ensureEOF(dec *json.Decoder) error { + var extra json.RawMessage + err := dec.Decode(&extra) + if err == nil { + return errors.New("extra JSON value") + } + if errors.Is(err, io.EOF) { + return nil + } + return err +} + +func (p *Parser) applyRaw(ctx context.Context, st *State, raw *rawIntent) (Result, error) { + switch raw.Action { + case actionReview: + if raw.Review == nil { + p.clearPending(st) + return RejectResult("the LLM returned action review without a review object", + "Try again, or use /review."), nil + } + s := mergeReviewSlots(slotsFromState(st), raw) + switch raw.Review.Type { + case reviewWorkspace, reviewRange, reviewCommit: + default: + p.clearPending(st) + return RejectResult("the LLM returned an unknown review type: "+raw.Review.Type, + "Try again, or use /review."), nil + } + return p.finalizeReview(ctx, st, s) + + case actionScan: + if raw.Scan == nil { + p.clearPending(st) + return RejectResult("the LLM returned action scan without a scan object", + "Try again, or use /scan."), nil + } + s := mergeScanSlots(slotsFromState(st), raw) + return p.finalizeScan(ctx, st, s) + + case actionClarify: + if strings.TrimSpace(raw.Question) == "" { + p.clearPending(st) + return RejectResult("the LLM returned clarify without a question", + "Try again, or use /review or /scan."), nil + } + if raw.Review != nil || raw.Scan != nil { + var partial *pendingSlots + if raw.Review != nil { + partial = mergeReviewSlots(slotsFromState(st), raw) + } else { + partial = mergeScanSlots(slotsFromState(st), raw) + } + p.storePending(st, partial, raw.Missing) + } else { + p.clearPending(st) + } + return ClarifyResult(raw.Question, raw.Missing...), nil + + case actionReject: + if strings.TrimSpace(raw.Reason) == "" { + p.clearPending(st) + return RejectResult("the LLM returned reject without a reason", + "Try again, or use /review or /scan."), nil + } + p.clearPending(st) + return RejectResult(raw.Reason, raw.Hint), nil + + default: + p.clearPending(st) + return RejectResult("the LLM returned an unknown action: "+raw.Action, + "Try again, or use /review or /scan."), nil + } +} + +// Omitted lists inherit compatible pending state; explicit lists (including +// empty arrays) replace it. Never concatenate targets or command options. +func mergeScanSlots(prev *pendingSlots, raw *rawIntent) *pendingSlots { + s := &pendingSlots{action: actionScan, paths: append([]string(nil), raw.Scan.Paths...), extra: append([]string(nil), raw.Extra...)} + if prev != nil && prev.action == actionScan { + if raw.Scan.Paths == nil { + s.paths = append([]string(nil), prev.paths...) + } + if raw.Extra == nil { + s.extra = append([]string(nil), prev.extra...) + } + } + return s +} + +func mergeReviewSlots(prev *pendingSlots, raw *rawIntent) *pendingSlots { + s := &pendingSlots{action: actionReview, extra: append([]string(nil), raw.Extra...)} + if raw.Review.Type == reviewRange { + s.reviewType = reviewRange + s.from, s.to = raw.Review.From, raw.Review.To + } else if raw.Review.Type == reviewCommit { + s.reviewType = raw.Review.Type + s.commit = raw.Review.Commit + } else { + s.reviewType = raw.Review.Type + } + if prev == nil || prev.action != actionReview || prev.reviewType != s.reviewType { + return s + } + if raw.Extra == nil { + s.extra = append([]string(nil), prev.extra...) + } + if s.reviewType == reviewWorkspace { + return s + } + if s.from == "" { + s.from = prev.from + } + if s.to == "" { + s.to = prev.to + } + if s.commit == "" { + s.commit = prev.commit + } + return s +} diff --git a/acp/internal/intent/natural_test.go b/acp/internal/intent/natural_test.go new file mode 100644 index 000000000..28f8d1626 --- /dev/null +++ b/acp/internal/intent/natural_test.go @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" +) + +func TestNaturalResponseLanguageContract(t *testing.T) { + for _, tc := range []struct { + name, input, reply, message string + kind Kind + }{ + {"Chinese greeting", "\u4f60\u597d", `{"action":"reject","reason":"\u4f60\u597d\uff01","hint":"\u8bf7\u4f7f\u7528 /review \u6216 /scan\u3002"}`, "\u4f60\u597d\uff01", KindReject}, + {"English greeting", "hello", `{"action":"reject","reason":"Hello!","hint":"Use /review or /scan."}`, "Hello!", KindReject}, + {"Japanese clarification", "\u30b3\u30df\u30c3\u30c8\u3092\u30ec\u30d3\u30e5\u30fc", `{"action":"clarify","question":"\u3069\u306e\u30b3\u30df\u30c3\u30c8\u3067\u3059\u304b\uff1f","missing":["commit"]}`, "\u3069\u306e\u30b3\u30df\u30c3\u30c8\u3067\u3059\u304b\uff1f", KindClarify}, + } { + t.Run(tc.name, func(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, tc.reply)} + r, err := newTestParser(llm, nil).Parse(context.Background(), tc.input, &State{}) + if err != nil || r.Kind != tc.kind || llm.calls != 1 { + t.Fatalf("unexpected parse: %+v %v", r, err) + } + if !strings.Contains(llm.lastReq.System, "language of the current user request") || !strings.Contains(llm.lastReq.System, "explicit request for a different response language") || !strings.Contains(llm.lastReq.User, tc.input) { + t.Fatal("response-language instructions or original request missing") + } + if r.Kind == KindReject && (r.Reject.Reason != tc.message || !strings.Contains(r.Reject.Hint, "/review")) { + t.Fatalf("localized rejection lost: %+v", r.Reject) + } + if r.Kind == KindClarify && (r.Clarify.Question != tc.message || r.Clarify.Missing[0] != "commit") { + t.Fatalf("localized clarification changed slots: %+v", r.Clarify) + } + }) + } +} + +func TestNaturalLatestCommitInstructionsAndValidation(t *testing.T) { + for _, invalidHead := range []bool{false, true} { + t.Run(fmt.Sprintf("invalidHead=%v", invalidHead), func(t *testing.T) { + input := "\u5ba1\u67e5\u4e00\u4e0b\u6700\u65b0\u63d0\u4ea4\u7684commit" + llm := &fakeLLM{call: intentCall(t, `{"action":"review","review":{"type":"commit","commit":"HEAD"}}`)} + repo := &fakeRepo{bad: map[string]bool{"HEAD": invalidHead}} + state := NewState() + state.setPending(&Pending{Action: actionReview, ReviewType: reviewCommit, Commit: "older-ref"}) + result, err := NewParser(llm, repo).Parse(context.Background(), input, state) + if err != nil || llm.calls != 1 || !strings.Contains(llm.lastReq.User, input) { + t.Fatalf("parse = %+v, %v; calls=%d", result, err, llm.calls) + } + // Verify the actual model request carries the relative-ref policy; + // the canned response only tests deterministic validation and argv. + for _, required := range []string{ + `"latest commit"`, `"most recent commit"`, `"last commit"`, + `{"action":"review","review":{"type":"commit","commit":"HEAD"}}`, + `/review --commit HEAD`, `Do not ask for a SHA`, + } { + if !strings.Contains(llm.lastReq.System, required) { + t.Errorf("model instructions missing %q", required) + } + } + if !strings.Contains(string(llm.lastReq.Tool.Parameters), "HEAD") { + t.Error("commit field schema does not describe HEAD") + } + if !equalArgs(repo.calls, []string{"HEAD"}) { + t.Fatalf("commit was not checked against the repository: %v", repo.calls) + } + if invalidHead { + requireClarify(t, result, "ref") + if pending := state.Pending(); pending == nil || pending.Commit != "HEAD" { + t.Fatalf("invalid ref not retained for correction: %+v", pending) + } + return + } else if got := requireIntent(t, result); !equalArgs(got, []string{"review", "--commit", "HEAD", "--format", "json", "--audience", "human", "--color", "never"}) { + t.Fatalf("latest commit argv = %v", got) + } + if state.Pending() != nil { + t.Fatalf("completed request retained pending state: %+v", state.Pending()) + } + }) + } +} + +func TestNaturalIntents(t *testing.T) { + tests := []struct { + name string + reply string + want []string + }{ + { + "workspace", + `{"action":"review","review":{"type":"workspace"}}`, + []string{"review", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + "range", + `{"action":"review","review":{"type":"range","from":"main","to":"feature"}}`, + []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + "commit", + `{"action":"review","review":{"type":"commit","commit":"abc1234"}}`, + []string{"review", "--commit", "abc1234", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + "scan paths", + `{"action":"scan","scan":{"paths":["internal/agent"]}}`, + []string{"scan", "--path", "internal/agent", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + "scan root", + `{"action":"scan","scan":{}}`, + []string{"scan", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + "review with extra", + `{"action":"review","review":{"type":"workspace"},"extra":["--effort","high"]}`, + []string{"review", "--effort", "high", "--format", "json", "--audience", "human", "--color", "never"}, + }, + { + "scan with extra", + `{"action":"scan","scan":{"paths":["pkg"]},"extra":["--batch","by-language"]}`, + []string{"scan", "--path", "pkg", "--batch", "by-language", "--format", "json", "--audience", "human", "--color", "never"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, tt.reply)} + p := newTestParser(llm, nil) + r, err := p.Parse(context.Background(), "a natural language request", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := requireIntent(t, r); !equalArgs(got, tt.want) { + t.Fatalf("argv = %v, want %v", got, tt.want) + } + if llm.calls != 1 { + t.Fatalf("LLM calls = %d, want 1", llm.calls) + } + }) + } +} + +func TestNaturalClarify(t *testing.T) { + reply := `{"action":"clarify","question":"Which two refs?","missing":["from","to"],"review":{"type":"range"}}` + p := newTestParser(&fakeLLM{call: intentCall(t, reply)}, nil) + st := NewState() + r, err := p.Parse(context.Background(), "compare two branches", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireClarify(t, r, "from", "to") + if r.Clarify.Question != "Which two refs?" { + t.Fatalf("question = %q", r.Clarify.Question) + } + pend := st.Pending() + if pend == nil || pend.Action != "review" || pend.ReviewType != "range" { + t.Fatalf("pending = %+v", pend) + } +} + +func TestNaturalClarifyWithoutPartialSlots(t *testing.T) { + reply := `{"action":"clarify","question":"What should I review?"}` + p := newTestParser(&fakeLLM{call: intentCall(t, reply)}, nil) + st := NewState() + st.setPending(&Pending{Action: actionReview, ReviewType: reviewRange, From: "main", Missing: []string{"to"}}) + r, err := p.Parse(context.Background(), "do the thing", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireClarify(t, r) + if st.Pending() != nil { + t.Fatalf("pending should be cleared, got %+v", st.Pending()) + } +} + +func TestNaturalFollowUpMergesPendingSlots(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, `{"action":"clarify","question":"Which head ref?","missing":["to"],"review":{"type":"range","from":"main"}}`)} + p := newTestParser(llm, nil) + st := NewState() + if r, _ := p.Parse(context.Background(), "compare branches", st); r.Kind != KindClarify { + t.Fatalf("first parse = %+v", r) + } + llm.call = intentCall(t, `{"action":"review","review":{"type":"range","to":"feature"}}`) + r, err := p.Parse(context.Background(), "feature", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"} + if got := requireIntent(t, r); !equalArgs(got, want) { + t.Fatalf("argv = %v, want %v", got, want) + } + if st.Pending() != nil { + t.Fatalf("pending not cleared after completion: %+v", st.Pending()) + } +} + +func TestNaturalClarifyChainPreservesPartialSlots(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, `{"action":"clarify","question":"Which head?","missing":["to"],"review":{"type":"range","from":"main"}}`)} + p := newTestParser(llm, nil) + st := NewState() + if r, _ := p.Parse(context.Background(), "compare", st); r.Kind != KindClarify { + t.Fatal(r) + } + llm.call = intentCall(t, `{"action":"clarify","question":"Which head?","missing":["to"],"review":{"type":"range"}}`) + if r, _ := p.Parse(context.Background(), "still deciding", st); r.Kind != KindClarify { + t.Fatal(r) + } + if st.Pending().From != "main" { + t.Fatalf("pending lost from: %+v", st.Pending()) + } + llm.call = intentCall(t, `{"action":"review","review":{"type":"range","to":"feature"}}`) + r, err := p.Parse(context.Background(), "feature", st) + if err != nil { + t.Fatal(err) + } + want := []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"} + if got := requireIntent(t, r); !equalArgs(got, want) { + t.Fatalf("argv = %v, want %v", got, want) + } + if st.Pending() != nil { + t.Fatal("pending not cleared") + } +} + +func TestNaturalClarifyTypeSwitchDoesNotLeakReview(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, `{"action":"clarify","question":"Which ref?","missing":["commit"],"review":{"type":"commit"}}`)} + p := newTestParser(llm, nil) + st := NewState() + st.setPending(&Pending{Action: actionReview, ReviewType: reviewRange, From: "main", To: "feature"}) + if _, err := p.Parse(context.Background(), "commit", st); err != nil { + t.Fatal(err) + } + if got := st.Pending(); got == nil || got.From != "" || got.To != "" { + t.Fatalf("cross-type leak: %+v", got) + } +} + +func TestClarificationMergeCompatibilityAndLists(t *testing.T) { + for _, tc := range []struct { + name, reply string + previous Pending + from, to, commit string + extra, paths []string + }{ + {name: "new range value wins", previous: Pending{Action: actionReview, ReviewType: reviewRange, From: "old", To: "tip", Extra: []string{"--no-filter"}}, reply: `{"action":"clarify","question":"Confirm?","review":{"type":"range","from":"new"}}`, from: "new", to: "tip", extra: []string{"--no-filter"}}, + {name: "explicit empty extra clears", previous: Pending{Action: actionReview, ReviewType: reviewRange, From: "main", Extra: []string{"--no-filter"}}, reply: `{"action":"clarify","question":"Head?","review":{"type":"range"},"extra":[]}`, from: "main"}, + {name: "extra replaces", previous: Pending{Action: actionReview, ReviewType: reviewRange, Extra: []string{"--no-filter"}}, reply: `{"action":"clarify","question":"Refs?","review":{"type":"range"},"extra":["--effort","low"]}`, extra: []string{"--effort", "low"}}, + {name: "commit preserved", previous: Pending{Action: actionReview, ReviewType: reviewCommit, Commit: "abc"}, reply: `{"action":"clarify","question":"Confirm?","review":{"type":"commit"}}`, commit: "abc"}, + {name: "commit replaced", previous: Pending{Action: actionReview, ReviewType: reviewCommit, Commit: "abc"}, reply: `{"action":"clarify","question":"Confirm?","review":{"type":"commit","commit":"def"}}`, commit: "def"}, + {name: "commit to range", previous: Pending{Action: actionReview, ReviewType: reviewCommit, Commit: "abc", Extra: []string{"--no-filter"}}, reply: `{"action":"clarify","question":"Head?","review":{"type":"range","from":"main"}}`, from: "main"}, + {name: "scan to review", previous: Pending{Action: actionScan, Paths: []string{"a.go"}, Extra: []string{"--no-plan"}}, reply: `{"action":"clarify","question":"Head?","review":{"type":"range","from":"main"}}`, from: "main"}, + {name: "review to scan", previous: Pending{Action: actionReview, ReviewType: reviewRange, From: "main", Extra: []string{"--effort", "high"}}, reply: `{"action":"clarify","question":"Files?","scan":{}}`}, + {name: "scan omitted lists retained", previous: Pending{Action: actionScan, Paths: []string{"a.go"}, Extra: []string{"--no-plan"}}, reply: `{"action":"clarify","question":"Confirm?","scan":{}}`, paths: []string{"a.go"}, extra: []string{"--no-plan"}}, + {name: "scan empty lists clear", previous: Pending{Action: actionScan, Paths: []string{"a.go"}, Extra: []string{"--no-plan"}}, reply: `{"action":"clarify","question":"Confirm?","scan":{"paths":[]},"extra":[]}`}, + {name: "scan lists replace", previous: Pending{Action: actionScan, Paths: []string{"a.go"}, Extra: []string{"--no-plan"}}, reply: `{"action":"clarify","question":"Confirm?","scan":{"paths":["b.go"]},"extra":["--no-summary"]}`, paths: []string{"b.go"}, extra: []string{"--no-summary"}}, + } { + t.Run(tc.name, func(t *testing.T) { + st := NewState() + st.setPending(&tc.previous) + llm := &fakeLLM{call: intentCall(t, tc.reply)} + p := newTestParser(llm, nil) + r, err := p.Parse(context.Background(), "follow up", st) + if err != nil || r.Kind != KindClarify { + t.Fatalf("%+v %v", r, err) + } + got := st.Pending() + if got == nil || got.From != tc.from || got.To != tc.to || got.Commit != tc.commit || !equalArgs(got.Extra, tc.extra) || !equalArgs(got.Paths, tc.paths) { + t.Fatalf("pending=%+v", got) + } + }) + } +} + +func TestScanClarificationChainCompletesWithMergedLists(t *testing.T) { + for _, explicitEmpty := range []bool{false, true} { + llm := &fakeLLM{call: intentCall(t, `{"action":"clarify","question":"Confirm?","scan":{"paths":["a.go"]},"extra":["--no-plan"]}`)} + p := newTestParser(llm, nil) + st := NewState() + if _, err := p.Parse(context.Background(), "scan files", st); err != nil { + t.Fatal(err) + } + llm.call = intentCall(t, `{"action":"clarify","question":"Confirm again?","scan":{}}`) + if r, err := p.Parse(context.Background(), "continue", st); err != nil || r.Kind != KindClarify { + t.Fatalf("%+v %v", r, err) + } + llm.call = intentCall(t, `{"action":"scan","scan":{}}`) + want := []string{"scan", "--path", "a.go", "--no-plan", "--format", "json", "--audience", "human", "--color", "never"} + if explicitEmpty { + llm.call = intentCall(t, `{"action":"scan","scan":{"paths":[]},"extra":[]}`) + want = []string{"scan", "--format", "json", "--audience", "human", "--color", "never"} + } + r, err := p.Parse(context.Background(), "confirmed", st) + if err != nil { + t.Fatal(err) + } + if got := requireIntent(t, r); !equalArgs(got, want) { + t.Fatalf("argv=%v want=%v", got, want) + } + if st.Pending() != nil { + t.Fatal("pending not cleared") + } + } +} + +func TestWorkspaceClarificationOnlyPreservesCompatibleOptions(t *testing.T) { + st := NewState() + st.setPending(&Pending{Action: actionReview, ReviewType: reviewWorkspace, Extra: []string{"--no-filter"}}) + llm := &fakeLLM{call: intentCall(t, `{"action":"clarify","question":"Confirm?","review":{"type":"workspace"}}`)} + p := newTestParser(llm, nil) + if _, err := p.Parse(context.Background(), "confirm later", st); err != nil { + t.Fatal(err) + } + if got := st.Pending(); !equalArgs(got.Extra, []string{"--no-filter"}) || got.From != "" || got.Commit != "" { + t.Fatalf("pending=%+v", got) + } +} + +func TestNaturalCommitClarificationPreservesTypeAndQuestion(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, `{"action":"review","review":{"type":"commit"}}`)} + p := newTestParser(llm, nil) + st := NewState() + r, err := p.Parse(context.Background(), "review a commit", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + c := requireClarify(t, r, "commit") + if c.Question != "Which commit should I review? Add --commit ." { + t.Fatalf("question = %q", c.Question) + } + if pending := st.Pending(); pending == nil || pending.ReviewType != reviewCommit { + t.Fatalf("pending = %+v", pending) + } + llm.call = intentCall(t, `{"action":"review","review":{"type":"commit","commit":"abc1234"}}`) + r, err = p.Parse(context.Background(), "abc1234", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if r.Review == nil || r.Review.Commit != "abc1234" || st.Pending() != nil { + t.Fatalf("unexpected result or pending: %+v / %+v", r.Review, st.Pending()) + } +} + +func TestNaturalReject(t *testing.T) { + reply := `{"action":"reject","reason":"the adapter only reviews","hint":"Use /review"}` + p := newTestParser(&fakeLLM{call: intentCall(t, reply)}, nil) + st := NewState() + st.setPending(&Pending{Action: "review", From: "main", Missing: []string{"to"}}) + r, err := p.Parse(context.Background(), "fix these bugs", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + rej := requireReject(t, r) + if rej.Hint != "Use /review" { + t.Fatalf("hint = %q", rej.Hint) + } + if st.Pending() != nil { + t.Fatal("reject did not clear pending") + } +} + +func TestNaturalReviewMissingSlots(t *testing.T) { + tests := []struct { + name string + reply string + missing string + }{ + {"range missing to", `{"action":"review","review":{"type":"range","from":"main"}}`, "to"}, + {"commit missing sha", `{"action":"review","review":{"type":"commit"}}`, "commit"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestParser(&fakeLLM{call: intentCall(t, tt.reply)}, nil) + r, err := p.Parse(context.Background(), "x", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireClarify(t, r, tt.missing) + }) + } +} + +func TestNaturalRefValidation(t *testing.T) { + repo := &fakeRepo{bad: map[string]bool{"nope": true}} + + p := newTestParser(&fakeLLM{call: intentCall(t, `{"action":"review","review":{"type":"range","from":"nope","to":"main"}}`)}, repo) + r, err := p.Parse(context.Background(), "x", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireClarify(t, r, "ref") + if len(repo.calls) != 1 || repo.calls[0] != "nope" { + t.Fatalf("repo calls = %v", repo.calls) + } + + p = newTestParser(&fakeLLM{call: intentCall(t, `{"action":"review","review":{"type":"commit","commit":"nope"}}`)}, repo) + r, err = p.Parse(context.Background(), "x", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireClarify(t, r, "ref") + + p = newTestParser(&fakeLLM{call: intentCall(t, `{"action":"review","review":{"type":"range","from":"main","to":"feature"}}`)}, repo) + r, err = p.Parse(context.Background(), "x", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireIntent(t, r) +} + +func TestNaturalForbiddenExtra(t *testing.T) { + tests := []string{ + `{"action":"review","review":{"type":"workspace"},"extra":["--format","yaml"]}`, + `{"action":"scan","scan":{},"extra":["--ocr-binary","/tmp/evil"]}`, + `{"action":"review","review":{"type":"workspace"},"extra":["--repo","/tmp/x"]}`, + `{"action":"review","review":{"type":"workspace"},"extra":["--effort","ultra"]}`, + } + for i, reply := range tests { + p := newTestParser(&fakeLLM{call: intentCall(t, reply)}, nil) + r, err := p.Parse(context.Background(), "x", NewState()) + if err != nil { + t.Fatalf("case %d Parse: %v", i, err) + } + requireReject(t, r) + } +} + +func TestNaturalLLMFailures(t *testing.T) { + tests := []struct { + name string + llm LLMClient + parse func(t *testing.T, p *Parser) Result + }{ + {"llm error", &fakeLLM{err: errors.New("boom")}, nil}, + {"nil llm", nil, nil}, + {"wrong tool", &fakeLLM{call: ToolCall{Name: "other", Arguments: []byte(`{}`)}}, nil}, + {"malformed json", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{not json`)}}, nil}, + {"trailing data", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"review"}{"x":1}`)}}, nil}, + {"unknown field", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"review","bogus":1}`)}}, nil}, + {"unknown action", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"dance"}`)}}, nil}, + {"review missing object", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"review"}`)}}, nil}, + {"scan missing object", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"scan"}`)}}, nil}, + {"unknown review type", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"review","review":{"type":"sideways"}}`)}}, nil}, + {"clarify no question", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"clarify"}`)}}, nil}, + {"reject no reason", &fakeLLM{call: ToolCall{Name: submitIntentToolName, Arguments: []byte(`{"action":"reject"}`)}}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + llm := tt.llm + if tt.name == "nil llm" { + llm = nil + } + p := newTestParser(llm, nil) + r, err := p.Parse(context.Background(), "x", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireReject(t, r) + }) + } +} + +func TestNaturalTimeout(t *testing.T) { + llm := &fakeLLM{block: true} + p := newTestParser(llm, nil).WithTimeout(20 * time.Millisecond) + r, err := p.Parse(context.Background(), "x", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireReject(t, r) +} + +type diagnosticTimeoutLLM struct{} + +func (diagnosticTimeoutLLM) CallTool(ctx context.Context, _ LLMRequest) (ToolCall, error) { + <-ctx.Done() + return ToolCall{}, fmt.Errorf("waiting for response headers: %w", ctx.Err()) +} + +func TestNaturalTimeoutDiagnostics(t *testing.T) { + for _, name := range []string{"parser timeout", "parent cancellation", "parent deadline"} { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + switch name { + case "parent cancellation": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + case "parent deadline": + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, time.Now().Add(-time.Second)) + defer cancel() + } + p := newTestParser(diagnosticTimeoutLLM{}, nil).WithTimeout(time.Millisecond) + r, err := p.Parse(ctx, "review my changes", NewState()) + if err != nil { + t.Fatal(err) + } + requireReject(t, r) + if !strings.Contains(r.Reject.Reason, "waiting for response headers:") { + t.Fatalf("lost underlying diagnostic: %+v", r.Reject) + } + if !strings.Contains(r.Reject.Hint, "/review") || !strings.Contains(r.Reject.Hint, "/scan") { + t.Fatalf("missing direct command alternatives: %+v", r.Reject) + } + ownTimeout := strings.Contains(r.Reject.Reason, "configured parser timeout of 1ms") + if ownTimeout != (name == "parser timeout") { + t.Fatalf("incorrect timeout attribution: %+v", r.Reject) + } + if name == "parser timeout" && !strings.Contains(r.Reject.Reason, "OCR has not started") { + t.Fatalf("missing execution stage: %+v", r.Reject) + } + }) + } +} + +func TestNaturalRequestShape(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, workspaceCallJSON)} + p := newTestParser(llm, nil) + st := NewState() + st.setPending(&Pending{Action: "review", ReviewType: "range", From: "main", Missing: []string{"to"}}) + if _, err := p.Parse(context.Background(), "feature is the head", st); err != nil { + t.Fatalf("Parse: %v", err) + } + req := llm.lastReq + if req.Tool.Name != submitIntentToolName { + t.Fatalf("tool name = %q", req.Tool.Name) + } + if req.System == "" { + t.Fatal("system prompt is empty") + } + if !strings.Contains(req.User, "feature is the head") { + t.Fatalf("user prompt missing request text: %q", req.User) + } + if !strings.Contains(req.User, `"From":"main"`) { + t.Fatalf("user prompt missing pending slots: %q", req.User) + } + if !strings.Contains(req.User, "Unfinished request") { + t.Fatalf("user prompt missing pending explanation: %q", req.User) + } +} diff --git a/acp/internal/intent/nl_catalog_test.go b/acp/internal/intent/nl_catalog_test.go new file mode 100644 index 000000000..9358981c6 --- /dev/null +++ b/acp/internal/intent/nl_catalog_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + _ "embed" + "strings" + "testing" +) + +//go:embed testdata/nl_cases.txt +var nlCasesFile []byte + +type nlCase struct { + Name string + Input string + Reply string + Argv []string + Kind string +} + +func parseNLCases(t *testing.T, content string) []nlCase { + t.Helper() + var cases []nlCase + var cur *nlCase + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "-- ") { + cases = append(cases, nlCase{Name: strings.TrimSpace(line[3:])}) + cur = &cases[len(cases)-1] + continue + } + if cur == nil { + t.Fatalf("catalog line before any case: %q", line) + } + switch { + case strings.HasPrefix(line, "input: "): + cur.Input = strings.TrimPrefix(line, "input: ") + case strings.HasPrefix(line, "reply: "): + cur.Reply = strings.TrimPrefix(line, "reply: ") + case strings.HasPrefix(line, "argv: "): + cur.Argv = strings.Fields(strings.TrimPrefix(line, "argv: ")) + case strings.HasPrefix(line, "kind: "): + cur.Kind = strings.TrimPrefix(line, "kind: ") + default: + t.Fatalf("unknown catalog line: %q", line) + } + } + return cases +} + +func TestNaturalLanguageCatalog(t *testing.T) { + cases := parseNLCases(t, string(nlCasesFile)) + if len(cases) < 8 { + t.Fatalf("catalog has %d cases, want at least 8", len(cases)) + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + if c.Input == "" || c.Reply == "" { + t.Fatalf("case is missing input or reply: %+v", c) + } + p := newTestParser(&fakeLLM{call: intentCall(t, c.Reply)}, nil) + r, err := p.Parse(context.Background(), c.Input, NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + switch { + case len(c.Argv) > 0: + if got := requireIntent(t, r); !equalArgs(got, c.Argv) { + t.Fatalf("argv = %v, want %v", got, c.Argv) + } + case c.Kind == "clarify": + requireClarify(t, r) + case c.Kind == "reject": + requireReject(t, r) + default: + t.Fatalf("case has neither argv nor kind: %+v", c) + } + }) + } +} diff --git a/acp/internal/intent/parser.go b/acp/internal/intent/parser.go new file mode 100644 index 000000000..a6df375ee --- /dev/null +++ b/acp/internal/intent/parser.go @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "strings" + "time" + + "github.com/alibaba/open-code-review/acp/internal/contract" +) + +const ( + actionReview = "review" + actionScan = "scan" + actionClarify = "clarify" + actionReject = "reject" + + reviewWorkspace = "workspace" + reviewRange = "range" + reviewCommit = "commit" + + defaultParseTimeout = 15 * time.Second +) + +// LLMClient performs one constrained tool call for natural-language parsing. +// Implementations live outside this package (phase 4 ships one in +// internal/intent/llm); tests inject a scripted fake. +type LLMClient interface { + CallTool(ctx context.Context, req LLMRequest) (ToolCall, error) +} + +// LLMRequest is a single tool-call request. +type LLMRequest struct { + System string + User string + Tool ToolSchema +} + +// ToolSchema is the JSON-Schema tool the model must call. +type ToolSchema struct { + Name string + Description string + Parameters []byte +} + +// ToolCall is one tool invocation returned by the model. +type ToolCall struct { + Name string + Arguments []byte +} + +// Parser converts prompt text into a Result. The LLM is used only for +// natural-language input; slash commands are always deterministic. +type Parser struct { + llm LLMClient + repo RepoContext + timeout time.Duration +} + +// NewParser builds a parser. Both dependencies may be nil: a nil LLM makes +// natural-language input a rejection, and a nil RepoContext skips ref checks. +func NewParser(llm LLMClient, repo RepoContext) *Parser { + return &Parser{llm: llm, repo: repo, timeout: defaultParseTimeout} +} + +// WithTimeout overrides the natural-language parse timeout. +func (p *Parser) WithTimeout(d time.Duration) *Parser { + p.timeout = d + return p +} + +// WithRepo returns a copy bound to a session workspace without mutating the +// shared parser used by other sessions. +func (p *Parser) WithRepo(repo RepoContext) *Parser { + copy := *p + copy.repo = repo + return © +} + +// Parse routes one prompt. A leading slash selects the deterministic parser; +// anything else goes to the LLM. +func (p *Parser) Parse(ctx context.Context, text string, st *State) (Result, error) { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return RejectResult("empty request", "Send /review, /scan, or describe what to review."), nil + } + if strings.HasPrefix(trimmed, "/") { + return p.parseSlash(ctx, trimmed, st) + } + return p.parseNatural(ctx, text, st) +} + +// pendingSlots is the unvalidated working set shared by both parsers. +type pendingSlots struct { + action string + reviewType string + from string + to string + commit string + paths []string + extra []string +} + +func newSlots(action string) *pendingSlots { + return &pendingSlots{action: action} +} + +func slotsFromState(st *State) *pendingSlots { + if st == nil { + return nil + } + pend := st.Pending() + if pend == nil || pend.Action == "" { + return nil + } + return &pendingSlots{ + action: pend.Action, + reviewType: pend.ReviewType, + from: pend.From, + to: pend.To, + commit: pend.Commit, + paths: append([]string(nil), pend.Paths...), + extra: append([]string(nil), pend.Extra...), + } +} + +func (p *Parser) storePending(st *State, s *pendingSlots, missing []string) { + if s == nil { + return + } + st.setPending(&Pending{ + Action: s.action, + ReviewType: s.reviewType, + From: s.from, + To: s.to, + Commit: s.commit, + Paths: append([]string(nil), s.paths...), + Extra: append([]string(nil), s.extra...), + Missing: append([]string(nil), missing...), + }) +} + +func (p *Parser) clearPending(st *State) { + st.Clear() +} + +// finalizeReview validates slots, builds argv, checks refs, and updates state. +func (p *Parser) finalizeReview(ctx context.Context, st *State, s *pendingSlots) (Result, error) { + var ri *contract.ReviewIntent + var missing []string + + switch { + case s.reviewType == reviewCommit || s.commit != "": + if s.commit == "" { + missing = append(missing, "commit") + } else { + ri = &contract.ReviewIntent{Type: contract.ReviewTypeCommit, Commit: s.commit, Extra: s.extra} + } + case s.reviewType == reviewRange || s.from != "" || s.to != "": + if s.from == "" { + missing = append(missing, "from") + } + if s.to == "" { + missing = append(missing, "to") + } + if len(missing) == 0 { + ri = &contract.ReviewIntent{Type: contract.ReviewTypeRange, From: s.from, To: s.to, Extra: s.extra} + } + default: + ri = &contract.ReviewIntent{Type: contract.ReviewTypeWorkspace, Extra: s.extra} + } + + if len(missing) > 0 { + if s.reviewType == "" { + if missing[0] == "commit" { + s.reviewType = reviewCommit + } else { + s.reviewType = reviewRange + } + } + p.storePending(st, s, missing) + return ClarifyResult(reviewClarifyQuestion(missing), missing...), nil + } + + if _, err := contract.BuildReviewArgs(ri); err != nil { + p.clearPending(st) + return RejectResult(err.Error(), "Use /review or /scan with supported flags."), nil + } + if err := p.validateReviewRefs(ctx, ri); err != nil { + p.storePending(st, s, []string{"ref"}) + return ClarifyResult("I could not resolve that ref: "+err.Error()+" Please check the branch or commit name.", "ref"), nil + } + p.clearPending(st) + return IntentResult(ri, nil), nil +} + +func (p *Parser) finalizeScan(ctx context.Context, st *State, s *pendingSlots) (Result, error) { + si := &contract.ScanIntent{Paths: s.paths, Extra: s.extra} + if _, err := contract.BuildScanArgs(si); err != nil { + p.clearPending(st) + return RejectResult(err.Error(), "Use /scan with relative paths inside the scan root."), nil + } + p.clearPending(st) + return IntentResult(nil, si), nil +} + +func (p *Parser) validateReviewRefs(ctx context.Context, ri *contract.ReviewIntent) error { + if p.repo == nil { + return nil + } + switch ri.Type { + case contract.ReviewTypeRange: + if err := p.repo.ResolveCommit(ctx, ri.From); err != nil { + return err + } + return p.repo.ResolveCommit(ctx, ri.To) + case contract.ReviewTypeCommit: + return p.repo.ResolveCommit(ctx, ri.Commit) + } + return nil +} + +func reviewClarifyQuestion(missing []string) string { + if len(missing) >= 2 { + return "Which two refs should I compare? Provide both --from and --to." + } + if missing[0] == "from" { + return "Which base ref should I compare from? Add --from ." + } + if missing[0] == "commit" { + return "Which commit should I review? Add --commit ." + } + return "Which head ref should I compare to? Add --to ." +} diff --git a/acp/internal/intent/parser_extra_test.go b/acp/internal/intent/parser_extra_test.go new file mode 100644 index 000000000..df4662e1f --- /dev/null +++ b/acp/internal/intent/parser_extra_test.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "testing" +) + +type extraRepo struct{} + +func (extraRepo) ResolveCommit(_ context.Context, _ string) error { return nil } + +func TestParserWithRepo(t *testing.T) { + p := NewParser(nil, nil) + if p.WithRepo(extraRepo{}).repo == nil { + t.Fatal("repo was not assigned") + } +} diff --git a/acp/internal/intent/result.go b/acp/internal/intent/result.go new file mode 100644 index 000000000..8a372e26d --- /dev/null +++ b/acp/internal/intent/result.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package intent turns one user prompt into a structured OCR invocation. +// +// It reports three outcomes: a runnable review/scan intent, a clarification +// question, or a rejection. Slash commands are parsed deterministically; every +// other input goes to an LLM through a single constrained tool call. The LLM +// only proposes intent fields. A deterministic layer then builds the final argv +// with the phase 3 contract package, so no prompt text can reach adapter-owned +// flags such as --format or the OCR binary path. +package intent + +import "github.com/alibaba/open-code-review/acp/internal/contract" + +// Kind is the outcome kind of a parse. +type Kind int + +const ( + // KindIntent means the prompt produced a runnable intent. + KindIntent Kind = iota + // KindClarify means information is missing; no process may start. + KindClarify + // KindReject means the prompt is invalid or out of scope. + KindReject +) + +// Clarify is a question plus the slots that are still missing. +type Clarify struct { + Question string + Missing []string +} + +// Reject explains why a prompt was refused and how to retry. +type Reject struct { + Reason string + Hint string +} + +// Result is the three-state parse outcome. Exactly one payload is set, matching Kind. +type Result struct { + Kind Kind + Review *contract.ReviewIntent + Scan *contract.ScanIntent + Clarify *Clarify + Reject *Reject +} + +// IntentResult wraps a runnable intent. +func IntentResult(review *contract.ReviewIntent, scan *contract.ScanIntent) Result { + return Result{Kind: KindIntent, Review: review, Scan: scan} +} + +// ClarifyResult asks the user for the missing slots. +func ClarifyResult(question string, missing ...string) Result { + return Result{Kind: KindClarify, Clarify: &Clarify{Question: question, Missing: missing}} +} + +// RejectResult refuses the prompt with an actionable hint. +func RejectResult(reason, hint string) Result { + return Result{Kind: KindReject, Reject: &Reject{Reason: reason, Hint: hint}} +} diff --git a/acp/internal/intent/slash.go b/acp/internal/intent/slash.go new file mode 100644 index 000000000..408c23c2b --- /dev/null +++ b/acp/internal/intent/slash.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "strings" +) + +// parseSlash parses a deterministic slash command. It never calls the LLM, so +// /review and /scan keep working while the parsing LLM is down. +func (p *Parser) parseSlash(ctx context.Context, text string, st *State) (Result, error) { + fields := strings.Fields(text) + cmd := fields[0] + + var action string + switch cmd { + case "/review": + action = actionReview + case "/scan": + action = actionScan + default: + p.clearPending(st) + return RejectResult("unknown command: "+cmd, + "Supported commands are /review and /scan. Describe anything else in natural language."), nil + } + + rest := fields[1:] + s := newSlots(action) + if canResumeSlash(st.Pending(), action, rest) { + s = slotsFromState(st) + } + + for i := 0; i < len(rest); i++ { + tok := rest[i] + name, inline, hasInline := splitFlag(tok) + + if action == actionReview && (name == "--from" || name == "--to" || name == "--commit") { + value, ok := takeValue(rest, &i, inline, hasInline) + if !ok { + p.clearPending(st) + example := "Example: /review --from main --to feature" + if name == "--commit" { + example = "Example: /review --commit HEAD" + } + return RejectResult("missing value for "+name, example), nil + } + if strings.Contains(value, "..") { + p.clearPending(st) + return RejectResult("range syntax "+value+" is not supported", + "Write /review --from --to instead."), nil + } + switch name { + case "--from": + if s.reviewType == reviewCommit || s.commit != "" { + p.clearPending(st) + return RejectResult("--commit cannot be combined with --from or --to", + "Use either /review --commit or /review --from --to ."), nil + } + s.from, s.reviewType = value, reviewRange + case "--to": + if s.reviewType == reviewCommit || s.commit != "" { + p.clearPending(st) + return RejectResult("--commit cannot be combined with --from or --to", + "Use either /review --commit or /review --from --to ."), nil + } + s.to, s.reviewType = value, reviewRange + case "--commit": + if s.reviewType == reviewRange || s.from != "" || s.to != "" { + p.clearPending(st) + return RejectResult("--commit cannot be combined with --from or --to", + "Use either /review --commit or /review --from --to ."), nil + } + s.commit, s.reviewType = value, reviewCommit + } + continue + } + + if action == actionScan && name == "--path" { + value, ok := takeValue(rest, &i, inline, hasInline) + if !ok { + p.clearPending(st) + return RejectResult("missing value for --path", "Example: /scan --path internal/agent"), nil + } + for _, part := range strings.Split(value, ",") { + if part = strings.TrimSpace(part); part != "" { + s.paths = append(s.paths, part) + } + } + continue + } + + switch { + case name == "--staged": + p.clearPending(st) + return RejectResult("--staged is not supported by the OCR CLI", + "Use /review for the whole change set (staged, unstaged and untracked)."), nil + case action == actionReview && name == "--path": + p.clearPending(st) + return RejectResult("--path belongs to /scan, not /review", + "Use /scan --path

, or /review for the whole change set."), nil + case action == actionScan && (name == "--from" || name == "--to" || name == "--commit"): + p.clearPending(st) + return RejectResult(name+" is not a /scan flag", + "Use /review "+name+" ... instead."), nil + default: + // Any other token is forwarded to the contract validator, which + // owns the flag whitelist and enum checks. A bare value that + // follows a value-taking flag lands here too, so --effort high + // reaches validateExtra as the pair it expects. + s.extra = append(s.extra, tok) + } + } + + if action == actionReview { + return p.finalizeReview(ctx, st, s) + } + return p.finalizeScan(ctx, st, s) +} + +// canResumeSlash permits only a direct answer to a pending range +// clarification. Every other slash command begins a new request so stale +// values cannot change its target. +func canResumeSlash(p *Pending, action string, rest []string) bool { + if p == nil || p.Action != action || action != actionReview || p.ReviewType != reviewRange { + return false + } + missing := make(map[string]bool, len(p.Missing)) + for _, name := range p.Missing { + missing[name] = true + } + if len(missing) == 0 { + return false + } + if len(rest) == 0 || len(rest) > 2 { + return false + } + name, inline, hasInline := splitFlag(rest[0]) + if (name != "--from" && name != "--to") || !missing[strings.TrimPrefix(name, "--")] { + return false + } + if hasInline { + return len(rest) == 1 && inline != "" && !strings.HasPrefix(inline, "-") + } + return len(rest) == 2 && !strings.HasPrefix(rest[1], "-") +} + +// splitFlag separates an inline --flag=value form. Non-flags return the token +// itself as name so callers can report it as a stray argument. +func splitFlag(tok string) (name, value string, hasValue bool) { + if !strings.HasPrefix(tok, "-") { + return tok, "", false + } + if idx := strings.IndexByte(tok, '='); idx >= 0 { + return tok[:idx], tok[idx+1:], true + } + return tok, "", false +} + +// takeValue reads the value of a flag, from the inline form or the next token. +// A missing value or a next token that looks like a flag reports failure +// instead of consuming a flag as a value. +func takeValue(rest []string, i *int, inline string, hasInline bool) (string, bool) { + if hasInline { + if inline == "" { + return "", false + } + return inline, true + } + if *i+1 >= len(rest) { + return "", false + } + next := rest[*i+1] + if strings.HasPrefix(next, "-") { + return "", false + } + *i++ + return next, true +} diff --git a/acp/internal/intent/slash_test.go b/acp/internal/intent/slash_test.go new file mode 100644 index 000000000..c23e65f67 --- /dev/null +++ b/acp/internal/intent/slash_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "testing" +) + +func TestSlashCommands(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {"review workspace", "/review", []string{"review", "--format", "json", "--audience", "human", "--color", "never"}}, + {"review range", "/review --from main --to feature", []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"}}, + {"review range inline", "/review --from=main --to=feature", []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"}}, + {"review commit", "/review --commit abc1234", []string{"review", "--commit", "abc1234", "--format", "json", "--audience", "human", "--color", "never"}}, + {"review effort", "/review --effort high", []string{"review", "--effort", "high", "--format", "json", "--audience", "human", "--color", "never"}}, + {"review bool flag", "/review --no-filter", []string{"review", "--no-filter", "--format", "json", "--audience", "human", "--color", "never"}}, + {"scan root", "/scan", []string{"scan", "--format", "json", "--audience", "human", "--color", "never"}}, + {"scan path", "/scan --path internal/agent", []string{"scan", "--path", "internal/agent", "--format", "json", "--audience", "human", "--color", "never"}}, + {"scan comma paths", "/scan --path a,b", []string{"scan", "--path", "a,b", "--format", "json", "--audience", "human", "--color", "never"}}, + {"scan batch", "/scan --batch by-language", []string{"scan", "--batch", "by-language", "--format", "json", "--audience", "human", "--color", "never"}}, + {"leading space is slash", " /review", []string{"review", "--format", "json", "--audience", "human", "--color", "never"}}, + {"extra whitespace", "/review --from main --to feature", []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, workspaceCallJSON)} + p := newTestParser(llm, nil) + r, err := p.Parse(context.Background(), tt.in, NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := requireIntent(t, r); !equalArgs(got, tt.want) { + t.Fatalf("argv = %v, want %v", got, tt.want) + } + if llm.calls != 0 { + t.Fatalf("slash command called the LLM %d times", llm.calls) + } + }) + } +} + +func TestSlashClarify(t *testing.T) { + p := newTestParser(&fakeLLM{}, nil) + st := NewState() + r, err := p.Parse(context.Background(), "/review --from main", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireClarify(t, r, "to") + pend := st.Pending() + if pend == nil || pend.Action != "review" || pend.From != "main" || pend.ReviewType != "range" { + t.Fatalf("pending = %+v", pend) + } + + // The clarification answer completes the pending range. + r, err = p.Parse(context.Background(), "/review --to feature", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{"review", "--from", "main", "--to", "feature", "--format", "json", "--audience", "human", "--color", "never"} + if got := requireIntent(t, r); !equalArgs(got, want) { + t.Fatalf("argv = %v, want %v", got, want) + } + if st.Pending() != nil { + t.Fatalf("pending not cleared after completion: %+v", st.Pending()) + } +} + +func TestSlashClarifyMissingFrom(t *testing.T) { + p := newTestParser(&fakeLLM{}, nil) + r, err := p.Parse(context.Background(), "/review --to feature", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireClarify(t, r, "from") +} + +func TestSlashNewCommandDoesNotInheritPendingSlots(t *testing.T) { + p := newTestParser(&fakeLLM{}, nil) + st := NewState() + if _, err := p.Parse(context.Background(), "/review --from main", st); err != nil { + t.Fatalf("initial Parse: %v", err) + } + + r, err := p.Parse(context.Background(), "/review --commit abc1234", st) + if err != nil { + t.Fatalf("commit Parse: %v", err) + } + want := []string{"review", "--commit", "abc1234", "--format", "json", "--audience", "human", "--color", "never"} + if got := requireIntent(t, r); !equalArgs(got, want) { + t.Fatalf("argv = %v, want %v", got, want) + } + if st.Pending() != nil { + t.Fatalf("pending not cleared after new command: %+v", st.Pending()) + } +} + +func TestSlashRejectsConflictingReviewSelectors(t *testing.T) { + p := newTestParser(&fakeLLM{}, nil) + r, err := p.Parse(context.Background(), "/review --from main --to feature --commit abc1234", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireReject(t, r) +} + +func TestSlashRejects(t *testing.T) { + tests := []struct { + name string + in string + }{ + {"unknown command", "/foo"}, + {"double dot", "/review main..feature"}, + {"double dot in from", "/review --from a..b --to c"}, + {"staged", "/review --staged"}, + {"path on review", "/review --path p"}, + {"from on scan", "/scan --from main"}, + {"unknown flag", "/review --bogus"}, + {"missing value", "/review --from"}, + {"missing path value", "/scan --path"}, + {"effort on scan", "/scan --effort high"}, + {"stray word", "/review main"}, + {"bad enum", "/review --effort ultra"}, + {"build cannot override format", "/review --format yaml"}, + {"repo flag", "/review --repo /tmp/x"}, + {"ocr binary flag", "/scan --ocr-binary /tmp/evil"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, workspaceCallJSON)} + p := newTestParser(llm, nil) + r, err := p.Parse(context.Background(), tt.in, NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + rej := requireReject(t, r) + if rej.Hint == "" { + t.Fatal("reject has no hint") + } + if llm.calls != 0 { + t.Fatalf("reject path called the LLM %d times", llm.calls) + } + }) + } +} + +func TestSlashNonSlashGoesToLLM(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, workspaceCallJSON)} + p := newTestParser(llm, nil) + r, err := p.Parse(context.Background(), "please use /review here", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireIntent(t, r) + if llm.calls != 1 { + t.Fatalf("LLM calls = %d, want 1", llm.calls) + } +} + +func TestEmptyInputRejected(t *testing.T) { + llm := &fakeLLM{call: intentCall(t, workspaceCallJSON)} + p := newTestParser(llm, nil) + r, err := p.Parse(context.Background(), " ", NewState()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireReject(t, r) + if llm.calls != 0 { + t.Fatalf("empty input called the LLM %d times", llm.calls) + } +} + +func TestSlashRangeScreenRespectsFlagValues(t *testing.T) { + for _, tc := range []struct { + input string + want []string + }{ + {"/scan --path ./a/../b", []string{"scan", "--path", "b"}}, + {"/scan --path=./a/../b", []string{"scan", "--path", "b"}}, + {"/scan --path a..b", []string{"scan", "--path", "a..b"}}, + {"/scan --path=a..b", []string{"scan", "--path", "a..b"}}, + {"/review --background a..b", []string{"review", "--background", "a..b"}}, + {"/review --background-file a..b", []string{"review", "--background-file", "a..b"}}, + {"/scan --background a..b", []string{"scan", "--background", "a..b"}}, + } { + t.Run(tc.input, func(t *testing.T) { + r, err := newTestParser(nil, nil).Parse(context.Background(), tc.input, NewState()) + if err != nil { + t.Fatal(err) + } + got := requireIntent(t, r) + want := append(tc.want, "--format", "json", "--audience", "human", "--color", "never") + if !equalArgs(got, want) { + t.Fatalf("argv = %q, want %q", got, want) + } + }) + } +} + +func TestSlashCommitMissingValueHint(t *testing.T) { + for _, input := range []string{"/review --commit", "/review --commit="} { + r, err := newTestParser(nil, nil).Parse(context.Background(), input, NewState()) + if err != nil { + t.Fatal(err) + } + if got := requireReject(t, r).Hint; got != "Example: /review --commit HEAD" { + t.Fatalf("hint = %q", got) + } + } +} diff --git a/acp/internal/intent/state.go b/acp/internal/intent/state.go new file mode 100644 index 000000000..5a0c3f404 --- /dev/null +++ b/acp/internal/intent/state.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import "sync" + +// Pending is the single clarification slot kept for the current command. It is +// deliberately not a conversation memory: phase 6 stores it per session, a new +// command replaces it, and completing or cancelling clears it. +type Pending struct { + Action string // "review" or "scan" + ReviewType string // workspace | range | commit, when Action is review + From string + To string + Commit string + Paths []string + Extra []string + Missing []string +} + +// State holds at most one pending clarification. +type State struct { + mu sync.Mutex + pending *Pending +} + +// NewState returns an empty clarification state. +func NewState() *State { return &State{} } + +// Pending returns a deep copy of the pending clarification, or nil. +func (s *State) Pending() *Pending { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return clonePending(s.pending) +} + +func (s *State) setPending(p *Pending) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.pending = clonePending(p) +} + +// Clear drops any pending clarification. Phase 6 calls it on session/cancel and +// on task completion; the parser also clears it when a command replaces or +// resolves the pending one. +func (s *State) Clear() { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.pending = nil +} + +func clonePending(p *Pending) *Pending { + if p == nil { + return nil + } + c := *p + c.Paths = append([]string(nil), p.Paths...) + c.Extra = append([]string(nil), p.Extra...) + c.Missing = append([]string(nil), p.Missing...) + return &c +} diff --git a/acp/internal/intent/state_test.go b/acp/internal/intent/state_test.go new file mode 100644 index 000000000..4994575fe --- /dev/null +++ b/acp/internal/intent/state_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "testing" +) + +func TestStateLifecycle(t *testing.T) { + st := NewState() + if st.Pending() != nil { + t.Fatal("new state should have no pending clarification") + } + + st.setPending(&Pending{Action: "review", From: "main", Paths: []string{"a"}, Extra: []string{"--effort"}, Missing: []string{"to"}}) + got := st.Pending() + if got.Action != "review" || got.From != "main" || len(got.Paths) != 1 || len(got.Missing) != 1 { + t.Fatalf("pending = %+v", got) + } + + // Pending must be a copy: mutating it cannot change stored state. + got.From = "other" + got.Paths[0] = "b" + if again := st.Pending(); again.From != "main" || again.Paths[0] != "a" { + t.Fatalf("stored pending was mutated through its copy: %+v", again) + } + + st.Clear() + if st.Pending() != nil { + t.Fatal("Clear did not drop the pending clarification") + } +} + +func TestStateReplacesOnNewCommand(t *testing.T) { + st := NewState() + st.setPending(&Pending{Action: "review", From: "main", Missing: []string{"to"}}) + st.setPending(&Pending{Action: "scan", Missing: nil}) + if got := st.Pending(); got == nil || got.Action != "scan" { + t.Fatalf("new command did not replace pending: %+v", got) + } +} + +func TestStateNilReceiver(t *testing.T) { + var st *State + if st.Pending() != nil { + t.Fatal("nil state Pending should be nil") + } + st.Clear() + st.setPending(&Pending{Action: "review"}) + st.setPending(nil) +} + +func TestNewCommandReplacesPending(t *testing.T) { + p := newTestParser(&fakeLLM{}, nil) + st := NewState() + if _, err := p.Parse(context.Background(), "/review --from main", st); err != nil { + t.Fatalf("Parse: %v", err) + } + if st.Pending() == nil { + t.Fatal("expected pending after incomplete range") + } + // A completed new command replaces and clears the pending clarification. + r, err := p.Parse(context.Background(), "/scan", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireIntent(t, r) + if st.Pending() != nil { + t.Fatalf("pending not cleared by a new command: %+v", st.Pending()) + } +} + +func TestRejectClearsPending(t *testing.T) { + p := newTestParser(&fakeLLM{}, nil) + st := NewState() + if _, err := p.Parse(context.Background(), "/review --from main", st); err != nil { + t.Fatalf("Parse: %v", err) + } + r, err := p.Parse(context.Background(), "/foo", st) + if err != nil { + t.Fatalf("Parse: %v", err) + } + requireReject(t, r) + if st.Pending() != nil { + t.Fatalf("reject did not clear pending: %+v", st.Pending()) + } +} diff --git a/acp/internal/intent/testdata/nl_cases.txt b/acp/internal/intent/testdata/nl_cases.txt new file mode 100644 index 000000000..2b2ff3343 --- /dev/null +++ b/acp/internal/intent/testdata/nl_cases.txt @@ -0,0 +1,84 @@ +# Natural-language cases supported in phase 4. +# +# Format: +# -- +# input: +# reply: +# argv: (intent case) +# kind: clarify | reject (non-intent case) +# +# The input lines carry non-English phrasing on purpose. This file is .txt, which +# scripts/verify-english-only.go does not scan, and testdata/ is ignored by the +# license check, so no SPDX header is needed here. The test feeds each reply to a +# scripted fake LLM and asserts the deterministic layer produces the same result, +# which keeps this catalog honest. + +-- review workspace +input: 评审当前改动 +reply: {"action":"review","review":{"type":"workspace"}} +argv: review --format json --audience human --color never + +-- review range +input: 以 main 为基线评审 feature 分支 +reply: {"action":"review","review":{"type":"range","from":"main","to":"feature"}} +argv: review --from main --to feature --format json --audience human --color never + +-- review commit +input: 评审提交 abc1234 +reply: {"action":"review","review":{"type":"commit","commit":"abc1234"}} +argv: review --commit abc1234 --format json --audience human --color never + +-- scan path +input: 扫描 internal/agent +reply: {"action":"scan","scan":{"paths":["internal/agent"]}} +argv: scan --path internal/agent --format json --audience human --color never + +-- latest commit in Chinese +input: 审查一下最新提交的commit +reply: {"action":"review","review":{"type":"commit","commit":"HEAD"}} +argv: review --commit HEAD --format json --audience human --color never + +-- most recent commit in Chinese +input: 审查一下最近的一次提交 +reply: {"action":"review","review":{"type":"commit","commit":"HEAD"}} +argv: review --commit HEAD --format json --audience human --color never + +-- last commit in English +input: Review the last commit +reply: {"action":"review","review":{"type":"commit","commit":"HEAD"}} +argv: review --commit HEAD --format json --audience human --color never + +-- commit before latest +input: 审查最新提交之前的那一个提交 +reply: {"action":"review","review":{"type":"commit","commit":"HEAD~1"}} +argv: review --commit HEAD~1 --format json --audience human --color never + +-- latest commit on explicit branch +input: 审查 feature 分支最新的一个提交 +reply: {"action":"review","review":{"type":"commit","commit":"feature"}} +argv: review --commit feature --format json --audience human --color never + +-- unspecified commit needs clarification +input: 审查一个提交 +reply: {"action":"clarify","review":{"type":"commit"},"question":"Which commit should I review?","missing":["commit"]} +kind: clarify + +-- scan root +input: 扫描整个仓库 +reply: {"action":"scan","scan":{}} +argv: scan --format json --audience human --color never + +-- clarify missing refs +input: 比较两个分支 +reply: {"action":"clarify","question":"Which two refs should I compare?","missing":["from","to"],"review":{"type":"range"}} +kind: clarify + +-- reject staged only +input: 只评审暂存区 +reply: {"action":"reject","reason":"the CLI cannot review only the staged area","hint":"Use /review for the whole change set, or stage nothing."} +kind: reject + +-- reject auto fix +input: 帮我修复这些问题 +reply: {"action":"reject","reason":"this adapter only reviews; it does not edit code","hint":"Ask for a review instead."} +kind: reject diff --git a/acp/internal/intent/tool.go b/acp/internal/intent/tool.go new file mode 100644 index 000000000..a52862dc2 --- /dev/null +++ b/acp/internal/intent/tool.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "encoding/json" + "strings" +) + +const submitIntentToolName = "submit_intent" + +// rawIntent mirrors the tool schema. The decoder uses DisallowUnknownFields, so +// a model that invents a field fails the parse instead of smuggling a value in. +type rawIntent struct { + Action string `json:"action"` + Review *rawReview `json:"review"` + Scan *rawScan `json:"scan"` + Extra []string `json:"extra"` + Question string `json:"question"` + Missing []string `json:"missing"` + Reason string `json:"reason"` + Hint string `json:"hint"` +} + +type rawReview struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + Commit string `json:"commit"` +} + +type rawScan struct { + Paths []string `json:"paths"` +} + +const submitIntentDescription = "Submit exactly one parsed code-review request for the OpenCodeReview CLI." + +// submitIntentParameters is the JSON Schema the model must satisfy. Adapter-owned +// fields (binary path, output format, audience, color, repo, rule, model, and +// provider) are absent by construction, which is the first line of defense. +const submitIntentParameters = `{ + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "enum": ["review", "scan", "clarify", "reject"], + "description": "review or scan to request work; clarify when a value is missing; reject when the request is out of scope." + }, + "review": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["workspace", "range", "commit"], + "description": "workspace reviews current changes; range compares two refs; commit reviews one commit." + }, + "from": {"type": "string", "description": "Base ref for range."}, + "to": {"type": "string", "description": "Head ref for range."}, + "commit": {"type": "string", "description": "Single commit SHA or Git ref. Use HEAD for the latest/most recent commit of the current checkout; HEAD~1 for the commit before it. Preserve an explicitly named ref."} + }, + "required": ["type"] + }, + "scan": { + "type": "object", + "additionalProperties": false, + "properties": { + "paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Relative paths to scan. Omit or leave empty to scan the whole root." + } + } + }, + "extra": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional review/scan flags. review: --effort, --no-filter, --background, --background-file. scan: --batch, --no-plan, --no-dedup, --no-summary, --background. Do not invent flags." + }, + "question": {"type": "string", "description": "Required for clarify. Write in the language of the current user request, unless the user explicitly requests another response language."}, + "missing": { + "type": "array", + "items": {"type": "string"}, + "description": "Slot names still missing, for clarify." + }, + "reason": {"type": "string", "description": "Required for reject. Write in the language of the current user request, unless the user explicitly requests another response language."}, + "hint": {"type": "string", "description": "Actionable next step, for reject. Use the same response language as reason; preserve literal CLI commands and flags."} + }, + "required": ["action"] +} +` + +// SubmitIntentTool returns the tool definition sent to the parsing model. +func SubmitIntentTool() ToolSchema { + return ToolSchema{ + Name: submitIntentToolName, + Description: submitIntentDescription, + Parameters: []byte(submitIntentParameters), + } +} + +const systemPrompt = `You convert one user request into exactly one submit_intent tool call for the OpenCodeReview CLI. + +Rules: +- Write user-facing question, reason, and hint in the language of the current user request. Honor an explicit request for a different response language. For mixed-language requests, use the language of the surrounding prose, not code, paths, or CLI flags. Do not default to English because these instructions or previous pending slots are in English. +- Keep schema keys, action values, missing slot names, CLI commands, flags, refs, and paths unchanged. Response language changes presentation only, never the parsed operation or arguments. +- Greetings and unrelated conversation are out of scope: give a brief, friendly reason and a useful /review or /scan hint in the user's language; do not start a review or switch to general chat. +- Submit executable intent through the tool fields, never as a shell command. Never invent flags. Only the flags listed in the extra field description are allowed. +- If user-facing guidance includes a slash command, use /review --commit for one commit, /review --from --to for a range, or /review for workspace changes. There is no /review commit subcommand. +- review workspace: review the current change set (staged, unstaged and untracked). This is the default for "review my changes". +- review range: compare two refs. Both "from" and "to" are required. +- review commit: review one commit. "commit" is required. +- "latest commit", "most recent commit", and "last commit" without another target mean HEAD, the current checkout's latest commit, including a detached HEAD. Do not ask for a SHA when this target is already clear. These requests select a single commit, not workspace changes or a range. Apply this meaning in every user language. +- The commit before the latest one is HEAD~1. If the user names a branch or another ref, preserve that target instead of substituting HEAD. Preserve ref punctuation such as ~ and ^. The adapter validates whether refs exist; do not invent hashes or fetch remote refs. +- "Review a commit" without a ref or a clear relative target still requires clarification. Do not infer HEAD for an unspecified commit, author, or remote target. +- scan: scan source files. "paths" are relative to the scan root; an empty list means the whole root. +- If a required value is missing or ambiguous, use "clarify" with a specific question and list the missing slot names. +- If the request is out of scope (auto-fixing code, general questions, staging only, or anything the CLI cannot do), use "reject" with a short reason and an actionable hint. +- The CLI cannot review only the staged area. Reject that with a hint to use /review or git add first. + +Examples (the same meanings apply in other languages): +- Review the latest commit -> {"action":"review","review":{"type":"commit","commit":"HEAD"}}. Equivalent slash command: /review --commit HEAD. +- Review the commit before the latest one -> {"action":"review","review":{"type":"commit","commit":"HEAD~1"}}. +- Review the latest commit on feature -> {"action":"review","review":{"type":"commit","commit":"feature"}}. +` + +func buildUserPrompt(text string, st *State) string { + var b strings.Builder + b.WriteString("User request:\n") + b.WriteString(text) + if st != nil { + if pend := st.Pending(); pend != nil { + if encoded, err := json.Marshal(pend); err == nil { + b.WriteString("\n\nUnfinished request from the previous turn. These slots are already known:\n") + b.Write(encoded) + b.WriteString("\nMerge the new request into these slots and submit the completed intent.") + } + } + } + return b.String() +} diff --git a/acp/internal/intent/tool_test.go b/acp/internal/intent/tool_test.go new file mode 100644 index 000000000..ce607eda8 --- /dev/null +++ b/acp/internal/intent/tool_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "encoding/json" + "testing" +) + +func TestSubmitIntentToolSchema(t *testing.T) { + tool := SubmitIntentTool() + if tool.Name != submitIntentToolName || tool.Description == "" { + t.Fatalf("tool = %+v", tool) + } + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + } + if err := json.Unmarshal(tool.Parameters, &schema); err != nil { + t.Fatalf("schema is not valid JSON: %v", err) + } + want := map[string]bool{ + "action": true, "review": true, "scan": true, "extra": true, + "question": true, "missing": true, "reason": true, "hint": true, + } + if len(schema.Properties) != len(want) { + t.Fatalf("properties = %v, want exactly the allowed set", schema.Properties) + } + for k := range schema.Properties { + if !want[k] { + t.Fatalf("schema exposes unexpected property %q", k) + } + } + // No adapter-owned or infrastructure flag is expressible. + for _, forbidden := range []string{"format", "audience", "color", "output", "repo", "rule", "model", "provider", "ocr_binary", "binary", "staged", "resume"} { + if _, ok := schema.Properties[forbidden]; ok { + t.Fatalf("schema exposes forbidden property %q", forbidden) + } + } + if len(schema.Required) != 1 || schema.Required[0] != "action" { + t.Fatalf("required = %v, want [action]", schema.Required) + } +} + +func TestBuildUserPromptWithoutState(t *testing.T) { + if got := buildUserPrompt("hello", nil); got != "User request:\nhello" { + t.Fatalf("prompt = %q", got) + } +} diff --git a/acp/internal/intent/validate.go b/acp/internal/intent/validate.go new file mode 100644 index 000000000..76b6f48f5 --- /dev/null +++ b/acp/internal/intent/validate.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// RepoContext validates refs against the target repository. Phase 5 supplies the +// real repository root; phase 4 only needs the seam so tests stay offline. +type RepoContext interface { + // ResolveCommit reports whether ref resolves to a commit in the target repo. + ResolveCommit(ctx context.Context, ref string) error +} + +// GitRepo validates refs with git rev-parse in Dir. An empty Dir uses the +// current working directory. --end-of-options keeps a ref that starts with a +// dash from being read as a flag. +type GitRepo struct{ Dir string } + +// ResolveCommit implements RepoContext. +func (g GitRepo) ResolveCommit(ctx context.Context, ref string) error { + if strings.TrimSpace(ref) == "" { + return fmt.Errorf("empty ref") + } + cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}") + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("ref %q does not resolve to a commit: %s", ref, msg) + } + return nil +} diff --git a/acp/internal/intent/validate_extra_test.go b/acp/internal/intent/validate_extra_test.go new file mode 100644 index 000000000..ad7888e3a --- /dev/null +++ b/acp/internal/intent/validate_extra_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package intent + +import ( + "context" + "os/exec" + "testing" +) + +func TestGitRepoRejectsEmptyRef(t *testing.T) { + if err := (GitRepo{}).ResolveCommit(context.Background(), ""); err == nil { + t.Fatal("expected empty ref error") + } +} + +func TestGitRepoResolvesHead(t *testing.T) { + if err := exec.Command("git", "rev-parse", "--verify", "HEAD^{commit}").Run(); err != nil { + t.Skip("workspace is not a git checkout") + } + if err := (GitRepo{Dir: "../.."}).ResolveCommit(context.Background(), "HEAD"); err != nil { + t.Fatal(err) + } +} diff --git a/acp/internal/orchestrator/decode.go b/acp/internal/orchestrator/decode.go new file mode 100644 index 000000000..3444df69c --- /dev/null +++ b/acp/internal/orchestrator/decode.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + "github.com/alibaba/open-code-review/acp/internal/contract" +) + +func decodeResult(args []string, stdout []byte) (*Result, error) { + if len(args) == 0 { + return nil, newError(ErrorInvalidRequest, "OCR arguments are empty") + } + if len(bytes.TrimSpace(stdout)) == 0 { + return nil, newError(ErrorDecode, "OCR stdout is empty") + } + switch args[0] { + case "review": + var result contract.ReviewResult + if err := decodeOne(stdout, &result); err != nil { + return nil, newError(ErrorDecode, "decode OCR review result: %v", err) + } + if !successfulReview(result) { + return &Result{Review: &result}, newError(ErrorResult, "OCR review returned non-success status %q", result.Status) + } + return &Result{Review: &result}, nil + case "scan": + var result contract.ScanResult + if err := decodeOne(stdout, &result); err != nil { + return nil, newError(ErrorDecode, "decode OCR scan result: %v", err) + } + if !successfulScan(result) { + return &Result{Scan: &result}, newError(ErrorResult, "OCR scan returned non-success status %q", result.Status) + } + return &Result{Scan: &result}, nil + default: + return nil, newError(ErrorInvalidRequest, "OCR arguments must start with review or scan, got %q", args[0]) + } +} + +func decodeOne(data []byte, destination any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + if err := dec.Decode(destination); err != nil { + return err + } + var extra json.RawMessage + if err := dec.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON documents") + } + return err + } + return nil +} + +func successfulReview(result contract.ReviewResult) bool { + if result.Status != contract.StatusSuccess && result.Status != contract.StatusComplete && result.Status != contract.StatusPartial && result.Status != contract.StatusSkipped && result.Status != contract.StatusCompletedWithWarnings && result.Status != contract.StatusCompletedWithErrors { + return false + } + if result.Manifest == nil || result.Manifest.TerminalState == "" { + return true + } + state := result.Manifest.TerminalState + return state == contract.StatusComplete || state == contract.StatusSuccess || state == contract.StatusPartial || state == contract.StatusSkipped || state == contract.StatusCompletedWithWarnings || state == contract.StatusCompletedWithErrors +} + +func successfulScan(result contract.ScanResult) bool { + return result.Status == contract.StatusSuccess || result.Status == contract.StatusComplete || result.Status == contract.StatusSkipped || result.Status == contract.StatusCompletedWithWarnings || result.Status == contract.StatusCompletedWithErrors +} diff --git a/acp/internal/orchestrator/internal_test.go b/acp/internal/orchestrator/internal_test.go new file mode 100644 index 000000000..56fe9e904 --- /dev/null +++ b/acp/internal/orchestrator/internal_test.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +type repeatedByteReader struct{ remaining int } + +func (r *repeatedByteReader) Read(p []byte) (int, error) { + if r.remaining == 0 { + return 0, io.EOF + } + n := min(len(p), r.remaining) + for i := range p[:n] { + p[i] = 'x' + } + r.remaining -= n + return n, nil +} + +func TestStderrLongLineUsesBoundedMemory(t *testing.T) { + // Generate the input without allocating it. A ReadString-based reader would + // allocate the entire 32 MiB line before applying either configured limit. + input := &repeatedByteReader{remaining: 32 << 20} + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + count := 0 + tail, cut, warnings, err := readStderr(input, 1024, 64, func(event Event) { + count++ + if len(event.Message) > 64 || !event.Truncated { + t.Errorf("unbounded or unmarked event: %+v", event) + } + }) + runtime.ReadMemStats(&after) + if err != nil || input.remaining != 0 || len(tail) != 1024 || !cut || len(warnings) != 1 || count != 2 { + t.Fatalf("tail=%d cut=%v warnings=%d events=%d remaining=%d err=%v", len(tail), cut, len(warnings), count, input.remaining, err) + } + // Leave ample room for runtime bookkeeping; the input itself exceeds this + // ceiling fourfold, so allocating a full line cannot pass. + if allocated := after.TotalAlloc - before.TotalAlloc; allocated > 8<<20 { + t.Fatalf("stderr reader allocated %d bytes for a bounded line", allocated) + } +} + +func TestStderrBufferChunksDoNotImplyTruncation(t *testing.T) { + for _, tc := range []struct { + name string + size int + truncated bool + }{ + {"within limit", 48 << 10, false}, + {"over limit", 70000, true}, + } { + t.Run(tc.name, func(t *testing.T) { + message := strings.Repeat("x", tc.size) + var events []Event + tail, cut, warnings, err := readStderr(strings.NewReader(message+"\n"), 1<<20, 64<<10, func(e Event) { events = append(events, e) }) + if err != nil || cut || tail != message+"\n" { + t.Fatalf("tail length=%d cut=%v err=%v", len(tail), cut, err) + } + wantEvents, wantWarnings := 1, 0 + if tc.truncated { + wantEvents, wantWarnings = 2, 1 + } + if len(events) != wantEvents || len(warnings) != wantWarnings { + t.Fatalf("events=%d warnings=%d", len(events), len(warnings)) + } + if events[0].Message != message[:min(len(message), 64<<10)] || events[0].Truncated != tc.truncated { + t.Fatalf("message length=%d truncated=%v", len(events[0].Message), events[0].Truncated) + } + }) + } +} + +type failingStderrReader struct{ err error } + +func (r failingStderrReader) Read(p []byte) (int, error) { + return copy(p, "[ocr] partial activity"), r.err +} + +func TestStderrEmitsPendingLineBeforeReadError(t *testing.T) { + failure := errors.New("stderr read failed") + var events []Event + tail, cut, warnings, err := readStderr(failingStderrReader{failure}, 1024, 1024, func(e Event) { events = append(events, e) }) + if !errors.Is(err, failure) || cut || len(warnings) != 0 || tail != "[ocr] partial activity" { + t.Fatalf("tail=%q cut=%v warnings=%v err=%v", tail, cut, warnings, err) + } + if len(events) != 1 || events[0].Kind != EventProgress || events[0].Message != tail || events[0].Truncated { + t.Fatalf("pending line missing or changed: %+v", events) + } +} + +func TestDecodeResultValidationAndStatuses(t *testing.T) { + for _, test := range []struct { + args []string + data string + kind ErrorKind + }{ + {nil, `{}`, ErrorInvalidRequest}, + {[]string{"review"}, ` `, ErrorDecode}, + {[]string{"other"}, `{}`, ErrorInvalidRequest}, + {[]string{"review"}, `{"status":"failed","comments":[]}`, ErrorResult}, + {[]string{"scan"}, `{"status":"failed","comments":[]}`, ErrorResult}, + } { + _, err := decodeResult(test.args, []byte(test.data)) + assertErrorKind(t, err, test.kind) + } +} + +func TestDecodeResultAcceptsContractSuccessStatuses(t *testing.T) { + for _, test := range []struct { + name string + args []string + data string + }{ + {name: "review partial", args: []string{"review"}, data: " \n{\"status\":\"partial\",\"comments\":[]}\n "}, + {name: "review skipped", args: []string{"review"}, data: `{"status":"skipped","comments":[]}`}, + {name: "scan completed with errors", args: []string{"scan"}, data: `{"status":"completed_with_errors","comments":[]}`}, + {name: "scan skipped", args: []string{"scan"}, data: `{"status":"skipped","comments":[]}`}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := decodeResult(test.args, []byte(test.data)); err != nil { + t.Fatalf("decodeResult: %v", err) + } + }) + } + + for _, args := range [][]string{{"review"}, {"scan"}} { + if _, err := decodeResult(args, []byte(`{"status":"unknown","comments":[]}`)); err == nil { + t.Fatalf("decodeResult(%q) accepted an unknown status", args[0]) + } + } +} + +func TestValidateRequestAdditionalFailures(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + binary string + cwd string + }{ + {"", dir}, + {"ocr", ""}, + {"ocr", file}, + } { + if err := validateRequest(test.binary, Request{CWD: test.cwd, Args: []string{"review"}}); err == nil { + t.Fatalf("validateRequest(%q, %q) succeeded", test.binary, test.cwd) + } + } +} + +func TestLimitedAndTailBuffers(t *testing.T) { + limited := limitedBuffer{limit: 3} + if n, err := limited.Write([]byte("abcdef")); n != 3 || !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("limited write = %d, %v", n, err) + } + if !limited.exceeded || limited.String() != "abc" { + t.Fatalf("limited = %q exceeded=%v", limited.String(), limited.exceeded) + } + tail := tailBuffer{limit: 4} + tail.Write([]byte("abcdef")) + if tail.String() != "cdef" || !tail.cut { + t.Fatalf("tail = %q cut=%v", tail.String(), tail.cut) + } + tail.Write([]byte("gh")) + if tail.String() != "efgh" { + t.Fatalf("tail after append = %q", tail.String()) + } +} + +func TestTypedErrorWithoutCause(t *testing.T) { + err := (&Error{Kind: ErrorCleanup}).Error() + if err != string(ErrorCleanup) { + t.Fatalf("Error() = %q", err) + } +} + +func TestCommandExitCodeAndLocalOutcomeError(t *testing.T) { + if commandExitCode(nil) != 0 || commandExitCode(errors.New("x")) != -1 { + t.Fatal("unexpected exit codes") + } + if localOutcomeError(context.Canceled) == nil { + t.Fatal("expected local error") + } +} diff --git a/acp/internal/orchestrator/process/process.go b/acp/internal/orchestrator/process/process.go new file mode 100644 index 000000000..6ba242b7e --- /dev/null +++ b/acp/internal/orchestrator/process/process.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package process manages platform resources for entire child process trees. +package process + +import ( + "errors" + "os/exec" +) + +// Start applies OS process-tree membership and starts cmd. +// On Windows the process is born suspended, assigned to a Job Object, then +// resumed so descendants cannot escape between CreateProcess and assignment. +func Start(cmd *exec.Cmd) error { + if err := configureProcessGroup(cmd); err != nil { + return err + } + if err := cmd.Start(); err != nil { + Close(cmd) + return err + } + if err := attachProcessGroup(cmd); err != nil { + // Keep the cleanup failure instead of discarding it: if the process + // survived the kill, its error explains why rather than leaving only + // the attach failure behind. + killErr := Kill(cmd) + waitErr := cmd.Wait() + Close(cmd) + return errors.Join(err, killErr, waitErr) + } + return nil +} diff --git a/acp/internal/orchestrator/process/process_test.go b/acp/internal/orchestrator/process/process_test.go new file mode 100644 index 000000000..0161f52ba --- /dev/null +++ b/acp/internal/orchestrator/process/process_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package process + +import ( + "errors" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestKillProcessGroup(t *testing.T) { + binary := testutil.Install(t, t.TempDir(), "ocr", &testutil.Config{Block: true}) + cmd := exec.Command(binary, "review", "block") + if err := Start(cmd); err != nil { + t.Fatal(err) + } + defer Close(cmd) + if err := Kill(cmd); err != nil { + t.Fatalf("killProcessGroup: %v", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("killed process did not exit") + } + if err := Kill(cmd); err != nil && !strings.Contains(err.Error(), "no such process") && !errors.Is(err, os.ErrProcessDone) && !strings.Contains(err.Error(), "process already finished") && !strings.Contains(err.Error(), "Access is denied") && !strings.Contains(err.Error(), "invalid argument") { + t.Fatalf("second kill = %v", err) + } +} diff --git a/acp/internal/orchestrator/process/process_unix.go b/acp/internal/orchestrator/process/process_unix.go new file mode 100644 index 000000000..400afe307 --- /dev/null +++ b/acp/internal/orchestrator/process/process_unix.go @@ -0,0 +1,58 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package process + +import ( + "errors" + "os/exec" + "runtime" + "syscall" + "time" +) + +func configureProcessGroup(cmd *exec.Cmd) error { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + return nil +} + +func attachProcessGroup(*exec.Cmd) error { return nil } + +// Close releases the OS resources owned by a managed command. +func Close(*exec.Cmd) {} + +// Interrupt requests cooperative termination of a managed command. +func Interrupt(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGINT); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + return nil +} + +// Kill forcibly terminates the managed process tree, including descendants. +func Kill(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + return killUnixProcessGroup(cmd.Process.Pid, syscall.Kill) +} + +func killUnixProcessGroup(pid int, kill func(int, syscall.Signal) error) error { + for attempt := 0; ; attempt++ { + err := kill(-pid, syscall.SIGKILL) + if err == nil || errors.Is(err, syscall.ESRCH) { + return nil + } + // Darwin returns EPERM for a group containing only unreaped zombies. + // Allow its reaper a bounded window; genuine permission failures survive. + if runtime.GOOS != "darwin" || !errors.Is(err, syscall.EPERM) || attempt == 10 { + return err + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/acp/internal/orchestrator/process/process_unix_test.go b/acp/internal/orchestrator/process/process_unix_test.go new file mode 100644 index 000000000..758ad1a2e --- /dev/null +++ b/acp/internal/orchestrator/process/process_unix_test.go @@ -0,0 +1,55 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package process + +import ( + "runtime" + "syscall" + "testing" +) + +func TestKillProcessGroupRetriesOnlyDarwinPermissionRace(t *testing.T) { + for _, final := range []error{nil, syscall.ESRCH, syscall.EINVAL} { + calls := 0 + got := killUnixProcessGroup(123, func(pid int, signal syscall.Signal) error { + if pid != -123 || signal != syscall.SIGKILL { + t.Fatalf("kill(%d, %v)", pid, signal) + } + calls++ + if calls == 1 { + return syscall.EPERM + } + return final + }) + if runtime.GOOS != "darwin" { + if got != syscall.EPERM || calls != 1 { + t.Fatalf("non-Darwin result=%v calls=%d", got, calls) + } + continue + } + want := final + if final == syscall.ESRCH { + want = nil + } + if got != want || calls != 2 { + t.Fatalf("result=%v want=%v calls=%d", got, want, calls) + } + } +} + +func TestKillProcessGroupPreservesPersistentErrors(t *testing.T) { + for _, failure := range []error{syscall.EPERM, syscall.EINVAL} { + calls := 0 + got := killUnixProcessGroup(123, func(int, syscall.Signal) error { calls++; return failure }) + wantCalls := 1 + if runtime.GOOS == "darwin" && failure == syscall.EPERM { + wantCalls = 11 + } + if got != failure || calls != wantCalls { + t.Fatalf("result=%v calls=%d want=%v calls=%d", got, calls, failure, wantCalls) + } + } +} diff --git a/acp/internal/orchestrator/process/process_windows.go b/acp/internal/orchestrator/process/process_windows.go new file mode 100644 index 000000000..d74464134 --- /dev/null +++ b/acp/internal/orchestrator/process/process_windows.go @@ -0,0 +1,107 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package process + +import ( + "errors" + "os/exec" + "sync" + "syscall" +) + +// jobs maps each managed command to a Job Object created before Start. +// The process is created CREATE_SUSPENDED, assigned, then resumed so no +// descendant can be born outside the job. +var jobs sync.Map + +func configureProcessGroup(cmd *exec.Cmd) error { + job, err := createKillOnCloseJob() + if err != nil { + return err + } + attr := cmd.SysProcAttr + if attr == nil { + attr = &syscall.SysProcAttr{} + cmd.SysProcAttr = attr + } + attr.HideWindow = true + attr.CreationFlags |= createSuspended | syscall.CREATE_NEW_PROCESS_GROUP + jobs.Store(cmd, job) + return nil +} + +func attachProcessGroup(cmd *exec.Cmd) error { + job, ok := loadJob(cmd) + if !ok || cmd.Process == nil { + return errors.New("OCR Job Object is missing after process start") + } + // Leave the process suspended if assignment fails so Start's caller can + // kill it before any descendant is born outside the job. + if err := assignProcessToJob(job, cmd.Process.Pid); err != nil { + return err + } + return resumeProcessThreads(cmd.Process.Pid) +} + +// Interrupt requests cooperative termination of a managed command. +func Interrupt(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + // os.Interrupt is not implemented for Process.Signal on Windows. + // CREATE_NEW_PROCESS_GROUP lets GenerateConsoleCtrlEvent deliver CTRL_BREAK, + // which Go maps to os.Interrupt. Programs that do not handle console control + // events stay alive until TerminateJobObject after the grace period. + // + // CTRL_BREAK is best-effort: delivery requires the caller to share a console + // with the process group, and an agent launched without one (the common + // editor/GUI arrangement) cannot deliver it at all. Treat that expected + // failure as degradation so an ordinary cancellation is not reported as a + // cleanup error; a genuine failure of the guaranteed path still surfaces + // through Kill. + if err := generateCtrlBreak(cmd.Process.Pid); err != nil && !ctrlBreakUndeliverable(err) { + return err + } + return nil +} + +// Kill forcibly terminates the managed process tree, including descendants. +func Kill(cmd *exec.Cmd) error { + var errs []error + if job, ok := loadJob(cmd); ok { + if err := terminateJob(job, 1); err != nil && !ignoreAlreadyExited(err) { + errs = append(errs, err) + } + } + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil && !ignoreAlreadyExited(err) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// Close releases the OS resources owned by a managed command. +// Closing the last Job Object handle also terminates any remaining descendants. +func Close(cmd *exec.Cmd) { + v, ok := jobs.LoadAndDelete(cmd) + if !ok { + return + } + job, _ := v.(syscall.Handle) + if job != 0 { + _ = syscall.CloseHandle(job) + } +} + +func loadJob(cmd *exec.Cmd) (syscall.Handle, bool) { + v, ok := jobs.Load(cmd) + if !ok { + return 0, false + } + job, ok := v.(syscall.Handle) + return job, ok && job != 0 +} diff --git a/acp/internal/orchestrator/process/stub_main_test.go b/acp/internal/orchestrator/process/stub_main_test.go new file mode 100644 index 000000000..da45944eb --- /dev/null +++ b/acp/internal/orchestrator/process/stub_main_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package process + +import ( + "fmt" + "os" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestMain(m *testing.M) { + code := m.Run() + if err := testutil.Cleanup(); err != nil { + fmt.Fprintln(os.Stderr, "clean up OCR stub:", err) + code = 1 + } + os.Exit(code) +} diff --git a/acp/internal/orchestrator/process/winapi_windows.go b/acp/internal/orchestrator/process/winapi_windows.go new file mode 100644 index 000000000..bf8c185e7 --- /dev/null +++ b/acp/internal/orchestrator/process/winapi_windows.go @@ -0,0 +1,268 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package process + +import ( + "errors" + "fmt" + "os" + "syscall" + "time" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + modntdll = syscall.NewLazyDLL("ntdll.dll") + procCreateJobObjectW = modkernel32.NewProc("CreateJobObjectW") + procAssignProcessToJobObject = modkernel32.NewProc("AssignProcessToJobObject") + procTerminateJobObject = modkernel32.NewProc("TerminateJobObject") + procSetInformationJobObject = modkernel32.NewProc("SetInformationJobObject") + procOpenThread = modkernel32.NewProc("OpenThread") + procResumeThread = modkernel32.NewProc("ResumeThread") + procThread32First = modkernel32.NewProc("Thread32First") + procThread32Next = modkernel32.NewProc("Thread32Next") + procGenerateConsoleCtrlEvent = modkernel32.NewProc("GenerateConsoleCtrlEvent") + procNtResumeProcess = modntdll.NewProc("NtResumeProcess") +) + +const ( + createSuspended = 0x00000004 + jobObjectExtendedLimitClass = 9 + jobObjectLimitKillOnJobClose = 0x00002000 + processSetQuota = 0x0100 + processSuspendResume = 0x0800 + threadSuspendResume = 0x0002 + ctrlBreakEvent = 1 + errorNoMoreFiles = syscall.Errno(18) +) + +type jobObjectBasicLimitInformation struct { + PerProcessUserTimeLimit int64 + PerJobUserTimeLimit int64 + LimitFlags uint32 + MinimumWorkingSetSize uintptr + MaximumWorkingSetSize uintptr + ActiveProcessLimit uint32 + Affinity uintptr + PriorityClass uint32 + SchedulingClass uint32 +} + +type ioCounters struct { + ReadOperationCount uint64 + WriteOperationCount uint64 + OtherOperationCount uint64 + ReadTransferCount uint64 + WriteTransferCount uint64 + OtherTransferCount uint64 +} + +type jobObjectExtendedLimitInformation struct { + BasicLimitInformation jobObjectBasicLimitInformation + IoInfo ioCounters + ProcessMemoryLimit uintptr + JobMemoryLimit uintptr + PeakProcessMemoryUsed uintptr + PeakJobMemoryUsed uintptr +} + +type threadEntry32 struct { + Size uint32 + Usage uint32 + ThreadID uint32 + OwnerProcessID uint32 + BasePri int32 + DeltaPri int32 + Flags uint32 +} + +func createKillOnCloseJob() (syscall.Handle, error) { + r0, _, e := procCreateJobObjectW.Call(0, 0) + job := syscall.Handle(r0) + if job == 0 { + return 0, fmt.Errorf("CreateJobObject: %v", e) + } + var info jobObjectExtendedLimitInformation + info.BasicLimitInformation.LimitFlags = jobObjectLimitKillOnJobClose + r1, _, e := procSetInformationJobObject.Call( + uintptr(job), + jobObjectExtendedLimitClass, + uintptr(unsafe.Pointer(&info)), + uintptr(unsafe.Sizeof(info)), + ) + if r1 == 0 { + _ = syscall.CloseHandle(job) + return 0, fmt.Errorf("SetInformationJobObject: %v", e) + } + return job, nil +} + +func assignProcessToJob(job syscall.Handle, pid int) error { + access := uint32(syscall.PROCESS_TERMINATE | processSetQuota | syscall.PROCESS_QUERY_INFORMATION | syscall.SYNCHRONIZE) + process, err := syscall.OpenProcess(access, false, uint32(pid)) + if err != nil { + return fmt.Errorf("OpenProcess: %w", err) + } + defer syscall.CloseHandle(process) + r1, _, e := procAssignProcessToJobObject.Call(uintptr(job), uintptr(process)) + if r1 == 0 { + return fmt.Errorf("AssignProcessToJobObject: %v", e) + } + return nil +} + +func terminateJob(job syscall.Handle, exitCode uint32) error { + r1, _, e := procTerminateJobObject.Call(uintptr(job), uintptr(exitCode)) + if r1 == 0 { + return e + } + return nil +} + +func resumeProcessThreads(pid int) error { + if err := ntResumeProcess(pid); err == nil { + return nil + } + return resumeThreadsBySnapshot(pid) +} + +func ntResumeProcess(pid int) error { + access := uint32(processSuspendResume | syscall.PROCESS_QUERY_INFORMATION | syscall.SYNCHRONIZE) + process, err := syscall.OpenProcess(access, false, uint32(pid)) + if err != nil { + return err + } + defer syscall.CloseHandle(process) + r0, _, e := procNtResumeProcess.Call(uintptr(process)) + if r0 != 0 { + if e != syscall.Errno(0) { + return e + } + return fmt.Errorf("NtResumeProcess status %d", r0) + } + return nil +} + +func resumeThreadsBySnapshot(pid int) error { + var last error + deadline := time.Now().Add(200 * time.Millisecond) + for { + n, err := resumeThreadsOnce(pid) + if n > 0 { + // Resuming any thread runs the process, which is all that is + // required. A non-nil err here describes threads that exited between + // the snapshot and OpenThread, or a Thread32Next enumeration error; + // reporting it would fail startup and kill a healthy process. + return nil + } + last = err + if time.Now().After(deadline) { + break + } + time.Sleep(time.Millisecond) + } + return fmt.Errorf("no threads resumed for pid %d: %v", pid, last) +} + +func resumeThreadsOnce(pid int) (int, error) { + snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPTHREAD, 0) + if err != nil { + return 0, err + } + defer syscall.CloseHandle(snapshot) + var entry threadEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + r1, _, e := procThread32First.Call(uintptr(snapshot), uintptr(unsafe.Pointer(&entry))) + if r1 == 0 { + return 0, e + } + var errs error + resumed := 0 + for { + if entry.OwnerProcessID == uint32(pid) { + thread, err := openThread(threadSuspendResume, entry.ThreadID) + if err != nil { + errs = errors.Join(errs, err) + } else { + if err := resumeThread(thread); err != nil { + errs = errors.Join(errs, err) + } else { + resumed++ + } + _ = syscall.CloseHandle(thread) + } + } + r1, _, e = procThread32Next.Call(uintptr(snapshot), uintptr(unsafe.Pointer(&entry))) + if r1 == 0 { + if e == errorNoMoreFiles { + break + } + errs = errors.Join(errs, e) + break + } + } + return resumed, errs +} + +func openThread(access uint32, threadID uint32) (syscall.Handle, error) { + r0, _, e := procOpenThread.Call(uintptr(access), 0, uintptr(threadID)) + h := syscall.Handle(r0) + if h == 0 { + return 0, fmt.Errorf("OpenThread: %v", e) + } + return h, nil +} + +func resumeThread(thread syscall.Handle) error { + r1, _, e := procResumeThread.Call(uintptr(thread)) + if r1 == uintptr(^uint32(0)) { + return fmt.Errorf("ResumeThread: %v", e) + } + return nil +} + +func generateCtrlBreak(pid int) error { + r1, _, e := procGenerateConsoleCtrlEvent.Call(ctrlBreakEvent, uintptr(uint32(pid))) + if r1 == 0 { + return e + } + return nil +} + +// ctrlBreakUndeliverable reports whether err can only mean that a console +// control event reached no process, which is an expected outcome rather than a +// cleanup failure. GenerateConsoleCtrlEvent fails with ERROR_INVALID_HANDLE when +// the caller has no console to share with the target group, and with +// ERROR_INVALID_PARAMETER when the identifier is not a live process group. +func ctrlBreakUndeliverable(err error) bool { + var errno syscall.Errno + if !errors.As(err, &errno) { + return false + } + switch uint32(errno) { + case 6, 87: // ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER + return true + } + return false +} + +func ignoreAlreadyExited(err error) bool { + if err == nil || errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.EINVAL) { + return true + } + var errno syscall.Errno + if errors.As(err, &errno) { + switch uint32(errno) { + case 0, 5, 6, 87: // SUCCESS, ACCESS_DENIED, INVALID_HANDLE, INVALID_PARAMETER + return true + } + } + // The errno cases above are the locale-independent source of truth. The + // previous English message matching was redundant with them and silently + // changed behaviour on non-English Windows, so it is intentionally gone. + return false +} diff --git a/acp/internal/orchestrator/process/winapi_windows_test.go b/acp/internal/orchestrator/process/winapi_windows_test.go new file mode 100644 index 000000000..f427d67f8 --- /dev/null +++ b/acp/internal/orchestrator/process/winapi_windows_test.go @@ -0,0 +1,33 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package process + +import ( + "fmt" + "syscall" + "testing" +) + +// A console-less agent cannot deliver CTRL_BREAK at all. That is the expected +// outcome of the cooperative interrupt, not a cleanup failure, so it must be +// classified as undeliverable while other Windows errors still surface. +func TestCtrlBreakUndeliverable(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"invalid handle", syscall.Errno(6), true}, + {"invalid parameter", syscall.Errno(87), true}, + {"wrapped invalid handle", fmt.Errorf("GenerateConsoleCtrlEvent: %w", syscall.Errno(6)), true}, + {"access denied", syscall.Errno(5), false}, + } { + if got := ctrlBreakUndeliverable(tc.err); got != tc.want { + t.Errorf("%s: ctrlBreakUndeliverable = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/acp/internal/orchestrator/process_alive_unix_test.go b/acp/internal/orchestrator/process_alive_unix_test.go new file mode 100644 index 000000000..fb6ddd6bc --- /dev/null +++ b/acp/internal/orchestrator/process_alive_unix_test.go @@ -0,0 +1,31 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "os" + "strconv" + "strings" + "syscall" +) + +func processRunning(pid int) bool { + if syscall.Kill(pid, 0) != nil { + return false + } + // Linux container PID 1 may leave an orphan zombie unreaped. It cannot run + // or keep the output pipe open, and does not mean termination failed. + if data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil { + if end := strings.LastIndex(string(data), ") "); end >= 0 { + return !strings.HasPrefix(string(data[end+2:]), "Z ") + } + } + return true +} + +func killPID(pid int) error { + return syscall.Kill(pid, syscall.SIGKILL) +} diff --git a/acp/internal/orchestrator/process_alive_windows_test.go b/acp/internal/orchestrator/process_alive_windows_test.go new file mode 100644 index 000000000..9fc69015b --- /dev/null +++ b/acp/internal/orchestrator/process_alive_windows_test.go @@ -0,0 +1,56 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "os" + "syscall" + "unsafe" +) + +func processRunning(pid int) bool { + return windowsPIDRunning(pid) +} + +func killPID(pid int) error { + p, err := os.FindProcess(pid) + if err != nil { + return err + } + err = p.Kill() + if err != nil && err != os.ErrProcessDone { + return err + } + return nil +} + +var ( + procWaitForSingleObject = syscall.NewLazyDLL("kernel32.dll").NewProc("WaitForSingleObject") + procGetProcessHandleCount = syscall.NewLazyDLL("kernel32.dll").NewProc("GetProcessHandleCount") +) + +func windowsPIDRunning(pid int) bool { + h, err := syscall.OpenProcess(syscall.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + return false + } + defer syscall.CloseHandle(h) + r1, _, _ := procWaitForSingleObject.Call(uintptr(h), 0) + return r1 == uintptr(syscall.WAIT_TIMEOUT) +} + +func processHandleCount() (uint32, error) { + current, err := syscall.GetCurrentProcess() + if err != nil { + return 0, err + } + var n uint32 + r1, _, e := procGetProcessHandleCount.Call(uintptr(current), uintptr(unsafe.Pointer(&n))) + if r1 == 0 { + return 0, e + } + return n, nil +} diff --git a/acp/internal/orchestrator/process_group_test.go b/acp/internal/orchestrator/process_group_test.go new file mode 100644 index 000000000..8cc44537d --- /dev/null +++ b/acp/internal/orchestrator/process_group_test.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestRunnerReclaimsDescendantsAfterLeaderExit(t *testing.T) { + for _, command := range []string{"review", "scan"} { + for _, exitCode := range []int{0, 7} { + for _, inherited := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/exit=%d/inherited=%t", command, exitCode, inherited), func(t *testing.T) { + dir := t.TempDir() + pidFile := filepath.Join(dir, "child.pid") + binary := testutil.Install(t, dir, "ocr", &testutil.Config{ + HoldPIDFile: pidFile, + HoldInherit: inherited, + HoldMS: 30000, + ExitCode: exitCode, + StderrLines: []string{"[ocr] finished"}, + Stdout: `{"status":"success","message":"complete output"}` + "\n", + }) + t.Cleanup(func() { + if data, err := os.ReadFile(pidFile); err == nil { + if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + _ = killPID(pid) + } + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, _ := runRequest(t, NewRunner(binary), ctx, Request{CWD: dir, Args: []string{command}}) + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for processRunning(pid) && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if processRunning(pid) { + t.Fatalf("descendant survived leader exit: kind=%s err=%v", outcome.Kind, outcome.Err) + } + if inherited { + var typed *Error + if outcome.ExitCode != exitCode || outcome.Kind != OutcomeFailed || !errors.As(outcome.Err, &typed) || typed.Kind != ErrorCleanup { + t.Fatalf("unbounded pipe holder did not report cleanup failure: %+v", outcome) + } + return + } + if outcome.ExitCode != exitCode || !strings.Contains(outcome.Diagnostics, "[ocr] finished") || outcome.Result == nil { + t.Fatalf("lost exit status or output: %+v", outcome) + } + if command == "review" && (outcome.Result.Review == nil || outcome.Result.Review.Message != "complete output") { + t.Fatalf("lost review output: %+v", outcome.Result) + } + if command == "scan" && (outcome.Result.Scan == nil || outcome.Result.Scan.Message != "complete output") { + t.Fatalf("lost scan output: %+v", outcome.Result) + } + if exitCode == 0 { + if outcome.Kind != OutcomeCompleted || outcome.Err != nil { + t.Fatalf("successful leader changed outcome: %+v", outcome) + } + } else { + var typed *Error + if outcome.Kind != OutcomeFailed || !errors.As(outcome.Err, &typed) || typed.Kind != ErrorExit { + t.Fatalf("leader exit error was masked: %+v", outcome) + } + } + }) + } + } + } +} + +func TestRunnerAllowsBriefInheritedOutputWait(t *testing.T) { + for _, command := range []string{"review", "scan"} { + for _, exitCode := range []int{0, 7} { + t.Run(fmt.Sprintf("%s/exit=%d", command, exitCode), func(t *testing.T) { + dir := t.TempDir() + binary := testutil.Install(t, dir, "ocr", &testutil.Config{ + Stdout: `{"status":`, + FlushMS: 300, + FlushStdout: `"success","message":"flushed"}`, + FlushStderr: "late diagnostics\n", + ExitCode: exitCode, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, _ := runRequest(t, NewRunner(binary), ctx, Request{CWD: dir, Args: []string{command}}) + if outcome.ExitCode != exitCode || outcome.Result == nil || !strings.Contains(outcome.Diagnostics, "late diagnostics") { + t.Fatalf("helper output lost: %+v", outcome) + } + if command == "review" && (outcome.Result.Review == nil || outcome.Result.Review.Message != "flushed") { + t.Fatalf("review JSON incomplete: %+v", outcome) + } + if command == "scan" && (outcome.Result.Scan == nil || outcome.Result.Scan.Message != "flushed") { + t.Fatalf("scan JSON incomplete: %+v", outcome) + } + if exitCode == 0 { + if outcome.Kind != OutcomeCompleted || outcome.Err != nil { + t.Fatalf("helper output rejected: %+v", outcome) + } + } else { + var typed *Error + if outcome.Kind != OutcomeFailed || !errors.As(outcome.Err, &typed) || typed.Kind != ErrorExit { + t.Fatalf("exit failure masked: %+v", outcome) + } + } + }) + } + } +} + +func TestRunnerCancellationReapsProcessGroupChild(t *testing.T) { + binary := buildPhaseThreeMock(t) + pidFile := filepath.Join(t.TempDir(), "child.pid") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + runner := NewRunner(binary) + events, outcomes := runner.Run(ctx, Request{ + CWD: t.TempDir(), + Args: []string{"review", "-scenario", "spawn-child", "-child-pid-file", pidFile}, + }) + for event := range events { + if event.Message == "READY" { + cancel() + break + } + } + outcome := <-outcomes + if outcome.Kind != OutcomeCancelled { + t.Fatalf("outcome = %+v", outcome) + } + contents, err := os.ReadFile(pidFile) + if err != nil { + t.Fatalf("read child PID: %v", err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(contents))) + if err != nil { + t.Fatalf("parse child PID %q: %v", contents, err) + } + deadline := time.Now().Add(2 * time.Second) + for processRunning(pid) && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if processRunning(pid) { + t.Fatalf("child process %d is still alive", pid) + } +} diff --git a/acp/internal/orchestrator/process_unix_test.go b/acp/internal/orchestrator/process_unix_test.go new file mode 100644 index 000000000..eca04c502 --- /dev/null +++ b/acp/internal/orchestrator/process_unix_test.go @@ -0,0 +1,67 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "bufio" + "errors" + "os/exec" + "syscall" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/orchestrator/process" +) + +func TestStopKillsIgnoringMemberAfterLeaderExit(t *testing.T) { + // Both processes are children of the test so their exit can be reaped and + // verified even on containers whose PID 1 does not reap orphan zombies. + leader := exec.Command("sh", "-c", "read line") + input, err := leader.StdinPipe() + if err != nil { + t.Fatal(err) + } + defer input.Close() + if err := process.Start(leader); err != nil { + t.Fatal(err) + } + defer func() { _ = leader.Process.Kill(); _ = leader.Wait() }() + child := exec.Command("sh", "-c", "trap '' INT; echo READY; exec sleep 30") + child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pgid: leader.Process.Pid} + output, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := child.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = child.Process.Kill(); _ = child.Wait() }() + if line, err := bufio.NewReader(output).ReadString('\n'); err != nil || line != "READY\n" { + t.Fatalf("child readiness = %q, %v", line, err) + } + _ = input.Close() + waited := make(chan error, 1) + waited <- leader.Wait() + if err := syscall.Kill(child.Process.Pid, 0); err != nil { + t.Fatalf("group member exited before cleanup: %v", err) + } + if _, err := NewRunner("").stop(leader, waited); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- child.Wait() }() + select { + case err := <-done: + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.Sys().(syscall.WaitStatus).Signal() != syscall.SIGKILL { + t.Fatalf("expected forced group termination, got %v", err) + } + case <-time.After(3 * time.Second): + _ = child.Process.Kill() + <-done + t.Fatal("group member survived cleanup after leader exit") + } +} diff --git a/acp/internal/orchestrator/process_windows_test.go b/acp/internal/orchestrator/process_windows_test.go new file mode 100644 index 000000000..06306f1b4 --- /dev/null +++ b/acp/internal/orchestrator/process_windows_test.go @@ -0,0 +1,98 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestWindowsJobObjectReapsGrandchild(t *testing.T) { + dir := t.TempDir() + pidFile := filepath.Join(dir, "child.pid") + binary := testutil.Install(t, dir, "ocr", &testutil.Config{ + HoldPIDFile: pidFile, + HoldInherit: false, + HoldMS: 30000, + StderrLines: []string{"[ocr] finished"}, + Stdout: `{"status":"success","comments":[]}` + "\n", + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, _ := runRequest(t, NewRunner(binary), ctx, Request{CWD: dir, Args: []string{"review"}}) + if outcome.Kind != OutcomeCompleted { + t.Fatalf("outcome=%+v", outcome) + } + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for processRunning(pid) && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if processRunning(pid) { + t.Fatalf("job left grandchild %d running", pid) + } +} + +func TestWindowsPathWithSpacesAndNonASCII(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "评审 dir") // allow-non-english: Windows path fixture + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + binary := testutil.Install(t, dir, "ocr tool", nil) + resolved, err := ResolveBinary(ResolveOptions{Explicit: binary, Version: true}) + if err != nil || resolved.Path != binary { + t.Fatalf("resolved=%+v err=%v", resolved, err) + } + outcome, _ := runRequest(t, NewRunner(binary), context.Background(), Request{CWD: dir, Args: []string{"review"}}) + if outcome.Kind != OutcomeCompleted { + t.Fatalf("outcome=%+v", outcome) + } +} + +func TestWindowsRepeatedCancelDoesNotLeakJobs(t *testing.T) { + binary := writeOCRScript(t) + before, err := processHandleCount() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 8; i++ { + ctx, cancel := context.WithCancel(context.Background()) + events, outcomes := NewRunner(binary).Run(ctx, Request{CWD: t.TempDir(), Args: []string{"review", "block"}}) + for event := range events { + if event.Message == "READY" { + cancel() + break + } + } + outcome := <-outcomes + cancel() + if outcome.Kind != OutcomeCancelled { + t.Fatalf("iteration %d: %+v", i, outcome) + } + } + after, err := processHandleCount() + if err != nil { + t.Fatal(err) + } + if after > before+32 { + t.Fatalf("handle count grew from %d to %d", before, after) + } +} diff --git a/acp/internal/orchestrator/resolve.go b/acp/internal/orchestrator/resolve.go new file mode 100644 index 000000000..05ce4e459 --- /dev/null +++ b/acp/internal/orchestrator/resolve.go @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/alibaba/open-code-review/acp/internal/orchestrator/process" +) + +// ResolveOptions controls OCR binary discovery. Explicit and Environment are +// passed by the adapter; Environment defaults to OCR_BINARY when empty. +type ResolveOptions struct { + Explicit string + Environment string + StartupCWD string + LookupPath func(string) (string, error) + Version bool + VersionTimeout time.Duration +} + +// ResolvedBinary is a fixed absolute executable path and its optional version +// probe output. Source identifies which configuration won discovery. A probe +// failure records VersionWarning but does not make an otherwise runnable OCR +// binary unavailable. +type ResolvedBinary struct { + Path string + Source string + Version string + VersionWarning string +} + +// ResolveBinary discovers and validates OCR exactly once. An invalid explicit +// path is an error and never falls back to the environment or PATH. +func ResolveBinary(opts ResolveOptions) (ResolvedBinary, error) { + lookup := opts.LookupPath + if lookup == nil { + lookup = exec.LookPath + } + startup := opts.StartupCWD + if startup == "" { + var err error + startup, err = os.Getwd() + if err != nil { + return ResolvedBinary{}, newError(ErrorStart, "get startup working directory: %v", err) + } + } + path, source := opts.Explicit, "--ocr-binary" + if path == "" { + path, source = opts.Environment, "OCR_BINARY" + if path == "" { + path = os.Getenv("OCR_BINARY") + } + } + if path == "" { + var err error + path, err = lookup("ocr") + if err != nil { + return ResolvedBinary{}, newError(ErrorStart, "find OCR binary from PATH: %v", err) + } + source = "PATH" + } + if !filepath.IsAbs(path) { + path = filepath.Join(startup, path) + } + path, err := filepath.Abs(path) + if err != nil { + return ResolvedBinary{}, newError(ErrorStart, "resolve OCR binary from %s (%q): %v", source, path, err) + } + path, info, err := statOCRBinary(path) + if err != nil { + return ResolvedBinary{}, newError(ErrorStart, "OCR binary from %s (%q) is unavailable: %v", source, path, err) + } + if info.IsDir() { + return ResolvedBinary{}, newError(ErrorStart, "OCR binary from %s (%q) is a directory", source, path) + } + if !isRunnableFile(path, info) { + return ResolvedBinary{}, newError(ErrorStart, "OCR binary from %s (%q) is not executable", source, path) + } + resolved := ResolvedBinary{Path: path, Source: source} + if opts.Version || opts.VersionTimeout != 0 { + timeout := opts.VersionTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + version, err := probeVersion(path, timeout) + if err != nil { + resolved.Version = "unknown" + resolved.VersionWarning = fmt.Sprintf("OCR version probe for %s failed: %v", source, err) + return resolved, nil + } + resolved.Version = version + } + return resolved, nil +} + +func statOCRBinary(path string) (string, os.FileInfo, error) { + info, err := os.Stat(path) + if err == nil { + return path, info, nil + } + if runtime.GOOS == "windows" && filepath.Ext(path) == "" { + alt := path + ".exe" + info, altErr := os.Stat(alt) + if altErr == nil { + return alt, info, nil + } + } + return path, nil, err +} + +func isRunnableFile(path string, info os.FileInfo) bool { + if info.IsDir() { + return false + } + if runtime.GOOS != "windows" { + return info.Mode()&0o111 != 0 + } + switch strings.ToLower(filepath.Ext(path)) { + case ".exe", ".com", ".bat", ".cmd": + return true + default: + return false + } +} + +func probeVersion(binary string, timeout time.Duration) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "--version") + // CommandContext's default cancellation only kills the leader. Version + // helpers can inherit its output pipes, so terminate the whole group and + // bound the copy goroutines even when the leader has already exited. + cmd.Cancel = func() error { return process.Kill(cmd) } + // Allow short-lived helpers to finish copying output after the leader exits. + // An expired wait remains a probe failure: buffered text may be incomplete. + cmd.WaitDelay = time.Second + stdin, err := os.Open(os.DevNull) + if err != nil { + return "", err + } + defer stdin.Close() + cmd.Stdin = stdin + var output limitedBuffer + output.limit = 1024 + cmd.Stdout = &output + cmd.Stderr = &output + if err := process.Start(cmd); err != nil { + return "", err + } + defer process.Close(cmd) + err = cmd.Wait() + // Also remove descendants after a normal leader exit or a pipe-wait limit. + // WaitDelay closes the local pipe readers before Wait returns. + err = errors.Join(err, process.Kill(cmd)) + if output.exceeded { + return "", fmt.Errorf("version output exceeded 1024 bytes") + } + if ctx.Err() != nil { + return "", ctx.Err() + } + if err != nil { + return "", err + } + value := strings.TrimSpace(output.String()) + if value == "" { + return "", fmt.Errorf("version output is empty") + } + return value, nil +} + +type limitedBuffer struct { + buf bytes.Buffer + limit int + exceeded bool +} + +// Keep the buffer private so io.Copy cannot bypass Write via Buffer.ReadFrom. +func (b *limitedBuffer) Len() int { return b.buf.Len() } + +func (b *limitedBuffer) Write(p []byte) (int, error) { + if b.limit-b.Len() <= 0 { + b.exceeded = true + return len(p), nil + } + remaining := b.limit - b.Len() + if len(p) > remaining { + _, _ = b.buf.Write(p[:remaining]) + b.exceeded = true + return remaining, io.ErrShortWrite + } + return b.buf.Write(p) +} + +func (b *limitedBuffer) String() string { + return b.buf.String() +} + +var _ io.Writer = (*limitedBuffer)(nil) diff --git a/acp/internal/orchestrator/resolve_probe_test.go b/acp/internal/orchestrator/resolve_probe_test.go new file mode 100644 index 000000000..8b5e055e9 --- /dev/null +++ b/acp/internal/orchestrator/resolve_probe_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestVersionProbeAllowsBriefInheritedOutputWait(t *testing.T) { + binary := testutil.Install(t, t.TempDir(), "ocr", &testutil.Config{ + VersionPrefix: "ocr ", + VersionSuffix: "1.2.3\n", + FlushMS: 300, + }) + version, err := probeVersion(binary, 5*time.Second) + if err != nil || version != "ocr 1.2.3" { + t.Fatalf("version=%q err=%v", version, err) + } +} + +func TestVersionProbeBoundsInheritedOutputWait(t *testing.T) { + dir := t.TempDir() + pidFile := filepath.Join(dir, "child.pid") + binary := testutil.Install(t, dir, "ocr", &testutil.Config{ + VersionText: "ocr-test", + HoldPIDFile: pidFile, + HoldInherit: true, + HoldMS: 30000, + }) + result := make(chan error, 1) + go func() { + _, err := probeVersion(binary, 2*time.Second) + result <- err + }() + readChild := func() int { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + var data []byte + var err error + for time.Now().Before(deadline) { + data, err = os.ReadFile(pidFile) + if err == nil && strings.TrimSpace(string(data)) != "" { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + t.Fatal(err) + } + return pid + } + select { + case err := <-result: + pid := readChild() + t.Cleanup(func() { _ = killPID(pid) }) + if err == nil { + t.Fatal("inherited output did not fail version probe") + } + deadline := time.Now().Add(time.Second) + for processRunning(pid) && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if processRunning(pid) { + t.Fatal("version probe left its child running") + } + case <-time.After(4 * time.Second): + _ = killPID(readChild()) + <-result + t.Fatal("version probe ignored its deadline while a descendant held output") + } +} diff --git a/acp/internal/orchestrator/resolve_test.go b/acp/internal/orchestrator/resolve_test.go new file mode 100644 index 000000000..4f77a5af1 --- /dev/null +++ b/acp/internal/orchestrator/resolve_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestResolveBinaryPriorityAndAbsolutePath(t *testing.T) { + t.Setenv("OCR_BINARY", "") + startup := t.TempDir() + explicit := testutil.Install(t, startup, "explicit", &testutil.Config{VersionText: "ocr 1.2.3"}) + environment := testutil.Install(t, startup, "environment", &testutil.Config{VersionText: "unused"}) + lookedUp := false + resolved, err := ResolveBinary(ResolveOptions{ + Explicit: testutil.ExeName("explicit"), + Environment: environment, + StartupCWD: startup, + LookupPath: func(string) (string, error) { lookedUp = true; return "", nil }, + Version: true, + }) + if err != nil { + t.Fatalf("ResolveBinary: %v", err) + } + if resolved.Source != "--ocr-binary" || resolved.Path != explicit || resolved.Version != "ocr 1.2.3" || lookedUp { + t.Fatalf("resolved = %+v, lookup=%v", resolved, lookedUp) + } +} + +func TestResolveBinaryEnvironmentAndPATH(t *testing.T) { + startup := t.TempDir() + environment := testutil.Install(t, startup, "environment", &testutil.Config{VersionText: "env"}) + t.Setenv("OCR_BINARY", environment) + resolved, err := ResolveBinary(ResolveOptions{StartupCWD: startup}) + if err != nil || resolved.Source != "OCR_BINARY" || resolved.Path != environment { + t.Fatalf("environment resolved=%+v err=%v", resolved, err) + } + t.Setenv("OCR_BINARY", "") + pathBinary := testutil.Install(t, startup, "path-ocr", &testutil.Config{VersionText: "path"}) + resolved, err = ResolveBinary(ResolveOptions{LookupPath: func(string) (string, error) { return pathBinary, nil }, StartupCWD: startup}) + if err != nil || resolved.Source != "PATH" || resolved.Path != pathBinary { + t.Fatalf("PATH resolved=%+v err=%v", resolved, err) + } +} + +func TestResolveBinaryInvalidExplicitDoesNotFallBack(t *testing.T) { + t.Setenv("OCR_BINARY", "") + called := false + _, err := ResolveBinary(ResolveOptions{ + Explicit: filepath.Join(t.TempDir(), "missing"), + Environment: testutil.Install(t, t.TempDir(), "ocr", nil), + LookupPath: func(string) (string, error) { called = true; return "", nil }, + }) + if err == nil || called || !strings.Contains(err.Error(), "--ocr-binary") { + t.Fatalf("err = %v, lookup=%v", err, called) + } +} + +func TestResolveBinaryRejectsDirectoryAndNonExecutable(t *testing.T) { + t.Setenv("OCR_BINARY", "") + dir := t.TempDir() + for _, path := range []string{dir, filepath.Join(dir, "plain")} { + if path != dir { + if err := os.WriteFile(path, []byte("not executable"), 0o644); err != nil { + t.Fatal(err) + } + } + if _, err := ResolveBinary(ResolveOptions{Explicit: path}); err == nil { + t.Fatalf("ResolveBinary(%q) succeeded", path) + } + } +} + +func TestProbeVersionRejectsOutputLimitAndTimeout(t *testing.T) { + large := testutil.Install(t, t.TempDir(), "large", &testutil.Config{VersionBytes: 2048}) + if _, err := probeVersion(large, time.Second); err == nil || !strings.Contains(err.Error(), "exceeded") { + t.Fatalf("large version error = %v", err) + } + slow := testutil.Install(t, t.TempDir(), "slow", &testutil.Config{SleepMS: 10000}) + if _, err := probeVersion(slow, 20*time.Millisecond); err == nil { + t.Fatal("slow version probe succeeded") + } +} + +func TestVersionCaptureBoundsChildOutput(t *testing.T) { + binary := testutil.Install(t, t.TempDir(), "large", &testutil.Config{VersionBytes: 64 << 10}) + output := limitedBuffer{limit: 1024} + cmd := exec.Command(binary, "--version") + cmd.Stdout, cmd.Stderr = &output, &output + // Closing the full capture's reader can also terminate the child with a + // broken pipe. Either way, the stored bytes must remain bounded. + var exitError *exec.ExitError + if err := cmd.Run(); err != nil && !errors.Is(err, io.ErrShortWrite) && !errors.As(err, &exitError) { + t.Fatal(err) + } + if output.Len() != output.limit || !output.exceeded { + t.Fatalf("capture length=%d limit=%d exceeded=%v", output.Len(), output.limit, output.exceeded) + } +} + +func TestProbeVersionAcceptsExactLimit(t *testing.T) { + binary := testutil.Install(t, t.TempDir(), "exact", &testutil.Config{VersionBytes: 1024}) + version, err := probeVersion(binary, 5*time.Second) + if err != nil || len(version) != 1024 { + t.Fatalf("version length=%d err=%v", len(version), err) + } +} + +func TestResolveBinaryContinuesAfterVersionProbeFailure(t *testing.T) { + for _, test := range []struct { + name string + cfg testutil.Config + }{ + {name: "nonzero", cfg: testutil.Config{ExitCode: 7, VersionText: "unsupported"}}, + {name: "output limit", cfg: testutil.Config{VersionBytes: 2048}}, + {name: "timeout", cfg: testutil.Config{SleepMS: 10000}}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := test.cfg + binary := testutil.Install(t, t.TempDir(), "ocr", &cfg) + resolved, err := ResolveBinary(ResolveOptions{Explicit: binary, Version: true, VersionTimeout: 20 * time.Millisecond}) + if err != nil { + t.Fatalf("ResolveBinary: %v", err) + } + if resolved.Path != binary || resolved.Version != "unknown" || resolved.VersionWarning == "" { + t.Fatalf("resolved = %+v", resolved) + } + }) + } +} + +func TestResolveBinaryWindowsExeSuffix(t *testing.T) { + t.Setenv("OCR_BINARY", "") + dir := t.TempDir() + installed := testutil.Install(t, dir, "ocr", &testutil.Config{VersionText: "ocr 9.9.9"}) + withoutExt := strings.TrimSuffix(installed, ".exe") + if withoutExt == installed { + t.Skip("executable suffix is not added on this platform") + } + resolved, err := ResolveBinary(ResolveOptions{Explicit: withoutExt, Version: true}) + if err != nil || resolved.Path != installed || resolved.Version != "ocr 9.9.9" { + t.Fatalf("resolved=%+v err=%v want %s", resolved, err, installed) + } +} diff --git a/acp/internal/orchestrator/runner.go b/acp/internal/orchestrator/runner.go new file mode 100644 index 000000000..d866ea90b --- /dev/null +++ b/acp/internal/orchestrator/runner.go @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "context" + "errors" + "os" + "os/exec" + "sync" + "time" + + "github.com/alibaba/open-code-review/acp/internal/orchestrator/process" +) + +func (r *ProcessRunner) run(ctx context.Context, request Request, limits Limits, events chan Event, outcomes chan<- Outcome) { + defer close(outcomes) + var closeEvents sync.Once + defer func() { closeEvents.Do(func() { close(events) }) }() + now := time.Now + if r.Now != nil { + now = r.Now + } + emit := func(event Event) { + if event.OccurredAt.IsZero() { + event.OccurredAt = now() + } + critical := event.Kind == EventWarning || event.Truncated + select { + case events <- event: + default: + if !critical { + return + } + // Preserve warnings by evicting an older event. + // Outcome.Warnings remains the authoritative fallback. + select { + case <-events: + default: + } + select { + case events <- event: + default: + } + } + } + finish := func(outcome Outcome) { outcomes <- outcome } + preterminated := func() bool { + if cause := localTermination(ctx, request.Deadline); cause != "" { + finish(Outcome{Kind: cause, ExitCode: -1, Err: terminationError(cause)}) + return true + } + return false + } + if preterminated() { + return + } + + if err := validateRequest(r.Binary, request); err != nil { + finish(Outcome{Kind: OutcomeFailed, ExitCode: -1, Err: err}) + return + } + + stdin, err := os.Open(os.DevNull) + if err != nil { + finish(Outcome{Kind: OutcomeFailed, ExitCode: -1, Err: newError(ErrorStart, "open null stdin: %v", err)}) + return + } + defer stdin.Close() + + cmd := exec.Command(r.Binary, request.Args...) + cmd.Dir = request.CWD + cmd.Stdin = stdin + stdoutRead, stdoutWrite, err := os.Pipe() + if err != nil { + finish(Outcome{Kind: OutcomeFailed, ExitCode: -1, Err: newError(ErrorStart, "create stdout pipe: %v", err)}) + return + } + defer stdoutRead.Close() + defer stdoutWrite.Close() + cmd.Stdout = stdoutWrite + stderrRead, stderrWrite, err := os.Pipe() + if err != nil { + stdoutWrite.Close() + finish(Outcome{Kind: OutcomeFailed, ExitCode: -1, Err: newError(ErrorStart, "create stderr pipe: %v", err)}) + return + } + defer stderrRead.Close() + defer stderrWrite.Close() + cmd.Stderr = stderrWrite + // Recheck after setup, immediately before the OS start boundary. Cancellation + // between this check and Start is handled by await; the two are not atomic. + if preterminated() { + return + } + if err := process.Start(cmd); err != nil { + stdoutWrite.Close() + stderrWrite.Close() + if preterminated() { + return + } + finish(Outcome{Kind: OutcomeFailed, ExitCode: -1, Err: newError(ErrorStart, "start OCR: %v", err)}) + return + } + defer process.Close(cmd) + + streams := consumeStreams(stdoutRead, stderrRead, limits, emit) + defer func() { <-streams.done; closeEvents.Do(func() { close(events) }) }() + waited := make(chan error, 1) + go func() { + err := cmd.Wait() + _ = stdoutWrite.Close() + _ = stderrWrite.Close() + waited <- err + }() + + cause, waitErr, cleanupErr := r.await(ctx, request.Deadline, cmd, waited) + stream, cleanErr := awaitStreams(streams) + // Let short-lived helpers finish flushing inherited output before reclaiming + // the process group. If they hold a pipe past the bounded drain window, + // force cleanup so no descendant remains behind. + if cause == "" { + if err := process.Kill(cmd); err != nil { + cleanupErr = errors.Join(cleanupErr, newError(ErrorCleanup, "clean process group after OCR exit: %v", err)) + } + } + exitCode := commandExitCode(waitErr) + result, decodeErr := decodeResult(request.Args, stream.stdout) + // Final decision point after stream collection and decoding. A cause already + // selected by await wins permanently; otherwise observe again before delivery. + // A cancellation after this point cannot retroactively rewrite completion. + if cause == "" { + cause = localTermination(ctx, request.Deadline) + if cause != "" { + replayed := make(chan error, 1) + replayed <- waitErr + var stopErr error + waitErr, stopErr = r.stop(cmd, replayed) + cleanupErr = errors.Join(cleanupErr, stopErr) + } + } + + // Local intent always wins. A cancellation may still carry a useful partial + // document, but inability to decode it must not turn cancellation into an + // ordinary command failure. + if cause == OutcomeTimedOut { + finish(Outcome{Kind: OutcomeTimedOut, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings, Err: localOutcomeError(context.DeadlineExceeded, cleanupErr, cleanErr)}) + return + } + if cause == OutcomeCancelled { + finish(Outcome{Kind: OutcomeCancelled, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings, Err: localOutcomeError(context.Canceled, cleanupErr, cleanErr)}) + return + } + if err := errors.Join(cleanupErr, cleanErr); err != nil { + finish(Outcome{Kind: OutcomeFailed, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings, Err: err}) + return + } + if stream.err != nil { + finish(Outcome{Kind: OutcomeFailed, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings, Err: newError(ErrorStream, "read OCR output: %v", stream.err)}) + return + } + if stream.stdoutExceeded { + finish(Outcome{Kind: OutcomeFailed, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings, Err: newError(ErrorStdoutLimit, "OCR stdout exceeded %d byte limit", limits.StdoutBytes)}) + return + } + if waitErr != nil { + finish(Outcome{Kind: OutcomeFailed, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings, Err: newError(ErrorExit, "OCR exited with code %d: %v", exitCode, waitErr)}) + return + } + if decodeErr != nil { + finish(Outcome{Kind: OutcomeFailed, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings, Err: decodeErr}) + return + } + finish(Outcome{Kind: OutcomeCompleted, Result: result, ExitCode: exitCode, Diagnostics: stream.stderrTail, Warnings: stream.warnings}) +} + +func validateRequest(binary string, request Request) error { + if binary == "" { + return newError(ErrorInvalidRequest, "OCR binary is empty") + } + if len(request.Args) == 0 || (request.Args[0] != "review" && request.Args[0] != "scan") { + return newError(ErrorInvalidRequest, "OCR arguments must start with review or scan") + } + if request.CWD == "" { + return newError(ErrorInvalidCWD, "working directory is empty") + } + info, err := os.Stat(request.CWD) + if err != nil { + return newError(ErrorInvalidCWD, "stat working directory %q: %v", request.CWD, err) + } + if !info.IsDir() { + return newError(ErrorInvalidCWD, "working directory %q is not a directory", request.CWD) + } + return nil +} + +// localTermination is a synchronous observation, not a watcher notification. +// Once an observation selects a cause, callers retain it throughout cleanup. +// If both reasons are first observed together, an expired deadline wins; a +// previously observed cancellation is never overwritten by a later deadline. +// Context exposes no manual-cancellation timestamp, so this does not claim to +// reconstruct the order of events that happened before the first observation. +func localTermination(ctx context.Context, deadline time.Time) OutcomeKind { + err := ctx.Err() + now := time.Now() + if !deadline.IsZero() && !now.Before(deadline) { + return OutcomeTimedOut + } + if ctxDeadline, ok := ctx.Deadline(); ok && !now.Before(ctxDeadline) { + return OutcomeTimedOut + } + if errors.Is(err, context.DeadlineExceeded) { + return OutcomeTimedOut + } + if err != nil { + return OutcomeCancelled + } + return "" +} + +func terminationError(kind OutcomeKind) error { + if kind == OutcomeTimedOut { + return context.DeadlineExceeded + } + return context.Canceled +} + +func (r *ProcessRunner) await(ctx context.Context, deadline time.Time, cmd *exec.Cmd, waited <-chan error) (OutcomeKind, error, error) { + if cause := localTermination(ctx, deadline); cause != "" { + waitErr, cleanupErr := r.stop(cmd, waited) + return cause, waitErr, cleanupErr + } + var timerC <-chan time.Time + if !deadline.IsZero() { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + timerC = timer.C + } + var waitErr error + completed := false + select { + case waitErr = <-waited: + completed = true + case <-ctx.Done(): + case <-timerC: + } + // This is the process-completion decision point. Re-observe local state + // even when select picked waited, so ready cases cannot randomly decide + // the outcome. No watcher has to run before this observation. + cause := localTermination(ctx, deadline) + if cause == "" { + return "", waitErr, nil + } + if completed { + // Preserve the consumed result for stop's existing cleanup path. + // Reading waited a second time would deadlock. + replayed := make(chan error, 1) + replayed <- waitErr + waited = replayed + } + waitErr, cleanupErr := r.stop(cmd, waited) + return cause, waitErr, cleanupErr +} + +func (r *ProcessRunner) stop(cmd *exec.Cmd, waited <-chan error) (error, error) { + // Interrupt gives OCR a chance to emit its cancellation document before the + // bounded grace period expires. Unix sends SIGINT to the process group; + // Windows sends CTRL_BREAK to the CREATE_NEW_PROCESS_GROUP leader. + var cleanupErrs []error + if err := process.Interrupt(cmd); err != nil { + cleanupErrs = append(cleanupErrs, newError(ErrorCleanup, "interrupt OCR process group: %v", err)) + } + grace := r.GracePeriod + if grace <= 0 { + grace = defaultGracePeriod + } + select { + case err := <-waited: + // The leader may exit while descendants remain alive; always perform + // the bounded process-group cleanup before returning. + if cleanupErr := process.Kill(cmd); cleanupErr != nil { + return err, newError(ErrorCleanup, "clean process group: %v", cleanupErr) + } + return err, errors.Join(cleanupErrs...) + case <-time.After(grace): + if err := process.Kill(cmd); err != nil { + cleanupErrs = append(cleanupErrs, newError(ErrorCleanup, "kill OCR process group: %v", err)) + } + select { + case err := <-waited: + return err, errors.Join(cleanupErrs...) + case <-time.After(grace): + cleanupErrs = append(cleanupErrs, newError(ErrorCleanup, "OCR process did not exit after forced termination")) + return nil, errors.Join(cleanupErrs...) + } + } +} + +func localOutcomeError(cause error, cleanupErrs ...error) error { + errs := []error{cause} + for _, err := range cleanupErrs { + if err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func awaitStreams(streams streamHandle) (streamResult, error) { + select { + case stream := <-streams.result: + return stream, nil + case <-time.After(drainGracePeriod): + // Do not wait forever for a descendant retaining a pipe. The process + // group was killed on local abort; on ordinary leader exit the caller + // reclaims remaining helpers after this bounded drain attempt. + streams.cancel() + return streamResult{}, newError(ErrorCleanup, "OCR stream readers did not exit within %s", drainGracePeriod) + } +} + +func commandExitCode(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ProcessState != nil { + return exitErr.ProcessState.ExitCode() + } + return -1 +} diff --git a/acp/internal/orchestrator/runner_test.go b/acp/internal/orchestrator/runner_test.go new file mode 100644 index 000000000..ccfd4885b --- /dev/null +++ b/acp/internal/orchestrator/runner_test.go @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestRunnerCompletesAndKeepsStderrSeparate(t *testing.T) { + binary := writeOCRScript(t) + runner := NewRunner(binary) + outcome, events := runRequest(t, runner, context.Background(), Request{CWD: t.TempDir(), Args: []string{"review"}}) + if outcome.Kind != OutcomeCompleted || outcome.Err != nil { + t.Fatalf("outcome = %+v", outcome) + } + if outcome.Result == nil || outcome.Result.Review == nil || outcome.Result.Review.Status != "complete" { + t.Fatalf("result = %+v", outcome.Result) + } + if !containsEvent(events, "[ocr] running") { + t.Fatalf("events = %+v, want stderr diagnostic", events) + } + if !strings.Contains(outcome.Diagnostics, "[ocr] running") { + t.Fatalf("diagnostics = %q", outcome.Diagnostics) + } +} + +func TestRunnerConsumesPhaseThreeMockOCR(t *testing.T) { + binary := buildPhaseThreeMock(t) + runner := NewRunner(binary) + outcome, events := runRequest(t, runner, context.Background(), Request{CWD: t.TempDir(), Args: []string{"review"}}) + if outcome.Kind != OutcomeCompleted || outcome.Result == nil || outcome.Result.Review == nil { + t.Fatalf("outcome = %+v", outcome) + } + if got := len(outcome.Result.Review.Comments); got != 2 { + t.Fatalf("comment count = %d, want 2", got) + } + if !containsEvent(events, "[ocr] 1 file(s) changed, reviewing 1") { + t.Fatalf("events = %+v, want mock OCR progress", events) + } +} + +func buildPhaseThreeMock(t *testing.T) string { + t.Helper() + binary := filepath.Join(t.TempDir(), testutil.ExeName("mock-ocr")) + build := exec.Command("go", "build", "-o", binary, "../../testdata/mock-ocr") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build phase 3 mock: %v\n%s", err, output) + } + return binary +} + +func TestRunnerRejectsNonZeroExitEvenWithJSON(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + outcome, _ := runRequest(t, runner, context.Background(), Request{CWD: t.TempDir(), Args: []string{"review", "nonzero"}}) + if outcome.Kind != OutcomeFailed || outcome.ExitCode != 7 { + t.Fatalf("outcome = %+v", outcome) + } + assertErrorKind(t, outcome.Err, ErrorExit) +} + +func TestRunnerRejectsInvalidAndMultipleJSON(t *testing.T) { + for _, arg := range []string{"invalid", "multiple"} { + t.Run(arg, func(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + outcome, _ := runRequest(t, runner, context.Background(), Request{CWD: t.TempDir(), Args: []string{"review", arg}}) + if outcome.Kind != OutcomeFailed { + t.Fatalf("outcome = %+v", outcome) + } + assertErrorKind(t, outcome.Err, ErrorDecode) + }) + } +} + +func TestRunnerLocalCancelOverridesExit(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events, outcomes := runner.Run(ctx, Request{CWD: t.TempDir(), Args: []string{"review", "block"}}) + for event := range events { + if event.Message == "READY" { + cancel() + break + } + } + outcome := <-outcomes + if outcome.Kind != OutcomeCancelled || !errors.Is(outcome.Err, context.Canceled) { + t.Fatalf("outcome = %+v", outcome) + } + if outcome.ExitCode != 1 { + t.Fatalf("exit code = %d, want 1", outcome.ExitCode) + } +} + +func TestRunnerDeadlineOverridesExit(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + runner.GracePeriod = 100 * time.Millisecond + deadline := time.Now().Add(100 * time.Millisecond) + outcome, _ := runRequest(t, runner, context.Background(), Request{CWD: t.TempDir(), Args: []string{"scan", "block"}, Deadline: deadline}) + if outcome.Kind != OutcomeTimedOut || !errors.Is(outcome.Err, context.DeadlineExceeded) { + t.Fatalf("outcome = %+v", outcome) + } +} + +func TestRunnerContextDeadlineOverridesExit(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + outcome, _ := runRequest(t, runner, ctx, Request{CWD: t.TempDir(), Args: []string{"review", "block"}}) + if outcome.Kind != OutcomeTimedOut { + t.Fatalf("outcome = %+v", outcome) + } +} + +func TestRunnerScanResult(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + outcome, _ := runRequest(t, runner, context.Background(), Request{CWD: t.TempDir(), Args: []string{"scan"}}) + if outcome.Kind != OutcomeCompleted || outcome.Result == nil || outcome.Result.Scan == nil { + t.Fatalf("outcome = %+v", outcome) + } +} + +func TestRunnerBoundsOutputAndStderrTail(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + runner.Limits = Limits{StdoutBytes: 100, StderrBytes: 100, StderrLine: 16, EventQueue: 2} + outcome, events := runRequest(t, runner, context.Background(), Request{CWD: t.TempDir(), Args: []string{"review", "large"}}) + if outcome.Kind != OutcomeFailed { + t.Fatalf("outcome = %+v", outcome) + } + assertErrorKind(t, outcome.Err, ErrorStdoutLimit) + if len(outcome.Diagnostics) > 100 { + t.Fatalf("diagnostics len = %d, want <= 100", len(outcome.Diagnostics)) + } + if !containsWarning(events) { + t.Fatalf("events = %+v, want truncation warning", events) + } +} + +func TestRunnerRetainsTruncationWarningWithoutEventConsumer(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + runner.Limits = Limits{StdoutBytes: 100, StderrBytes: 100, StderrLine: 16, EventQueue: 1} + events, outcomes := runner.Run(context.Background(), Request{CWD: t.TempDir(), Args: []string{"review", "large"}}) + outcome := <-outcomes + if outcome.Kind != OutcomeFailed { + t.Fatalf("outcome = %+v", outcome) + } + if len(outcome.Warnings) != 1 || outcome.Warnings[0].Kind != EventWarning || !outcome.Warnings[0].Truncated { + t.Fatalf("warnings = %+v", outcome.Warnings) + } + for range events { + } +} + +func TestRunnerChannelsCloseAfterSingleOutcome(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + events, outcomes := runner.Run(context.Background(), Request{CWD: t.TempDir(), Args: []string{"review"}}) + outcome, ok := <-outcomes + if !ok || outcome.Kind != OutcomeCompleted { + t.Fatalf("outcome = %+v, open=%v", outcome, ok) + } + if _, ok := <-outcomes; ok { + t.Fatal("outcomes channel remained open after terminal outcome") + } + for range events { + } +} + +func TestRunnerRejectsInvalidCWDAndArgs(t *testing.T) { + runner := NewRunner(writeOCRScript(t)) + for _, request := range []Request{ + {CWD: filepath.Join(t.TempDir(), "missing"), Args: []string{"review"}}, + {CWD: t.TempDir(), Args: []string{"unexpected"}}, + } { + outcome, _ := runRequest(t, runner, context.Background(), request) + if outcome.Kind != OutcomeFailed { + t.Fatalf("outcome = %+v", outcome) + } + if !errors.Is(outcome.Err, context.Canceled) && outcome.Err == nil { + t.Fatal("expected validation error") + } + } +} + +func TestConsumeStreamsCanCancelBlockedReaders(t *testing.T) { + stdoutRead, stdoutWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer stdoutWrite.Close() + stderrRead, stderrWrite, err := os.Pipe() + if err != nil { + stdoutRead.Close() + t.Fatal(err) + } + defer stderrWrite.Close() + + streams := consumeStreams(stdoutRead, stderrRead, Limits{}, func(Event) {}) + streams.cancel() + select { + case <-streams.done: + case <-time.After(time.Second): + t.Fatal("stream readers did not stop after cancellation") + } + select { + case result := <-streams.result: + if result.err == nil { + t.Fatal("expected reader error after cancellation") + } + case <-time.After(time.Second): + t.Fatal("stream result was not reported after cancellation") + } +} + +func runRequest(t *testing.T, runner Runner, ctx context.Context, request Request) (Outcome, []Event) { + t.Helper() + events, outcomes := runner.Run(ctx, request) + collected := make(chan []Event, 1) + go func() { + var result []Event + for event := range events { + result = append(result, event) + } + collected <- result + }() + outcome := <-outcomes + return outcome, <-collected +} + +func assertErrorKind(t *testing.T, err error, want ErrorKind) { + t.Helper() + var got *Error + if !errors.As(err, &got) || got.Kind != want { + t.Fatalf("error = %v, want kind %s", err, want) + } +} + +func containsEvent(events []Event, message string) bool { + for _, event := range events { + if event.Message == message { + return true + } + } + return false +} + +func containsWarning(events []Event) bool { + for _, event := range events { + if event.Kind == EventWarning { + return true + } + } + return false +} + +func writeOCRScript(t *testing.T) string { + t.Helper() + return testutil.Install(t, t.TempDir(), "ocr", nil) +} diff --git a/acp/internal/orchestrator/streams.go b/acp/internal/orchestrator/streams.go new file mode 100644 index 000000000..821616321 --- /dev/null +++ b/acp/internal/orchestrator/streams.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "bufio" + "bytes" + "io" + "strings" + "time" +) + +type streamResult struct { + stdout []byte + stdoutExceeded bool + stderrTail string + stderrCut bool + warnings []Event + err error +} + +type streamHandle struct { + result <-chan streamResult + done <-chan struct{} + cancel func() +} + +type tailBuffer struct { + limit int + data []byte + cut bool +} + +func (b *tailBuffer) Write(p []byte) { + if len(p) >= b.limit { + b.data = append(b.data[:0], p[len(p)-b.limit:]...) + b.cut = true + return + } + if len(b.data)+len(p) > b.limit { + drop := len(b.data) + len(p) - b.limit + copy(b.data, b.data[drop:]) + b.data = b.data[:len(b.data)-drop] + b.cut = true + } + b.data = append(b.data, p...) +} + +func (b *tailBuffer) String() string { return string(b.data) } + +func readStdout(r io.Reader, limit int) ([]byte, bool, error) { + var out bytes.Buffer + buf := make([]byte, 32<<10) + exceeded := false + for { + n, err := r.Read(buf) + if n > 0 { + if remaining := limit - out.Len(); remaining > 0 { + if n <= remaining { + _, _ = out.Write(buf[:n]) + } else { + _, _ = out.Write(buf[:remaining]) + exceeded = true + } + } else { + exceeded = true + } + } + if err == io.EOF { + return out.Bytes(), exceeded, nil + } + if err != nil { + return out.Bytes(), exceeded, err + } + } +} + +func readStderr(r io.Reader, byteLimit, lineLimit int, emit func(Event)) (string, bool, []Event, error) { + reader := bufio.NewReaderSize(r, 32<<10) + tail := tailBuffer{limit: byteLimit} + warned := false + var warnings []Event + for { + var line []byte + truncated := false + for { + part, err := reader.ReadSlice('\n') + if len(part) > 0 { + tail.Write(part) + if len(line) < lineLimit+1 { + remaining := lineLimit + 1 - len(line) + if len(part) > remaining { + part = part[:remaining] + truncated = true + } + line = append(line, part...) + } + } + if err == bufio.ErrBufferFull { + continue + } + if len(line) > 0 { + message := strings.TrimSuffix(strings.TrimSuffix(string(line), "\n"), "\r") + if len(message) > lineLimit { + message = message[:lineLimit] + truncated = true + } + kind := EventDiagnostic + if strings.HasPrefix(strings.TrimSpace(message), "[ocr]") { + kind = EventProgress + } + emit(Event{Kind: kind, Message: message, Truncated: truncated, OccurredAt: time.Now()}) + if (truncated || tail.cut) && !warned { + warned = true + warning := Event{Kind: EventWarning, Message: "OCR stderr was truncated", Truncated: true, OccurredAt: time.Now()} + warnings = append(warnings, warning) + emit(warning) + } + } + if err != nil { + if err == io.EOF { + err = nil + } + return tail.String(), tail.cut, warnings, err + } + break + } + } +} + +func consumeStreams(stdout, stderr io.Reader, limits Limits, emit func(Event)) streamHandle { + result := make(chan streamResult, 1) + done := make(chan struct{}, 2) + var stdoutData []byte + var stdoutExceeded bool + var stderrTail string + var stderrCut bool + var warnings []Event + var stdoutErr error + var stderrErr error + go func() { + defer func() { done <- struct{}{} }() + stdoutData, stdoutExceeded, stdoutErr = readStdout(stdout, limits.StdoutBytes) + }() + go func() { + defer func() { done <- struct{}{} }() + stderrTail, stderrCut, warnings, stderrErr = readStderr(stderr, limits.StderrBytes, limits.StderrLine, emit) + }() + allDone := make(chan struct{}) + go func() { + <-done + <-done + close(allDone) + if stdoutErr != nil { + result <- streamResult{stdout: stdoutData, stdoutExceeded: stdoutExceeded, stderrTail: stderrTail, stderrCut: stderrCut, warnings: warnings, err: stdoutErr} + return + } + result <- streamResult{stdout: stdoutData, stdoutExceeded: stdoutExceeded, stderrTail: stderrTail, stderrCut: stderrCut, warnings: warnings, err: stderrErr} + }() + return streamHandle{result: result, done: allDone, cancel: func() { + if c, ok := stdout.(io.Closer); ok { + _ = c.Close() + } + if c, ok := stderr.(io.Closer); ok { + _ = c.Close() + } + }} +} diff --git a/acp/internal/orchestrator/stub_main_test.go b/acp/internal/orchestrator/stub_main_test.go new file mode 100644 index 000000000..5a997dad6 --- /dev/null +++ b/acp/internal/orchestrator/stub_main_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "fmt" + "os" + "testing" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +func TestMain(m *testing.M) { + code := m.Run() + if err := testutil.Cleanup(); err != nil { + fmt.Fprintln(os.Stderr, "clean up OCR stub:", err) + code = 1 + } + os.Exit(code) +} diff --git a/acp/internal/orchestrator/termination_test.go b/acp/internal/orchestrator/termination_test.go new file mode 100644 index 000000000..34f8d5b4d --- /dev/null +++ b/acp/internal/orchestrator/termination_test.go @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package orchestrator + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/alibaba/open-code-review/acp/internal/testutil" +) + +// Deliberately hold Done delivery to prove that synchronous Err observation +// does not depend on a cancellation watcher being scheduled first. +type delayedDoneContext struct { + context.Context + release <-chan struct{} +} + +func (c delayedDoneContext) Done() <-chan struct{} { + <-c.release + return c.Context.Done() +} + +func TestPreterminatedRequestDoesNotStartOCR(t *testing.T) { + for _, name := range []string{"cancelled", "context deadline", "request deadline"} { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "started") + binary := testutil.Install(t, dir, "ocr", &testutil.Config{MarkerFile: marker}) + ctx := context.Background() + deadline := time.Time{} + want := OutcomeTimedOut + switch name { + case "cancelled": + c, cancel := context.WithCancel(ctx) + cancel() + ctx = c + want = OutcomeCancelled + case "context deadline": + c, cancel := context.WithDeadline(ctx, time.Now().Add(-time.Hour)) + defer cancel() + ctx = c + case "request deadline": + deadline = time.Now().Add(-time.Hour) + } + release := make(chan struct{}) + defer close(release) + ctx = delayedDoneContext{ctx, release} + events, outcomes := NewRunner(binary).Run(ctx, Request{CWD: dir, Args: []string{"scan"}, Deadline: deadline}) + select { + case outcome := <-outcomes: + if outcome.Kind != want { + t.Errorf("kind=%s, want %s", outcome.Kind, want) + } + case <-time.After(5 * time.Second): + t.Fatal("pre-start termination blocked") + } + for range events { + } + if _, open := <-outcomes; open { + t.Fatal("more than one outcome") + } + if _, err := os.Stat(filepath.Join(dir, "started")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("OCR executed: marker stat=%v", err) + } + }) + } +} + +func TestAwaitCompletedProcessObservesCancellationWithoutWatcher(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + release := make(chan struct{}) + defer close(release) + waited := make(chan error, 1) + exitErr := errors.New("observed exit") + waited <- exitErr + done := make(chan struct{}) + go func() { + defer close(done) + kind, err, cleanup := NewRunner("unused").await(delayedDoneContext{ctx, release}, time.Time{}, &exec.Cmd{}, waited) + if kind != OutcomeCancelled || err != exitErr || cleanup != nil { + t.Errorf("kind=%s wait=%v cleanup=%v", kind, err, cleanup) + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("completed result consumed twice or watcher required") + } +} + +func TestCompletedOutcomeIsNotRewritten(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + outcome, _ := runRequest(t, NewRunner(writeOCRScript(t)), ctx, Request{CWD: t.TempDir(), Args: []string{"scan"}}) + cancel() + if outcome.Kind != OutcomeCompleted { + t.Fatalf("outcome=%+v", outcome) + } +} + +type observationContext struct { + context.Context + observe func() +} + +func (c observationContext) Err() error { + c.observe() + return c.Context.Err() +} + +func TestAwaitRechecksAfterConsumingCompletion(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := 0 + wrapped := observationContext{Context: ctx, observe: func() { + calls++ + // The first observation admits waiting. The second occurs only after + // the ready completion has been consumed, and makes cancel observable. + if calls == 2 { + cancel() + } + }} + waited := make(chan error, 1) + exitErr := errors.New("completed result") + waited <- exitErr + done := make(chan struct{}) + go func() { + defer close(done) + kind, err, cleanup := NewRunner("unused").await(wrapped, time.Time{}, &exec.Cmd{}, waited) + if kind != OutcomeCancelled || err != exitErr || cleanup != nil { + t.Errorf("kind=%s err=%v cleanup=%v", kind, err, cleanup) + } + if len(waited) != 0 { + t.Error("did not exercise consumed completion") + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("completion was consumed twice") + } +} + +func TestAwaitFirstObservedCauseSurvivesCleanup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + observed := make(chan struct{}) + deadline := time.Now().Add(time.Second) + wrapped := observationContext{Context: ctx, observe: func() { close(observed) }} + waited := make(chan error) + result := make(chan OutcomeKind, 1) + runner := NewRunner("unused") + runner.GracePeriod = 3 * time.Second + go func() { kind, _, _ := runner.await(wrapped, deadline, &exec.Cmd{}, waited); result <- kind }() + <-observed + // Deadline expires while cleanup waits. The earlier cancellation must win. + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + <-timer.C + waited <- nil + if kind := <-result; kind != OutcomeCancelled { + t.Fatalf("kind=%s", kind) + } +} + +func TestSimultaneouslyObservableReasonsPreferTimeout(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + waited := make(chan error, 1) + waited <- nil + kind, _, _ := NewRunner("unused").await(ctx, time.Now().Add(-time.Hour), &exec.Cmd{}, waited) + if kind != OutcomeTimedOut { + t.Fatalf("kind=%s", kind) + } +} + +func TestCancellationAtStartBoundaryDoesNotStartOCR(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "started") + binary := testutil.Install(t, dir, "ocr", &testutil.Config{MarkerFile: marker}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observations := 0 + wrapped := observationContext{Context: ctx, observe: func() { + observations++ + if observations == 2 { + cancel() + } + }} + events, outcomes := NewRunner(binary).Run(wrapped, Request{CWD: dir, Args: []string{"scan"}}) + outcome := <-outcomes + for range events { + } + if outcome.Kind != OutcomeCancelled || outcome.ExitCode != -1 { + t.Fatalf("outcome=%+v", outcome) + } + if _, open := <-outcomes; open { + t.Fatal("outcomes not closed") + } + if _, err := os.Stat(filepath.Join(dir, "started")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("OCR started: %v", err) + } +} + +func TestAwaitObservedTimeoutIsNotReplacedByCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observed := make(chan struct{}) + wrapped := observationContext{Context: ctx, observe: func() { close(observed) }} + waited := make(chan error) + result := make(chan OutcomeKind, 1) + runner := NewRunner("unused") + runner.GracePeriod = 3 * time.Second + go func() { + kind, _, _ := runner.await(wrapped, time.Now().Add(-time.Hour), &exec.Cmd{}, waited) + result <- kind + }() + <-observed + cancel() + waited <- nil + if kind := <-result; kind != OutcomeTimedOut { + t.Fatalf("kind=%s", kind) + } +} diff --git a/acp/internal/orchestrator/types.go b/acp/internal/orchestrator/types.go new file mode 100644 index 000000000..bce4cb659 --- /dev/null +++ b/acp/internal/orchestrator/types.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package orchestrator executes the external OCR CLI without exposing process +// lifecycle details to the ACP protocol adapter. +package orchestrator + +import ( + "context" + "fmt" + "time" + + "github.com/alibaba/open-code-review/acp/internal/contract" +) + +const ( + defaultStdoutLimit = 10 << 20 + defaultStderrLimit = 1 << 20 + defaultStderrLine = 64 << 10 + defaultEventQueue = 64 + defaultGracePeriod = 2 * time.Second + drainGracePeriod = 2 * time.Second +) + +// EventKind identifies a non-terminal message emitted while OCR runs. +type EventKind string + +const ( + EventProgress EventKind = "progress" + EventDiagnostic EventKind = "diagnostic" + EventWarning EventKind = "warning" +) + +// Event is a bounded, best-effort progress or diagnostic message. The runner +// may drop an event when its consumer is slow. Critical stream warnings are +// also retained in Outcome.Warnings, which is the authoritative record. +type Event struct { + Kind EventKind + Message string + Truncated bool + OccurredAt time.Time +} + +// OutcomeKind is the local terminal state. A local timeout or cancellation +// takes precedence over the child process exit code and JSON status. +type OutcomeKind string + +const ( + OutcomeCompleted OutcomeKind = "completed" + OutcomeFailed OutcomeKind = "failed" + OutcomeCancelled OutcomeKind = "cancelled" + OutcomeTimedOut OutcomeKind = "timed_out" +) + +// Result is the decoded stdout document. Exactly one of Review and Scan is +// set, based on the OCR subcommand supplied in Request.Args. +type Result struct { + Review *contract.ReviewResult + Scan *contract.ScanResult +} + +// Outcome is delivered exactly once, after the process and stream readers +// have been reaped or their bounded cleanup period has elapsed. +type Outcome struct { + Kind OutcomeKind + Result *Result + ExitCode int + Diagnostics string + Warnings []Event + Err error +} + +// Request contains a prevalidated OCR invocation. Args must begin with review +// or scan; this package never reparses an intent or changes the argument list. +type Request struct { + CWD string + Args []string + Deadline time.Time +} + +// Limits bounds data retained by a single execution. Zero fields use the +// documented defaults. EventQueue is intentionally small and lossy. +type Limits struct { + StdoutBytes int + StderrBytes int + StderrLine int + EventQueue int +} + +func (l Limits) normalized() Limits { + if l.StdoutBytes <= 0 { + l.StdoutBytes = defaultStdoutLimit + } + if l.StderrBytes <= 0 { + l.StderrBytes = defaultStderrLimit + } + if l.StderrLine <= 0 { + l.StderrLine = defaultStderrLine + } + if l.EventQueue <= 0 { + l.EventQueue = defaultEventQueue + } + return l +} + +// ErrorKind makes failures machine-classifiable without forcing callers to +// match untrusted command text. +type ErrorKind string + +const ( + ErrorInvalidRequest ErrorKind = "invalid_request" + ErrorInvalidCWD ErrorKind = "invalid_cwd" + ErrorStart ErrorKind = "start" + ErrorStream ErrorKind = "stream" + ErrorStdoutLimit ErrorKind = "stdout_limit" + ErrorExit ErrorKind = "exit" + ErrorDecode ErrorKind = "decode" + ErrorResult ErrorKind = "result" + ErrorCleanup ErrorKind = "cleanup" + ErrorPlatform ErrorKind = "platform_unsupported" +) + +// Error describes a local orchestration failure. +type Error struct { + Kind ErrorKind + Err error +} + +func (e *Error) Error() string { + if e.Err == nil { + return string(e.Kind) + } + return fmt.Sprintf("%s: %v", e.Kind, e.Err) +} + +func (e *Error) Unwrap() error { return e.Err } + +func newError(kind ErrorKind, format string, args ...any) error { + return &Error{Kind: kind, Err: fmt.Errorf(format, args...)} +} + +// Runner is the phase-six-facing execution contract. +type Runner interface { + Run(ctx context.Context, request Request) (<-chan Event, <-chan Outcome) +} + +// ProcessRunner invokes one fixed OCR executable. ResolveBinary performs +// discovery at adapter startup; ProcessRunner deliberately accepts only that +// resolved path. +type ProcessRunner struct { + Binary string + Limits Limits + GracePeriod time.Duration + Now func() time.Time +} + +// NewRunner creates a runner with the documented resource limits. +func NewRunner(binary string) *ProcessRunner { + return &ProcessRunner{Binary: binary} +} + +// Run starts asynchronous execution and returns immediately. The events +// channel closes after stream readers finish; it may drop best-effort events +// when full. The outcomes channel has one buffered slot and receives exactly +// one Outcome before it closes, even for failures before process startup. +// It never writes child output to this process's stdout or stderr. +func (r *ProcessRunner) Run(ctx context.Context, request Request) (<-chan Event, <-chan Outcome) { + limits := r.Limits.normalized() + events := make(chan Event, limits.EventQueue) + outcomes := make(chan Outcome, 1) + go r.run(ctx, request, limits, events, outcomes) + return events, outcomes +} diff --git a/acp/internal/testutil/stub.go b/acp/internal/testutil/stub.go new file mode 100644 index 000000000..f0d61daf5 --- /dev/null +++ b/acp/internal/testutil/stub.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// Package testutil builds and installs the cross-platform OCR stub used by ACP tests. +package testutil + +import ( + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "testing" +) + +// Config is written beside an installed stub as .json. +type Config struct { + VersionText string `json:"version_text,omitempty"` + VersionPrefix string `json:"version_prefix,omitempty"` + VersionSuffix string `json:"version_suffix,omitempty"` + VersionBytes int `json:"version_bytes,omitempty"` + SleepMS int `json:"sleep_ms,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + HoldPIDFile string `json:"hold_pid_file,omitempty"` + HoldInherit bool `json:"hold_inherit,omitempty"` + HoldMS int `json:"hold_ms,omitempty"` + FlushMS int `json:"flush_ms,omitempty"` + FlushStdout string `json:"flush_stdout,omitempty"` + FlushStderr string `json:"flush_stderr,omitempty"` + MarkerFile string `json:"marker_file,omitempty"` + StderrLines []string `json:"stderr_lines,omitempty"` + Stdout string `json:"stdout,omitempty"` + Block bool `json:"block,omitempty"` + CleanupFile string `json:"cleanup_file,omitempty"` + HugeMessage int `json:"huge_message,omitempty"` +} + +var ( + buildOnce sync.Once + builtPath string + buildErr error +) + +// ExeName returns name with a Windows .exe suffix when needed. +func ExeName(name string) string { + if runtime.GOOS == "windows" && filepath.Ext(name) == "" { + return name + ".exe" + } + return name +} + +// Build compiles the stub once per test binary. +func Build(t testing.TB) string { + t.Helper() + if err := ensureBuilt(); err != nil { + t.Fatalf("build ocr-stub: %v", err) + } + return builtPath +} + +// Cleanup removes the cached build, including any partial build artifacts. +// Call it from TestMain only after m.Run returns and all stub users have stopped. +func Cleanup() error { + if builtPath == "" { + return nil + } + return os.RemoveAll(filepath.Dir(builtPath)) +} + +func ensureBuilt() error { + buildOnce.Do(func() { + dir, err := os.MkdirTemp("", "ocr-acp-stub") + if err != nil { + buildErr = err + return + } + builtPath = filepath.Join(dir, ExeName("ocr-stub")) + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + buildErr = errCaller() + return + } + src := filepath.Join(filepath.Dir(thisFile), "..", "..", "testdata", "ocr-stub") + cmd := exec.Command("go", "build", "-o", builtPath, src) + if out, err := cmd.CombinedOutput(); err != nil { + buildErr = errOutput(err, out) + } + }) + return buildErr +} + +type callerError struct{} + +func errCaller() error { return callerError{} } + +func (callerError) Error() string { return "cannot locate testutil source" } + +type outputError struct { + err error + out []byte +} + +func errOutput(err error, out []byte) error { return outputError{err: err, out: out} } + +func (e outputError) Error() string { + return e.err.Error() + "\n" + string(e.out) +} + +// Install copies the stub into directory as name and optionally writes a sidecar config. +func Install(t testing.TB, directory, name string, cfg *Config) string { + t.Helper() + src := Build(t) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + dst := filepath.Join(directory, ExeName(name)) + in, err := os.Open(src) + if err != nil { + t.Fatal(err) + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } + if cfg != nil { + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst+".json", data, 0o600); err != nil { + t.Fatal(err) + } + } + return dst +} diff --git a/acp/internal/testutil/stub_main_test.go b/acp/internal/testutil/stub_main_test.go new file mode 100644 index 000000000..847d2cd38 --- /dev/null +++ b/acp/internal/testutil/stub_main_test.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package testutil + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + code := m.Run() + if err := Cleanup(); err != nil { + fmt.Fprintln(os.Stderr, "clean up OCR stub:", err) + code = 1 + } + os.Exit(code) +} diff --git a/acp/internal/testutil/stub_test.go b/acp/internal/testutil/stub_test.go new file mode 100644 index 000000000..f12d43124 --- /dev/null +++ b/acp/internal/testutil/stub_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package testutil + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestBuildLifetime(t *testing.T) { + const recordEnv = "OCR_ACP_STUB_LIFETIME_RECORD" + if record := os.Getenv(recordEnv); record != "" { + var built string + t.Run("first user", func(t *testing.T) { built = Build(t) }) + t.Run("later user", func(t *testing.T) { + if got := Build(t); got != built { + t.Fatalf("cached build = %q, want %q", got, built) + } + if _, err := os.Stat(built); err != nil { + t.Fatalf("cached stub did not survive first test: %v", err) + } + }) + if err := os.WriteFile(record, []byte(built), 0o600); err != nil { + t.Fatal(err) + } + return + } + + record := filepath.Join(t.TempDir(), "built-path") + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(executable, "-test.run=^TestBuildLifetime$") + cmd.Env = append(os.Environ(), recordEnv+"="+record) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("stub lifetime subprocess: %v\n%s", err, out) + } + built, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + directory := filepath.Dir(string(built)) + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + if _, err := os.Stat(directory); !os.IsNotExist(err) { + t.Fatalf("stub directory remains after test process exit: %s (stat: %v)", directory, err) + } +} + +func TestExeName(t *testing.T) { + want := "ocr-stub" + if runtime.GOOS == "windows" { + want += ".exe" + } + if got := ExeName("ocr-stub"); got != want { + t.Fatalf("ExeName(ocr-stub) = %q, want %q", got, want) + } + if got := ExeName("ocr-stub.exe"); got != "ocr-stub.exe" { + t.Fatalf("ExeName(ocr-stub.exe) = %q, want it unchanged", got) + } +} + +func TestBuild(t *testing.T) { + built := Build(t) + if built == "" { + t.Fatal("Build returned an empty path") + } + info, err := os.Stat(built) + if err != nil { + t.Fatalf("stat built stub: %v", err) + } + if info.IsDir() { + t.Fatalf("built stub %q is a directory", built) + } +} + +func TestInstallWithoutConfig(t *testing.T) { + directory := filepath.Join(t.TempDir(), "nested", "stubs") + installed := Install(t, directory, "plain", nil) + if _, err := os.Stat(installed); err != nil { + t.Fatalf("stat installed stub: %v", err) + } + if _, err := os.Stat(installed + ".json"); !os.IsNotExist(err) { + t.Fatalf("unexpected sidecar config for a nil config: %v", err) + } +} + +func TestInstallWithConfig(t *testing.T) { + cfg := &Config{} + cfg.VersionText = "ocr 9.9.9" + cfg.ExitCode = 7 + cfg.Stdout = "stdout line" + cfg.StderrLines = []string{"stderr line"} + + installed := Install(t, t.TempDir(), "configured", cfg) + if runtime.GOOS != "windows" && !strings.HasSuffix(installed, "configured") { + t.Fatalf("installed path %q does not end with the requested name", installed) + } + + data, err := os.ReadFile(installed + ".json") + if err != nil { + t.Fatalf("read sidecar config: %v", err) + } + var decoded Config + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("decode sidecar config: %v", err) + } + if decoded.VersionText != cfg.VersionText || decoded.ExitCode != cfg.ExitCode { + t.Fatalf("sidecar config = %+v, want version %q and exit code %d", decoded, cfg.VersionText, cfg.ExitCode) + } + if len(decoded.StderrLines) != 1 || decoded.StderrLines[0] != "stderr line" { + t.Fatalf("sidecar stderr lines = %v", decoded.StderrLines) + } +} + +func TestErrorFormatting(t *testing.T) { + if got := errCaller().Error(); got != "cannot locate testutil source" { + t.Fatalf("errCaller = %q", got) + } + if got := (callerError{}).Error(); got != "cannot locate testutil source" { + t.Fatalf("callerError = %q", got) + } + if got := errOutput(os.ErrNotExist, []byte("build failure detail")).Error(); !strings.Contains(got, "build failure detail") { + t.Fatalf("errOutput = %q", got) + } + direct := outputError{err: os.ErrNotExist, out: []byte("more detail")} + if !strings.Contains(direct.Error(), "more detail") { + t.Fatal("outputError dropped the command output") + } +} diff --git a/acp/testdata/mock-ocr/interrupt_unix.go b/acp/testdata/mock-ocr/interrupt_unix.go new file mode 100644 index 000000000..eb525160e --- /dev/null +++ b/acp/testdata/mock-ocr/interrupt_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "os" + "syscall" +) + +func interruptSignals() []os.Signal { + return []os.Signal{syscall.SIGINT, syscall.SIGTERM} +} diff --git a/acp/testdata/mock-ocr/interrupt_windows.go b/acp/testdata/mock-ocr/interrupt_windows.go new file mode 100644 index 000000000..5d9a9b463 --- /dev/null +++ b/acp/testdata/mock-ocr/interrupt_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import "os" + +func interruptSignals() []os.Signal { + // CTRL_BREAK is mapped to os.Interrupt. syscall.SIGBREAK is not defined + // in the standard syscall package on Windows. + return []os.Signal{os.Interrupt} +} diff --git a/acp/testdata/mock-ocr/main.go b/acp/testdata/mock-ocr/main.go new file mode 100644 index 000000000..02d24d9c4 --- /dev/null +++ b/acp/testdata/mock-ocr/main.go @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// mock-ocr is a test double for the OCR CLI. +// It simulates various OCR behaviors for testing ocr-acp without real LLM calls. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "os/signal" + "time" +) + +var ( + scenario = flag.String("scenario", "success-review", "Test scenario to simulate") + delay = flag.Duration("delay", 0, "Delay before producing output") + childPIDFile = flag.String("child-pid-file", "", "Write the spawned child PID for process-tree tests") +) + +// The structs below mirror the real OCR CLI JSON envelope (cmd/opencodereview +// jsonOutput and model.LlmComment), NOT the adapter's DTO. Keeping the test +// double shaped like the real output is what lets the contract tests catch a +// DTO that drifted from the CLI. Field names therefore match the CLI tags +// (content/start_line/end_line), and thinking is present even though the +// adapter deliberately does not model it. +type runResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Summary *summary `json:"summary,omitempty"` + Comments []comment `json:"comments"` + SessionID string `json:"session_id,omitempty"` + Manifest *manifest `json:"manifest,omitempty"` +} + +type summary struct { + FilesReviewed int64 `json:"files_reviewed"` + Comments int64 `json:"comments"` + TotalTokens int64 `json:"total_tokens,omitempty"` + Elapsed string `json:"elapsed,omitempty"` +} + +type comment struct { + Path string `json:"path"` + Content string `json:"content"` + SuggestionCode string `json:"suggestion_code,omitempty"` + ExistingCode string `json:"existing_code,omitempty"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Category string `json:"category,omitempty"` + Severity string `json:"severity,omitempty"` + Thinking string `json:"thinking,omitempty"` +} + +type manifest struct { + SchemaVersion string `json:"schema_version,omitempty"` + Operation string `json:"operation,omitempty"` + TerminalState string `json:"terminal_state,omitempty"` + RunFailure *runFailure `json:"run_failure,omitempty"` + Coverage *coverage `json:"coverage,omitempty"` +} + +type runFailure struct { + Classification string `json:"classification,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type coverage struct { + Failed []coverageItem `json:"failed,omitempty"` +} + +type coverageItem struct { + Path string `json:"path,omitempty"` + Classification string `json:"classification,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func emit(result runResult) { + if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "mock-ocr: encoding result: %v\n", err) + os.Exit(1) + } +} + +func main() { + if len(os.Args) > 1 && (os.Args[1] == "review" || os.Args[1] == "scan") { + if err := flag.CommandLine.Parse(os.Args[2:]); err != nil { + os.Exit(2) + } + } else { + flag.Parse() + } + + // Set up signal handling for cancellation tests. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, interruptSignals()...) + + if *delay > 0 { + select { + case <-time.After(*delay): + case <-sigCh: + fmt.Fprintln(os.Stderr, "mock-ocr: received signal during delay") + os.Exit(130) + } + } + + switch *scenario { + case "success-review": + successReview() + case "success-scan": + successScan() + case "partial": + partialReview() + case "empty-comments": + emptyComments() + case "stderr-pollution": + stderrPollution() + case "invalid-json": + invalidJSON() + case "non-zero-exit": + nonZeroExit() + case "block-for-cancel": + blockForCancel(sigCh) + case "spawn-child": + spawnChild(sigCh) + case "child-block": + childBlock(sigCh) + default: + fmt.Fprintf(os.Stderr, "unknown scenario: %s\n", *scenario) + os.Exit(1) + } +} + +func successReview() { + fmt.Fprintln(os.Stderr, "[ocr] 1 file(s) changed, reviewing 1") + time.Sleep(50 * time.Millisecond) + fmt.Fprintln(os.Stderr, "[ocr] code_comment (0s)") + + emit(runResult{ + Status: "complete", + Message: "Review complete: 2 finding(s) across 1 selected item(s).", + Summary: &summary{FilesReviewed: 1, Comments: 2, TotalTokens: 18746, Elapsed: "7s"}, + SessionID: "843e2022-8130-416f-bb95-ef76ab43ef9d", + Comments: []comment{ + { + Path: "internal/agent/agent.go", + Content: "Potential nil dereference on the error path.", + StartLine: 42, + EndLine: 42, + Category: "bug", + Severity: "warning", + Thinking: "internal model reasoning that must not reach the user", + }, + { + Path: "internal/agent/agent.go", + Content: "Consider adding error handling here.", + SuggestionCode: "+return err", + ExistingCode: "return nil", + Category: "maintainability", + Severity: "info", + }, + }, + Manifest: &manifest{ + SchemaVersion: "ocr.run-manifest/v1", + Operation: "review", + TerminalState: "complete", + }, + }) +} + +func successScan() { + fmt.Fprintln(os.Stderr, "[ocr] scanning files...") + + emit(runResult{ + Status: "success", + Message: "Scan complete: 1 finding(s).", + Summary: &summary{FilesReviewed: 2, Comments: 1, Elapsed: "3s"}, + Comments: []comment{ + {Path: "cmd/main.go", Content: "Unused import.", StartLine: 10, EndLine: 10, Category: "style", Severity: "info"}, + }, + }) +} + +func partialReview() { + fmt.Fprintln(os.Stderr, "[ocr] review interrupted") + + emit(runResult{ + Status: "partial", + Message: "Review incomplete: 1 of 2 item(s) completed.", + Summary: &summary{FilesReviewed: 1, Comments: 1, Elapsed: "2s"}, + Comments: []comment{ + {Path: "file1.go", Content: "Issue found before timeout.", StartLine: 5, EndLine: 5, Category: "bug", Severity: "warning"}, + }, + Manifest: &manifest{ + SchemaVersion: "ocr.run-manifest/v1", + Operation: "review", + TerminalState: "partial", + Coverage: &coverage{Failed: []coverageItem{ + {Path: "file2.go", Classification: "timeout", Reason: "per-file timeout"}, + }}, + }, + }) +} + +func emptyComments() { + emit(runResult{ + Status: "complete", + Message: "Review complete: 0 finding(s).", + Summary: &summary{FilesReviewed: 1, Comments: 0, Elapsed: "1s"}, + // The normal empty-result path emits [] (an empty array), unlike the + // failure path which emits null. Both must decode. + Comments: []comment{}, + Manifest: &manifest{SchemaVersion: "ocr.run-manifest/v1", Operation: "review", TerminalState: "complete"}, + }) +} + +func stderrPollution() { + fmt.Fprintln(os.Stderr, "[ocr] diagnostic message") + // A second JSON document on stderr, as emitFailureUsage does in the real + // CLI. It must never be mistaken for the stdout result. + fmt.Fprintln(os.Stderr, `{"status":"failed","comments":null}`) + fmt.Fprintln(os.Stderr, "[ocr] more noise") + + emit(runResult{ + Status: "complete", + Message: "Review complete: 1 finding(s).", + Summary: &summary{FilesReviewed: 1, Comments: 1, Elapsed: "1s"}, + Comments: []comment{{Path: "test.go", Content: "Test finding.", StartLine: 1, EndLine: 1, Category: "bug", Severity: "info"}}, + }) +} + +func invalidJSON() { + fmt.Fprintln(os.Stderr, "[ocr] generating result...") + fmt.Fprintln(os.Stdout, "{invalid json here") + os.Exit(1) +} + +func nonZeroExit() { + emit(runResult{ + Status: "failed", + Message: "Review failed: unable to reach the model.", + Comments: nil, // the failure path emits null + Manifest: &manifest{ + SchemaVersion: "ocr.run-manifest/v1", + Operation: "review", + TerminalState: "failed", + RunFailure: &runFailure{Classification: "failed", Reason: "review failed"}, + }, + }) + os.Exit(1) +} + +func blockForCancel(sigCh chan os.Signal) { + fmt.Fprintln(os.Stderr, "[ocr] long-running operation...") + fmt.Fprintln(os.Stderr, "READY") // Signal that we're ready to be cancelled + + <-sigCh + fmt.Fprintln(os.Stderr, "mock-ocr: received cancellation signal") + + // Real review cancellation writes a failed document carrying cancelled + // classifications and exits non-zero. The adapter must combine its own + // cancel record with these fields, never the exit code alone. + emit(runResult{ + Status: "failed", + Message: "Review failed (cancelled): 1 finding(s); 1 of 1 item(s) failed.", + Summary: &summary{FilesReviewed: 1, Comments: 1, Elapsed: "5s"}, + Comments: []comment{ + {Path: "file.go", Content: "Found before cancel.", StartLine: 10, EndLine: 10, Category: "bug", Severity: "info"}, + }, + Manifest: &manifest{ + SchemaVersion: "ocr.run-manifest/v1", + Operation: "review", + TerminalState: "failed", + RunFailure: &runFailure{Classification: "cancelled", Reason: "review was cancelled"}, + Coverage: &coverage{Failed: []coverageItem{ + {Path: "file.go", Classification: "cancelled", Reason: "file review was cancelled"}, + }}, + }, + }) + os.Exit(1) +} + +func spawnChild(sigCh chan os.Signal) { + fmt.Fprintln(os.Stderr, "[ocr] running with child process...") + child := exec.Command(os.Args[0], "-scenario", "child-block") + child.Stdout = os.Stdout + childStderr, err := child.StderrPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "mock-ocr: child stderr pipe: %v\n", err) + os.Exit(1) + } + if err := child.Start(); err != nil { + fmt.Fprintf(os.Stderr, "mock-ocr: start child: %v\n", err) + os.Exit(1) + } + if *childPIDFile != "" { + if err := os.WriteFile(*childPIDFile, []byte(fmt.Sprintf("%d\n", child.Process.Pid)), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "mock-ocr: write child PID: %v\n", err) + _ = child.Process.Kill() + os.Exit(1) + } + } + scanner := bufio.NewScanner(childStderr) + if !scanner.Scan() || scanner.Text() != "CHILD_READY" { + fmt.Fprintf(os.Stderr, "mock-ocr: child did not become ready: %v\n", scanner.Err()) + _ = child.Process.Kill() + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "READY") + <-sigCh + _ = child.Wait() + + emit(runResult{ + Status: "failed", + Message: "Review cancelled with child process.", + Comments: []comment{}, + }) + os.Exit(1) +} + +func childBlock(sigCh chan os.Signal) { + fmt.Fprintln(os.Stderr, "CHILD_READY") + <-sigCh + os.Exit(130) +} diff --git a/acp/testdata/ocr-stub/interrupt_unix.go b/acp/testdata/ocr-stub/interrupt_unix.go new file mode 100644 index 000000000..26c1cb829 --- /dev/null +++ b/acp/testdata/ocr-stub/interrupt_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "os" + "os/signal" + "syscall" +) + +func waitForInterrupt() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + <-ch +} diff --git a/acp/testdata/ocr-stub/interrupt_windows.go b/acp/testdata/ocr-stub/interrupt_windows.go new file mode 100644 index 000000000..543fe4dc1 --- /dev/null +++ b/acp/testdata/ocr-stub/interrupt_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "os" + "os/signal" +) + +func waitForInterrupt() { + ch := make(chan os.Signal, 1) + // CTRL_BREAK is mapped to os.Interrupt. syscall.SIGBREAK is not defined + // in the standard syscall package on Windows. + signal.Notify(ch, os.Interrupt) + <-ch +} diff --git a/acp/testdata/ocr-stub/main.go b/acp/testdata/ocr-stub/main.go new file mode 100644 index 000000000..d185da018 --- /dev/null +++ b/acp/testdata/ocr-stub/main.go @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +// ocr-stub is a cross-platform stand-in for the OCR CLI used by ACP tests. +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +type config struct { + VersionText string `json:"version_text"` + VersionPrefix string `json:"version_prefix"` + VersionSuffix string `json:"version_suffix"` + VersionBytes int `json:"version_bytes"` + SleepMS int `json:"sleep_ms"` + ExitCode int `json:"exit_code"` + HoldPIDFile string `json:"hold_pid_file"` + HoldInherit bool `json:"hold_inherit"` + HoldMS int `json:"hold_ms"` + FlushMS int `json:"flush_ms"` + FlushStdout string `json:"flush_stdout"` + FlushStderr string `json:"flush_stderr"` + MarkerFile string `json:"marker_file"` + StderrLines []string `json:"stderr_lines"` + Stdout string `json:"stdout"` + Block bool `json:"block"` + CleanupFile string `json:"cleanup_file"` + HugeMessage int `json:"huge_message"` +} + +func main() { + if os.Getenv("OCR_STUB_CHILD") == "1" { + runChild() + return + } + cfg := loadConfig() + if versionRequested() { + runVersion(cfg) + return + } + if cfg.MarkerFile != "" { + if err := os.WriteFile(cfg.MarkerFile, []byte("started"), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: marker: %v\n", err) + os.Exit(1) + } + } + command, mode := commandAndMode() + if command != "review" && command != "scan" && mode == "" && !cfg.hasCommandOverride() { + fmt.Fprintf(os.Stderr, "ocr-stub: expected review or scan, got %q\n", strings.Join(os.Args[1:], " ")) + os.Exit(2) + } + exit := runCommand(command, mode, cfg) + os.Exit(exit) +} + +func (c config) hasCommandOverride() bool { + return len(c.StderrLines) > 0 || c.Stdout != "" || c.Block || c.HoldPIDFile != "" || c.FlushMS > 0 || c.CleanupFile != "" || c.HugeMessage > 0 +} + +func loadConfig() config { + self, err := os.Executable() + if err != nil { + return config{} + } + data, err := os.ReadFile(self + ".json") + if err != nil { + return config{} + } + var cfg config + if err := json.Unmarshal(data, &cfg); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: config: %v\n", err) + os.Exit(1) + } + return cfg +} + +func versionRequested() bool { + for _, arg := range os.Args[1:] { + if arg == "--version" { + return true + } + } + return false +} + +func commandAndMode() (string, string) { + if len(os.Args) < 2 { + return "", "" + } + command := os.Args[1] + if len(os.Args) < 3 || strings.HasPrefix(os.Args[2], "-") { + return command, "" + } + return command, os.Args[2] +} + +func runVersion(cfg config) { + if cfg.SleepMS > 0 { + time.Sleep(time.Duration(cfg.SleepMS) * time.Millisecond) + } + if cfg.VersionBytes > 0 { + _, _ = os.Stdout.Write([]byte(strings.Repeat("x", cfg.VersionBytes))) + return + } + if cfg.HoldPIDFile != "" { + if _, err := spawnChild(cfg.HoldInherit, holdDuration(cfg), "", ""); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: spawn: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(cfg.HoldPIDFile, []byte(strconv.Itoa(lastChildPID)+"\n"), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: pid file: %v\n", err) + os.Exit(1) + } + } + if cfg.FlushMS > 0 { + fmt.Print(cfg.VersionPrefix) + if _, err := spawnChild(true, time.Duration(cfg.FlushMS)*time.Millisecond, cfg.VersionSuffix, ""); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: flush spawn: %v\n", err) + os.Exit(1) + } + os.Exit(cfg.ExitCode) + return + } + text := cfg.VersionText + if text == "" { + text = "ocr-stub 0.0.0" + } + fmt.Println(text) + os.Exit(cfg.ExitCode) +} + +func runCommand(command, mode string, cfg config) int { + for _, line := range cfg.StderrLines { + fmt.Fprintln(os.Stderr, line) + } + if cfg.HugeMessage > 0 { + fmt.Printf("{\"status\":\"success\",\"comments\":[],\"message\":\"%s\"}\n", strings.Repeat("x", cfg.HugeMessage)) + return cfg.ExitCode + } + switch mode { + case "nonzero": + fmt.Println(`{"status":"complete","comments":[]}`) + return 7 + case "invalid": + fmt.Println("{invalid") + return 0 + case "multiple": + fmt.Println(`{"status":"complete","comments":[]}`) + fmt.Println(`{"status":"complete","comments":[]}`) + return 0 + case "block": + return runBlock(cfg, false) + case "cleanup": + return runBlock(cfg, true) + case "large": + for i := 0; i < 20; i++ { + fmt.Fprint(os.Stderr, "xxxxxxxxxx") + } + fmt.Fprintln(os.Stderr) + for i := 0; i < 30; i++ { + fmt.Fprint(os.Stdout, "xxxxxxxxxx") + } + fmt.Fprintln(os.Stdout) + return 0 + case "huge": + fmt.Printf("{\"status\":\"success\",\"comments\":[],\"message\":\"%s\"}\n", strings.Repeat("x", 1<<20)) + return 0 + } + if cfg.HoldPIDFile != "" { + if _, err := spawnChild(cfg.HoldInherit, holdDuration(cfg), "", ""); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: spawn: %v\n", err) + return 1 + } + if err := os.WriteFile(cfg.HoldPIDFile, []byte(strconv.Itoa(lastChildPID)+"\n"), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: pid file: %v\n", err) + return 1 + } + } + if cfg.FlushMS > 0 { + if cfg.Stdout != "" { + fmt.Print(cfg.Stdout) + } + if _, err := spawnChild(true, time.Duration(cfg.FlushMS)*time.Millisecond, cfg.FlushStdout, cfg.FlushStderr); err != nil { + fmt.Fprintf(os.Stderr, "ocr-stub: flush spawn: %v\n", err) + return 1 + } + if cfg.ExitCode != 0 { + return cfg.ExitCode + } + return 0 + } + if cfg.Block { + return runBlock(cfg, cfg.CleanupFile != "") + } + if cfg.Stdout != "" { + fmt.Print(cfg.Stdout) + if !strings.HasSuffix(cfg.Stdout, "\n") { + fmt.Println() + } + return cfg.ExitCode + } + if cfg.SleepMS > 0 { + time.Sleep(time.Duration(cfg.SleepMS) * time.Millisecond) + } + if command == "scan" { + fmt.Println(`{"status":"success","comments":[]}`) + } else { + fmt.Fprintln(os.Stderr, "[ocr] running") + fmt.Println(`{"status":"complete","comments":[],"manifest":{"terminal_state":"complete"}}`) + } + return cfg.ExitCode +} + +func runBlock(cfg config, cleanup bool) int { + fmt.Fprintln(os.Stderr, "READY") + waitForInterrupt() + if cleanup || cfg.CleanupFile != "" { + path := cfg.CleanupFile + if path == "" { + path = "cleaned" + } + _ = os.WriteFile(path, []byte("cleaned\n"), 0o600) + return 0 + } + fmt.Println(`{"status":"failed","comments":[]}`) + return 1 +} + +func holdDuration(cfg config) time.Duration { + if cfg.HoldMS > 0 { + return time.Duration(cfg.HoldMS) * time.Millisecond + } + return 30 * time.Second +} + +var lastChildPID int + +func spawnChild(inherit bool, delay time.Duration, stdout, stderr string) (int, error) { + self, err := os.Executable() + if err != nil { + return 0, err + } + cmd := exec.Command(self, "--stub-child") + cmd.Env = append(os.Environ(), + "OCR_STUB_CHILD=1", + "OCR_STUB_CHILD_SLEEP_MS="+strconv.Itoa(int(delay/time.Millisecond)), + "OCR_STUB_CHILD_STDOUT="+stdout, + "OCR_STUB_CHILD_STDERR="+stderr, + ) + if inherit { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else { + null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return 0, err + } + defer null.Close() + cmd.Stdin = null + cmd.Stdout = null + cmd.Stderr = null + } + if err := cmd.Start(); err != nil { + return 0, err + } + lastChildPID = cmd.Process.Pid + return lastChildPID, nil +} + +func runChild() { + ms, _ := strconv.Atoi(os.Getenv("OCR_STUB_CHILD_SLEEP_MS")) + if ms > 0 { + time.Sleep(time.Duration(ms) * time.Millisecond) + } + if text := os.Getenv("OCR_STUB_CHILD_STDOUT"); text != "" { + fmt.Print(text) + } + if text := os.Getenv("OCR_STUB_CHILD_STDERR"); text != "" { + fmt.Fprint(os.Stderr, text) + } +} diff --git a/acp/testdata/python-client-smoke.py b/acp/testdata/python-client-smoke.py new file mode 100644 index 000000000..02def7ddb --- /dev/null +++ b/acp/testdata/python-client-smoke.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 alibaba/open-code-review Contributors +"""Official ACP Python client against an independent adapter and mock OCR. + +Run from the repository root: + make -C acp build + uv run --no-project --managed-python --python 3.12.13 --with-requirements acp/testdata/python-smoke-requirements.txt acp/testdata/python-client-smoke.py +""" + +import asyncio +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import threading + +if sys.flags.optimize != 0: + raise SystemExit("smoke test requires assertions; run Python without -O") + +import acp + + +def mock_ocr(): + if "--version" in sys.argv: + print("ocr version 0.0.0-smoke") + return + if "--no-filter" in sys.argv: + print("[ocr] READY", file=sys.stderr, flush=True) + wait_for_cancel() + return + scan = "scan" in sys.argv + effort = sys.argv[sys.argv.index("--effort") + 1] if "--effort" in sys.argv else "medium" + selected = "--path" in sys.argv + if selected: + assert sys.argv[sys.argv.index("--path") + 1] == "sample.go" + print("[ocr] Reviewing selected files\n[ocr] Reading file diff\n[ocr] Review finished", + file=sys.stderr, flush=True) + partial = "--effort" in sys.argv and sys.argv[sys.argv.index("--effort") + 1] == "high" + result = { + "status": "success", "message": "Selected resource scanned" if selected else ("Smoke scan" if scan else "Smoke review"), + "summary": {"files_reviewed": 1, "comments": 1, "total_tokens": 120, "input_tokens": 100, "output_tokens": 20}, + "tool_calls": {"total": 3, "by_tool": {"read_file": 2, "code_search": 1}}, + "comments": [{"path": "sample.go" if scan else "nested/sample.go", + "start_line": 1, "end_line": 1, "severity": "critical", + "category": "bug", "content": "Smoke finding", + "suggestion_code": "safe()", "thinking": "DO_NOT_EXPOSE"}], + } + if partial: + result["status"] = "partial" + result["summary"]["files_reviewed"] = 4 + result["manifest"] = { + "terminal_state": "partial", + "coverage": { + "selected": [{"path": path} for path in ("nested/sample.go", "b.go", "c.go", "server.go")], + "completed": [{"path": path} for path in ("nested/sample.go", "b.go", "c.go")], + "reused": [], "waived": [], + "failed": [{"path": "server.go", "classification": "budget", + "reason": "reached the maximum tool-request rounds without finishing"}], + }, + } + if not scan: + if effort == "low": + result["comments"] = [] + result["summary"]["comments"] = 0 + print(json.dumps(result)) + + +def wait_for_cancel(): + stop = threading.Event() + + def handle(*_): + stop.set() + sys.exit(130) + + signal.signal(signal.SIGINT, handle) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, handle) + if hasattr(signal, "pause"): + signal.pause() + return + stop.wait() + + +def adapter_binary(): + root = Path(__file__).resolve().parents[2] / "dist" + name = "ocr-acp.exe" if os.name == "nt" else "ocr-acp" + return root / name + + +def write_mock_wrapper(root, script): + if os.name == "nt": + wrapper = root / "mock-ocr.cmd" + wrapper.write_text( + f'@echo off\r\n"{sys.executable}" -u "{script}" --mock-ocr %*\r\n', + encoding="utf-8", + ) + return wrapper + import shlex + wrapper = root / "mock-ocr" + wrapper.write_text("#!/bin/sh\nexec " + shlex.quote(sys.executable) + " " + + shlex.quote(str(script)) + + ' --mock-ocr "$@"\n', encoding="utf-8") + wrapper.chmod(0o700) + return wrapper + + +class SmokeClient: + def __init__(self): + self.updates = [] + self.ready = asyncio.Event() + self.commands_ready = asyncio.Event() + + def on_connect(self, conn): + pass + + async def session_update(self, session_id, update, **kwargs): + obj = update.model_dump(mode="json", by_alias=True, exclude_none=True) + self.updates.append(obj) + if obj["sessionUpdate"] == "available_commands_update": + self.commands_ready.set() + if obj["sessionUpdate"] == "tool_call_update" and "READY" in obj.get("title", ""): + self.ready.set() + + +def assert_execution(updates, status, expected_lines): + calls = [u for u in updates if u["sessionUpdate"] == "tool_call"] + assert len(calls) == 1, calls + call = calls[0] + assert call["kind"] == "other" and not call.get("locations"), call + assert call["status"] == "in_progress" + changes = [u for u in updates if u["sessionUpdate"] == "tool_call_update" + and u["toolCallId"] == call["toolCallId"]] + assert changes and changes[-1]["status"] == status, changes + details = "".join(c["content"]["text"] for c in changes[-1].get("content", []) + if c.get("type") == "content" and c["content"].get("type") == "text") + details = details.replace("\r\n", "\n") + assert all(line in details for line in expected_lines), details + if len(expected_lines) > 1: + assert "\n".join(expected_lines) in details, details + assert "```text\n" in details, details + assert "Command:" in details and "Working directory:" in details, details + messages = "".join(u["content"].get("text", "") for u in updates + if u["sessionUpdate"] == "agent_message_chunk") + assert not any(line in messages for line in expected_lines), messages + assert "Command:" not in messages, messages + opening_details = "".join(c["content"]["text"] for c in call.get("content", []) + if c.get("type") == "content" and c["content"].get("type") == "text") + assert "Command:" in opening_details and "Working directory:" in opening_details, opening_details + assert changes[-1]["title"].startswith(("OCR review · ", "OCR scan · ")), changes[-1] + + + +async def smoke(): + binary = adapter_binary() + with tempfile.TemporaryDirectory(prefix="ocr-acp-smoke-") as temp: + root = Path(temp).resolve() + subprocess.run(["git", "init", "-q", str(root)], check=True) + nested = root / "nested" + nested.mkdir() + source = nested / "sample.go" + source.write_text("package sample\n", encoding="utf-8") + wrapper = write_mock_wrapper(root, Path(__file__).resolve()) + client = SmokeClient() + async with acp.spawn_agent_process(client, str(binary), "--ocr-binary", str(wrapper), + transport_kwargs={"shutdown_timeout": 12}) as (conn, process): + initialized = await conn.initialize(protocol_version=1) + assert initialized.protocol_version == 1 + session = await conn.new_session(cwd=str(nested), mcp_servers=[]) + await asyncio.wait_for(client.commands_ready.wait(), 5) + commands = [u for u in client.updates if u["sessionUpdate"] == "available_commands_update"] + assert {c["name"] for c in commands[0]["availableCommands"]} == {"review", "scan"} + print("PASS initialize, session/new, command discovery") + for command in ("/review", "/scan"): + client.updates.clear() + response = await conn.prompt(session_id=session.session_id, prompt=[acp.text_block(command)]) + assert response.stop_reason == "end_turn" + assert_execution(client.updates, "completed", ["[ocr] Reviewing selected files", + "[ocr] Reading file diff", "[ocr] Review finished"]) + serialized = json.dumps(client.updates) + assert all(s in serialized for s in ("Critical", "Smoke finding", "safe()", "1 file reviewed", "3 calls", "100 input", "20 output")) + assert "DO_NOT_EXPOSE" not in serialized + messages = "".join(u["content"].get("text", "") for u in client.updates + if u["sessionUpdate"] == "agent_message_chunk") + assert source.as_uri() + "#L1" in messages, messages + assert "```go\nsafe()\n```" in messages, messages + assert serialized.count("Smoke finding") == 1, serialized + print(f"PASS {command}: execution log line breaks and completion, located finding, severity, suggestion, summary, no thinking") + client.updates.clear() + response = await conn.prompt(session_id=session.session_id, + prompt=[acp.text_block("/review --effort low")]) + assert response.stop_reason == "end_turn" + assert_execution(client.updates, "completed", ["[ocr] Review finished"]) + finished = [u for u in client.updates if u["sessionUpdate"] == "tool_call_update"][-1] + details = "".join(c["content"]["text"] for c in finished.get("content", []) + if c.get("type") == "content" and c["content"].get("type") == "text") + assert "Review stages" not in details, details + assert finished["title"].startswith("OCR review · Completed · "), finished + messages = "".join(u["content"].get("text", "") for u in client.updates + if u["sessionUpdate"] == "agent_message_chunk") + assert "No findings" in messages or "0 findings" in messages, messages + assert len([u for u in client.updates if u["sessionUpdate"] == "tool_call"]) == 1 + print("PASS ordinary CLI logs: no findings completes without structured progress") + client.updates.clear() + response = await conn.prompt(session_id=session.session_id, + prompt=[acp.text_block("/review --effort high")]) + assert response.stop_reason == "end_turn" + assert_execution(client.updates, "completed", ["[ocr] Review finished"]) + finished = [u for u in client.updates if u["sessionUpdate"] == "tool_call_update"][-1] + assert finished["title"].startswith("OCR review · Partial · "), finished + messages = "".join(u["content"].get("text", "") for u in client.updates + if u["sessionUpdate"] == "agent_message_chunk") + assert "## OCR review · Partial" in messages, messages + assert "3/4 files completed · 1 failed · 1 finding" in messages, messages + assert "4 files reviewed" not in messages, messages + assert "maximum tool-request rounds" in messages, messages + print("PASS partial review: title, manifest coverage and budget failure agree") + client.updates.clear() + response = await conn.prompt(session_id=session.session_id, prompt=[ + acp.text_block("/scan"), acp.resource_link_block("sample", source.as_uri())]) + assert response.stop_reason == "end_turn" + assert any(u["sessionUpdate"] == "tool_call" for u in client.updates) + assert "Selected resource scanned" in json.dumps(client.updates) + print("PASS local ResourceLink scan") + client.updates.clear() + client.ready.clear() + pending = asyncio.create_task(conn.prompt(session_id=session.session_id, + prompt=[acp.text_block("/review --no-filter")])) + await asyncio.wait_for(client.ready.wait(), 10) + assert not pending.done(), "progress was delivered only after completion" + await conn.cancel(session_id=session.session_id) + await conn.cancel(session_id=session.session_id) + response = await asyncio.wait_for(pending, 10) + assert response.stop_reason == "cancelled" + assert_execution(client.updates, "failed", ["READY"]) + assert "cancelled" in json.dumps(client.updates) + print("PASS cancel and repeated cancel, execution tool finalized with the same ID") + process.stdin.close() + assert await asyncio.wait_for(process.wait(), 10) == 0 + print("PASS clean EOF shutdown") + + +if __name__ == "__main__": + if "--mock-ocr" in sys.argv: + mock_ocr() + else: + asyncio.run(asyncio.wait_for(smoke(), 45)) diff --git a/acp/testdata/python-smoke-requirements.in b/acp/testdata/python-smoke-requirements.in new file mode 100644 index 000000000..cd4dc147c --- /dev/null +++ b/acp/testdata/python-smoke-requirements.in @@ -0,0 +1,2 @@ +# Regenerate python-smoke-requirements.txt with the command in its header. +agent-client-protocol==0.12.1 diff --git a/acp/testdata/python-smoke-requirements.txt b/acp/testdata/python-smoke-requirements.txt new file mode 100644 index 000000000..e02131017 --- /dev/null +++ b/acp/testdata/python-smoke-requirements.txt @@ -0,0 +1,126 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile acp/testdata/python-smoke-requirements.in --python-version 3.12.13 --universal --generate-hashes --output-file acp/testdata/python-smoke-requirements.txt +agent-client-protocol==0.12.1 \ + --hash=sha256:9963ccb590ed96d7dc542c9c023e90676fc76b1c6562e21a9ee112f4fbcb0095 \ + --hash=sha256:aaf3cc301ed87d9e2c4ccb91adfe1cd58784355f6ce7e513f50e7ca2391862ef + # via -r acp/testdata/python-smoke-requirements.in +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via pydantic +pydantic==2.11.7 \ + --hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \ + --hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b + # via agent-client-protocol +pydantic-core==2.33.2 \ + --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ + --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ + --hash=sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02 \ + --hash=sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56 \ + --hash=sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4 \ + --hash=sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22 \ + --hash=sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef \ + --hash=sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec \ + --hash=sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d \ + --hash=sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b \ + --hash=sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a \ + --hash=sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f \ + --hash=sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052 \ + --hash=sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab \ + --hash=sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916 \ + --hash=sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c \ + --hash=sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf \ + --hash=sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27 \ + --hash=sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a \ + --hash=sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8 \ + --hash=sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7 \ + --hash=sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612 \ + --hash=sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1 \ + --hash=sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039 \ + --hash=sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca \ + --hash=sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7 \ + --hash=sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a \ + --hash=sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6 \ + --hash=sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782 \ + --hash=sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b \ + --hash=sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7 \ + --hash=sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025 \ + --hash=sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849 \ + --hash=sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7 \ + --hash=sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b \ + --hash=sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa \ + --hash=sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e \ + --hash=sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea \ + --hash=sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac \ + --hash=sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51 \ + --hash=sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e \ + --hash=sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162 \ + --hash=sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65 \ + --hash=sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2 \ + --hash=sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954 \ + --hash=sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b \ + --hash=sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de \ + --hash=sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc \ + --hash=sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64 \ + --hash=sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb \ + --hash=sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9 \ + --hash=sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101 \ + --hash=sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d \ + --hash=sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef \ + --hash=sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3 \ + --hash=sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1 \ + --hash=sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5 \ + --hash=sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88 \ + --hash=sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d \ + --hash=sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290 \ + --hash=sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e \ + --hash=sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d \ + --hash=sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808 \ + --hash=sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc \ + --hash=sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d \ + --hash=sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc \ + --hash=sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e \ + --hash=sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640 \ + --hash=sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30 \ + --hash=sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e \ + --hash=sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9 \ + --hash=sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a \ + --hash=sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9 \ + --hash=sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f \ + --hash=sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb \ + --hash=sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5 \ + --hash=sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab \ + --hash=sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d \ + --hash=sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572 \ + --hash=sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593 \ + --hash=sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29 \ + --hash=sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535 \ + --hash=sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1 \ + --hash=sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f \ + --hash=sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8 \ + --hash=sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf \ + --hash=sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246 \ + --hash=sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9 \ + --hash=sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011 \ + --hash=sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9 \ + --hash=sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a \ + --hash=sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3 \ + --hash=sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6 \ + --hash=sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8 \ + --hash=sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a \ + --hash=sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2 \ + --hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \ + --hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \ + --hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d + # via pydantic +typing-extensions==4.14.1 \ + --hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ + --hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76 + # via + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.1 \ + --hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \ + --hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28 + # via pydantic diff --git a/cmd/opencodereview/auth_cmd.go b/cmd/opencodereview/auth_cmd.go new file mode 100644 index 000000000..39571d402 --- /dev/null +++ b/cmd/opencodereview/auth_cmd.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import "github.com/spf13/cobra" + +var ( + authLoginDevice bool + authLoginNoBrowser bool +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage Sign in with ChatGPT authentication", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, +} + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Sign in with ChatGPT", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateAuthProvider(); err != nil { + return err + } + return runChatGPTLogin(cmd.Context(), cmd.OutOrStdout()) + }, +} + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show saved ChatGPT registrations and authentication status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateAuthProvider(); err != nil { + return err + } + return runChatGPTStatus(cmd.OutOrStdout()) + }, +} + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Revoke the ChatGPT session and clear local tokens", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateAuthProvider(); err != nil { + return err + } + return runChatGPTLogout(cmd.Context(), cmd.OutOrStdout()) + }, +} + +func init() { + authLoginCmd.Flags().BoolVar(&authLoginDevice, "device", false, "unsupported in the ChatGPT preview; use the local browser loopback flow") + authLoginCmd.Flags().BoolVar(&authLoginNoBrowser, "no-browser", false, "print a one-time local URL instead of opening a browser") + authLoginCmd.MarkFlagsMutuallyExclusive("device", "no-browser") + authCmd.AddCommand(authLoginCmd) + authCmd.AddCommand(authStatusCmd) + authCmd.AddCommand(authLogoutCmd) +} diff --git a/cmd/opencodereview/auth_cmd_test.go b/cmd/opencodereview/auth_cmd_test.go new file mode 100644 index 000000000..a871f6735 --- /dev/null +++ b/cmd/opencodereview/auth_cmd_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/alibaba/open-code-review/internal/chatgptauth" + "github.com/spf13/cobra" +) + +func TestAuthDefaultsToChatGPT(t *testing.T) { + flag := authCmd.PersistentFlags().Lookup("provider") + if flag == nil || flag.DefValue != "chatgpt" { + t.Fatalf("provider flag = %v, want default chatgpt", flag) + } + commandChatGPTAccount(t) + oldProvider, oldLogin, oldLogout := authProvider, loginChatGPT, logoutChatGPT + t.Cleanup(func() { authProvider, loginChatGPT, logoutChatGPT = oldProvider, oldLogin, oldLogout }) + authProvider = flag.DefValue + loginCalled, logoutCalled := false, false + loginChatGPT = func(context.Context, chatgptauth.Store, string, bool, bool, bool, io.Writer) (*chatgptauth.Auth, error) { + loginCalled = true + return &chatgptauth.Auth{Scopes: strings.Fields(chatgptauth.Scope)}, nil + } + logoutChatGPT = func(context.Context, chatgptauth.Store, string) error { + logoutCalled = true + return nil + } + var out bytes.Buffer + for _, cmd := range []*cobra.Command{authLoginCmd, authStatusCmd, authLogoutCmd} { + oldOut, oldContext := cmd.OutOrStdout(), cmd.Context() + cmd.SetOut(&out) + cmd.SetContext(context.Background()) + err := cmd.RunE(cmd, nil) + cmd.SetOut(oldOut) + cmd.SetContext(oldContext) + if err != nil { + t.Fatal(err) + } + } + if !loginCalled || !logoutCalled || !strings.Contains(out.String(), "Using ChatGPT plan") || !strings.Contains(out.String(), "local tokens cleared") { + t.Fatalf("login=%t logout=%t output=%q", loginCalled, logoutCalled, out.String()) + } +} + +func TestAuthRejectsUnsupportedProviderWithoutAccountFlags(t *testing.T) { + old := authProvider + t.Cleanup(func() { authProvider = old }) + authProvider = "codex" + if err := validateAuthProvider(); err == nil || !strings.Contains(err.Error(), "expected chatgpt") { + t.Fatalf("validateAuthProvider = %v", err) + } +} + +func TestAuthLoginFlagsAreMutuallyExclusive(t *testing.T) { + rootCmd.SetArgs([]string{"auth", "login", "--device", "--no-browser"}) + t.Cleanup(func() { + rootCmd.SetArgs(nil) + for _, name := range []string{"device", "no-browser"} { + flag := authLoginCmd.Flags().Lookup(name) + _ = flag.Value.Set("false") + flag.Changed = false + } + }) + if err := rootCmd.Execute(); err == nil || !strings.Contains(err.Error(), "group [device no-browser]") { + t.Errorf("Execute error = %v", err) + } +} diff --git a/cmd/opencodereview/bedrock_config_test.go b/cmd/opencodereview/bedrock_config_test.go index 8d2bb7d08..9fd1cc901 100644 --- a/cmd/opencodereview/bedrock_config_test.go +++ b/cmd/opencodereview/bedrock_config_test.go @@ -281,62 +281,135 @@ func TestCheckAPIKeyRequirement(t *testing.T) { if !ok { t.Fatal("anthropic preset not registered") } + chatgpt, ok := llm.LookupProvider("chatgpt") + if !ok { + t.Fatal("chatgpt preset not registered") + } - if err := checkAPIKeyRequirement("bedrock", "", "", bedrock, true); err != nil { + if err := checkAPIKeyRequirement("bedrock", "", "", bedrock, true, ProviderEntry{}); err != nil { t.Errorf("ambient provider with no api_key = %v, want nil", err) } + if err := checkAPIKeyRequirement("chatgpt", "", "", chatgpt, true, ProviderEntry{}); err != nil { + t.Errorf("external-auth provider with no api_key = %v, want nil", err) + } t.Setenv(anthropic.EnvVar, "") - if err := checkAPIKeyRequirement("anthropic", "", "", anthropic, true); err == nil { + if err := checkAPIKeyRequirement("anthropic", "", "", anthropic, true, ProviderEntry{}); err == nil { t.Error("key-based provider with no api_key and no env var = nil, want an error") } - if err := checkAPIKeyRequirement("anthropic", "", "op read op://vault/key", anthropic, true); err != nil { + if err := checkAPIKeyRequirement("anthropic", "", "op read op://vault/key", anthropic, true, ProviderEntry{}); err != nil { t.Errorf("key-based provider with api_key_cmd = %v, want nil", err) } } -// TestProviderTUIAmbientProviderSkipsAPIKeyStep pins the wizard flow: the model -// step is the last one for a provider with no key to collect. An API-key prompt -// that must be left blank reads as a step the user failed to complete. -func TestProviderTUIAmbientProviderSkipsAPIKeyStep(t *testing.T) { - m := newProviderTUI(&Config{}, "") - idx := -1 - for i, p := range m.providers { - if p.Name == "bedrock" { - idx = i - break - } - } - if idx < 0 { - t.Fatal("bedrock not offered in the official provider list") - } - m.officialIdx = idx +// TestProviderTUINonConfigCredentialProviderSkipsAPIKeyStep pins the wizard +// flow: the model step is the last one for a provider with no key to collect. +func TestProviderTUINonConfigCredentialProviderSkipsAPIKeyStep(t *testing.T) { + for _, provider := range []string{"bedrock", "chatgpt"} { + t.Run(provider, func(t *testing.T) { + m := newProviderTUI(&Config{Providers: map[string]ProviderEntry{ + "chatgpt": {Models: []string{"fixture-model"}}, + }}, "") + idx := -1 + for i, p := range m.providers { + if p.Name == provider { + idx = i + break + } + } + if idx < 0 { + t.Fatalf("%s not offered in the official provider list", provider) + } + m.officialIdx = idx - result, _ := m.Update(enterKey()) - atModel := result.(providerTUIModel) - if atModel.step != stepModel { - t.Fatalf("after Enter on provider, step = %d, want %d (stepModel)", atModel.step, stepModel) - } + result, _ := m.Update(enterKey()) + atModel := result.(providerTUIModel) + if atModel.step != stepModel { + t.Fatalf("after Enter on provider, step = %d, want %d (stepModel)", atModel.step, stepModel) + } - result, cmd := atModel.Update(enterKey()) - done := result.(providerTUIModel) - if done.step == stepAPIKey { - t.Error("ambient provider advanced to stepAPIKey; want the model step to be final") - } - if !done.confirmed { - t.Error("confirmed = false; want the selection confirmed from the model step") - } - if cmd == nil { - t.Error("no command returned; want tea.Quit") - } - res := done.result() - if res.provider != "bedrock" { - t.Errorf("result provider = %q, want bedrock", res.provider) - } - if res.apiKey != "" { - t.Errorf("result apiKey = %q, want empty for an ambient provider", res.apiKey) + result, cmd := atModel.Update(enterKey()) + done := result.(providerTUIModel) + if done.step == stepAPIKey { + t.Error("provider advanced to stepAPIKey; want the model step to be final") + } + if !done.confirmed { + t.Error("confirmed = false; want the selection confirmed from the model step") + } + if cmd == nil { + t.Error("no command returned; want tea.Quit") + } + res := done.result() + if res.provider != provider { + t.Errorf("result provider = %q, want %q", res.provider, provider) + } + if res.apiKey != "" { + t.Errorf("result apiKey = %q, want empty", res.apiKey) + } + if got := res.resolvedModel(); got == "" { + t.Error("resolvedModel is empty; want the model selected on the model step") + } + }) } - if got := res.resolvedModel(); got == "" { - t.Error("resolvedModel is empty; want the model selected on the model step") +} + +func TestProviderTUINonConfigCredentialProviderDropsPreviousKey(t *testing.T) { + original := runLLMTestPath + t.Cleanup(func() { runLLMTestPath = original }) + runLLMTestPath = func(string) error { return nil } + for _, provider := range []string{"bedrock", "chatgpt"} { + for _, typed := range []bool{false, true} { + name := provider + "/saved" + if typed { + name = provider + "/typed" + } + t.Run(name, func(t *testing.T) { + cfg := &Config{Provider: "openai", Providers: map[string]ProviderEntry{ + "openai": {APIKey: "openai-fixture-key"}, + "chatgpt": {Models: []string{"fixture-model"}}, + }} + path := filepath.Join(t.TempDir(), "config.json") + m := newProviderTUI(cfg, path) + if m.apiKeyOriginal != "openai-fixture-key" || !m.apiKeyMasked { + t.Fatal("previous provider did not seed the saved key") + } + if typed { + result, _ := m.Update(enterKey()) + m = result.(providerTUIModel) + result, _ = m.Update(enterKey()) + m = result.(providerTUIModel) + if m.step != stepAPIKey { + t.Fatal("key-based provider skipped the key step") + } + m.beginAPIKeyReplace() + m.apiKeyInput.SetValue("typed-fixture-key") + result, _ = m.Update(escKey()) + m = result.(providerTUIModel) + result, _ = m.Update(escKey()) + m = result.(providerTUIModel) + } + for i, p := range m.providers { + if p.Name == provider { + m.officialIdx = i + } + } + result, _ := m.Update(enterKey()) + m = result.(providerTUIModel) + result, cmd := m.Update(enterKey()) + m = result.(providerTUIModel) + if !m.confirmed || cmd == nil || m.step == stepAPIKey { + t.Fatal("non-config credential provider did not finish at model selection") + } + if m.apiKeyOriginal != "" || m.apiKeyMasked || m.apiKeyInput.Value() != "" || m.result().apiKey != "" { + t.Fatal("previous provider's key survived selection") + } + if err := applyOfficialProviderConfig(path, cfg, m.result()); err != nil { + t.Fatal(err) + } + if cfg.Providers[provider].APIKey != "" || cfg.Providers["openai"].APIKey != "openai-fixture-key" { + t.Fatal("cross-provider key leaked or the original provider key changed") + } + }) + } } } diff --git a/cmd/opencodereview/chatgpt_auth.go b/cmd/opencodereview/chatgpt_auth.go new file mode 100644 index 000000000..bb975457e --- /dev/null +++ b/cmd/opencodereview/chatgpt_auth.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/alibaba/open-code-review/internal/chatgptauth" + "github.com/alibaba/open-code-review/internal/llm" + "github.com/spf13/cobra" +) + +var authProvider = "chatgpt" +var authAccount string +var authNewAccount bool +var authEnablePlan bool +var loginChatGPT = func(ctx context.Context, store chatgptauth.Store, account string, newAccount, noBrowser, consent bool, out io.Writer) (*chatgptauth.Auth, error) { + return chatgptauth.NewClient().Login(ctx, store, account, newAccount, noBrowser, consent, out) +} +var logoutChatGPT = func(ctx context.Context, store chatgptauth.Store, account string) error { + return chatgptauth.NewClient().Logout(ctx, store, account) +} + +func validateAuthProvider() error { + if authProvider != "chatgpt" { + return fmt.Errorf("unknown auth provider %q; expected chatgpt", authProvider) + } + return nil +} +func runChatGPTLogin(ctx context.Context, out io.Writer) error { + if authLoginDevice { + return errors.New("chatgpt SIWC preview does not support --device; use the local browser loopback flow") + } + s, err := chatgptauth.DefaultStore() + if err != nil { + return err + } + auth, err := loginChatGPT(ctx, s, authAccount, authNewAccount, authLoginNoBrowser, authEnablePlan, out) + if err != nil { + return err + } + if !auth.PlanEnabled() { + _, err = fmt.Fprintln(out, "Signed in, but ChatGPT plan usage is disabled. Run ocr auth login --provider chatgpt --enable-plan to grant plan permission, or configure an API-key provider.") + return err + } + _, err = fmt.Fprintln(out, "Signed in with ChatGPT. Using ChatGPT plan. Manage usage:", chatgptauth.UsageURL) + return err +} +func runChatGPTStatus(out io.Writer) error { + s, err := chatgptauth.DefaultStore() + if err != nil { + return err + } + d, err := s.Snapshot() + if err != nil { + return err + } + if len(d.Accounts) == 0 { + _, err = fmt.Fprintln(out, "Not signed in, run ocr auth login --provider chatgpt.") + return err + } + for _, a := range d.Accounts { + selected := "" + if a.ClientID == d.Active { + selected = " (active)" + } + status := "signed out" + if a.AccessToken != "" { + status = "signed in; ChatGPT plan usage disabled; run ocr auth login --provider chatgpt --enable-plan --account " + a.ClientID + if a.PlanEnabled() { + status = "Using ChatGPT plan" + } + } + if _, err = fmt.Fprintf(out, "Account: %s%s\nEmail: %s\nStatus: %s\n", a.ClientID, selected, a.Email, status); err != nil { + return err + } + if a.AccessToken != "" { + if _, err = fmt.Fprintf(out, "Expires: %s\n", a.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")); err != nil { + return err + } + } + } + _, err = fmt.Fprintln(out, "Manage usage:", chatgptauth.UsageURL) + return err +} +func runChatGPTLogout(ctx context.Context, out io.Writer) error { + s, err := chatgptauth.DefaultStore() + if err != nil { + return err + } + err = logoutChatGPT(ctx, s, authAccount) + if errors.Is(err, chatgptauth.ErrNotFound) { + _, err = fmt.Fprintln(out, "No local ChatGPT credentials were found.") + return err + } + if err != nil { + return err + } + _, err = fmt.Fprintln(out, "ChatGPT session revoked and local tokens cleared. Account registration and host ID retained.") + return err +} +func init() { + authCmd.PersistentFlags().StringVar(&authProvider, "provider", "chatgpt", "authentication provider: chatgpt") + authCmd.PersistentFlags().StringVar(&authAccount, "account", "", "ChatGPT registration client ID (default: active account)") + authLoginCmd.Flags().BoolVar(&authEnablePlan, "enable-plan", false, "request consent for plan usage on a retained ChatGPT registration") + authLoginCmd.Flags().BoolVar(&authNewAccount, "new-account", false, "register a separate ChatGPT account or workspace") + authCmd.AddCommand(&cobra.Command{Use: "select ", Short: "Select a saved ChatGPT registration", Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if err := validateAuthProvider(); err != nil { + return err + } + s, err := chatgptauth.DefaultStore() + if err != nil { + return err + } + return s.Select(cmd.Context(), args[0]) + }}) + llmCmd.AddCommand(&cobra.Command{Use: "models", Short: "List current account-specific ChatGPT models", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + models, err := llm.ChatGPTModels(cmd.Context()) + if err != nil { + return err + } + for _, m := range models { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", m.Slug, m.DisplayName); err != nil { + return err + } + } + return nil + }}) +} diff --git a/cmd/opencodereview/chatgpt_auth_lifecycle_test.go b/cmd/opencodereview/chatgpt_auth_lifecycle_test.go new file mode 100644 index 000000000..d3e783dc3 --- /dev/null +++ b/cmd/opencodereview/chatgpt_auth_lifecycle_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alibaba/open-code-review/internal/chatgptauth" + "github.com/spf13/cobra" +) + +func commandChatGPTAccount(t *testing.T) chatgptauth.Store { + t.Helper() + setTestHome(t, t.TempDir()) + s, err := chatgptauth.DefaultStore() + if err != nil { + t.Fatal(err) + } + if _, err := s.Host(context.Background()); err != nil { + t.Fatal(err) + } + d := chatgptauth.Database{Active: "oaiapp_one", Accounts: []chatgptauth.Auth{{ClientID: "oaiapp_one", Issuer: chatgptauth.Issuer, Subject: "subject", Email: "example@example.invalid", AccessToken: "fixture-secret", RefreshToken: "refresh-fixture", IDToken: "id-fixture", Scopes: strings.Fields(chatgptauth.Scope), ExpiresAt: time.Now().Add(time.Hour)}, {ClientID: "oaiapp_two", Issuer: chatgptauth.Issuer, Subject: "subject-two", AccessToken: "fixture-two"}, {ClientID: "oaiapp_three", Issuer: chatgptauth.Issuer, Subject: "subject-three"}}} + b, _ := json.Marshal(d) + if err := os.WriteFile(filepath.Join(s.Dir, "accounts.json"), b, 0600); err != nil { + t.Fatal(err) + } + return s +} +func TestChatGPTCommandLifecycle(t *testing.T) { + s := commandChatGPTAccount(t) + for _, cmd := range []*cobra.Command{authLoginCmd, authStatusCmd, authLogoutCmd} { + oldContext := cmd.Context() + cmd.SetContext(context.Background()) + t.Cleanup(func() { cmd.SetContext(oldContext) }) + } + oldProvider, oldAccount, oldNew, oldDevice, oldNoBrowser := authProvider, authAccount, authNewAccount, authLoginDevice, authLoginNoBrowser + oldLogin, oldLogout := loginChatGPT, logoutChatGPT + defer func() { + authProvider, authAccount, authNewAccount, authLoginDevice, authLoginNoBrowser = oldProvider, oldAccount, oldNew, oldDevice, oldNoBrowser + loginChatGPT, logoutChatGPT = oldLogin, oldLogout + }() + authProvider = "chatgpt" + authAccount = "" + authNewAccount = false + authLoginDevice = false + authLoginNoBrowser = false + if err := validateAuthProvider(); err != nil { + t.Fatal(err) + } + authProvider = "codex" + authAccount = "oaiapp_one" + if err := validateAuthProvider(); err == nil { + t.Fatal("codex selected ChatGPT account") + } + authAccount = "" + authNewAccount = true + if err := validateAuthProvider(); err == nil { + t.Fatal("codex registered ChatGPT account") + } + authProvider = "chatgpt" + authNewAccount = false + var out bytes.Buffer + if err := authStatusCmd.RunE(authStatusCmd, nil); err != nil { + t.Fatal(err) + } + if err := runChatGPTStatus(&out); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "fixture-secret") || !strings.Contains(out.String(), "(active)") || !strings.Contains(out.String(), "signed out") || !strings.Contains(out.String(), "disabled") { + t.Fatal(out.String()) + } + loginChatGPT = func(context.Context, chatgptauth.Store, string, bool, bool, bool, io.Writer) (*chatgptauth.Auth, error) { + return &chatgptauth.Auth{Scopes: strings.Fields(chatgptauth.Scope)}, nil + } + authLoginCmd.SetOut(&out) + defer authLoginCmd.SetOut(nil) + if err := authLoginCmd.RunE(authLoginCmd, nil); err != nil { + t.Fatal(err) + } + loginChatGPT = func(context.Context, chatgptauth.Store, string, bool, bool, bool, io.Writer) (*chatgptauth.Auth, error) { + return &chatgptauth.Auth{}, nil + } + if err := runChatGPTLogin(context.Background(), &out); err != nil { + t.Fatal(err) + } + loginChatGPT = func(context.Context, chatgptauth.Store, string, bool, bool, bool, io.Writer) (*chatgptauth.Auth, error) { + return nil, errors.New("fixture failure") + } + if err := runChatGPTLogin(context.Background(), &out); err == nil { + t.Fatal("login error swallowed") + } + logoutChatGPT = func(ctx context.Context, store chatgptauth.Store, account string) error { + return store.Clear(ctx, account) + } + authLogoutCmd.SetOut(&out) + defer authLogoutCmd.SetOut(nil) + if err := authLogoutCmd.RunE(authLogoutCmd, nil); err != nil { + t.Fatal(err) + } + a, err := s.Selected("") + if err != nil || a.AccessToken != "" { + t.Fatal(a, err) + } + logoutChatGPT = func(context.Context, chatgptauth.Store, string) error { return errors.New("revocation not confirmed") } + if err := runChatGPTLogout(context.Background(), &out); err == nil { + t.Fatal("logout error swallowed") + } + var selectCmdFound bool + for _, cmd := range authCmd.Commands() { + if cmd.Name() == "select" { + selectCmdFound = true + cmd.SetContext(context.Background()) + if err := cmd.RunE(cmd, []string{"oaiapp_two"}); err != nil { + t.Fatal(err) + } + authProvider = "codex" + if err := cmd.RunE(cmd, []string{"oaiapp_one"}); err == nil { + t.Fatal("codex select") + } + authProvider = "invalid" + if err := cmd.RunE(cmd, []string{"oaiapp_one"}); err == nil { + t.Fatal("invalid select") + } + } + } + if !selectCmdFound { + t.Fatal("missing select") + } + authProvider = "invalid" + if err := authLoginCmd.RunE(authLoginCmd, nil); err == nil { + t.Fatal("invalid provider login") + } + if err := authStatusCmd.RunE(authStatusCmd, nil); err == nil { + t.Fatal("invalid provider status") + } + if err := authLogoutCmd.RunE(authLogoutCmd, nil); err == nil { + t.Fatal("invalid provider logout") + } +} +func TestChatGPTStatusExpiryAfterSignOut(t *testing.T) { + s := commandChatGPTAccount(t) + d, err := s.Snapshot() + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := runChatGPTStatus(&out); err != nil { + t.Fatal(err) + } + expiry := "Expires: " + d.Accounts[0].ExpiresAt.Format(time.RFC3339) + if !strings.Contains(out.String(), expiry) { + t.Fatal("signed-in expiry omitted", out.String()) + } + for _, a := range d.Accounts { + if err := s.Clear(context.Background(), a.ClientID); err != nil { + t.Fatal(err) + } + } + out.Reset() + if err := runChatGPTStatus(&out); err != nil { + t.Fatal(err) + } + if strings.Count(out.String(), "Status: signed out") != len(d.Accounts) || strings.Contains(out.String(), "Expires:") || strings.Contains(out.String(), "0001-") { + t.Fatal("signed-out expiry displayed", out.String()) + } +} + +func TestChatGPTCommandStoreErrors(t *testing.T) { + setTestHome(t, "") + var out bytes.Buffer + if err := runChatGPTStatus(&out); err == nil { + t.Fatal("missing home directory") + } + if err := runChatGPTLogout(context.Background(), &out); err == nil { + t.Fatal("missing home directory") + } + old := authLoginDevice + authLoginDevice = false + defer func() { authLoginDevice = old }() + if err := runChatGPTLogin(context.Background(), &out); err == nil { + t.Fatal("missing home directory") + } +} diff --git a/cmd/opencodereview/chatgpt_auth_test.go b/cmd/opencodereview/chatgpt_auth_test.go new file mode 100644 index 000000000..5b9346e1c --- /dev/null +++ b/cmd/opencodereview/chatgpt_auth_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/alibaba/open-code-review/internal/chatgptauth" +) + +func TestChatGPTAuthCLIValidation(t *testing.T) { + old := authProvider + oldDevice := authLoginDevice + defer func() { authProvider = old; authLoginDevice = oldDevice }() + authProvider = "bad" + if err := validateAuthProvider(); err == nil { + t.Fatal("unknown provider") + } + authProvider = "chatgpt" + authLoginDevice = true + if err := runChatGPTLogin(context.Background(), &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "device") { + t.Fatal(err) + } +} +func TestChatGPTAuthStatusEmpty(t *testing.T) { + setTestHome(t, t.TempDir()) + var out bytes.Buffer + if err := runChatGPTStatus(&out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Not signed in") { + t.Fatal(out.String()) + } +} +func TestChatGPTAuthLogoutEmpty(t *testing.T) { + setTestHome(t, t.TempDir()) + s, err := chatgptauth.DefaultStore() + if err != nil { + t.Fatal(err) + } + if _, err := s.Host(context.Background()); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := runChatGPTLogout(context.Background(), &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "No local") { + t.Fatal(out.String()) + } +} diff --git a/cmd/opencodereview/chatgpt_consent_test.go b/cmd/opencodereview/chatgpt_consent_test.go new file mode 100644 index 000000000..9e453620d --- /dev/null +++ b/cmd/opencodereview/chatgpt_consent_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/alibaba/open-code-review/internal/chatgptauth" +) + +func TestChatGPTEnablePlanCLI(t *testing.T) { + commandChatGPTAccount(t) + oldProvider, oldAccount, oldNew, oldEnable, oldDevice, oldBrowser := authProvider, authAccount, authNewAccount, authEnablePlan, authLoginDevice, authLoginNoBrowser + oldLogin := loginChatGPT + defer func() { + authProvider, authAccount, authNewAccount, authEnablePlan, authLoginDevice, authLoginNoBrowser = oldProvider, oldAccount, oldNew, oldEnable, oldDevice, oldBrowser + loginChatGPT = oldLogin + }() + flag := authLoginCmd.Flags().Lookup("enable-plan") + if flag == nil { + t.Fatal("missing consent flag") + } + if err := flag.Value.Set("true"); err != nil { + t.Fatal(err) + } + authProvider = "codex" + authAccount = "" + authNewAccount = false + if err := validateAuthProvider(); err == nil { + t.Fatal("codex accepted consent") + } + authProvider = "chatgpt" + authAccount = "oaiapp_one" + authLoginDevice = false + authLoginNoBrowser = true + called := false + loginChatGPT = func(_ context.Context, _ chatgptauth.Store, account string, newAccount, noBrowser, consent bool, _ io.Writer) (*chatgptauth.Auth, error) { + called = true + if account != "oaiapp_one" || newAccount || !noBrowser || !consent { + t.Fatal("consent flags not passed") + } + return &chatgptauth.Auth{}, nil + } + var out bytes.Buffer + if err := runChatGPTLogin(context.Background(), &out); err != nil || !called { + t.Fatal(err) + } + if !strings.Contains(out.String(), "ocr auth login --provider chatgpt --enable-plan") { + t.Fatal("disabled plan advice", out.String()) + } + out.Reset() + if err := runChatGPTStatus(&out); err != nil || !strings.Contains(out.String(), "--enable-plan --account oaiapp_two") { + t.Fatal("status advice", out.String(), err) + } +} diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index 78ed1140d..8065489f5 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -641,6 +641,11 @@ func setConfigValue(cfg *Config, key, value string) error { switch key { case "provider": + if value != "" { + if err := validateProviderName(value); err != nil { + return err + } + } if cfg.Provider != value { cfg.Model = "" } @@ -995,33 +1000,35 @@ func ensureModelInList(models []string, model string) []string { } func setProviderValue(cfg *Config, key, value string) error { - parts := strings.SplitN(key, ".", 3) - if len(parts) != 3 || parts[1] == "" || parts[2] == "" { + dot := strings.LastIndex(key, ".") + if dot <= len("providers.") || dot == len(key)-1 { return fmt.Errorf("invalid provider key %q: expected providers..", key) } - if _, isPreset := llm.LookupProvider(parts[1]); !isPreset { - return setCustomProviderField(cfg, parts[1], parts[2], key, value) + name, field := key[len("providers."):dot], key[dot+1:] + if _, isPreset := llm.LookupProvider(name); !isPreset { + return setCustomProviderField(cfg, name, field, key, value) + } + entry := cfg.Providers[name] + if err := applyProviderField(name, &entry, field, key, value); err != nil { + return err } if cfg.Providers == nil { cfg.Providers = make(map[string]ProviderEntry) } - entry := cfg.Providers[parts[1]] - if err := applyProviderField(parts[1], &entry, parts[2], key, value); err != nil { - return err - } - cfg.Providers[parts[1]] = entry + cfg.Providers[name] = entry return nil } func setCustomProviderValue(cfg *Config, key, value string) error { - parts := strings.SplitN(key, ".", 3) - if len(parts) != 3 || parts[1] == "" || parts[2] == "" { + dot := strings.LastIndex(key, ".") + if dot <= len("custom_providers.") || dot == len(key)-1 { return fmt.Errorf("invalid custom provider key %q: expected custom_providers..", key) } - if preset, isPreset := llm.LookupProvider(parts[1]); isPreset { - return fmt.Errorf("custom provider name %q conflicts with a preset provider; use providers.%s.%s to configure the preset or choose a different custom provider name", parts[1], preset.Name, parts[2]) + name, field := key[len("custom_providers."):dot], key[dot+1:] + if preset, isPreset := llm.LookupProvider(name); isPreset { + return fmt.Errorf("custom provider name %q conflicts with a preset provider; use providers.%s.%s to configure the preset or choose a different custom provider name", name, preset.Name, field) } - return setCustomProviderField(cfg, parts[1], parts[2], key, value) + return setCustomProviderField(cfg, name, field, key, value) } func isAuxiliaryProviderField(field string) bool { @@ -1033,18 +1040,33 @@ func isAuxiliaryProviderField(field string) bool { } } +func validateProviderName(name string) error { + for _, segment := range strings.Split(name, ".") { + if strings.TrimSpace(segment) == "" { + return fmt.Errorf("invalid provider name %q: dot-separated segments must not be empty or whitespace", name) + } + } + return nil +} + func setCustomProviderField(cfg *Config, name, field, key, value string) error { + if err := validateProviderName(name); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + if _, exists := cfg.CustomProviders[name]; strings.Contains(name, ".") && !exists { + return fmt.Errorf("provider %q is not configured for key %q; create or select it first:\n ocr config set provider %s", name, key, name) + } if _, exists := cfg.CustomProviders[name]; isAuxiliaryProviderField(field) && !exists { providerKey := strings.TrimSuffix(key, "."+field) return fmt.Errorf("provider %q is not configured; set a core field first (protocol is required for every custom provider):\n ocr config set %s.protocol ", name, providerKey) } - if cfg.CustomProviders == nil { - cfg.CustomProviders = make(map[string]ProviderEntry) - } entry := cfg.CustomProviders[name] if err := applyProviderField(name, &entry, field, key, value); err != nil { return err } + if cfg.CustomProviders == nil { + cfg.CustomProviders = make(map[string]ProviderEntry) + } cfg.CustomProviders[name] = entry return nil } diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go index 4ebb675d0..618d5acb5 100644 --- a/cmd/opencodereview/config_cmd_test.go +++ b/cmd/opencodereview/config_cmd_test.go @@ -220,6 +220,118 @@ func TestSetConfigValueProviderEntryNonPresetWritesCustomProvider(t *testing.T) } } +func TestSetConfigValueDottedProviderName(t *testing.T) { + fields := []struct { + field string + value string + }{ + {"url", "https://example.invalid/v1"}, + {"protocol", llm.ProtocolAnthropicBedrock}, + {"model", "some-model"}, + {"api_key", "test-key"}, + {"api_key_cmd", "secret-tool lookup service x"}, + {"models", `["some-model","other-model"]`}, + {"auth_header", "authorization"}, + {"timeout_sec", "120"}, + {"extra_body", `{"temperature":0.2}`}, + {"extra_headers", "X-Gateway=test"}, + {"retry_codes", "400,403"}, + {"aws_region", "us-west-2"}, + {"aws_profile", "test-profile"}, + } + want := ProviderEntry{ + URL: "https://example.invalid/v1", + Protocol: llm.ProtocolAnthropicBedrock, + Model: "some-model", + APIKey: "test-key", + APIKeyCmd: "secret-tool lookup service x", + Models: []string{"some-model", "other-model"}, + AuthHeader: "authorization", + TimeoutSec: 120, + ExtraBody: map[string]any{"temperature": 0.2}, + ExtraHeaders: map[string]string{"X-Gateway": "test"}, + RetryCodes: []int{400, 403}, + AWSRegion: "us-west-2", + AWSProfile: "test-profile", + } + for _, prefix := range []string{"providers", "custom_providers"} { + for _, name := range []string{"mimo-v2.6-flash", "openai.gateway.v2", "foo.api_key"} { + t.Run(prefix+"."+name, func(t *testing.T) { + cfg := &Config{} + if err := setConfigValue(cfg, "provider", name); err != nil { + t.Fatal(err) + } + for _, tt := range fields { + key := prefix + "." + name + "." + tt.field + if err := setConfigValue(cfg, key, tt.value); err != nil { + t.Fatalf("setConfigValue(%q): %v", key, err) + } + } + if !reflect.DeepEqual(cfg.CustomProviders, map[string]ProviderEntry{name: want}) { + t.Errorf("CustomProviders = %#v, want only %q with %#v", cfg.CustomProviders, name, want) + } + if len(cfg.Providers) != 0 || cfg.Provider != name { + t.Errorf("Providers = %#v, Provider = %q", cfg.Providers, cfg.Provider) + } + }) + } + } +} + +func TestSetConfigValueRejectsInvalidProviderNames(t *testing.T) { + for _, name := range []string{" ", ".a", "a.", "a..b", "a. .b", "anthropic."} { + for _, key := range []string{"provider", "providers." + name + ".url", "custom_providers." + name + ".url"} { + t.Run(key+"/"+name, func(t *testing.T) { + cfg := &Config{Provider: "openai", Model: "saved-model", CustomProviders: map[string]ProviderEntry{name: {}}} + before, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + value := "https://example.invalid/v1" + if key == "provider" { + value = name + } + if err := setConfigValue(cfg, key, value); err == nil || !strings.Contains(err.Error(), "invalid provider name") { + t.Fatalf("setConfigValue(%q, %q) = %v, want invalid provider name", key, value, err) + } + after, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("configuration changed after rejected write: %s", after) + } + }) + } + } +} + +func TestSetConfigValueDottedProviderRequiresExistingEntry(t *testing.T) { + for _, prefix := range []string{"providers", "custom_providers"} { + for _, name := range []string{"mimo-v2.6-flash", "foo.api_key", "openai.extra_body"} { + key := prefix + "." + name + ".model" + t.Run(key, func(t *testing.T) { + cfg := &Config{Provider: "openai", Model: "saved-model"} + want := *cfg + err := setConfigValue(cfg, key, "some-model") + if err == nil || !strings.Contains(err.Error(), key) || !strings.Contains(err.Error(), "ocr config set provider "+name) { + t.Fatalf("setConfigValue(%q) = %v, want explicit creation guidance", key, err) + } + if !reflect.DeepEqual(*cfg, want) { + t.Fatalf("configuration changed after rejected write: %#v", cfg) + } + cfg.CustomProviders = map[string]ProviderEntry{name: {}} + if err := setConfigValue(cfg, key, "some-model"); err != nil { + t.Fatal(err) + } + if cfg.Provider != "openai" || cfg.CustomProviders[name].Model != "some-model" { + t.Fatalf("unexpected configuration: %#v", cfg) + } + }) + } + } +} + func TestSetConfigValueProviderEntryModelsJSON(t *testing.T) { cfg := &Config{} @@ -364,10 +476,15 @@ func TestSetConfigValueProviderEntryInvalidKey(t *testing.T) { } func TestSetConfigValueProviderEntryInvalidPath(t *testing.T) { - cfg := &Config{} - - if err := setConfigValue(cfg, "providers.anthropic", "value"); err == nil { - t.Fatal("expected error for incomplete provider path") + for _, prefix := range []string{"providers", "custom_providers"} { + for _, suffix := range []string{"", "anthropic", ".url", "anthropic.", "mimo-v2.6-flash."} { + key := prefix + "." + suffix + t.Run(key, func(t *testing.T) { + if err := setConfigValue(&Config{}, key, "value"); err == nil || !strings.Contains(err.Error(), "invalid") { + t.Fatalf("setConfigValue(%q) = %v, want invalid path error", key, err) + } + }) + } } } diff --git a/cmd/opencodereview/llm_cmd.go b/cmd/opencodereview/llm_cmd.go index 7113b6fd9..cdb54e903 100644 --- a/cmd/opencodereview/llm_cmd.go +++ b/cmd/opencodereview/llm_cmd.go @@ -85,6 +85,9 @@ func runLLMTestWithConfigPath(configPath string) error { if task.Timeout > 0 { timeout = time.Duration(task.Timeout) * time.Second } + if ep.ChatGPTPlan { + timeout = llmTestTimeout(task.Timeout, ep.Timeout) + } // No retry collector: llm test is a connectivity probe, not a review, and the // retry report only describes ocr review. @@ -165,6 +168,17 @@ func runLLMTestWithConfigPath(configPath string) error { return nil } +func llmTestTimeout(taskSeconds int, endpointTimeout time.Duration) time.Duration { + timeout := 30 * time.Second + if taskTimeout := time.Duration(taskSeconds) * time.Second; taskTimeout > timeout { + timeout = taskTimeout + } + if endpointTimeout > timeout { + timeout = endpointTimeout + } + return timeout +} + // testToolDefs offers the configured self-test tool, or none when the task // defines no tool. func testToolDefs(spec *testconnection.ToolSpec) []llm.ToolDef { diff --git a/cmd/opencodereview/llm_cmd_test.go b/cmd/opencodereview/llm_cmd_test.go index 795fca633..f4cfbd178 100644 --- a/cmd/opencodereview/llm_cmd_test.go +++ b/cmd/opencodereview/llm_cmd_test.go @@ -7,11 +7,33 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/alibaba/open-code-review/internal/config/testconnection" "github.com/alibaba/open-code-review/internal/llm" ) +func TestLLMTestTimeout(t *testing.T) { + tests := []struct { + name string + taskSeconds int + endpointTimeout time.Duration + want time.Duration + }{ + {name: "keeps thirty second floor", taskSeconds: 5, endpointTimeout: 10 * time.Second, want: 30 * time.Second}, + {name: "uses task timeout", taskSeconds: 120, endpointTimeout: 0, want: 120 * time.Second}, + {name: "raises deadline to endpoint timeout", taskSeconds: 120, endpointTimeout: 5 * time.Minute, want: 5 * time.Minute}, + {name: "does not lower a longer task timeout", taskSeconds: 120, endpointTimeout: time.Minute, want: 120 * time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := llmTestTimeout(tt.taskSeconds, tt.endpointTimeout); got != tt.want { + t.Errorf("llmTestTimeout(%d, %s) = %s, want %s", tt.taskSeconds, tt.endpointTimeout, got, tt.want) + } + }) + } +} + // TestToolRoundTripNote guards the reporting contract for #1357: a provider that // never calls the test tool leaves the round trip unproven, and saying so is the // whole point — a plain success line is what let a broken provider look healthy. diff --git a/cmd/opencodereview/main_test.go b/cmd/opencodereview/main_test.go index b7a16cf18..c89da5de3 100644 --- a/cmd/opencodereview/main_test.go +++ b/cmd/opencodereview/main_test.go @@ -4,12 +4,79 @@ package main import ( + "bytes" "os" "os/exec" "strings" "testing" ) +func TestRunConfigSetProviderNames(t *testing.T) { + if os.Getenv("OCR_TEST_CONFIG_SET") == "1" { + rootCmd.SetArgs(os.Args[3:]) + os.Exit(run()) + } + setTestHome(t, t.TempDir()) + configPath, err := defaultConfigPath() + if err != nil { + t.Fatal(err) + } + if err := saveConfig(configPath, &Config{Provider: "openai", Model: "saved-model"}); err != nil { + t.Fatal(err) + } + tests := []struct { + key string + value string + code int + }{ + {"providers.openai.extra_body.model", "some-model", 1}, + {"custom_providers.openai.extra_body.model", "some-model", 1}, + {"providers.openai..url", "https://example.invalid/v1", 1}, + {"providers..a.url", "https://example.invalid/v1", 1}, + {"providers. .url", "https://example.invalid/v1", 1}, + {"custom_providers.anthropic..url", "https://example.invalid/v1", 1}, + {"provider", ".gateway", 1}, + {"provider", "gateway.", 1}, + {"provider", "gateway..v2", 1}, + {"provider", " ", 1}, + {"provider", "mimo-v2.6-flash", 0}, + {"providers.mimo-v2.6-flash.url", "https://example.invalid/v1", 0}, + {"custom_providers.mimo-v2.6-flash.protocol", "openai", 0}, + {"provider", "foo.api_key", 0}, + {"custom_providers.foo.api_key.model", "some-model", 0}, + } + for _, tt := range tests { + t.Run(tt.key+"/"+tt.value, func(t *testing.T) { + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestRunConfigSetProviderNames$", "--", "config", "set", tt.key, tt.value) + cmd.Env = append(os.Environ(), "OCR_TEST_CONFIG_SET=1", "OCR_NO_UPDATE=1", "OCR_ENABLE_TELEMETRY=0") + output, err := cmd.CombinedOutput() + if cmd.ProcessState == nil || cmd.ProcessState.ExitCode() != tt.code { + t.Fatalf("exit error = %v, want code %d; output:\n%s", err, tt.code, output) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if tt.code != 0 && !bytes.Equal(before, after) { + t.Fatalf("configuration changed after rejected write: %s", after) + } + }) + } + cfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatal(err) + } + if cfg.CustomProviders["mimo-v2.6-flash"].URL != "https://example.invalid/v1" || + cfg.CustomProviders["mimo-v2.6-flash"].Protocol != "openai" || + cfg.CustomProviders["foo.api_key"].Model != "some-model" { + t.Fatalf("dotted provider fields were not persisted: %#v", cfg.CustomProviders) + } +} + func TestRunFlushesTelemetryOnError(t *testing.T) { if os.Getenv("OCR_TEST_RUN_ERROR") == "1" { rootCmd.SetArgs([]string{"--invalid-flag"}) diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go index 7d892e469..46df5bbae 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -67,6 +67,21 @@ func isSubtaskErrorType(warningType string) bool { return warningType == "subtask_error" || warningType == "scan_subtask_error" } +// hasReviewLimitWarning reports whether a declared review limit — the +// tool-request round budget or the aggregate token budget — was reached. +// llmloop records the warning regardless of how the grace round ended, and an +// item completed there is still a completed, reusable result, so this must not +// affect status or coverage. It only keeps a zero-finding result from reading +// as a clean review when a limit truncated it. +func hasReviewLimitWarning(warnings []agent.AgentWarning) bool { + for _, w := range warnings { + if w.Type == llmloop.WarningReviewLimitReached { + return true + } + } + return false +} + func outputTextWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning, manifest *session.RunManifest, out io.Writer) { if manifest != nil { fmt.Fprintln(out, manifestMessage(manifest, len(comments))) @@ -74,9 +89,12 @@ func outputTextWithWarnings(comments []model.LlmComment, warnings []agent.AgentW renderComment(c, out) } } else if len(comments) == 0 { - if hasSubtaskErrors(warnings) { + switch { + case hasSubtaskErrors(warnings): fmt.Fprintln(out, "Some files could not be reviewed due to errors (see warnings below).") - } else { + case hasReviewLimitWarning(warnings): + fmt.Fprintln(out, "A review limit was reached before finishing; some findings may be missing (see warnings below).") + default: fmt.Fprintln(out, "No comments generated. Looks good to me.") } } else { @@ -386,9 +404,12 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW payload.Status = string(manifest.TerminalState) payload.Message = manifestMessage(manifest, len(comments)) } else if len(comments) == 0 { - if hasSubtaskErrors(warnings) { + switch { + case hasSubtaskErrors(warnings): payload.Message = "Some files could not be reviewed due to errors." - } else { + case hasReviewLimitWarning(warnings): + payload.Message = "A review limit was reached before finishing; some findings may be missing." + default: payload.Message = "No comments generated. Looks good to me." } } diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index 60575ba7a..36ec229bf 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -40,6 +40,28 @@ func TestHasSubtaskErrors(t *testing.T) { } } +func TestHasReviewLimitWarning(t *testing.T) { + tests := []struct { + name string + warnings []agent.AgentWarning + want bool + }{ + {"nil warnings", nil, false}, + {"empty", []agent.AgentWarning{}, false}, + {"unrelated warning", []agent.AgentWarning{{Type: "token_budget_reached"}}, false}, + {"subtask error only", []agent.AgentWarning{{Type: "subtask_error"}}, false}, + {"limit reached", []agent.AgentWarning{{Type: llmloop.WarningReviewLimitReached}}, true}, + {"mixed", []agent.AgentWarning{{Type: "other"}, {Type: llmloop.WarningReviewLimitReached}}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := hasReviewLimitWarning(tc.warnings); got != tc.want { + t.Errorf("hasReviewLimitWarning() = %v, want %v", got, tc.want) + } + }) + } +} + func TestWrapByRunes(t *testing.T) { tests := []struct { name string @@ -372,6 +394,37 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { } } +func TestOutputJSONWithWarnings_NoCommentsWithReviewLimitReached(t *testing.T) { + warnings := []agent.AgentWarning{{ + Type: llmloop.WarningReviewLimitReached, + File: "a.go", + Message: "reached the aggregate token budget before finishing; the grace round completed the task", + }} + got := captureStdout(t, func() { + err := outputJSONWithWarnings(nil, warnings, 1, 40_000, 0, 40_000, 0, 0, + time.Second, "", nil, nil, "trace-1", nil, "sess-1", nil, false, nil, os.Stdout, nil, nil) + if err != nil { + t.Fatalf("outputJSONWithWarnings: %v", err) + } + }) + var out jsonOutput + if err := json.Unmarshal([]byte(got), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if strings.Contains(out.Message, "Looks good") { + t.Errorf("message = %q, a reached limit must not read as clean", out.Message) + } + if !strings.Contains(out.Message, "A review limit was reached") { + t.Errorf("message = %q, want the limit qualifier", out.Message) + } + if out.Status != "completed_with_warnings" { + t.Errorf("status = %q, want completed_with_warnings", out.Status) + } + if len(out.Warnings) != 1 || out.Warnings[0].Type != llmloop.WarningReviewLimitReached { + t.Errorf("warnings = %+v, want the structured limit warning", out.Warnings) + } +} + func TestOutputJSONNoFiles(t *testing.T) { old := os.Stdout r, w, _ := os.Pipe() @@ -525,6 +578,31 @@ func TestOutputTextWithWarnings_NoCommentsWithSubtaskError(t *testing.T) { } } +// A limit the grace round completed still truncates the review, so the +// zero-finding output must not read as an unqualified approval. +func TestOutputTextWithWarnings_NoCommentsWithReviewLimitReached(t *testing.T) { + warnings := []agent.AgentWarning{{ + Type: llmloop.WarningReviewLimitReached, + File: "a.go", + Message: "reached the maximum tool-request rounds without finishing; the grace round completed the task", + }} + var got string + stderr := captureStderr(t, func() { + got = captureStdout(t, func() { + outputTextWithWarnings(nil, warnings, nil, os.Stdout) + }) + }) + if strings.Contains(got, "Looks good") { + t.Errorf("a reached limit must not read as clean, got %q", got) + } + if !strings.Contains(got, "A review limit was reached") { + t.Errorf("expected the limit message, got %q", got) + } + if !strings.Contains(stderr, "["+llmloop.WarningReviewLimitReached+"]") { + t.Errorf("expected the structured warning on stderr, got %q", stderr) + } +} + func TestOutputTextWithWarnings_WithComments(t *testing.T) { comments := []model.LlmComment{ {Path: "a.go", StartLine: 1, EndLine: 3, Content: "fix this"}, diff --git a/cmd/opencodereview/parent_cmd_test.go b/cmd/opencodereview/parent_cmd_test.go index 13f42b794..406d8ef8e 100644 --- a/cmd/opencodereview/parent_cmd_test.go +++ b/cmd/opencodereview/parent_cmd_test.go @@ -23,6 +23,7 @@ func TestParentCommands_UnknownSubcommand(t *testing.T) { {"session", []string{"session", "bogus"}, "unknown command"}, {"sessions alias", []string{"sessions", "bogus"}, "unknown command"}, {"config", []string{"config", "bogus"}, "unknown command"}, + {"auth", []string{"auth", "bogus"}, "unknown command"}, {"delegate", []string{"delegate", "bogus"}, "unknown command"}, {"delegate alias", []string{"d", "bogus"}, "unknown command"}, {"llm", []string{"llm", "bogus"}, "unknown command"}, @@ -61,6 +62,9 @@ func TestParentCommands_KnownSubcommandStillWorks(t *testing.T) { {"session show help", []string{"session", "show", "--help"}}, {"config set help", []string{"config", "set", "--help"}}, {"config provider help", []string{"config", "provider", "--help"}}, + {"auth login help", []string{"auth", "login", "--help"}}, + {"auth status help", []string{"auth", "status", "--help"}}, + {"auth logout help", []string{"auth", "logout", "--help"}}, {"delegate preview help", []string{"delegate", "preview", "--help"}}, {"delegate rule help", []string{"delegate", "rule", "--help"}}, {"llm test help", []string{"llm", "test", "--help"}}, @@ -92,6 +96,7 @@ func TestParentCommands_NoArgsPrintsHelp(t *testing.T) { }{ {"session no args", []string{"session"}}, {"config no args", []string{"config"}}, + {"auth no args", []string{"auth"}}, {"delegate no args", []string{"delegate"}}, {"llm no args", []string{"llm"}}, {"rules no args", []string{"rules"}}, diff --git a/cmd/opencodereview/provider_cmd.go b/cmd/opencodereview/provider_cmd.go index c4f2ed6f6..59f467085 100644 --- a/cmd/opencodereview/provider_cmd.go +++ b/cmd/opencodereview/provider_cmd.go @@ -158,6 +158,9 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU if result.provider == "" { return fmt.Errorf("provider name is required") } + if err := validateProviderName(result.provider); err != nil { + return err + } model := result.resolvedModel() if model == "" { return fmt.Errorf("model is required") @@ -237,15 +240,15 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU // command as unset, so without this a command of " " would satisfy the check // here and then fail resolution with "no api_key or api_key_cmd configured". // -// An ambient-auth provider has no credential to save at all: demanding one would -// make it impossible to configure, since the credentials live in the AWS chain +// Ambient-auth and external-auth providers have no credential to save here: +// their credentials come from the environment chain or a dedicated token store // rather than the config file. -func checkAPIKeyRequirement(providerName, apiKey, apiKeyCmd string, preset llm.Provider, isPreset bool) error { +func checkAPIKeyRequirement(providerName, apiKey, apiKeyCmd string, preset llm.Provider, isPreset bool, entry ProviderEntry) error { if apiKey != "" || strings.TrimSpace(apiKeyCmd) != "" { return nil } switch { - case isPreset && preset.AmbientAuth: + case isPreset && providerUsesExternalCredentials(preset, entry): return nil case isPreset && preset.EnvVar != "": if os.Getenv(preset.EnvVar) == "" { @@ -257,6 +260,16 @@ func checkAPIKeyRequirement(providerName, apiKey, apiKeyCmd string, preset llm.P } } +func providerUsesExternalCredentials(preset llm.Provider, entry ProviderEntry) bool { + if preset.ExternalAuth { + return true + } + if entry.Protocol != "" { + return llm.NormalizeProtocol(entry.Protocol) == llm.ProtocolAnthropicBedrock + } + return preset.AmbientAuth || llm.NormalizeProtocol(preset.Protocol) == llm.ProtocolAnthropicBedrock +} + func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error { if result.provider == "" { return fmt.Errorf("provider and model are required") @@ -268,7 +281,7 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider preset, isPreset := llm.LookupProvider(result.provider) - if err := checkAPIKeyRequirement(result.provider, result.apiKey, cfg.Providers[result.provider].APIKeyCmd, preset, isPreset); err != nil { + if err := checkAPIKeyRequirement(result.provider, result.apiKey, cfg.Providers[result.provider].APIKeyCmd, preset, isPreset, cfg.Providers[result.provider]); err != nil { return err } diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go index 70b0599d3..e861aa828 100644 --- a/cmd/opencodereview/provider_cmd_test.go +++ b/cmd/opencodereview/provider_cmd_test.go @@ -289,6 +289,21 @@ func TestApplyCustomProviderConfig_MissingProvider(t *testing.T) { } } +func TestApplyCustomProviderConfigInvalidName(t *testing.T) { + for _, name := range []string{" ", ".gateway", "gateway.", "gateway..v2"} { + t.Run(name, func(t *testing.T) { + cfg := &Config{Provider: "openai", Model: "saved-model"} + err := applyCustomProviderConfig(filepath.Join(t.TempDir(), "config.json"), cfg, providerTUIResult{provider: name, model: "some-model"}) + if err == nil { + t.Fatal("expected invalid provider name error") + } + if cfg.Provider != "openai" || cfg.Model != "saved-model" || cfg.CustomProviders != nil { + t.Fatalf("configuration changed after rejected write: %#v", cfg) + } + }) + } +} + func TestApplyCustomProviderConfig_MissingModel(t *testing.T) { err := applyCustomProviderConfig("", &Config{}, providerTUIResult{provider: "p", model: ""}) if err == nil { diff --git a/cmd/opencodereview/provider_gateway_test.go b/cmd/opencodereview/provider_gateway_test.go new file mode 100644 index 000000000..65b817481 --- /dev/null +++ b/cmd/opencodereview/provider_gateway_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "path/filepath" + "testing" + + "github.com/alibaba/open-code-review/internal/llm" +) + +func TestProviderGatewayKeySurvivesModelSelection(t *testing.T) { + t.Setenv("OCR_LLM_TIMEOUT", "") + t.Setenv("OCR_LLM_EXTRA_HEADERS", "") + setTestHome(t, t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") + cfg := &Config{ + Provider: "bedrock", + Model: "gateway-a", + Providers: map[string]ProviderEntry{ + "bedrock": {Protocol: llm.ProtocolOpenAIChatCompletions, URL: "https://example.invalid/v1", APIKey: "dummy-gateway-key", Model: "gateway-a", Models: []string{"gateway-a", "gateway-b"}}, + }, + } + if err := saveConfig(path, cfg); err != nil { + t.Fatal(err) + } + before, err := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "bedrock"}) + if err != nil || before.Token != "dummy-gateway-key" || before.Protocol != llm.ProtocolOpenAIChatCompletions { + t.Fatalf("initial gateway configuration must resolve: %v", err) + } + m := newProviderTUI(cfg, path) + if m.currentProvider().Name != "bedrock" { + t.Fatal("wrong provider") + } + updated, _ := m.Update(enterKey()) + m = updated.(providerTUIModel) + found := false + for i, model := range m.models() { + if model == "gateway-b" { + m.modelIdx = i + found = true + } + } + if !found { + t.Fatal("target model missing") + } + updated, _ = m.Update(enterKey()) + m = updated.(providerTUIModel) + if m.step == stepAPIKey { + updated, _ = m.Update(enterKey()) + m = updated.(providerTUIModel) + } + if !m.confirmed { + t.Fatalf("expected completed model selection, got step=%d", m.step) + } + result := m.result() + if result.resolvedModel() != "gateway-b" { + t.Fatalf("wrong selected model: %s", result.resolvedModel()) + } + oldTest := runLLMTestPath + t.Cleanup(func() { runLLMTestPath = oldTest }) + runLLMTestPath = func(path string) error { + _, err := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "bedrock"}) + return err + } + if err := applyOfficialProviderConfig(path, cfg, result); err != nil { + t.Fatal(err) + } + after, resolveErr := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "bedrock"}) + saved, err := loadOrCreateConfig(path) + if err != nil { + t.Fatal(err) + } + t.Logf("selected_model=%s persisted_key_cleared=%t resolution_error=%v", saved.Providers["bedrock"].Model, saved.Providers["bedrock"].APIKey == "", resolveErr) + if resolveErr != nil || after.Token != "dummy-gateway-key" { + t.Fatalf("changing the model must preserve the existing gateway credential: %v", resolveErr) + } +} + +func TestProviderAmbientProtocolOverrideCanBeSaved(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + setTestHome(t, t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") + cfg := &Config{Provider: "anthropic", Providers: map[string]ProviderEntry{ + "anthropic": {Protocol: llm.ProtocolAnthropicBedrock, AWSRegion: "us-west-2", Model: "fixture-model", Models: []string{"fixture-model"}}, + }} + m := newProviderTUI(cfg, path) + updated, _ := m.Update(enterKey()) + m = updated.(providerTUIModel) + updated, _ = m.Update(enterKey()) + m = updated.(providerTUIModel) + if !m.confirmed || m.step == stepAPIKey { + t.Fatal("ambient protocol unexpectedly requires a key") + } + previous := runLLMTestPath + t.Cleanup(func() { runLLMTestPath = previous }) + runLLMTestPath = func(path string) error { + ep, err := llm.ResolveEndpointWithOptions(path, llm.ResolveOptions{Provider: "anthropic"}) + if err == nil && (!ep.AmbientAuth || ep.Token != "") { + t.Error("saved ambient protocol requires a token") + } + return err + } + if err := applyOfficialProviderConfig(path, cfg, m.result()); err != nil { + t.Fatal(err) + } +} + +func TestProviderGatewayOverrideRequiresCredential(t *testing.T) { + setTestHome(t, t.TempDir()) + cfg := &Config{Provider: "bedrock", Providers: map[string]ProviderEntry{ + "bedrock": {Protocol: llm.ProtocolOpenAIChatCompletions, URL: "https://example.invalid/v1", Model: "gateway", Models: []string{"gateway"}}, + }} + m := newProviderTUI(cfg, "") + updated, _ := m.Update(enterKey()) + m = updated.(providerTUIModel) + updated, _ = m.Update(enterKey()) + m = updated.(providerTUIModel) + if m.step != stepAPIKey { + t.Fatal("token protocol skipped its credential step") + } + if ok, _ := m.apiKeyStepCanConfirm(); ok { + t.Fatal("token protocol accepted an absent credential") + } + preset, _ := llm.LookupProvider("bedrock") + if err := checkAPIKeyRequirement("bedrock", "", "", preset, true, cfg.Providers["bedrock"]); err == nil { + t.Fatal("save validation accepted an absent credential") + } +} diff --git a/cmd/opencodereview/provider_tui.go b/cmd/opencodereview/provider_tui.go index bdef802a9..02c0b05ad 100644 --- a/cmd/opencodereview/provider_tui.go +++ b/cmd/opencodereview/provider_tui.go @@ -983,6 +983,15 @@ func (m providerTUIModel) apiKeyCmdForStep() string { return "" } +func (m providerTUIModel) officialProviderUsesExternalCredentials() bool { + p := m.currentProvider() + var entry ProviderEntry + if m.existingCfg != nil { + entry = m.existingCfg.Providers[p.Name] + } + return providerUsesExternalCredentials(p, entry) +} + func (m providerTUIModel) apiKeyStepCanConfirm() (ok bool, errMsg string) { if m.apiKeyOriginal != "" { return true, "" @@ -998,9 +1007,9 @@ func (m providerTUIModel) apiKeyStepCanConfirm() (ok bool, errMsg string) { } if m.activeTab == tabOfficial { p := m.currentProvider() - if p.AmbientAuth { + if m.officialProviderUsesExternalCredentials() { // Reachable when an existing config is edited: an empty key is the - // correct state for a provider that signs from the AWS chain. + // correct state when credentials are supplied outside the config. return true, "" } if officialProviderEnvKeySet(p) { @@ -1237,6 +1246,11 @@ func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) { m.cpStep = cpStepName return m, m.cpNameInput.Focus() } + if err := validateProviderName(r.provider); err != nil { + m.formError = err.Error() + m.cpStep = cpStepName + return m, m.cpNameInput.Focus() + } if m.customProviderNameTaken(r.provider) { m.formError = fmt.Sprintf(`Provider "%s" already exists`, r.provider) m.cpStep = cpStepName @@ -1869,10 +1883,17 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { m.formError = err.Error() return m, nil } - if m.activeTab == tabOfficial && m.currentProvider().AmbientAuth { - // An ambient-auth provider has no key to collect, so the model step - // is the last one. Showing an API-key prompt that must be left blank - // would read as a step the user failed to complete. + if m.activeTab == tabOfficial && m.officialProviderUsesExternalCredentials() { + // This provider has no key to collect, so the model step is the last + // one. Showing an API-key prompt that must be left blank would read as + // a step the user failed to complete. + // + // The api-key state still carries whatever the previously active + // provider seeded (or the user typed before backing out), and + // result() would report it as this provider's key — drop it. + m.apiKeyOriginal = "" + m.apiKeyMasked = false + m.apiKeyInput.SetValue("") m.formError = "" m.confirmed = true return m, tea.Quit diff --git a/cmd/opencodereview/provider_tui_funcs_test.go b/cmd/opencodereview/provider_tui_funcs_test.go index 8c5ea92f7..094cd3db0 100644 --- a/cmd/opencodereview/provider_tui_funcs_test.go +++ b/cmd/opencodereview/provider_tui_funcs_test.go @@ -1951,6 +1951,11 @@ func TestApiKeyStepCanConfirm(t *testing.T) { cfg: &Config{Provider: "deepseek", Providers: map[string]ProviderEntry{"deepseek": {}}}, wantOK: true, }, + { + name: "official external auth", + cfg: &Config{Provider: "chatgpt", Providers: map[string]ProviderEntry{"chatgpt": {}}}, + wantOK: true, + }, { name: "custom saved api_key", customTab: true, diff --git a/cmd/opencodereview/provider_tui_persist_test.go b/cmd/opencodereview/provider_tui_persist_test.go index 817430908..b9b38d1bb 100644 --- a/cmd/opencodereview/provider_tui_persist_test.go +++ b/cmd/opencodereview/provider_tui_persist_test.go @@ -184,6 +184,22 @@ func TestApplyCreateCustomProvider(t *testing.T) { return m } + for _, name := range []string{".gateway", "gateway.", "gateway..v2"} { + t.Run("invalid name "+name, func(t *testing.T) { + cfg := &Config{Provider: "openai", Model: "saved-model"} + m := setup(t, cfg, filepath.Join(t.TempDir(), "config.json")) + m.cpNameInput.SetValue(name) + out, _ := m.applyCreateCustomProvider() + got := out.(providerTUIModel) + if got.formError == "" || got.savedInSession || got.cpStep != cpStepName { + t.Fatalf("invalid name was not rejected: formError=%q, saved=%v, step=%v", got.formError, got.savedInSession, got.cpStep) + } + if cfg.Provider != "openai" || cfg.Model != "saved-model" || cfg.CustomProviders != nil { + t.Fatalf("configuration changed after rejected write: %#v", cfg) + } + }) + } + t.Run("nil config guard", func(t *testing.T) { m := setup(t, &Config{}, filepath.Join(t.TempDir(), "c.json")) m.existingCfg = nil diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 2fdd2b2f2..898a084b0 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -163,6 +163,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) (retErr error rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, llm.ResolveOptions{ Provider: opts.provider, Model: opts.model, + Context: ctx, }) if err != nil { return err diff --git a/cmd/opencodereview/root.go b/cmd/opencodereview/root.go index e22633c2d..238bec980 100644 --- a/cmd/opencodereview/root.go +++ b/cmd/opencodereview/root.go @@ -59,6 +59,7 @@ func init() { rootCmd.AddCommand(delegateCmd) rootCmd.AddCommand(sessionCmd) rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(authCmd) rootCmd.AddCommand(llmCmd) rootCmd.AddCommand(rulesCmd) rootCmd.AddCommand(viewerCmd) diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go index a629eca13..38b67abd0 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -85,7 +85,7 @@ var scanCmd = &cobra.Command{ if err := validateScanOptions(&scanOpts); err != nil { return err } - return executeScan(scanOpts) + return executeScanContext(cmd.Context(), scanOpts) }, } @@ -108,7 +108,11 @@ func splitPaths(raw string) []string { return out } -func executeScan(opts scanOptions) (retErr error) { +func executeScan(opts scanOptions) error { + return executeScanContext(context.Background(), opts) +} + +func executeScanContext(ctx context.Context, opts scanOptions) (retErr error) { out, closeOut, err := resolveOutputWriter(opts.outputPath, opts.outputFormat) if err != nil { return err @@ -169,6 +173,7 @@ func executeScan(opts scanOptions) (retErr error) { rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, llm.ResolveOptions{ Provider: opts.provider, Model: opts.model, + Context: ctx, }) if err != nil { return err @@ -232,7 +237,7 @@ func executeScan(opts scanOptions) (retErr error) { q := newQuietHandle(opts.outputFormat, opts.audience) defer q.Restore() - ctx, span := telemetry.StartSpan(telemetry.ContextWithTraceParentFromEnv(context.Background()), "scan.run") + ctx, span := telemetry.StartSpan(telemetry.ContextWithTraceParentFromEnv(ctx), "scan.run") defer span.End() var traceID string if telemetry.IsEnabled() { diff --git a/cmd/opencodereview/scan_prompt_overflow_test.go b/cmd/opencodereview/scan_prompt_overflow_test.go new file mode 100644 index 000000000..e2a381d5c --- /dev/null +++ b/cmd/opencodereview/scan_prompt_overflow_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestExecuteScanPromptOverflowCannotReportClean(t *testing.T) { + freshOCRHome(t) + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected LLM request", http.StatusBadRequest) + })) + defer server.Close() + t.Setenv("OCR_LLM_URL", server.URL+"/v1/chat/completions") + t.Setenv("OCR_LLM_TOKEN", "test-token") + t.Setenv("OCR_LLM_MODEL", "test-model") + t.Setenv("OCR_LLM_PROTOCOL", "openai") + t.Setenv("OCR_USE_ANTHROPIC", "false") + + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, format := range []string{"text", "json"} { + t.Run(format, func(t *testing.T) { + opts, err := parseScanFlags([]string{ + "--repo", repo, "--max-tokens", "100", "--no-plan", "--no-dedup", + "--no-summary", "--format", format, "--audience", "agent", + }) + if err != nil { + t.Fatal(err) + } + var scanErr error + output := captureStdout(t, func() { scanErr = executeScan(opts) }) + t.Logf("scan error: %v; output: %s", scanErr, output) + if scanErr == nil || !strings.Contains(scanErr.Error(), "all 1 file scan(s) failed") { + t.Errorf("scan error = %v, want complete scan failure", scanErr) + } + if strings.Contains(output, "Looks good to me") { + t.Errorf("unreviewed file was reported as clean: %s", output) + } + }) + } + if got := requests.Load(); got != 0 { + t.Errorf("LLM requests = %d, want zero because the prompt was rejected locally", got) + } +} diff --git a/cmd/opencodereview/shared_flags.go b/cmd/opencodereview/shared_flags.go index 8040943dc..134fcc20f 100644 --- a/cmd/opencodereview/shared_flags.go +++ b/cmd/opencodereview/shared_flags.go @@ -49,9 +49,11 @@ func addExcludeFlag(cmd *cobra.Command, target *string) { cmd.Flags().StringVar(target, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes") } +const taskTimeoutHelp = "concurrent task budget in minutes, including LLM requests and retries (0 = unlimited); independent per-request timeout: OCR_LLM_TIMEOUT or provider timeout_sec in seconds (default 300)" + func addConcurrencyFlags(cmd *cobra.Command, concurrency, timeout, maxTools, maxGitProcs, maxTokens, maxTokensBudget *int) { cmd.Flags().IntVar(concurrency, "concurrency", 8, "max concurrent subtasks") - cmd.Flags().IntVar(timeout, "timeout", 15, "concurrent task timeout in minutes") + cmd.Flags().IntVar(timeout, "timeout", 15, taskTimeoutHelp) cmd.Flags().IntVar(maxTools, "max-tools", 0, "max tool call rounds per subtask (0 = template default; min 50)") cmd.Flags().IntVar(maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses") cmd.Flags().IntVar(maxTokens, "max-tokens", 0, "per-group prompt token ceiling (0 = configured or template default)") @@ -224,12 +226,12 @@ func registerScanFlags(cmd *cobra.Command, opts *scanOptions) { addToolsFlag(cmd, &opts.toolConfigPath) addRuleFlag(cmd, &opts.rulePath) addRepoFlag(cmd, &opts.repoDir) - cmd.Flags().StringVar(&opts.paths, "path", "", "comma-separated repo-relative directories or files to scan (default: whole repo)") + cmd.Flags().StringVar(&opts.paths, "path", "", "comma-separated repo-relative directories or files to scan (default: whole repo; nonexistent paths are an error)") addExcludeFlag(cmd, &opts.excludes) addOutputFlags(cmd, &opts.outputFormat, &opts.audience) addOutputPathFlag(cmd, &opts.outputPath) cmd.Flags().IntVar(&opts.concurrency, "concurrency", 8, "max concurrent subtasks") - cmd.Flags().IntVar(&opts.concurrentTaskTimeout, "timeout", 15, "concurrent task timeout in minutes") + cmd.Flags().IntVar(&opts.concurrentTaskTimeout, "timeout", 15, taskTimeoutHelp) cmd.Flags().IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per subtask; only takes effect when greater than template default") cmd.Flags().IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses") cmd.Flags().IntVar(&opts.maxTokens, "max-tokens", 0, "per-file prompt token ceiling (0 = configured or template default)") diff --git a/cmd/opencodereview/timeout_help_test.go b/cmd/opencodereview/timeout_help_test.go new file mode 100644 index 000000000..cfd584043 --- /dev/null +++ b/cmd/opencodereview/timeout_help_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package main + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestTaskTimeoutHelp(t *testing.T) { + for _, name := range []string{"review", "scan"} { + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{Use: name} + if name == "review" { + registerReviewFlags(cmd, &reviewOptions{}) + } else { + registerScanFlags(cmd, &scanOptions{}) + } + flag := cmd.Flags().Lookup("timeout") + if flag.DefValue != "15" { + t.Fatalf("default = %s, want 15", flag.DefValue) + } + for _, text := range []string{"minutes", "retries", "0 = unlimited", "independent", "OCR_LLM_TIMEOUT", "timeout_sec", "seconds", "300"} { + if !strings.Contains(flag.Usage, text) { + t.Errorf("help %q does not contain %q", flag.Usage, text) + } + } + }) + } +} diff --git a/docs/i18n/CONTRIBUTING.ja-JP.md b/docs/i18n/CONTRIBUTING.ja-JP.md index b34c752ad..7d3848c0a 100644 --- a/docs/i18n/CONTRIBUTING.ja-JP.md +++ b/docs/i18n/CONTRIBUTING.ja-JP.md @@ -22,7 +22,7 @@ OpenCodeReviewへのコントリビューションに興味を持っていただ ### 前提条件 -- [Go 1.25+](https://go.dev/dl/) +- [Go 1.26.9+](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -90,6 +90,15 @@ fix(llm): handle timeout errors in Anthropic API calls docs(README): update configuration examples ``` +`PR Commit Policy` は、PR タイトルと本文を含むすべてのコミットメッセージを、 +大文字・小文字を区別せずに検査します。`[skip ci]`、`[ci skip]`、`[no ci]`、 +`[skip actions]`、`[actions skip]`、または `skip-checks: true` / `skip-checks:true` +だけの行を含めないでください。 + +検査に失敗した場合は、PR タイトルを編集するか、自分の作業ブランチで該当するコミットメッセージを書き換えてください。 +これらの指示を含まないコミットを追加しても、過去のコミットの違反は解消されません。 +PR の説明ではこれらの指示について議論でき、検査の対象にはなりません。 + ### ライセンスヘッダー すべてのソースファイル(`.go`、`.js`、`.mjs`、`.ts`、`.tsx`、`.kt`、`.kts`、`.sh`、`.py`、`.css`)にはSPDXライセンスヘッダーが必要です。新しいファイルを作成した後、以下を実行してください: @@ -118,20 +127,38 @@ make build ### プロジェクト構成 ``` +├── acp/ # CLI 向け ACP アダプター +├── bin/ # NPMラッパー ├── cmd/opencodereview/ # CLIエントリーポイント +├── docs/ # ローカライズ版 README と CONTRIBUTING +├── examples/ # CI/CD 連携のサンプル +├── extensions/ # エディタ拡張(VS Code と IDEA) +├── imgs/ # ロゴ、ベンチマーク、ハイライト画像 ├── internal/ │ ├── agent/ # レビューエージェントのロジック +│ ├── chatgptauth/ # ChatGPT の OAuth と認証情報管理 │ ├── config/ # 設定管理 +│ ├── delegate/ # 委任モードのレビュー仕様(LLM なし) │ ├── diff/ # Git diffのパース -│ ├── llm/ # LLM APIクライアント(Anthropic & OpenAI) +│ ├── gitcmd/ # 同時実行数を制限する git サブプロセスランナー +│ ├── llm/ # LLM APIクライアント(Anthropic、Bedrock、OpenAI) +│ ├── llmloop/ # サブタスク共有のツール使用ループ +│ ├── mcp/ # MCP サーバークライアントとツールアダプター │ ├── model/ # データモデル +│ ├── pathutil/ # 正規化パスと包含チェック +│ ├── release/ # リリース資産 URL の命名チェック +│ ├── scan/ # ファイル全体のコードレビュー(ocr scan) │ ├── session/ # レビューセッション管理 -│ ├── tool/ # 組み込みツール(file_read、code_searchなど) +│ ├── stdout/ # 切り替え可能な stdout ライター +│ ├── suggestdiff/ # 提案表示用の行単位 diff │ ├── telemetry/ # OpenTelemetry統合 +│ ├── tool/ # 組み込みツール(file_read、code_searchなど) │ └── viewer/ # WebUIセッションビューアー -├── pages/ # WebUIフロントエンド -├── scripts/ # ビルド & インストールスクリプト -└── bin/ # NPMラッパー +├── npm/ # プラットフォーム別の optional バイナリパッケージ +├── pages/ # ランディングページ +├── plugins/ # コーディングエージェント向けプラグイン +├── scripts/ # インストール、公開、チェック用スクリプト +└── skills/ # エージェントスキルのマニフェスト ``` ## AI支援開発 diff --git a/docs/i18n/CONTRIBUTING.ko-KR.md b/docs/i18n/CONTRIBUTING.ko-KR.md index 079b05465..f6f22d9b6 100644 --- a/docs/i18n/CONTRIBUTING.ko-KR.md +++ b/docs/i18n/CONTRIBUTING.ko-KR.md @@ -22,7 +22,7 @@ OpenCodeReview에 기여해 주셔서 감사합니다. 오타 수정, bug report ### 전제 조건 -- [Go 1.25+](https://go.dev/dl/) +- [Go 1.26.9+](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -90,6 +90,14 @@ fix(llm): handle timeout errors in Anthropic API calls docs(README): update configuration examples ``` +`PR Commit Policy`는 PR 제목과 본문을 포함한 모든 커밋 메시지를 대소문자 구분 없이 검사합니다. +`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, `[actions skip]` 또는 +`skip-checks: true` / `skip-checks:true`만 있는 줄을 포함하지 마세요. + +검사에 실패하면 PR 제목을 수정하거나 자신의 기여 브랜치에서 해당 커밋 메시지를 다시 작성하세요. +이러한 지시가 없는 새 커밋을 추가해도 이전 커밋의 위반 사항은 해결되지 않습니다. +PR 설명에서는 이러한 지시를 논의할 수 있으며 검사 대상이 아닙니다. + ### License Headers 모든 소스 파일(`.go`, `.js`, `.mjs`, `.ts`, `.tsx`, `.kt`, `.kts`, `.sh`, `.py`, `.css`)에는 SPDX 라이선스 헤더가 필요합니다. 새 파일을 생성한 후 다음을 실행하세요: @@ -119,20 +127,38 @@ make build ```text . +├── acp/ # ACP adapter for the CLI +├── bin/ # NPM wrapper ├── cmd/opencodereview/ # CLI entry point +├── docs/ # Localized README and CONTRIBUTING +├── examples/ # CI/CD integration examples +├── extensions/ # Editor extensions (VS Code and IDEA) +├── imgs/ # Logo, benchmark, and highlight images ├── internal/ │ ├── agent/ # Review agent logic +│ ├── chatgptauth/ # ChatGPT OAuth and credential management │ ├── config/ # Configuration management +│ ├── delegate/ # Delegation-mode review specs (no LLM) │ ├── diff/ # Git diff parsing -│ ├── llm/ # LLM API client (Anthropic & OpenAI) +│ ├── gitcmd/ # Bounded git subprocess runner +│ ├── llm/ # LLM API client (Anthropic, Bedrock & OpenAI) +│ ├── llmloop/ # Shared per-subtask tool-use loop +│ ├── mcp/ # MCP server client and tool adapter │ ├── model/ # Data models +│ ├── pathutil/ # Canonical paths and containment checks +│ ├── release/ # Release asset URL naming checks +│ ├── scan/ # Full-file code review (ocr scan) │ ├── session/ # Review session management -│ ├── tool/ # Built-in tools (file_read, code_search, etc.) +│ ├── stdout/ # Swappable stdout writer +│ ├── suggestdiff/ # Line-level diffs for suggestion rendering │ ├── telemetry/ # OpenTelemetry integration +│ ├── tool/ # Built-in tools (file_read, code_search, etc.) │ └── viewer/ # WebUI session viewer -├── pages/ # WebUI frontend -├── scripts/ # Build & install scripts -└── bin/ # NPM wrapper +├── npm/ # Per-platform optional binary packages +├── pages/ # Landing page +├── plugins/ # Coding agent plugins +├── scripts/ # Install, publish, and check scripts +└── skills/ # Agent skill manifests ``` ## AI 지원 개발 diff --git a/docs/i18n/CONTRIBUTING.ru-RU.md b/docs/i18n/CONTRIBUTING.ru-RU.md index 6467fbdad..55c438011 100644 --- a/docs/i18n/CONTRIBUTING.ru-RU.md +++ b/docs/i18n/CONTRIBUTING.ru-RU.md @@ -22,7 +22,7 @@ ### Требования -- [Go 1.25+](https://go.dev/dl/) +- [Go 1.26.9+](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -90,6 +90,16 @@ fix(llm): handle timeout errors in Anthropic API calls docs(README): update configuration examples ``` +`PR Commit Policy` проверяет заголовок PR и полное сообщение каждого коммита, +включая тело, без учёта регистра. Не добавляйте `[skip ci]`, `[ci skip]`, `[no ci]`, +`[skip actions]`, `[actions skip]` или отдельную строку +`skip-checks: true` / `skip-checks:true`. + +Если проверка не пройдена, измените заголовок PR или перепишите сообщения +соответствующих коммитов в своей рабочей ветке. Новый коммит без этих инструкций +не устраняет нарушения в предыдущих коммитах. Описание PR может содержать +обсуждение этих инструкций и не проверяется. + ### Заголовки лицензии Каждый исходный файл (`.go`, `.js`, `.mjs`, `.ts`, `.tsx`, `.kt`, `.kts`, `.sh`, `.py`, `.css`) должен содержать заголовок лицензии SPDX. После создания новых файлов выполните: @@ -118,20 +128,38 @@ make build ### Структура проекта ``` +├── acp/ # ACP-адаптер для CLI +├── bin/ # NPM-обёртка ├── cmd/opencodereview/ # Точка входа CLI +├── docs/ # Локализованные README и CONTRIBUTING +├── examples/ # Примеры интеграции с CI/CD +├── extensions/ # Расширения редактора (VS Code и IDEA) +├── imgs/ # Изображения логотипа, бенчмарков и обзора ├── internal/ │ ├── agent/ # Логика ревью-агента +│ ├── chatgptauth/ # OAuth и управление учётными данными ChatGPT │ ├── config/ # Управление конфигурацией +│ ├── delegate/ # Спеки ревью для режима делегирования (без LLM) │ ├── diff/ # Разбор git-диффов -│ ├── llm/ # Клиент LLM API (Anthropic и OpenAI) +│ ├── gitcmd/ # Ограниченный запуск git-подпроцессов +│ ├── llm/ # Клиент LLM API (Anthropic, Bedrock и OpenAI) +│ ├── llmloop/ # Общий цикл вызова инструментов для подзадач +│ ├── mcp/ # Клиент MCP-сервера и адаптер инструментов │ ├── model/ # Модели данных +│ ├── pathutil/ # Канонические пути и проверка вложенности +│ ├── release/ # Проверки имён URL артефактов релиза +│ ├── scan/ # Пофайловое ревью кода (ocr scan) │ ├── session/ # Управление сессиями ревью -│ ├── tool/ # Встроенные инструменты (file_read, code_search и др.) +│ ├── stdout/ # Подменяемый writer для stdout +│ ├── suggestdiff/ # Построчные diff для отрисовки предложений │ ├── telemetry/ # Интеграция с OpenTelemetry +│ ├── tool/ # Встроенные инструменты (file_read, code_search и др.) │ └── viewer/ # WebUI-просмотрщик сессий -├── pages/ # Фронтенд WebUI -├── scripts/ # Скрипты сборки и установки -└── bin/ # NPM-обёртка +├── npm/ # Опциональные бинарные пакеты по платформам +├── pages/ # Лендинг +├── plugins/ # Плагины для coding-агентов +├── scripts/ # Скрипты установки, публикации и проверок +└── skills/ # Манифесты agent skill ``` ## Разработка с помощью ИИ diff --git a/docs/i18n/CONTRIBUTING.zh-CN.md b/docs/i18n/CONTRIBUTING.zh-CN.md index e065e36aa..3b984265e 100644 --- a/docs/i18n/CONTRIBUTING.zh-CN.md +++ b/docs/i18n/CONTRIBUTING.zh-CN.md @@ -22,7 +22,7 @@ ### 前置条件 -- [Go 1.25+](https://go.dev/dl/) +- [Go 1.26.9+](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -90,6 +90,14 @@ fix(llm): handle timeout errors in Anthropic API calls docs(README): update configuration examples ``` +`PR Commit Policy` 会检查 PR 标题以及每条提交的完整信息(包括正文),不区分大小写。 +请勿包含 `[skip ci]`、`[ci skip]`、`[no ci]`、`[skip actions]`、`[actions skip]`, +或单独一行的 `skip-checks: true` / `skip-checks:true`。 + +检查失败时,请修改 PR 标题,或在你的贡献分支上重写相关提交的信息。 +新增一条不含这些指令的提交,无法消除历史提交中的违规内容。 +PR 描述可以讨论这些指令,不在检查范围内。 + ### 许可证头 每个源文件(`.go`、`.js`、`.mjs`、`.ts`、`.tsx`、`.kt`、`.kts`、`.sh`、`.py`、`.css`)都必须包含 SPDX 许可证头。创建新文件后请运行: @@ -118,20 +126,38 @@ make build ### 项目结构 ``` +├── acp/ # CLI 的 ACP 适配器 +├── bin/ # NPM 包装器 ├── cmd/opencodereview/ # CLI 入口 +├── docs/ # 本地化的 README 与 CONTRIBUTING +├── examples/ # CI/CD 集成示例 +├── extensions/ # 编辑器扩展(VS Code 与 IDEA) +├── imgs/ # 徽标、基准与亮点图片 ├── internal/ │ ├── agent/ # 评审 Agent 逻辑 +│ ├── chatgptauth/ # ChatGPT OAuth 与凭证管理 │ ├── config/ # 配置管理 +│ ├── delegate/ # 委托模式评审规格(不调用 LLM) │ ├── diff/ # Git diff 解析 -│ ├── llm/ # LLM API 客户端(Anthropic & OpenAI) +│ ├── gitcmd/ # 有界并发的 git 子进程运行器 +│ ├── llm/ # LLM API 客户端(Anthropic、Bedrock 与 OpenAI) +│ ├── llmloop/ # 共享的子任务工具调用循环 +│ ├── mcp/ # MCP 服务客户端与工具适配器 │ ├── model/ # 数据模型 +│ ├── pathutil/ # 规范路径与包含关系检查 +│ ├── release/ # 发布产物 URL 命名检查 +│ ├── scan/ # 全文件代码评审(ocr scan) │ ├── session/ # 评审会话管理 -│ ├── tool/ # 内置工具(file_read, code_search 等) +│ ├── stdout/ # 可切换的 stdout 写入器 +│ ├── suggestdiff/ # 建议渲染用的行级 diff │ ├── telemetry/ # OpenTelemetry 集成 +│ ├── tool/ # 内置工具(file_read, code_search 等) │ └── viewer/ # WebUI 会话查看器 -├── pages/ # WebUI 前端 -├── scripts/ # 构建和安装脚本 -└── bin/ # NPM 包装器 +├── npm/ # 各平台可选二进制包 +├── pages/ # 落地页 +├── plugins/ # 编程 Agent 插件 +├── scripts/ # 安装、发布与检查脚本 +└── skills/ # Agent skill 清单 ``` ## AI 参与辅助开发 diff --git a/docs/i18n/README.ja-JP.md b/docs/i18n/README.ja-JP.md index 7bdbdccaa..73eb88226 100644 --- a/docs/i18n/README.ja-JP.md +++ b/docs/i18n/README.ja-JP.md @@ -22,7 +22,7 @@ npm Build status License - Ask DeepWiki + Ask DeepWiki OpenSSF Best Practices

diff --git a/docs/i18n/README.ko-KR.md b/docs/i18n/README.ko-KR.md index 98a466aad..721bb386b 100644 --- a/docs/i18n/README.ko-KR.md +++ b/docs/i18n/README.ko-KR.md @@ -22,7 +22,7 @@ npm Build status License - Ask DeepWiki + Ask DeepWiki OpenSSF Best Practices

diff --git a/docs/i18n/README.ru-RU.md b/docs/i18n/README.ru-RU.md index 89d78c5a8..4475e7cff 100644 --- a/docs/i18n/README.ru-RU.md +++ b/docs/i18n/README.ru-RU.md @@ -22,7 +22,7 @@ npm Build status License - Ask DeepWiki + Ask DeepWiki OpenSSF Best Practices

diff --git a/docs/i18n/README.zh-CN.md b/docs/i18n/README.zh-CN.md index cd6d64e1c..333987a14 100644 --- a/docs/i18n/README.zh-CN.md +++ b/docs/i18n/README.zh-CN.md @@ -22,7 +22,7 @@ npm Build status License - Ask DeepWiki + Ask DeepWiki OpenSSF Best Practices

diff --git a/extensions/frontend/package-lock.json b/extensions/frontend/package-lock.json index 985247784..18bb9a840 100644 --- a/extensions/frontend/package-lock.json +++ b/extensions/frontend/package-lock.json @@ -608,9 +608,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -2007,9 +2007,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2709,9 +2709,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -2894,13 +2894,13 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -4600,9 +4600,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4852,9 +4852,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5185,9 +5185,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/extensions/frontend/src/shared/__tests__/providers.test.ts b/extensions/frontend/src/shared/__tests__/providers.test.ts index 5546d4bed..4a74bcbe0 100644 --- a/extensions/frontend/src/shared/__tests__/providers.test.ts +++ b/extensions/frontend/src/shared/__tests__/providers.test.ts @@ -3,10 +3,18 @@ import { PROVIDER_PRESETS as generatedPresets } from '../providers.generated'; import { isPresetProvider, lookupPreset, mergeModelLists, PROVIDER_PRESETS, usesAmbientAuth } from '../providers'; -import { buildOfficialSaveEntries, detectInitialTab, isConfigReady } from '../configUtils'; +import { buildOfficialSaveEntries, detectInitialTab, isConfigReady, isValidProviderName } from '../configUtils'; import { OcrConfig } from '../types'; describe('generated provider presets', () => { + it.each(['my-gateway', 'mimo-v2.6-flash', 'foo.api_key'])('accepts provider name %s', (name) => { + expect(isValidProviderName(name)).toBe(true); + }); + + it.each(['', ' ', '.a', 'a.', 'a..b', 'a. .b'])('rejects provider name %s', (name) => { + expect(isValidProviderName(name)).toBe(false); + }); + it('keeps providers sorted by name with the existing initial provider', () => { const names = PROVIDER_PRESETS.map((preset) => preset.name); expect(names).toEqual([...names].sort()); diff --git a/extensions/frontend/src/shared/configUtils.ts b/extensions/frontend/src/shared/configUtils.ts index d3e9266bf..d438a3ce6 100644 --- a/extensions/frontend/src/shared/configUtils.ts +++ b/extensions/frontend/src/shared/configUtils.ts @@ -18,6 +18,10 @@ export interface ConfigEntry { value: string; } +export function isValidProviderName(name: string): boolean { + return name.split('.').every((segment) => segment.trim() !== ''); +} + export function detectInitialTab(config: OcrConfig | null): ProviderTab { if (!config) return 'official'; if (config.provider) { @@ -120,15 +124,15 @@ export function buildCustomCreateSaveEntries(params: { authHeader: string; }): ConfigEntry[] { const entries: ConfigEntry[] = [ + { key: 'provider', value: params.name.trim() }, { key: `custom_providers.${params.name}.protocol`, value: params.protocol }, { key: `custom_providers.${params.name}.url`, value: params.url.trim() }, { key: `custom_providers.${params.name}.model`, value: params.model.trim() }, { key: `custom_providers.${params.name}.api_key`, value: params.apiKey.trim() }, - { key: 'provider', value: params.name.trim() }, ]; const models = params.models.trim(); if (models) { - entries.splice(3, 0, { key: `custom_providers.${params.name}.models`, value: models }); + entries.splice(4, 0, { key: `custom_providers.${params.name}.models`, value: models }); } if (params.authHeader.trim()) { entries.push({ key: `custom_providers.${params.name}.auth_header`, value: params.authHeader.trim() }); diff --git a/extensions/frontend/src/shared/providers.generated.ts b/extensions/frontend/src/shared/providers.generated.ts index 08d308248..80c9f9136 100644 --- a/extensions/frontend/src/shared/providers.generated.ts +++ b/extensions/frontend/src/shared/providers.generated.ts @@ -60,6 +60,14 @@ export const PROVIDER_PRESETS: OcrProviderPreset[] = [ "global.anthropic.claude-opus-4-8" ] }, + { + "name": "chatgpt", + "displayName": "ChatGPT plan (Sign in with ChatGPT preview)", + "protocol": "openai-responses", + "baseUrl": "https://api.openai.com/v1", + "envVar": "", + "models": [] + }, { "name": "dashscope", "displayName": "Alibaba DashScope API", diff --git a/extensions/frontend/src/webview/views/ConfigView.tsx b/extensions/frontend/src/webview/views/ConfigView.tsx index 8d2a85abf..c86860c60 100644 --- a/extensions/frontend/src/webview/views/ConfigView.tsx +++ b/extensions/frontend/src/webview/views/ConfigView.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; -import { ConfigEntry, ConfigPanelFocus, ProviderTab, buildCustomCreateSaveEntries, buildCustomUpdateSaveEntries, buildOfficialSaveEntries, describeActiveProvider, detectInitialTab, isConfigReady, listCustomProviderNames } from '../../shared/configUtils'; +import { ConfigEntry, ConfigPanelFocus, ProviderTab, buildCustomCreateSaveEntries, buildCustomUpdateSaveEntries, buildOfficialSaveEntries, describeActiveProvider, detectInitialTab, isConfigReady, isValidProviderName, listCustomProviderNames } from '../../shared/configUtils'; import { mergeModelLists, PROVIDER_PRESETS, usesAmbientAuth } from '../../shared/providers'; import { EnvCheckResult, LogLine, OcrConfig } from '../../shared/types'; import { CliStatus, ConnTest } from '../configStore'; @@ -450,8 +450,8 @@ function CustomForm({ const [apiKeyTouched, setApiKeyTouched] = useState(false); const [authHeader, setAuthHeader] = useState(entry?.authHeader ?? ''); - const canSaveCreate = name.trim() !== '' && url.trim() !== '' && model.trim() !== '' && apiKey.trim() !== ''; - const canSaveEdit = !isCreate && name.trim() !== '' && url.trim() !== '' && model.trim() !== '' + const canSaveCreate = isValidProviderName(name) && url.trim() !== '' && model.trim() !== '' && apiKey.trim() !== ''; + const canSaveEdit = !isCreate && isValidProviderName(name) && url.trim() !== '' && model.trim() !== '' && Boolean(entry?.apiKey || apiKey.trim()); const canSave = isCreate ? canSaveCreate : canSaveEdit; diff --git a/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraft.kt b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraft.kt index 322059c6a..a62cc7337 100644 --- a/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraft.kt +++ b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraft.kt @@ -113,16 +113,22 @@ private fun RawConfig.setProviderEntryField( } } -private fun RawConfig.setCustomProviderField(name: String, field: String, value: String) = +private fun isValidProviderName(name: String): Boolean = name.split('.').all { it.isNotBlank() } + +private fun RawConfig.setCustomProviderField(name: String, field: String, value: String) { + if (!isValidProviderName(name)) return + if ('.' in name && (this["custom_providers"] as? JsonObject)?.containsKey(name) != true) return setProviderEntryField("custom_providers", name, field, value) +} /** `providers..`: if name is a built-in provider, write to providers; otherwise write to custom_providers. */ private fun RawConfig.setProviderValue(key: String, value: String) { - val parts = key.split('.') - if (parts.size != 3) return - val name = parts[1] - val field = parts[2] - if (isPresetProvider(name)) { + val prefix = if (key.startsWith("providers.")) "providers." else "custom_providers." + val dot = key.lastIndexOf('.') + if (dot <= prefix.length || dot == key.length - 1) return + val name = key.substring(prefix.length, dot) + val field = key.substring(dot + 1) + if (prefix == "providers." && isPresetProvider(name)) { // Consistent with isPresetProvider / ConfigUtils: preset keys are stored canonically lowercased, so "OpenAI" // is not judged preset yet written under a capitalized key that later lookups would miss. setProviderEntryField("providers", name.trim().lowercase(Locale.ROOT), field, value) @@ -145,18 +151,14 @@ private fun RawConfig.setLlmField(field: String, element: JsonElement) { } private fun RawConfig.setConfigValue(key: String, value: String) { - if (key.startsWith("providers.")) { + if (key.startsWith("providers.") || key.startsWith("custom_providers.")) { setProviderValue(key, value) return } - if (key.startsWith("custom_providers.")) { - val parts = key.split('.') - if (parts.size == 3) setCustomProviderField(parts[1], parts[2], value) - return - } when (key) { "provider" -> { + if (value.isNotEmpty() && !isValidProviderName(value)) return // Preset providers are stored canonically lowercased, matching the providers bucket keys; custom providers keep the user's original name. val providerName = if (isPresetProvider(value)) value.trim().lowercase(Locale.ROOT) else value // Clear the top-level model when the provider changes: the old model usually does not belong to the new provider diff --git a/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt index 881a94926..f4c1ca1a2 100644 --- a/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt +++ b/extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ProviderNames.generated.kt @@ -10,6 +10,7 @@ internal fun generatedPresetProviderNames(): Set = setOf( "anthropic", "baidu-qianfan", "bedrock", + "chatgpt", "dashscope", "dashscope-tokenplan", "deepseek", diff --git a/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt b/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt index a3249dfdc..880865ed2 100644 --- a/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt +++ b/extensions/idea/src/test/kotlin/com/alibaba/opencodereview/idea/services/ConfigDraftTest.kt @@ -14,6 +14,58 @@ import kotlin.test.assertTrue class ConfigDraftTest { + @Test + fun `explicitly created dotted providers retain all draft fields`() { + for (prefix in listOf("providers", "custom_providers")) { + for (name in listOf("mimo-v2.6-flash", "foo.api_key", "openai.extra_body")) { + val d = draft( + "{}", + "provider" to name, + "$prefix.$name.protocol" to "openai", + "$prefix.$name.url" to "https://example.invalid/v1", + "$prefix.$name.model" to "some-model", + "$prefix.$name.api_key" to "test-key", + "$prefix.$name.models" to "some-model,other-model", + "$prefix.$name.auth_header" to "authorization", + ) + assertEquals(name, d.str("provider")) + val expected = parseRawConfig( + """{"protocol":"openai","url":"https://example.invalid/v1","model":"some-model","api_key":"test-key","models":["some-model","other-model"],"auth_header":"authorization"}""", + ) + assertEquals(JsonObject(expected), d.obj("custom_providers", name)) + assertNull(d.obj("providers")) + } + } + } + + @Test + fun `dotted field writes require an existing provider without changing the active provider`() { + for (prefix in listOf("providers", "custom_providers")) { + for (name in listOf("mimo-v2.6-flash", "foo.api_key", "openai.extra_body")) { + val base = parseRawConfig("""{"provider":"openai","model":"saved-model"}""") + val entries = listOf(ConfigEntry("$prefix.$name.model", "some-model")) + assertEquals(base, applyConfigEntries(base, entries)) + val existing = parseRawConfig("""{"provider":"openai","custom_providers":{"$name":{}}}""") + val d = applyConfigEntries(existing, entries) + assertEquals("openai", d.str("provider")) + assertEquals("some-model", d.str("custom_providers", name, "model")) + assertEquals(JsonObject(emptyMap()), existing.obj("custom_providers", name)) + } + } + } + + @Test + fun `invalid provider names leave the draft unchanged`() { + for (name in listOf(" ", ".a", "a.", "a..b", "a. .b", "anthropic.")) { + val base = parseRawConfig("""{"provider":"openai","model":"saved-model","custom_providers":{"$name":{}}}""") + assertEquals(base, applyConfigEntries(base, listOf(ConfigEntry("provider", name)))) + for (prefix in listOf("providers", "custom_providers")) { + val entries = listOf(ConfigEntry("$prefix.$name.url", "https://example.invalid/v1")) + assertEquals(base, applyConfigEntries(base, entries)) + } + } + } + private fun draft(base: String, vararg entries: Pair): RawConfig = applyConfigEntries(parseRawConfig(base), entries.map { ConfigEntry(it.first, it.second) }) diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index db1a643a4..9462e2266 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -110,13 +110,19 @@ "webpack-cli": "^7.2.3" }, "resolutions": { - "undici": ">=8.9.0", + "undici": "^8.10.2", "form-data": ">=4.0.6", - "js-yaml": ">=5.2.2", + "js-yaml": "^5.4.1", "minimatch": "^10.2.6", "@typescript-eslint/typescript-estree/minimatch": "^10.2.6", - "brace-expansion": ">=5.0.8 <6", - "fast-uri": ">=4.1.3", + "brace-expansion": "^5.0.12", + "fast-uri": "^4.1.5", + "handlebars": "^4.7.10", + "markdown-it": "^14.3.1", + "nanoid": "^3.3.18", + "postcss-selector-parser": "^7.1.6", + "qs": "^6.16.0", + "source-map-js": "^1.2.2", "postcss": ">=8.5.23", "linkify-it": ">=5.0.2 <6", "browserslist": ">=4.28.7 <5" @@ -124,4 +130,4 @@ "dependencies": { "preact": "^10.29.8" } -} \ No newline at end of file +} diff --git a/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts b/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts index 898a0a39c..e0274caf1 100644 --- a/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts @@ -2,8 +2,48 @@ // Copyright 2026 alibaba/open-code-review Contributors import { applyConfigEntries } from '../configDraft'; +import { buildCustomCreateSaveEntries } from '@shared/configUtils'; describe('applyConfigEntries', () => { + it.each(['mimo-v2.6-flash', 'foo.api_key', 'openai.extra_body'])('creates %s before applying form fields', (name) => { + const entries = buildCustomCreateSaveEntries({ + name, protocol: 'openai', url: 'https://example.invalid/v1', model: 'some-model', + models: 'some-model,other-model', apiKey: 'test-key', authHeader: 'authorization', + }); + expect(entries[0]).toEqual({ key: 'provider', value: name }); + const base = { provider: 'openai', model: 'saved-model' }; + const draft = applyConfigEntries(base, entries); + expect(draft.provider).toBe(name); + expect(draft.custom_providers).toEqual({ + [name]: { + protocol: 'openai', url: 'https://example.invalid/v1', model: 'some-model', + models: ['some-model', 'other-model'], api_key: 'test-key', auth_header: 'authorization', + }, + }); + expect(base).toEqual({ provider: 'openai', model: 'saved-model' }); + }); + + it.each(['providers', 'custom_providers'])('requires existing dotted names for %s', (prefix) => { + const base = { provider: 'openai', model: 'saved-model' }; + for (const name of ['mimo-v2.6-flash', 'foo.api_key', 'openai.extra_body']) { + const entry = { key: `${prefix}.${name}.model`, value: 'some-model' }; + expect(applyConfigEntries(base, [entry])).toEqual(base); + const existing = { ...base, custom_providers: { [name]: {} } }; + const draft = applyConfigEntries(existing, [entry]); + expect(draft.provider).toBe('openai'); + expect(draft.custom_providers).toEqual({ [name]: { model: 'some-model' } }); + expect(existing.custom_providers[name]).toEqual({}); + } + }); + + it.each([' ', '.a', 'a.', 'a..b', 'a. .b', 'anthropic.'])('rejects invalid name %s', (name) => { + const base = { provider: 'openai', model: 'saved-model', custom_providers: { [name]: {} } }; + expect(applyConfigEntries(base, [{ key: 'provider', value: name }])).toEqual(base); + for (const prefix of ['providers', 'custom_providers']) { + expect(applyConfigEntries(base, [{ key: `${prefix}.${name}.url`, value: 'https://example.invalid/v1' }])).toEqual(base); + } + }); + it('merges built-in provider entries', () => { const draft = applyConfigEntries({}, [ { key: 'provider', value: 'anthropic' }, diff --git a/extensions/vscode/src/extension/services/configDraft.ts b/extensions/vscode/src/extension/services/configDraft.ts index e3e593d72..4c31b8a8c 100644 --- a/extensions/vscode/src/extension/services/configDraft.ts +++ b/extensions/vscode/src/extension/services/configDraft.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 alibaba/open-code-review Contributors -import { ConfigEntry } from '@shared/configUtils'; +import { ConfigEntry, isValidProviderName } from '@shared/configUtils'; import { isPresetProvider } from '@shared/providers'; type RawProviderEntry = Record; @@ -57,6 +57,8 @@ function applyProviderField(entry: RawProviderEntry, field: string, value: strin } function setCustomProviderField(cfg: RawConfig, name: string, field: string, value: string): void { + if (!isValidProviderName(name)) return; + if (name.includes('.') && !Object.prototype.hasOwnProperty.call(cfg.custom_providers ?? {}, name)) return; if (!cfg.custom_providers) cfg.custom_providers = {}; const entry = { ...(cfg.custom_providers[name] ?? {}) }; applyProviderField(entry, field, value); @@ -64,11 +66,12 @@ function setCustomProviderField(cfg: RawConfig, name: string, field: string, val } function setProviderValue(cfg: RawConfig, key: string, value: string): void { - const parts = key.split('.'); - if (parts.length !== 3) return; - const name = parts[1]; - const field = parts[2]; - if (isPresetProvider(name)) { + const prefix = key.startsWith('providers.') ? 'providers.' : 'custom_providers.'; + const dot = key.lastIndexOf('.'); + if (dot <= prefix.length || dot === key.length - 1) return; + const name = key.slice(prefix.length, dot); + const field = key.slice(dot + 1); + if (prefix === 'providers.' && isPresetProvider(name)) { if (!cfg.providers) cfg.providers = {}; const entry = { ...(cfg.providers[name] ?? {}) }; applyProviderField(entry, field, value); @@ -79,18 +82,14 @@ function setProviderValue(cfg: RawConfig, key: string, value: string): void { } function setConfigValue(cfg: RawConfig, key: string, value: string): void { - if (key.startsWith('providers.')) { + if (key.startsWith('providers.') || key.startsWith('custom_providers.')) { setProviderValue(cfg, key, value); return; } - if (key.startsWith('custom_providers.')) { - const parts = key.split('.'); - if (parts.length === 3) setCustomProviderField(cfg, parts[1], parts[2], value); - return; - } switch (key) { case 'provider': + if (value && !isValidProviderName(value)) return; if (cfg.provider !== value) cfg.model = ''; cfg.provider = value; if (isPresetProvider(value)) { diff --git a/extensions/vscode/yarn.lock b/extensions/vscode/yarn.lock index 541f6bef3..bdcd7fa2f 100644 --- a/extensions/vscode/yarn.lock +++ b/extensions/vscode/yarn.lock @@ -1899,10 +1899,10 @@ boundary@^2.0.0: resolved "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz#169c8b1f0d44cf2c25938967a328f37e0a4e5efc" integrity sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA== -"brace-expansion@>=5.0.8 <6", brace-expansion@^5.0.8: - version "5.0.8" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.8.tgz#135ad0d8d808eb18eb5e0ec9a21f3a0b92ef18cf" - integrity sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg== +brace-expansion@^5.0.12, brace-expansion@^5.0.8: + version "5.0.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== dependencies: balanced-match "^4.0.2" @@ -2369,9 +2369,9 @@ enhanced-resolve@^5.24.4: graceful-fs "^4.2.4" tapable "^2.3.3" -entities@^4.2.0, entities@^4.4.0: +entities@^4.2.0, entities@^4.5.0: version "4.5.0" - resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== entities@^6.0.0: @@ -2618,10 +2618,10 @@ fast-levenshtein@^2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== -fast-uri@>=4.1.3, fast-uri@^3.0.1: - version "4.1.4" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-4.1.4.tgz#2076a9d7bce86a86aa31ac40650b935fd5b1cd60" - integrity sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ== +fast-uri@^3.0.1, fast-uri@^4.1.5: + version "4.2.2" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-4.2.2.tgz#428437b7ff49d069f1e16be3bdb6597e8cd72cb9" + integrity sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw== fastq@^1.6.0: version "1.20.1" @@ -2876,12 +2876,12 @@ graphemer@^1.4.0: resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -handlebars@^4.7.9: - version "4.7.9" - resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" - integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== +handlebars@^4.7.10, handlebars@^4.7.9: + version "4.7.10" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.10.tgz#12419ea1b41391b3ffcf003f2164dcbce090c417" + integrity sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg== dependencies: - minimist "^1.2.5" + minimist "^1.2.8" neo-async "^2.6.2" source-map "^0.6.1" wordwrap "^1.0.0" @@ -3610,10 +3610,10 @@ js-tokens@^4.0.0: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@>=5.2.2, js-yaml@^3.13.1, js-yaml@^4.1.0, js-yaml@^4.1.1: - version "5.2.2" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.2.tgz#497bfe63f0b0db11c7bbc5ce8bc568e836c8b08c" - integrity sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw== +js-yaml@^3.13.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^5.4.1: + version "5.4.3" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.4.3.tgz#4ae09e2145682daea9c91b4b1f0610de4c80c941" + integrity sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw== dependencies: argparse "^2.0.1" @@ -3737,7 +3737,7 @@ lines-and-columns@^1.1.6: resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -"linkify-it@>=5.0.2 <6", linkify-it@^5.0.1: +"linkify-it@>=5.0.2 <6", linkify-it@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.2.tgz#d3be0a693af3da9df3883f1e346a0e97461a8c19" integrity sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q== @@ -3849,14 +3849,14 @@ make-error@^1.3.6: resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== -markdown-it@^14.1.0: - version "14.2.0" - resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz#06d48d9035e77d5b1c85adb315482fc8240289ef" - integrity sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ== +markdown-it@^14.1.0, markdown-it@^14.3.1: + version "14.3.2" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.3.2.tgz#eb41b5855120836603c33c1be4c92f58001f8a26" + integrity sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q== dependencies: argparse "^2.0.1" - entities "^4.4.0" - linkify-it "^5.0.1" + entities "^4.5.0" + linkify-it "^5.0.2" mdurl "^2.0.0" punycode.js "^2.3.1" uc.micro "^2.1.0" @@ -3928,9 +3928,9 @@ minimatch@9.0.3, minimatch@^10.2.2, minimatch@^10.2.6, minimatch@^3.0.5, minimat dependencies: brace-expansion "^5.0.8" -minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.8: version "1.2.8" - resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== minimizer-webpack-plugin@^5.7.0: @@ -3963,10 +3963,10 @@ mute-stream@~0.0.4: resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== +nanoid@^3.3.16, nanoid@^3.3.18: + version "3.3.20" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.20.tgz#dfd4444d85aa10111ef66ba85ed72e08ba0855d8" + integrity sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ== napi-build-utils@^2.0.0: version "2.0.0" @@ -4316,10 +4316,10 @@ postcss-modules-values@^4.0.0: dependencies: icss-utils "^5.0.0" -postcss-selector-parser@^7.0.0: - version "7.1.4" - resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz#69dc7a526517572ff6b150e352b36a016017b485" - integrity sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg== +postcss-selector-parser@^7.0.0, postcss-selector-parser@^7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz#45e970f2d93d4547d6754167ed1132f43012c4bf" + integrity sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw== dependencies: cssesc "^3.0.0" util-deprecate "^1.0.2" @@ -4409,10 +4409,10 @@ pure-rand@^7.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-7.0.1.tgz#6f53a5a9e3e4a47445822af96821ca509ed37566" integrity sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ== -qs@^6.9.1: - version "6.15.3" - resolved "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" - integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== +qs@^6.16.0, qs@^6.9.1: + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== dependencies: es-define-property "^1.0.1" side-channel "^1.1.1" @@ -4702,10 +4702,10 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" -source-map-js@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" - integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +source-map-js@^1.2.1, source-map-js@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab" + integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw== source-map-support@~0.5.20: version "0.5.21" @@ -5087,10 +5087,10 @@ undici-types@~8.3.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== -undici@>=8.9.0, undici@^7.19.0: - version "8.10.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.0.tgz#67ed7c4087f0f40fba7bef3a46f2be80572f2473" - integrity sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ== +undici@^7.19.0, undici@^8.10.2: + version "8.11.2" + resolved "https://registry.yarnpkg.com/undici/-/undici-8.11.2.tgz#b8f6f6504384ae535c8e46f8403fd8075f011d46" + integrity sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ== unicorn-magic@^0.1.0: version "0.1.0" diff --git a/go.mod b/go.mod index cf1d1266b..290a66d92 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/alibaba/open-code-review -go 1.25.5 +go 1.26.9 require ( charm.land/bubbles/v2 v2.1.1 @@ -16,6 +16,7 @@ require ( github.com/pkoukk/tiktoken-go v0.1.8 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 + github.com/tidwall/sjson v1.2.5 go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 @@ -27,6 +28,7 @@ require ( go.opentelemetry.io/otel/sdk v1.45.0 go.opentelemetry.io/otel/sdk/metric v1.45.0 go.opentelemetry.io/otel/trace v1.45.0 + golang.org/x/sys v0.48.0 ) require ( @@ -74,21 +76,19 @@ require ( github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 16e779f83..cf2f44e31 100644 --- a/go.sum +++ b/go.sum @@ -179,28 +179,28 @@ go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= -google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2de06d9f3..1d02bb092 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -615,9 +615,7 @@ func (a *Agent) injectDiffMap() { m := make(map[string]string, len(a.diffs)) for i := range a.diffs { d := &a.diffs[i] - if d.NewPath != "/dev/null" { - m[d.NewPath] = d.Diff - } + m[effectivePath(*d)] = d.Diff } dm := tool.NewDiffMap(m) if p, ok := a.args.Tools.Get(tool.FileReadDiff.Name()); ok { @@ -1193,12 +1191,14 @@ var errMainTaskEmpty = errors.New("main_task.messages is empty in template") // safe, generic reason. It never returns the raw error text (which may embed a // provider payload, credentials or absolute paths); the full error is persisted // separately in the session checkpoint. Context deadline/cancel are recognized -// via errors.Is (the per-group subtask timeout is the only deadline in play), and the +// via errors.Is, and the // empty-template precondition is a configuration failure. func classifyItemError(err error) (session.FailureClass, string) { switch { + case errors.Is(err, llm.ErrRequestTimeout): + return session.FailureTimeout, "LLM request timed out; configure OCR_LLM_TIMEOUT or provider timeout_sec" case errors.Is(err, context.DeadlineExceeded): - return session.FailureTimeout, "file review exceeded its time limit" + return session.FailureTimeout, "file review exhausted its task deadline (--timeout)" case errors.Is(err, context.Canceled): return session.FailureCancelled, "file review was cancelled" case errors.Is(err, errMainTaskEmpty): @@ -1641,7 +1641,7 @@ func formatDiffEntry(d model.Diff) string { case d.IsRenamed: status = "RENAMED" } - return fmt.Sprintf("%s %s (+%d/-%d)", status, d.NewPath, d.Insertions, d.Deletions) + return fmt.Sprintf("%s %s (+%d/-%d)", status, effectivePath(d), d.Insertions, d.Deletions) } // buildChangeFilesExceptGroup returns a formatted list of changed files excluding all group members. diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index b9300e13f..08ed858e4 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -17,6 +17,7 @@ import ( "github.com/alibaba/open-code-review/internal/config/toolsconfig" "github.com/alibaba/open-code-review/internal/diff" "github.com/alibaba/open-code-review/internal/llm" + "github.com/alibaba/open-code-review/internal/llmloop" "github.com/alibaba/open-code-review/internal/model" "github.com/alibaba/open-code-review/internal/session" "github.com/alibaba/open-code-review/internal/stdout" @@ -905,7 +906,9 @@ func TestBuildChangeFilesExceptGroup(t *testing.T) { {NewPath: "main.go", OldPath: "main.go", Insertions: 4, Deletions: 2}, {NewPath: "moved_new.go", OldPath: "moved_old.go", IsRenamed: true}, {NewPath: "helper.go", OldPath: "helper.go", IsNew: true, Insertions: 12}, - {NewPath: "removed.go", OldPath: "removed.go", IsDeleted: true, Deletions: 30}, + // A deletion as ParseDiffText produces it: NewPath is the sentinel and + // the name is in OldPath. + {NewPath: "/dev/null", OldPath: "removed.go", IsDeleted: true, Deletions: 30}, {NewPath: "renamed.go", OldPath: "old_name.go", IsRenamed: true, Insertions: 5, Deletions: 5}, {NewPath: "bin.dat", OldPath: "bin.dat", IsBinary: true}, } @@ -953,6 +956,9 @@ func TestBuildChangeFilesExceptGroup(t *testing.T) { if strings.Contains(got, "bin.dat") { t.Error("binary files should be skipped") } + if strings.Contains(got, "/dev/null") { + t.Errorf("a deletion must be listed under its own path, not the /dev/null sentinel; got:\n%s", got) + } // The separator is emitted before each entry, so a skipped final diff cannot // leave a trailing newline behind. Excluding an entire group (plus binaries) @@ -1157,9 +1163,22 @@ func TestDispatchSubtasks_MainTaskWithoutTaskDoneIsNotReusableCheckpoint(t *test if client.calls != 1 { t.Fatalf("LLM calls = %d, want 1", client.calls) } - warnings := a.Warnings() - if len(warnings) != 1 || warnings[0].Type != "subtask_error" || warnings[0].File != diff.NewPath { - t.Fatalf("warnings = %+v, want one subtask_error for %s", warnings, diff.NewPath) + // The runner also records the spent round budget, with its incomplete + // verdict; the subtask_error is the failure diagnostic this test is about. + var limitWarnings, subtaskErrors []AgentWarning + for _, w := range a.Warnings() { + switch w.Type { + case llmloop.WarningReviewLimitReached: + limitWarnings = append(limitWarnings, w) + case "subtask_error": + subtaskErrors = append(subtaskErrors, w) + } + } + if len(limitWarnings) != 1 || !strings.Contains(limitWarnings[0].Message, "did not complete the task") { + t.Fatalf("limit warnings = %+v, want one incomplete verdict", limitWarnings) + } + if len(subtaskErrors) != 1 || subtaskErrors[0].File != diff.NewPath { + t.Fatalf("subtask errors = %+v, want one for %s", subtaskErrors, diff.NewPath) } sess.Finalize() @@ -1220,9 +1239,14 @@ func TestDispatchSubtasks_IncompleteMainTaskMarksPartialFailure(t *testing.T) { if err != nil { t.Fatalf("one completed file should keep the review partial, got %v", err) } - warnings := a.Warnings() - if len(warnings) != 1 || warnings[0].Type != "subtask_error" || warnings[0].File != "incomplete.go" { - t.Fatalf("warnings = %+v, want one subtask_error for incomplete.go", warnings) + var subtaskErrors []AgentWarning + for _, w := range a.Warnings() { + if w.Type == "subtask_error" { + subtaskErrors = append(subtaskErrors, w) + } + } + if len(subtaskErrors) != 1 || subtaskErrors[0].File != "incomplete.go" { + t.Fatalf("subtask errors = %+v, want one for incomplete.go", subtaskErrors) } } diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go index f035db8da..0cab28833 100644 --- a/internal/agent/coverage_test.go +++ b/internal/agent/coverage_test.go @@ -128,7 +128,8 @@ func TestInjectDiffMap(t *testing.T) { }) a.diffs = []model.Diff{ {NewPath: "main.go", OldPath: "main.go", Diff: "+new code"}, - {NewPath: "/dev/null", OldPath: "deleted.go", Diff: "-deleted"}, + {NewPath: "/dev/null", OldPath: "deleted.go", Diff: "-deleted", IsDeleted: true}, + {NewPath: "renamed.go", OldPath: "old_name.go", Diff: "+renamed", IsRenamed: true}, } a.injectDiffMap() @@ -146,8 +147,15 @@ func TestInjectDiffMap(t *testing.T) { result2, _ := frd.Execute(context.Background(), map[string]any{ "path_array": []any{"deleted.go"}, }) - if !strings.Contains(result2, "not found") { - t.Errorf("/dev/null path should not be in DiffMap, got: %q", result2) + if !strings.Contains(result2, "-deleted") { + t.Errorf("DiffMap did not contain deleted.go diff, got: %q", result2) + } + + result3, _ := frd.Execute(context.Background(), map[string]any{ + "path_array": []any{"renamed.go"}, + }) + if !strings.Contains(result3, "+renamed") { + t.Errorf("DiffMap did not contain renamed.go diff, got: %q", result3) } } @@ -949,6 +957,7 @@ func TestClassifyItemError(t *testing.T) { }{ {"deadline", context.DeadlineExceeded, session.FailureTimeout}, {"deadline_wrapped", fmt.Errorf("review %s: %w", secret, context.DeadlineExceeded), session.FailureTimeout}, + {"request_timeout", fmt.Errorf("%w: %w", llm.ErrRequestTimeout, context.DeadlineExceeded), session.FailureTimeout}, {"cancelled", context.Canceled, session.FailureCancelled}, {"cancelled_wrapped", fmt.Errorf("aborted %s: %w", secret, context.Canceled), session.FailureCancelled}, {"main_task_empty", errMainTaskEmpty, session.FailureConfiguration}, @@ -963,6 +972,12 @@ func TestClassifyItemError(t *testing.T) { if reason == "" { t.Error("reason is empty; a static safe reason is required") } + if errors.Is(tc.err, llm.ErrRequestTimeout) && !strings.Contains(reason, "OCR_LLM_TIMEOUT") { + t.Errorf("request timeout reason = %q", reason) + } + if errors.Is(tc.err, context.DeadlineExceeded) && !errors.Is(tc.err, llm.ErrRequestTimeout) && !strings.Contains(reason, "--timeout") { + t.Errorf("task deadline reason = %q", reason) + } if strings.Contains(reason, "sk-LEAKED-SECRET") || strings.Contains(reason, "/home/alice") { t.Errorf("reason leaked raw error text: %q", reason) } diff --git a/internal/chatgptauth/api_error.go b/internal/chatgptauth/api_error.go new file mode 100644 index 000000000..b676b257c --- /dev/null +++ b/internal/chatgptauth/api_error.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "unicode" +) + +type APIError struct { + Status int + Code string + Param string + RequestID string + Shape string + Detail string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("ChatGPT API request failed: status=%d code=%q param=%q request_id=%q body_shape=%s detail=%q", e.Status, e.Code, e.Param, e.RequestID, e.Shape, e.Detail) +} + +var apiSecretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\bBearer\s+[^\s,;"']+`), + regexp.MustCompile(`(?i)\b(?:access_token|refresh_token|id_token|api_key|token|authorization)\s*[=:]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)`), + regexp.MustCompile(`\bsk-[A-Za-z0-9_-]+`), + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`), +} + +func diagnosticText(text string, auth *Auth) string { + clean := func(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { + return -1 + } + return r + }, strings.ToValidUTF8(s, "")) + } + text = clean(text) + for _, secret := range []string{auth.AccessToken, auth.RefreshToken, auth.IDToken} { + if secret = clean(secret); secret != "" { + text = strings.ReplaceAll(text, secret, "[REDACTED]") + } + } + for _, pattern := range apiSecretPatterns { + text = pattern.ReplaceAllString(text, "[REDACTED]") + } + if len(text) > 1024 { + text = strings.ToValidUTF8(text[:1024], "") + "... (truncated)" + } + return text +} +func catalogError(status int, requestID string, body []byte, auth *Auth) error { + e := &APIError{Status: status, RequestID: diagnosticText(requestID, auth), Shape: "non_json"} + if !json.Valid(body) { + return e + } + switch bytes.TrimSpace(body)[0] { + case '[': + e.Shape = "array" + case '"': + e.Shape = "string" + case 'n': + e.Shape = "null" + case 't', 'f': + e.Shape = "boolean" + case '{': + e.Shape = "object" + default: + e.Shape = "number" + } + if e.Shape != "object" { + return e + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return e + } + readString := func(b json.RawMessage) string { + var text string + _ = json.Unmarshal(b, &text) + return diagnosticText(text, auth) + } + if raw, ok := fields["error"]; ok { + var nested map[string]json.RawMessage + if json.Unmarshal(raw, &nested) == nil && nested != nil { + e.Shape = "error_object" + e.Code = readString(nested["code"]) + e.Param = readString(nested["param"]) + e.Detail = readString(nested["message"]) + } else { + var text string + if json.Unmarshal(raw, &text) == nil { + e.Shape = "error_string" + e.Detail = diagnosticText(text, auth) + } + } + } + if raw, ok := fields["detail"]; ok { + if e.Shape == "object" { + e.Shape = "detail" + } + if text := readString(raw); text != "" { + if e.Detail != "" { + e.Detail += "; " + } + e.Detail += text + } + } + return e +} +func (c *Client) catalogRequest(req *http.Request, auth *Auth, target any) error { + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("ChatGPT API transport failed: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1)) + if err != nil { + return err + } + if len(body) > 1<<20 { + return &APIError{Status: resp.StatusCode, RequestID: diagnosticText(resp.Header.Get("x-request-id"), auth), Shape: "oversized", Detail: "response exceeded size limit"} + } + if resp.StatusCode != http.StatusOK { + return catalogError(resp.StatusCode, resp.Header.Get("x-request-id"), body, auth) + } + if json.Unmarshal(body, target) != nil { + return fmt.Errorf("invalid ChatGPT API response JSON") + } + return nil +} diff --git a/internal/chatgptauth/api_error_test.go b/internal/chatgptauth/api_error_test.go new file mode 100644 index 000000000..63547fb4f --- /dev/null +++ b/internal/chatgptauth/api_error_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "unicode/utf8" +) + +type failedCatalogBody struct{} + +func (failedCatalogBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (failedCatalogBody) Close() error { return nil } + +func TestCatalogErrorBoundsAndShapes(t *testing.T) { + a := testAuth() + a.IDToken = "long-id-token" + for _, tc := range []struct{ body, shape string }{ + {`[]`, "array"}, {`"unsafe access"`, "string"}, {`123`, "number"}, {`null`, "null"}, {`true`, "boolean"}, + {`{"other":"access"}`, "object"}, {`{"error":{},"detail":"route restriction"}`, "error_object"}, + } { + e := catalogError(401, "req", []byte(tc.body), &a).(*APIError) + if e.Shape != tc.shape || strings.Contains(e.Error(), a.AccessToken) { + t.Fatal(e) + } + if strings.Contains(tc.body, "route restriction") && e.Detail != "route restriction" { + t.Fatal(e) + } + } + raw := "safe \x1b\n\r\t\x00 text access refresh long-id-token token='other secret' " + strings.Repeat("z", 1023) + "\u20ac" + got := diagnosticText(raw, &a) + if strings.ContainsAny(got, "\x1b\n\r\t\x00") || !utf8.ValidString(got) || len(got) > 1040 || !strings.HasSuffix(got, "... (truncated)") { + t.Fatal("invalid bounded diagnostic") + } + for _, secret := range []string{"access", "refresh", "long-id-token", "other secret"} { + if strings.Contains(got, secret) { + t.Fatal("secret in bounded diagnostic") + } + } +} + +func TestCatalogRequestFailurePaths(t *testing.T) { + a := testAuth() + for _, scenario := range []string{"network", "read", "oversized-error", "oversized-success", "invalid-json"} { + t.Run(scenario, func(t *testing.T) { + c := NewClient() + c.http = &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + if scenario == "network" { + return nil, errors.New("network unavailable") + } + status := 200 + body := io.NopCloser(strings.NewReader("invalid-json")) + switch scenario { + case "read": + body = failedCatalogBody{} + case "oversized-error": + status = 403 + body = io.NopCloser(strings.NewReader(strings.Repeat("x", (1<<20)+1))) + case "oversized-success": + body = io.NopCloser(strings.NewReader(strings.Repeat("x", (1<<20)+1))) + } + return &http.Response{StatusCode: status, Header: http.Header{"X-Request-Id": {"req-bound"}}, Body: body}, nil + })} + _, err := c.Models(context.Background(), &a) + if err == nil { + t.Fatal("invalid response accepted") + } + if strings.HasPrefix(scenario, "oversized") { + var api *APIError + if !errors.As(err, &api) || api.Shape != "oversized" || api.RequestID != "req-bound" || len(err.Error()) > 2048 { + t.Fatal(err) + } + } + }) + } +} diff --git a/internal/chatgptauth/auth_test.go b/internal/chatgptauth/auth_test.go new file mode 100644 index 000000000..a7bd4ce29 --- /dev/null +++ b/internal/chatgptauth/auth_test.go @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func testStore(t *testing.T) Store { t.Helper(); return Store{Dir: filepath.Join(t.TempDir(), "auth")} } +func testAuth() Auth { + return Auth{ClientID: "oaiapp_one", Issuer: Issuer, Subject: "subject", Nonce: "nonce", Email: "test@example.invalid", AccessToken: "access", RefreshToken: "refresh", IDToken: "id", Scopes: strings.Fields(Scope), ExpiresAt: time.Now().Add(time.Hour), TokenType: "Bearer"} +} +func saveTest(t *testing.T, s Store, a Auth) { + t.Helper() + if err := s.withLock(context.Background(), func() error { + d, err := s.load() + if err != nil { + return err + } + d.Accounts = append(d.Accounts, a) + d.Active = a.ClientID + return s.save(d) + }); err != nil { + t.Fatal(err) + } +} +func TestStoreLifecycle(t *testing.T) { + s := testStore(t) + ctx := context.Background() + h, err := s.Host(ctx) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(h, "urn:uuid:") { + t.Fatal(h) + } + again, err := s.Host(ctx) + if err != nil || h != again { + t.Fatal(again, err) + } + a := testAuth() + saveTest(t, s, a) + b := a + b.ClientID = "oaiapp_two" + saveTest(t, s, b) + d, err := s.Snapshot() + if err != nil || len(d.Accounts) != 2 || d.Active != b.ClientID { + t.Fatal(d, err) + } + for _, f := range []string{"accounts.json", "host.json", "session.lock"} { + info, err := os.Stat(filepath.Join(s.Dir, f)) + if err != nil || !info.Mode().IsRegular() { + t.Fatal(f, info, err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { + t.Fatal(f, info.Mode()) + } + } + info, err := os.Stat(s.Dir) + if err != nil || !info.IsDir() { + t.Fatal(info, err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0700 { + t.Fatal(info.Mode()) + } + if err := s.Clear(ctx, b.ClientID); err != nil { + t.Fatal(err) + } + a2, err := s.Selected("") + if err != nil || a2.AccessToken != "" || a2.IDToken != "" || a2.Subject != a.Subject { + t.Fatal(a2, err) + } + h2, _ := s.Host(ctx) + if h2 != h { + t.Fatal(h2) + } +} +func TestStoreCorruptionAndLockCancellation(t *testing.T) { + s := testStore(t) + if _, err := s.Selected(""); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if err := s.withLock(context.Background(), func() error { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + return s.withLock(ctx, func() error { t.Fatal("lock acquired twice"); return nil }) + }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(s.Dir, "accounts.json"), []byte("bad"), 0600); err != nil { + t.Fatal(err) + } + if _, err := s.Snapshot(); err == nil { + t.Fatal("corrupt DB accepted") + } + if err := os.WriteFile(filepath.Join(s.Dir, "host.json"), []byte(`{"id":"bad"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err := s.Host(context.Background()); err == nil { + t.Fatal("bad host accepted") + } +} + +type oauthFixture struct { + client *Client + key *rsa.PrivateKey + server *httptest.Server + nonce string + claims map[string]any + calls atomic.Int32 + token func(*http.Request) any +} + +func fixture(t *testing.T) *oauthFixture { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + f := &oauthFixture{key: key, nonce: "nonce"} + f.claims = map[string]any{"iss": Issuer, "sub": "subject", "aud": "oaiapp_one", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "nonce": f.nonce, "email": "test@example.invalid"} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + json.NewEncoder(w).Encode(map[string]any{"issuer": Issuer, "jwks_uri": f.server.URL + "/.well-known/jwks.json", "revocation_endpoint": f.server.URL + "/revoke"}) + case "/.well-known/jwks.json": + json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{"kid": "key", "kty": "RSA", "alg": "RS256", "use": "sig", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": "AQAB"}}}) + case "/api/accounts/oauth/token": + f.calls.Add(1) + r.ParseForm() + if f.token != nil { + json.NewEncoder(w).Encode(f.token(r)) + return + } + json.NewEncoder(w).Encode(f.tokens()) + case "/revoke": + r.ParseForm() + if r.Form.Get("client_id") != "oaiapp_one" || r.Form.Get("token") != "refresh" { + t.Error("bad revocation form") + } + w.WriteHeader(200) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(404) + } + })) + t.Cleanup(f.server.Close) + f.client = NewClient() + f.client.base = f.server.URL + f.client.http = f.server.Client() + return f +} +func (f *oauthFixture) jwt() string { + h := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"key"}`)) + b, _ := json.Marshal(f.claims) + p := h + "." + base64.RawURLEncoding.EncodeToString(b) + d := sha256.Sum256([]byte(p)) + s, _ := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, d[:]) + return p + "." + base64.RawURLEncoding.EncodeToString(s) +} +func (f *oauthFixture) tokens() map[string]any { + return map[string]any{"access_token": "access-new", "refresh_token": "refresh-new", "id_token": f.jwt(), "token_type": "Bearer", "expires_in": 3600, "scope": Scope} +} +func TestVerifyIDToken(t *testing.T) { + f := fixture(t) + if _, err := f.client.verify(context.Background(), f.jwt(), "oaiapp_one", "nonce"); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + field string + value any + }{{"iss", "other"}, {"aud", "wrong"}, {"exp", int64(1)}, {"nonce", "wrong"}, {"sub", ""}, {"iat", nil}, {"nbf", time.Now().Add(time.Hour).Unix()}, {"aud", []string{"oaiapp_one", "other"}}, {"azp", "other"}} { + t.Run(tc.field+fmt.Sprint(tc.value), func(t *testing.T) { + old, ok := f.claims[tc.field] + f.claims[tc.field] = tc.value + defer func() { + if ok { + f.claims[tc.field] = old + } else { + delete(f.claims, tc.field) + } + }() + if _, err := f.client.verify(context.Background(), f.jwt(), "oaiapp_one", "nonce"); err == nil { + t.Fatal("invalid claim accepted") + } + }) + } + good := f.jwt() + parts := strings.Split(good, ".") + parts[2] = base64.RawURLEncoding.EncodeToString([]byte("bad")) + if _, err := f.client.verify(context.Background(), strings.Join(parts, "."), "oaiapp_one", "nonce"); err == nil { + t.Fatal("bad signature accepted") + } + for _, bad := range []string{"bad", "e30.e30.e30", strings.Replace(good, parts[0], base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","kid":"key"}`)), 1)} { + if _, err := f.client.verify(context.Background(), bad, "oaiapp_one", "nonce"); err == nil { + t.Fatal("bad JWT accepted") + } + } + f.claims["aud"] = []string{"oaiapp_one", "other"} + f.claims["azp"] = "oaiapp_one" + if _, err := f.client.verify(context.Background(), f.jwt(), "oaiapp_one", "nonce"); err != nil { + t.Fatal(err) + } +} +func TestAuthorizationAndCallbacks(t *testing.T) { + c := NewClient() + a := attempt{state: "state", nonce: "nonce", verifier: "verifier", redirect: "http://127.0.0.1:123/auth/callback", host: "urn:uuid:fixture"} + u, _ := url.Parse(c.authorize(a, nil)) + q := u.Query() + if q.Get("client_id") != DynamicClient || q.Get("agent_name_hint") != "OpenCodeReview" || q.Get("resource") != Resource || q.Get("scope") != Scope || q.Get("nonce") != "nonce" || q.Get("code_challenge") == "" { + t.Fatal(q) + } + old := testAuth() + u, _ = url.Parse(c.authorize(a, &old)) + q = u.Query() + if q.Get("client_id") != old.ClientID || q.Get("agent_name_hint") != "" || q.Get("id_token_hint") != "id" { + t.Fatal(q) + } + old.IDToken = "" + u, _ = url.Parse(c.authorize(a, &old)) + if u.Query().Get("id_token_hint") != "" { + t.Fatal("logout hint") + } + for _, tc := range []struct { + query string + old *Auth + ok bool + }{{"state=state&code=code&client_id=oaiapp_one", nil, true}, {"state=state&code=code", nil, false}, {"state=wrong&code=code&client_id=oaiapp_one", nil, false}, {"state=state&error=access_denied", nil, false}, {"state=state&code=code&client_id=dynamic_agent_client", nil, false}, {"state=state&code=code", &old, true}, {"state=state&code=code&client_id=oaiapp_other", &old, false}, {"state=state&state=state&code=code", &old, false}} { + q, _ := url.ParseQuery(tc.query) + _, _, err := a.callback(q, tc.old) + if (err == nil) != tc.ok { + t.Fatalf("%s: %v", tc.query, err) + } + } +} +func TestExchangeIdentityAndScopes(t *testing.T) { + f := fixture(t) + a := attempt{nonce: "nonce", verifier: "verifier", redirect: "http://127.0.0.1:44/auth/callback"} + f.token = func(r *http.Request) any { + if r.Form.Get("resource") != Resource || r.Form.Get("client_id") != "oaiapp_one" || r.Form.Get("redirect_uri") != a.redirect || r.Form.Get("code_verifier") != a.verifier { + t.Error("bad code form") + } + return f.tokens() + } + auth, err := f.client.exchange(context.Background(), a, "code", "oaiapp_one", nil) + if err != nil || !auth.PlanEnabled() { + t.Fatal(auth, err) + } + old := testAuth() + f.claims["sub"] = "other" + if _, err := f.client.exchange(context.Background(), a, "code", "oaiapp_one", &old); err == nil { + t.Fatal("identity changed") + } + f.claims["sub"] = "subject" + f.token = func(r *http.Request) any { b := f.tokens(); b["scope"] = "openid email"; return b } + auth, err = f.client.exchange(context.Background(), a, "code", "oaiapp_one", nil) + if err != nil || auth.PlanEnabled() { + t.Fatal(auth, err) + } +} +func TestRefreshRotationAndConcurrency(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + a.ExpiresAt = time.Now().Add(-time.Minute) + saveTest(t, s, a) + f.token = func(r *http.Request) any { + if r.Form.Get("scope") != "" || r.Form.Get("resource") != Resource || r.Form.Get("client_id") != a.ClientID || r.Form.Get("refresh_token") != "refresh" { + t.Error("bad refresh form") + } + b := f.tokens() + delete(b, "id_token") + delete(b, "scope") + return b + } + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + r, err := f.client.Credentials(context.Background(), s, "") + if err != nil || r.RefreshToken != "refresh-new" || r.IDToken != "id" || !r.PlanEnabled() { + t.Error(r, err) + } + }() + } + wg.Wait() + if f.calls.Load() != 1 { + t.Fatal(f.calls.Load()) + } +} +func TestRefreshRejectsIncompleteRotation(t *testing.T) { + for _, field := range []string{"access_token", "refresh_token", "expires_in", "token_type"} { + t.Run(field, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + a.ExpiresAt = time.Now().Add(-time.Minute) + saveTest(t, s, a) + f.token = func(r *http.Request) any { b := f.tokens(); delete(b, field); return b } + if _, err := f.client.Credentials(context.Background(), s, ""); err == nil { + t.Fatal("incomplete rotation accepted") + } + old, _ := s.Selected("") + if old.RefreshToken != "refresh" { + t.Fatal("prior tokens lost") + } + }) + } +} +func TestLogout(t *testing.T) { + f := fixture(t) + s := testStore(t) + saveTest(t, s, testAuth()) + if err := f.client.Logout(context.Background(), s, ""); err != nil { + t.Fatal(err) + } + a, err := s.Selected("") + if err != nil || a.AccessToken != "" || a.ClientID != "oaiapp_one" { + t.Fatal(a, err) + } +} + +func TestSigningKeyCacheAndCancellation(t *testing.T) { + f := fixture(t) + transport := f.client.http.Transport + var discoveries, sets atomic.Int32 + f.client.http.Transport = roundTrip(func(r *http.Request) (*http.Response, error) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + discoveries.Add(1) + case "/.well-known/jwks.json": + sets.Add(1) + } + return transport.RoundTrip(r) + }) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + key, err := f.client.signingKey(context.Background(), "key") + if err != nil || key == nil || key.N.Cmp(f.key.N) != 0 { + t.Error("cached signing key did not match", err) + } + }() + } + wg.Wait() + if discoveries.Load() != 1 || sets.Load() != 1 { + t.Fatal("concurrent lookups duplicated key discovery", discoveries.Load(), sets.Load()) + } + f.client.keys.until = time.Now().Add(-time.Minute) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := f.client.signingKey(ctx, "key"); !errors.Is(err, context.Canceled) { + t.Fatal("canceled discovery did not fail", err) + } + if _, err := f.client.signingKey(context.Background(), "key"); err != nil { + t.Fatal("discovery did not recover after cancellation", err) + } + if sets.Load() != 2 { + t.Fatal("expired cache did not fetch replacement keys", sets.Load()) + } +} diff --git a/internal/chatgptauth/browser_unix.go b/internal/chatgptauth/browser_unix.go new file mode 100644 index 000000000..1b60547fa --- /dev/null +++ b/internal/chatgptauth/browser_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "os/exec" + "runtime" +) + +func openBrowserURL(ctx context.Context, target string) error { + if err := ctx.Err(); err != nil { + return err + } + cmd := "xdg-open" + if runtime.GOOS == "darwin" { + cmd = "open" + } + // A browser may outlive the login; cancelling the callback must not close it. + return exec.Command(cmd, target).Run() +} diff --git a/internal/chatgptauth/browser_windows.go b/internal/chatgptauth/browser_windows.go new file mode 100644 index 000000000..e46ac80ab --- /dev/null +++ b/internal/chatgptauth/browser_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "os/exec" +) + +func openBrowserURL(ctx context.Context, target string) error { + if err := ctx.Err(); err != nil { + return err + } + return exec.Command("rundll32", "url.dll,FileProtocolHandler", target).Run() +} diff --git a/internal/chatgptauth/io_test.go b/internal/chatgptauth/io_test.go new file mode 100644 index 000000000..41d461f8d --- /dev/null +++ b/internal/chatgptauth/io_test.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestDefaultStoreAndInvalidStores(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + s, err := DefaultStore() + if err != nil || s.Dir != filepath.Join(home, ".opencodereview", "auth", "chatgpt") { + t.Fatal(s, err) + } + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + if _, err := DefaultStore(); err == nil { + t.Fatal("empty home directory") + } + if _, err := (Store{}).Host(context.Background()); err == nil { + t.Fatal("empty store dir") + } + bad := filepath.Join(t.TempDir(), "file") + os.WriteFile(bad, []byte("x"), 0600) + if err := (Store{Dir: bad}).withLock(context.Background(), func() error { return nil }); err == nil { + t.Fatal("store is a file") + } + s = testStore(t) + s.secure() + if err := s.write("invalid", make(chan int)); err == nil { + t.Fatal("marshal accepted channel") + } + if err := s.Select(context.Background(), ""); err == nil { + t.Fatal("empty account") + } + if err := s.Clear(context.Background(), ""); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + for _, content := range []string{`{"accounts":[{"client_id":"dynamic_agent_client","issuer":"https://auth.openai.com","subject":"s"}]}`, `{"active":"missing","accounts":[]}`, `{"accounts":[{"client_id":"oaiapp","issuer":"other","subject":"s"}]}`} { + os.WriteFile(filepath.Join(s.Dir, "accounts.json"), []byte(content), 0600) + if _, err := s.Snapshot(); err == nil { + t.Fatal("invalid registration accepted") + } + } + os.Remove(filepath.Join(s.Dir, "accounts.json")) + os.Mkdir(filepath.Join(s.Dir, "accounts.json"), 0700) + if _, err := s.Selected(""); err == nil { + t.Fatal("store path is directory") + } + os.Remove(filepath.Join(s.Dir, "accounts.json")) + os.WriteFile(filepath.Join(s.Dir, "host.json"), []byte("bad"), 0600) + if _, err := s.Host(context.Background()); err == nil { + t.Fatal("invalid host JSON") + } +} +func TestLogoutRevocationRetriesAndCancellation(t *testing.T) { + for _, scenario := range []string{"discovery", "revoke", "permanent", "cancel", "signed-out"} { + t.Run(scenario, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + if scenario == "signed-out" { + a.clear() + } + saveTest(t, s, a) + baseTransport := f.client.http.Transport + attempts := 0 + ctx := context.Background() + var cancel context.CancelFunc + if scenario == "cancel" { + ctx, cancel = context.WithTimeout(ctx, 20*time.Millisecond) + defer cancel() + } + f.client.http.Transport = roundTrip(func(r *http.Request) (*http.Response, error) { + if scenario == "signed-out" { + t.Error("signed-out revocation called") + } + if (scenario == "discovery" && strings.Contains(r.URL.Path, "openid")) || (scenario != "discovery" && r.URL.Path == "/revoke") { + attempts++ + if scenario == "permanent" { + return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader(`{"error":"invalid_client"}`)), Header: make(http.Header)}, nil + } + if attempts < 2 || scenario == "cancel" { + return &http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader(`{"error":"server_error"}`)), Header: make(http.Header)}, nil + } + } + return baseTransport.RoundTrip(r) + }) + err := f.client.Logout(ctx, s, "") + if scenario == "cancel" || scenario == "permanent" { + if err == nil { + t.Fatal("unconfirmed revocation ignored") + } + } else if err != nil { + t.Fatal(err) + } + stored, _ := s.Selected("") + if stored.AccessToken != "" || stored.IDToken != "" { + t.Fatal("tokens retained") + } + if scenario == "discovery" || scenario == "revoke" { + if attempts != 2 { + t.Fatal(attempts) + } + } + }) + } +} +func TestOAuthNoRedirects(t *testing.T) { + c := NewClient() + r := &http.Request{} + if err := c.http.CheckRedirect(r, nil); err != http.ErrUseLastResponse { + t.Fatal(err) + } +} +func TestBrowserOpenerUsesExecutableAndWaits(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix executable fixture") + } + dir := t.TempDir() + name := "xdg-open" + if runtime.GOOS == "darwin" { + name = "open" + } + marker := filepath.Join(dir, "opened") + script := fmt.Sprintf("#!/bin/sh\nprintf '%%s' \"$1\" > '%s'\n", marker) + if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + if err := openBrowserURL(context.Background(), "http://127.0.0.1/fixture"); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(marker) + if string(b) != "http://127.0.0.1/fixture" { + t.Fatal(string(b)) + } + os.Remove(filepath.Join(dir, name)) + if err := openBrowserURL(context.Background(), "http://127.0.0.1/fixture"); err == nil { + t.Fatal("missing browser opener") + } +} diff --git a/internal/chatgptauth/jwt.go b/internal/chatgptauth/jwt.go new file mode 100644 index 000000000..c63103317 --- /dev/null +++ b/internal/chatgptauth/jwt.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "strings" + "sync" + "time" +) + +type identity struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Email string `json:"email"` + Audience json.RawMessage `json:"aud"` + AuthorizedParty string `json:"azp"` + Expires int64 `json:"exp"` + Issued int64 `json:"iat"` + NotBefore int64 `json:"nbf"` + Nonce string `json:"nonce"` +} +type jwk struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` +} +type keyCache struct { + mu sync.Mutex + keys []jwk + until time.Time +} + +type invalidIDTokenError string + +func (e invalidIDTokenError) Error() string { return string(e) } + +func (c *Client) signingKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + c.keys.mu.Lock() + defer c.keys.mu.Unlock() + find := func() (jwk, bool) { + for _, k := range c.keys.keys { + if k.Kid == kid { + return k, true + } + } + return jwk{}, false + } + k, ok := find() + if !ok || time.Now().After(c.keys.until) { + var d discovery + if err := c.discover(ctx, &d); err != nil { + return nil, err + } + var set struct { + Keys []jwk `json:"keys"` + } + if err := c.get(ctx, d.JWKS, &set); err != nil { + return nil, err + } + c.keys.keys = set.Keys + c.keys.until = time.Now().Add(5 * time.Minute) + k, ok = find() + } + if !ok || k.Kty != "RSA" || k.Alg != "RS256" || (k.Use != "" && k.Use != "sig") { + return nil, errors.New("ID-token signing key is not supported") + } + n, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + return nil, errors.New("invalid RSA signing key") + } + e, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil || len(e) == 0 || len(e) > 4 { + return nil, errors.New("invalid RSA exponent") + } + var exponent int64 + for _, b := range e { + exponent = exponent*256 + int64(b) + } + modulus := new(big.Int).SetBytes(n) + if modulus.BitLen() < 2048 || exponent < 3 || exponent > 1<<31-1 || exponent%2 == 0 { + return nil, errors.New("invalid RSA signing key") + } + return &rsa.PublicKey{N: modulus, E: int(exponent)}, nil +} +func (c *Client) verify(ctx context.Context, token, clientID, nonce string) (identity, error) { + return c.verifyAt(ctx, token, clientID, nonce, time.Time{}) +} + +func (c *Client) verifyAt(ctx context.Context, token, clientID, nonce string, receivedAt time.Time) (identity, error) { + var claims identity + p := strings.Split(token, ".") + if len(p) != 3 || len(token) > 1<<20 { + return claims, invalidIDTokenError("invalid ID token") + } + decode := func(s string, v any) error { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return err + } + return json.Unmarshal(b, v) + } + var header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Crit []string `json:"crit"` + } + if err := decode(p[0], &header); err != nil || header.Alg != "RS256" || header.Kid == "" || len(header.Crit) > 0 { + return claims, invalidIDTokenError("invalid ID-token signing header") + } + key, err := c.signingKey(ctx, header.Kid) + if err != nil { + return claims, err + } + sig, err := base64.RawURLEncoding.DecodeString(p[2]) + if err != nil { + return claims, invalidIDTokenError("invalid ID-token signature") + } + digest := sha256.Sum256([]byte(p[0] + "." + p[1])) + if rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig) != nil { + return claims, invalidIDTokenError("invalid ID-token signature") + } + if err := decode(p[1], &claims); err != nil { + return claims, invalidIDTokenError("invalid ID-token claims") + } + var audiences []string + var single string + if json.Unmarshal(claims.Audience, &single) == nil { + audiences = []string{single} + } else if json.Unmarshal(claims.Audience, &audiences) != nil { + return claims, invalidIDTokenError("invalid ID-token audience") + } + found := false + for _, a := range audiences { + found = found || a == clientID + } + now := time.Now().Unix() + if !receivedAt.IsZero() { + // A persisted refresh response was received over the issuer's authenticated + // connection. Verify its lifetime at receipt, not after a network outage. + now = receivedAt.Unix() + } + if claims.Issuer != Issuer || claims.Subject == "" || !found || claims.Expires <= now || claims.Issued <= 0 || claims.Issued > now+5 || claims.NotBefore > now+5 || (claims.AuthorizedParty != "" && claims.AuthorizedParty != clientID) || (len(audiences) > 1 && claims.AuthorizedParty != clientID) { + return claims, invalidIDTokenError("ID-token issuer, audience, subject or lifetime did not match") + } + if nonce != "" && claims.Nonce != nonce { + return claims, invalidIDTokenError("ID-token nonce did not match") + } + return claims, nil +} diff --git a/internal/chatgptauth/lifecycle_test.go b/internal/chatgptauth/lifecycle_test.go new file mode 100644 index 000000000..50441d7d9 --- /dev/null +++ b/internal/chatgptauth/lifecycle_test.go @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoopbackLogin(t *testing.T) { + for _, scenario := range []string{"dynamic", "return", "missing-id", "changed-id", "bad-state", "denied", "wrong-identity", "bad-signature", "disabled-plan", "bad-host"} { + t.Run(scenario, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + returning := scenario == "return" || scenario == "changed-id" || scenario == "wrong-identity" || scenario == "bad-signature" + if returning { + saveTest(t, s, testAuth()) + } + previousBrowser := openBrowser + defer func() { openBrowser = previousBrowser }() + openBrowser = func(_ context.Context, local string) error { + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + r, err := client.Get(local) + if err != nil { + return err + } + r.Body.Close() + authorize, err := r.Location() + if err != nil { + return err + } + q := authorize.Query() + f.claims["nonce"] = q.Get("nonce") + redirect := q.Get("redirect_uri") + u, _ := url.Parse(redirect) + if u.Hostname() != "127.0.0.1" || u.Path != "/auth/callback" { + t.Fatal(redirect) + } + if returning && q.Get("client_id") != "oaiapp_one" { + t.Fatal(q) + } + if !returning && q.Get("client_id") != DynamicClient { + t.Fatal(q) + } + host, err := os.ReadFile(filepath.Join(s.Dir, "host.json")) + if err != nil || !bytes.Contains(host, []byte(q.Get("ext_agent_host_id"))) { + t.Fatal("host not saved before authorization", err) + } + f.token = func(r *http.Request) any { + if r.Form.Get("redirect_uri") != redirect || r.Form.Get("resource") != Resource || r.Form.Get("client_id") != "oaiapp_one" { + t.Error("exchange mismatch") + } + if r.Form.Get("code_verifier") == "" { + t.Error("missing PKCE") + } + b := f.tokens() + if scenario == "bad-signature" { + b["id_token"] = f.jwt() + "bad" + } + if scenario == "disabled-plan" { + b["scope"] = "openid profile email offline_access" + } + return b + } + callback := url.Values{"state": {q.Get("state")}, "code": {"code"}, "client_id": {"oaiapp_one"}} + switch scenario { + case "missing-id": + callback.Del("client_id") + case "return": + callback.Del("client_id") + case "changed-id": + callback.Set("client_id", "oaiapp_other") + case "bad-state": + callback.Set("state", "wrong") + case "denied": + callback.Set("error", "access_denied") + callback.Del("code") + case "wrong-identity": + f.claims["sub"] = "different" + } + req, _ := http.NewRequest("GET", redirect+"?"+callback.Encode(), nil) + if scenario == "bad-host" { + req.Host = "evil.invalid" + cancel() + } + r, err = client.Do(req) + if err == nil { + r.Body.Close() + } + if scenario == "bad-state" || scenario == "missing-id" || scenario == "changed-id" { + cancel() + } + return nil + } + var output bytes.Buffer + auth, err := f.client.Login(ctx, s, "", false, false, false, &output) + success := scenario == "dynamic" || scenario == "return" || scenario == "disabled-plan" + if (err == nil) != success { + t.Fatal(scenario, err) + } + if success { + if auth.ClientID != "oaiapp_one" { + t.Fatal(auth) + } + if scenario == "disabled-plan" { + if auth.PlanEnabled() { + t.Fatal("missing grant allowed") + } + if _, err := f.client.Credentials(ctx, s, ""); err == nil { + t.Fatal("inference allowed without direct scope") + } + } + } else if returning { + old, err := s.Selected("") + if err != nil || old.RefreshToken != "refresh" { + t.Fatal("failed login replaced prior credentials", err) + } + } + if strings.Contains(output.String(), "id_token_hint") || strings.Contains(output.String(), "access-new") { + t.Fatal("credentials in output") + } + }) + } +} + +func TestRefreshFailuresAndCancellation(t *testing.T) { + for _, scenario := range []string{"terminal", "temporary", "cancel", "identity", "nonce", "new-id", "new-scope", "early"} { + t.Run(scenario, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + a.Nonce = "nonce" + a.ExpiresAt = time.Now().Add(-time.Minute) + if scenario == "early" { + a.ExpiresAt = time.Now().Add(time.Minute) + a.EarliestRefreshAt = time.Now().Add(30 * time.Second) + } + saveTest(t, s, a) + if scenario == "terminal" || scenario == "temporary" || scenario == "cancel" { + f.client.http = &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + if scenario == "cancel" { + <-r.Context().Done() + return nil, r.Context().Err() + } + status := 400 + code := "invalid_grant" + if scenario == "temporary" { + status = 503 + code = "server_error" + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"` + code + `"}`))}, nil + })} + } else { + f.token = func(r *http.Request) any { + b := f.tokens() + switch scenario { + case "identity": + f.claims["sub"] = "other" + b["id_token"] = f.jwt() + case "nonce": + f.claims["nonce"] = "other" + b["id_token"] = f.jwt() + case "new-scope": + b["scope"] = "openid email" + case "new-id": + delete(f.claims, "nonce") + b["id_token"] = f.jwt() + } + return b + } + } + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + if scenario != "cancel" { + cancel() + ctx = context.Background() + } else { + defer cancel() + } + result, err := f.client.Credentials(ctx, s, "") + success := scenario == "new-id" || scenario == "early" + if (err == nil) != success { + t.Fatal(result, err) + } + saved, loadErr := s.Selected("") + if loadErr != nil { + t.Fatal(loadErr) + } + if scenario == "terminal" && saved.AccessToken != "" { + t.Fatal("terminal tokens retained") + } + if (scenario == "temporary" || scenario == "cancel" || scenario == "identity" || scenario == "nonce") && saved.RefreshToken != "refresh" { + t.Fatal("old tokens lost") + } + if scenario == "early" && f.calls.Load() != 0 { + t.Fatal("refreshed before earliest_refresh_at") + } + if scenario == "new-scope" && saved.PlanEnabled() { + t.Fatal("new scope ignored") + } + }) + } +} + +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestDiscoveryAndJWKSFailures(t *testing.T) { + f := fixture(t) + for _, body := range []string{`{}`, `{"issuer":"wrong"}`, `{"issuer":"https://auth.openai.com","jwks_uri":"https://evil.invalid/jwks","revocation_endpoint":"https://evil.invalid/revoke"}`} { + f.client.keys = keyCache{} + f.client.http = &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + if _, err := f.client.verify(context.Background(), f.jwt(), "oaiapp_one", "nonce"); err == nil { + t.Fatal("bad discovery accepted") + } + } + f.client.http = f.server.Client() + f.client.keys = keyCache{keys: []jwk{{Kid: "key", Kty: "RSA", Alg: "RS256", N: "invalid!", E: "AQAB"}}, until: time.Now().Add(time.Hour)} + if _, err := f.client.verify(context.Background(), f.jwt(), "oaiapp_one", "nonce"); err == nil { + t.Fatal("bad JWK accepted") + } + f.client.keys = keyCache{keys: []jwk{{Kid: "key", Kty: "RSA", Alg: "RS256", N: base64.RawURLEncoding.EncodeToString(f.key.N.Bytes()), E: ""}}, until: time.Now().Add(time.Hour)} + if _, err := f.client.verify(context.Background(), f.jwt(), "oaiapp_one", "nonce"); err == nil { + t.Fatal("bad exponent") + } +} +func TestModelsCatalog(t *testing.T) { + c := NewClient() + a := testAuth() + for _, body := range []string{`{"models":[{"slug":"second","display_name":"Second","visibility":"list"},{"slug":"hidden","display_name":"Hidden","visibility":"hide"},{"slug":"first","display_name":"First","visibility":"list"}]}`, `{"data":[]}`, `{"models":[{"visibility":"list"}]}`} { + c.http = &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != Resource+"/models" || r.Header.Get("Authorization") != "Bearer access" { + t.Error("bad catalog request") + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })} + models, err := c.Models(context.Background(), &a) + if strings.Contains(body, "second") { + if err != nil || len(models) != 2 || models[0].Slug != "second" || models[1].Slug != "first" { + t.Fatal(models, err) + } + } else if err == nil { + t.Fatal("bad catalog accepted") + } + } + if _, err := c.Models(context.Background(), nil); err == nil { + t.Fatal("nil credentials accepted") + } +} +func TestStoreAtomicFailureAndSelect(t *testing.T) { + s := testStore(t) + a := testAuth() + saveTest(t, s, a) + b := a + b.ClientID = "oaiapp_two" + saveTest(t, s, b) + if err := s.Select(context.Background(), a.ClientID); err != nil { + t.Fatal(err) + } + got, _ := s.Selected("") + if got.ClientID != a.ClientID { + t.Fatal(got) + } + if err := s.Select(context.Background(), "missing"); err == nil { + t.Fatal("missing selection") + } + oldRename := renameFile + renameFile = func(string, string) error { return errors.New("fixture rename failure") } + defer func() { renameFile = oldRename }() + if err := s.Clear(context.Background(), ""); err == nil { + t.Fatal("rename failure swallowed") + } + got, _ = s.Selected("") + if got.RefreshToken != "refresh" { + t.Fatal("atomicity") + } + files, _ := filepath.Glob(filepath.Join(s.Dir, ".write-*")) + if len(files) > 0 { + t.Fatal(files) + } +} +func TestTokenResponseValidation(t *testing.T) { + scope := Scope + valid := tokenResponse{Access: "a", Refresh: "r", ID: "i", Type: "Bearer", Expires: 3600, Scope: &scope} + for _, early := range []string{`1790032532`, `"2020-01-01T00:00:00Z"`, `null`, `"invalid"`, `{}`, `999999999999`} { + v := valid + v.Earliest = json.RawMessage(early) + a := Auth{} + err := v.apply(&a, false) + if (err == nil) != (early == `1790032532` || early == `"2020-01-01T00:00:00Z"` || early == `null`) { + t.Fatal(early, err) + } + } + missing := valid + missing.ID = "" + if err := missing.apply(&Auth{}, false); err == nil { + t.Fatal("missing ID") + } + missing = valid + missing.Scope = nil + if err := missing.apply(&Auth{}, false); err == nil { + t.Fatal("missing scope") + } + valid.Expires = 86401 + if err := valid.apply(&Auth{}, true); err == nil { + t.Fatal("oversize expiry") + } +} +func TestLogoutUnconfirmedRevocation(t *testing.T) { + f := fixture(t) + s := testStore(t) + saveTest(t, s, testAuth()) + f.client.http = &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { return nil, errors.New("network unavailable") })} + if err := f.client.Logout(context.Background(), s, ""); err == nil || !strings.Contains(err.Error(), "not confirmed") { + t.Fatal(err) + } + a, _ := s.Selected("") + if a.AccessToken != "" || a.IDToken != "" { + t.Fatal("tokens retained") + } +} +func TestCrossProcessSessionLock(t *testing.T) { + if dir := os.Getenv("OCR_SIWC_TEST_LOCK_DIR"); dir != "" { + s := Store{Dir: dir} + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + if err := s.withLock(ctx, func() error { return errors.New("unexpectedly acquired") }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + return + } + s := testStore(t) + err := s.withLock(context.Background(), func() error { + cmd := exec.Command(os.Args[0], "-test.run=^TestCrossProcessSessionLock$") + cmd.Env = append(os.Environ(), "OCR_SIWC_TEST_LOCK_DIR="+s.Dir) + b, err := cmd.CombinedOutput() + if err != nil { + t.Log(string(b)) + } + return err + }) + if err != nil { + t.Fatal(err) + } +} +func TestHTTPFailureShape(t *testing.T) { + c := NewClient() + for _, body := range []string{`{"error":"invalid_refresh_token"}`, `{"error":"SECRET-ECHO"}`, `{"detail":"unavailable"}`, `invalid JSON`} { + c.http = &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 400, Header: http.Header{"X-Request-Id": {"fixture-request"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + err := c.post(context.Background(), Issuer+"/api/accounts/oauth/token", url.Values{}, nil) + if err == nil || strings.Contains(err.Error(), "SECRET") { + t.Fatal(err) + } + } +} + +type loginCancelWriter struct { + bytes.Buffer + cancel context.CancelFunc +} + +func (w *loginCancelWriter) Write(b []byte) (int, error) { + n, err := w.Buffer.Write(b) + if strings.Contains(string(b), "http://127.0.0.1:") { + w.cancel() + } + return n, err +} +func TestLoopbackNoBrowserCancellation(t *testing.T) { + s := testStore(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + out := loginCancelWriter{cancel: cancel} + if _, err := NewClient().Login(ctx, s, "", false, true, false, &out); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if !strings.Contains(out.String(), "http://127.0.0.1:") || strings.Contains(out.String(), "auth.openai.com") { + t.Fatal(out.String()) + } +} +func TestCallbackOneTimeAndWrongMethod(t *testing.T) { + f := fixture(t) + s := testStore(t) + old := openBrowser + defer func() { openBrowser = old }() + openBrowser = func(_ context.Context, local string) error { + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + start, err := client.Get(local) + if err != nil { + return err + } + target, _ := start.Location() + start.Body.Close() + second, _ := client.Get(local) + if second.StatusCode != 410 { + t.Error(second.StatusCode) + } + second.Body.Close() + q := target.Query() + callback := q.Get("redirect_uri") + req, _ := http.NewRequest("POST", callback, nil) + bad, err := client.Do(req) + if err != nil { + return err + } + if bad.StatusCode != 403 { + t.Error(bad.StatusCode) + } + bad.Body.Close() + f.claims["nonce"] = q.Get("nonce") + r, err := client.Get(callback + "?" + url.Values{"state": {q.Get("state")}, "code": {"code"}, "client_id": {"oaiapp_one"}}.Encode()) + if err == nil { + r.Body.Close() + } + return err + } + if _, err := f.client.Login(context.Background(), s, "", false, false, false, io.Discard); err != nil { + t.Fatal(err) + } +} + +func TestCallbackMalformedQueryDoesNotExchangeCode(t *testing.T) { + for _, suffix := range []string{"&broken=%zz", "&broken=a;b"} { + t.Run(suffix, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + old := openBrowser + defer func() { openBrowser = old }() + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + callback := q.Get("redirect_uri") + "?" + url.Values{"state": {q.Get("state")}, "code": {"sensitive-fixture-code"}, "client_id": {"oaiapp_one"}}.Encode() + suffix + r, err := http.Get(callback) + if err != nil { + return err + } + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if r.StatusCode != 400 || strings.Contains(string(body), "sensitive-fixture-code") || strings.Contains(string(body), q.Get("state")) { + t.Error("malformed callback response exposed authorization parameters or did not fail") + } + cancel() + return err + } + got, err := f.client.Login(ctx, s, "", false, false, false, io.Discard) + if got != nil || !errors.Is(err, context.Canceled) || f.calls.Load() != 0 { + t.Fatal("malformed callback reached code exchange", err) + } + d, err := s.Snapshot() + if err != nil || len(d.Accounts) != 0 { + t.Fatal("malformed callback saved an account", err) + } + }) + } +} diff --git a/internal/chatgptauth/lock_unix.go b/internal/chatgptauth/lock_unix.go new file mode 100644 index 000000000..b8d955d2d --- /dev/null +++ b/internal/chatgptauth/lock_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "errors" + "golang.org/x/sys/unix" + "os" +) + +func tryLock(f *os.File) (bool, error) { + err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if errors.Is(err, unix.EWOULDBLOCK) { + return false, nil + } + return err == nil, err +} +func unlock(f *os.File) { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) } diff --git a/internal/chatgptauth/lock_windows.go b/internal/chatgptauth/lock_windows.go new file mode 100644 index 000000000..5e095a6cc --- /dev/null +++ b/internal/chatgptauth/lock_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "errors" + "golang.org/x/sys/windows" + "os" +) + +func tryLock(f *os.File) (bool, error) { + err := windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return err == nil, err +} +func unlock(f *os.File) { + _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, &windows.Overlapped{}) +} diff --git a/internal/chatgptauth/login_recovery_test.go b/internal/chatgptauth/login_recovery_test.go new file mode 100644 index 000000000..aa5b227f1 --- /dev/null +++ b/internal/chatgptauth/login_recovery_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestLoginIgnoresUnrelatedCallbacks(t *testing.T) { + for _, query := range []string{"", "state=wrong", "state=STATE&state=STATE", "state=STATE&broken=%zz", "state=STATE", "state=STATE&code=code", "state=STATE&code=a&code=b", "state=STATE&error=denied&error=denied", "state=STATE&code=code&error=denied", "state=STATE&code=&error=access_denied", "state=STATE&code=code&error=&client_id=oaiapp_one"} { + t.Run(query, func(t *testing.T) { + f := fixture(t) + previous := openBrowser + defer func() { openBrowser = previous }() + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + invalid := strings.ReplaceAll(query, "STATE", q.Get("state")) + r, err := http.Get(q.Get("redirect_uri") + "?" + invalid) + if err != nil { + return err + } + r.Body.Close() + if r.StatusCode != http.StatusBadRequest { + return fmt.Errorf("invalid callback status: %d", r.StatusCode) + } + f.claims["nonce"] = q.Get("nonce") + return sendCallback(q, "oaiapp_one") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + got, err := f.client.Login(ctx, testStore(t), "", false, false, false, io.Discard) + if err != nil || got == nil || f.calls.Load() != 1 { + t.Fatal("valid callback could not complete login", err) + } + }) + } +} + +func TestLoginCompletesWhileBrowserOpenerIsRunning(t *testing.T) { + f := fixture(t) + previous := openBrowser + defer func() { openBrowser = previous }() + release, stopped := make(chan struct{}), make(chan struct{}) + defer func() { close(release); <-stopped }() + openBrowser = func(_ context.Context, local string) error { + defer close(stopped) + q, err := localAuthorization(local) + if err != nil { + return err + } + f.claims["nonce"] = q.Get("nonce") + if err := sendCallback(q, "oaiapp_one"); err != nil { + return err + } + <-release + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + got, err := f.client.Login(ctx, testStore(t), "", false, false, false, io.Discard) + if err != nil || got == nil || f.calls.Load() != 1 { + t.Fatal("browser lifetime blocked login", err) + } + select { + case <-stopped: + t.Fatal("browser opener exited before login completed") + default: + } +} + +func TestLoginAcceptsMatchingStateDenial(t *testing.T) { + f := fixture(t) + previous := openBrowser + defer func() { openBrowser = previous }() + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + r, err := http.Get(q.Get("redirect_uri") + "?" + url.Values{"state": {q.Get("state")}, "error": {"access_denied"}}.Encode()) + if err != nil { + return err + } + return r.Body.Close() + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := f.client.Login(ctx, testStore(t), "", false, false, false, io.Discard) + if err == nil || err.Error() != "ChatGPT authorization was declined" || f.calls.Load() != 0 { + t.Fatal("matching denial was not consumed", err) + } +} diff --git a/internal/chatgptauth/models.go b/internal/chatgptauth/models.go new file mode 100644 index 000000000..b0ea244ec --- /dev/null +++ b/internal/chatgptauth/models.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "errors" + "net/http" +) + +type Model struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Visibility string `json:"visibility"` +} + +func (c *Client) Models(ctx context.Context, auth *Auth) ([]Model, error) { + if auth == nil || auth.AccessToken == "" || !auth.PlanEnabled() { + return nil, errors.New("ChatGPT plan permission is required to list models") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, Resource+"/models", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+auth.AccessToken) + var catalog struct { + Models []Model `json:"models"` + } + if err := c.catalogRequest(req, auth, &catalog); err != nil { + return nil, err + } + if catalog.Models == nil { + return nil, errors.New("ChatGPT model catalog did not contain models") + } + var models []Model + for _, m := range catalog.Models { + if m.Visibility == "list" { + if m.Slug == "" || m.DisplayName == "" { + return nil, errors.New("invalid ChatGPT model catalog entry") + } + models = append(models, m) + } + } + return models, nil +} diff --git a/internal/chatgptauth/oauth.go b/internal/chatgptauth/oauth.go new file mode 100644 index 000000000..70effc730 --- /dev/null +++ b/internal/chatgptauth/oauth.go @@ -0,0 +1,666 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const Issuer = "https://auth.openai.com" +const Resource = "https://api.openai.com/v1" +const DynamicClient = "dynamic_agent_client" +const Scope = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct" +const UsageURL = "https://chatgpt.com/settings/usage" + +type Client struct { + base string + http *http.Client + keys keyCache +} + +func NewClient() *Client { + return &Client{base: Issuer, http: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} + +var openBrowser = openBrowserURL + +type discovery struct { + Issuer string `json:"issuer"` + JWKS string `json:"jwks_uri"` + Revocation string `json:"revocation_endpoint"` +} + +func (c *Client) safeEndpoint(endpoint string) bool { + u, err := url.Parse(endpoint) + b, baseErr := url.Parse(c.base) + return err == nil && baseErr == nil && u.Scheme == b.Scheme && u.Host == b.Host && u.User == nil && u.Fragment == "" && u.RawQuery == "" +} +func (c *Client) discover(ctx context.Context, d *discovery) error { + if err := c.get(ctx, c.base+"/.well-known/openid-configuration", d); err != nil { + return err + } + if d.Issuer != Issuer || !c.safeEndpoint(d.JWKS) || !c.safeEndpoint(d.Revocation) { + return errors.New("OpenAI discovery endpoints or issuer did not match") + } + return nil +} +func (c *Client) get(ctx context.Context, endpoint string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + return c.request(req, target) +} + +type OAuthError struct { + Status int + Code string + RequestID string +} + +func (e *OAuthError) Error() string { + return fmt.Sprintf("ChatGPT OAuth request failed: status=%d code=%s request_id=%s", e.Status, e.Code, e.RequestID) +} +func knownCode(code string) string { + switch code { + case "invalid_request", "invalid_client", "invalid_grant", "invalid_scope", "access_denied", "unauthorized_client", "unsupported_grant_type", "server_error", "temporarily_unavailable", "invalid_refresh_token", "token_expired", "refresh_token_expired", "refresh_token_invalidated", "refresh_token_reused": + return code + } + return "unknown_error" +} +func (c *Client) request(req *http.Request, target any) error { + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("ChatGPT OAuth transport failed: %w", err) + } + defer resp.Body.Close() + b, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1)) + if err != nil { + return err + } + if len(b) > 1<<20 { + return errors.New("ChatGPT OAuth response exceeded size limit") + } + if resp.StatusCode != 200 { + var e struct { + Error string `json:"error"` + } + _ = json.Unmarshal(b, &e) + return &OAuthError{Status: resp.StatusCode, Code: knownCode(e.Error), RequestID: resp.Header.Get("x-request-id")} + } + if target == nil { + return nil + } + if err := json.Unmarshal(b, target); err != nil { + return errors.New("invalid ChatGPT OAuth response JSON") + } + return nil +} +func (c *Client) post(ctx context.Context, endpoint string, form url.Values, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return c.request(req, target) +} +func randomValue() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +type attempt struct { + state, nonce, verifier, redirect, host string + consent bool +} + +func (c *Client) authorize(a attempt, old *Auth) string { + clientID := DynamicClient + if old != nil { + clientID = old.ClientID + } + sum := sha256.Sum256([]byte(a.verifier)) + q := url.Values{"client_id": {clientID}, "ext_agent_host_id": {a.host}, "response_type": {"code"}, "redirect_uri": {a.redirect}, "scope": {Scope}, "resource": {Resource}, "state": {a.state}, "nonce": {a.nonce}, "code_challenge_method": {"S256"}, "code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}} + if a.consent { + q.Set("prompt", "consent") + } + if old == nil { + q.Set("agent_name_hint", "OpenCodeReview") + } else { + if old.IDToken != "" { + q.Set("id_token_hint", old.IDToken) + } + if old.Email != "" { + q.Set("login_hint", old.Email) + } + } + return c.base + "/api/accounts/authorize?" + q.Encode() +} +func (a attempt) callback(q url.Values, old *Auth) (string, string, error) { + for _, key := range []string{"state", "code", "client_id", "error"} { + if len(q[key]) > 1 { + return "", "", errors.New("duplicate OAuth callback parameter") + } + } + if q.Get("state") != a.state || a.state == "" { + return "", "", errors.New("OAuth callback state did not match") + } + if q.Get("error") != "" { + return "", "", errors.New("ChatGPT authorization was declined") + } + code := q.Get("code") + if code == "" { + return "", "", errors.New("OAuth callback did not include an authorization code") + } + id := q.Get("client_id") + if old != nil { + if id != "" && id != old.ClientID { + return "", "", errors.New("OAuth callback changed the selected client ID") + } + id = old.ClientID + } + if id == "" || id == DynamicClient { + return "", "", errors.New("OAuth registration did not issue a client ID") + } + return code, id, nil +} + +type tokenResponse struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` + ID string `json:"id_token"` + Type string `json:"token_type"` + Expires int64 `json:"expires_in"` + Scope *string `json:"scope"` + Earliest json.RawMessage `json:"earliest_refresh_at"` +} + +func (t tokenResponse) apply(a *Auth, refresh bool) error { + return t.applyAt(a, refresh, time.Now()) +} + +func (t tokenResponse) applyAt(a *Auth, refresh bool, now time.Time) error { + if t.Access == "" || t.Refresh == "" || !strings.EqualFold(t.Type, "Bearer") || t.Expires <= 0 || t.Expires > 86400 { + return errors.New("ChatGPT token response is missing credentials, Bearer type or valid expiry") + } + if !refresh && (t.ID == "" || t.Scope == nil) { + return errors.New("ChatGPT sign-in response is missing ID token or granted scopes") + } + earliest := time.Time{} + if len(t.Earliest) > 0 && string(t.Earliest) != "null" { + var seconds int64 + if json.Unmarshal(t.Earliest, &seconds) == nil { + earliest = time.Unix(seconds, 0) + } else { + var text string + if json.Unmarshal(t.Earliest, &text) != nil { + return errors.New("invalid earliest_refresh_at") + } + var err error + earliest, err = time.Parse(time.RFC3339, text) + if err != nil { + return errors.New("invalid earliest_refresh_at") + } + } + if earliest.After(now.Add(time.Duration(t.Expires) * time.Second)) { + return errors.New("earliest_refresh_at exceeds token expiry") + } + } + a.AccessToken = t.Access + a.RefreshToken = t.Refresh + a.TokenType = "Bearer" + a.ExpiresAt = now.Add(time.Duration(t.Expires) * time.Second) + a.EarliestRefreshAt = earliest + if t.ID != "" { + a.IDToken = t.ID + } + if t.Scope != nil { + a.Scopes = strings.Fields(*t.Scope) + } + return nil +} +func (c *Client) exchange(ctx context.Context, a attempt, code, id string, old *Auth) (*Auth, error) { + var t tokenResponse + if err := c.post(ctx, c.base+"/api/accounts/oauth/token", url.Values{"grant_type": {"authorization_code"}, "client_id": {id}, "code": {code}, "code_verifier": {a.verifier}, "redirect_uri": {a.redirect}, "resource": {Resource}}, &t); err != nil { + return nil, err + } + identity, err := c.verify(ctx, t.ID, id, a.nonce) + if err != nil { + return nil, err + } + if old != nil && (identity.Subject != old.Subject || identity.Issuer != old.Issuer) { + return nil, errors.New("ChatGPT sign-in identity did not match the selected registration") + } + auth := &Auth{ClientID: id, Issuer: identity.Issuer, Subject: identity.Subject, Email: identity.Email, Nonce: a.nonce} + if old != nil { + auth.Welcomed = old.Welcomed + } + if err := t.apply(auth, false); err != nil { + return nil, err + } + return auth, nil +} + +func (c *Client) Login(ctx context.Context, s Store, account string, newAccount, noBrowser, consent bool, out io.Writer) (*Auth, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + if consent && newAccount { + return nil, errors.New("--enable-plan requires a retained ChatGPT registration") + } + host, err := s.Host(ctx) + if err != nil { + return nil, err + } + var old *Auth + err = s.withLock(ctx, func() error { + d, err := s.load() + if err != nil { + return err + } + if !newAccount && (len(d.Accounts) > 0 || consent || account != "") { + selected, err := d.selected(account) + if err != nil { + return err + } + copy := *selected + old = © + } else if account != "" { + return errors.New("an account cannot be selected for new registration") + } + return nil + }) + if err != nil { + return nil, err + } + registration := old + var auth *Auth + for n := 0; n < 2; n++ { + var issued string + auth, err = c.loginAttempt(ctx, host, registration, old, noBrowser, consent, out, &issued) + var oauth *OAuthError + if n == 0 && issued != "" && errors.As(err, &oauth) && oauth.Code == "invalid_grant" { + // Keep the issued client only within this pending authorization, never as an active identity. + registration = &Auth{ClientID: issued} + continue + } + break + } + if err != nil { + return nil, err + } + err = s.withLock(ctx, func() error { + d, err := s.load() + if err != nil { + return err + } + if old != nil { + current, err := d.selected(old.ClientID) + if err != nil { + return err + } + if current.Revision != old.Revision { + return errors.New("ChatGPT registration changed during sign-in; start sign-in again") + } + if current.Subject != auth.Subject || current.Issuer != auth.Issuer { + return errors.New("issued registration conflicts with saved identity") + } + auth.Revision = current.Revision + 1 + *current = *auth + } else { + for _, saved := range d.Accounts { + if saved.ClientID == auth.ClientID { + return errors.New("issued registration already exists; sign in using that account") + } + } + auth.Revision = 1 + d.Accounts = append(d.Accounts, *auth) + } + d.Active = auth.ClientID + if auth.PlanEnabled() && !auth.Welcomed { + if _, err := fmt.Fprintln(out, "You're using your ChatGPT plan. Manage usage:", UsageURL); err != nil { + return err + } + auth.Welcomed = true + for i := range d.Accounts { + if d.Accounts[i].ClientID == auth.ClientID { + d.Accounts[i].Welcomed = true + } + } + } + return s.save(d) + }) + if err != nil { + return nil, err + } + return auth, nil +} + +func (c *Client) loginAttempt(ctx context.Context, host string, registration, old *Auth, noBrowser, consent bool, out io.Writer, issued *string) (*Auth, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + defer listener.Close() + a := attempt{consent: consent, host: host, redirect: "http://" + listener.Addr().String() + "/auth/callback"} + if a.state, err = randomValue(); err != nil { + return nil, err + } + if a.nonce, err = randomValue(); err != nil { + return nil, err + } + if a.verifier, err = randomValue(); err != nil { + return nil, err + } + type result struct { + code, id string + err error + } + done := make(chan result, 1) + var once sync.Once + startOnce := sync.Once{} + mux := http.NewServeMux() + target := c.authorize(a, registration) + mux.HandleFunc("/auth/start/"+a.state, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.Host != listener.Addr().String() { + http.Error(w, "invalid callback origin", 403) + return + } + served := false + startOnce.Do(func() { + served = true + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + http.Redirect(w, r, target, http.StatusFound) + }) + if !served { + http.Error(w, "sign-in already started", 410) + } + }) + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.Host != listener.Addr().String() { + http.Error(w, "invalid callback origin", 403) + return + } + q, parseErr := url.ParseQuery(r.URL.RawQuery) + w.Header().Set("Cache-Control", "no-store") + if parseErr != nil || len(q["state"]) != 1 || q.Get("state") != a.state || len(q["code"]) > 1 || len(q["client_id"]) > 1 || len(q["error"]) > 1 || (len(q["error"]) > 0 && len(q["code"]) > 0) { + http.Error(w, "invalid OAuth callback state or query", http.StatusBadRequest) + return + } + code, id, callbackErr := a.callback(q, registration) + if callbackErr != nil && q.Get("error") == "" { + http.Error(w, "invalid OAuth callback parameters", http.StatusBadRequest) + return + } + consumed := false + once.Do(func() { consumed = true; done <- result{code: code, id: id, err: callbackErr} }) + if !consumed { + http.Error(w, "callback already consumed", 410) + } else if callbackErr != nil { + http.Error(w, "Sign-in could not be verified.", 400) + } else { + fmt.Fprintln(w, "Authorization received. Return to OpenCodeReview to confirm sign-in.") + } + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(listener) }() + defer server.Close() + localURL := "http://" + listener.Addr().String() + "/auth/start/" + a.state + if _, err := fmt.Fprintln(out, "Continue with ChatGPT. Eligible requests use your ChatGPT plan."); err != nil { + return nil, err + } + var browserResult <-chan error + if noBrowser { + if _, err := fmt.Fprintln(out, "Open this local URL in your browser:", localURL); err != nil { + return nil, err + } + } else { + opened := make(chan error, 1) + opener := openBrowser + go func() { opened <- opener(ctx, localURL) }() + browserResult = opened + } + for { + select { + case err := <-browserResult: + browserResult = nil + if err != nil { + return nil, errors.New("could not open browser; retry with --no-browser") + } + case <-ctx.Done(): + return nil, ctx.Err() + case err := <-serveErr: + return nil, fmt.Errorf("ChatGPT callback server stopped: %w", err) + case r := <-done: + if r.err != nil { + return nil, r.err + } + *issued = r.id + auth, err := c.exchange(ctx, a, r.code, r.id, old) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return auth, nil + } + } +} + +func terminalRefresh(err error) bool { + var e *OAuthError + if !errors.As(err, &e) { + return false + } + switch e.Code { + case "invalid_grant", "invalid_refresh_token", "token_expired", "refresh_token_expired", "refresh_token_invalidated", "refresh_token_reused": + return true + } + return false +} + +var errAccessNeedsRefresh = errors.New("ChatGPT access token needs refresh after verification; retry the request") + +func (a *Auth) refreshDue(now time.Time) bool { + if !now.Before(a.ExpiresAt) { + return true + } + return !a.ExpiresAt.After(now.Add(5*time.Minute)) && !now.Before(a.EarliestRefreshAt) +} + +func (c *Client) Credentials(ctx context.Context, s Store, account string) (*Auth, error) { + auth, err := c.credentials(ctx, s, account) + if errors.Is(err, errAccessNeedsRefresh) { + return c.credentials(ctx, s, account) + } + return auth, err +} + +func (c *Client) credentials(ctx context.Context, s Store, account string) (*Auth, error) { + var result *Auth + err := s.withLock(ctx, func() error { + d, err := s.load() + if err != nil { + return err + } + auth, err := d.selected(account) + if err != nil { + return err + } + signInRequired := func(reason string) error { + return fmt.Errorf("%s; run ocr auth login --provider chatgpt --account %s", reason, auth.ClientID) + } + if auth.PendingRefresh != nil && auth.PendingRefresh.IdentityError != "" { + return signInRequired(auth.PendingRefresh.IdentityError) + } + if auth.AccessToken == "" { + return errors.New("ChatGPT account is signed out; run ocr auth login --provider chatgpt") + } + if !auth.PlanEnabled() { + return fmt.Errorf("ChatGPT plan usage is disabled; run ocr auth login --provider chatgpt --enable-plan --account %s or select an API-key provider", auth.ClientID) + } + now := time.Now() + recoveringPending := auth.PendingRefresh != nil + if !recoveringPending && !auth.refreshDue(now) { + copy := *auth + result = © + return nil + } + if auth.RefreshToken == "" { + return errors.New("ChatGPT refresh token is missing; sign in again") + } + var t tokenResponse + if auth.PendingRefresh != nil { + t = auth.PendingRefresh.Response + now = auth.PendingRefresh.ReceivedAt + } else { + err = c.post(ctx, c.base+"/api/accounts/oauth/token", url.Values{"grant_type": {"refresh_token"}, "client_id": {auth.ClientID}, "refresh_token": {auth.RefreshToken}, "resource": {Resource}}, &t) + if err != nil { + if terminalRefresh(err) { + auth.clear() + if saveErr := s.save(d); saveErr != nil { + return saveErr + } + } + return err + } + now = time.Now() + } + rotated := *auth + if err := t.applyAt(&rotated, true, now); err != nil { + return err + } + if t.ID != "" { + // The issuer may already have consumed the old token. Keep the replacement + // recoverable, but never expose it for inference before identity verification. + if auth.PendingRefresh == nil { + auth.PendingRefresh = &pendingRefresh{Response: t, ReceivedAt: now} + auth.Revision++ + if err := s.save(d); err != nil { + return err + } + } + identity, err := c.verifyAt(ctx, t.ID, auth.ClientID, "", now) + if err == nil && (identity.Subject != auth.Subject || identity.Issuer != auth.Issuer) { + err = invalidIDTokenError("refresh changed ChatGPT identity") + } + if err == nil && identity.Nonce != "" && identity.Nonce != auth.Nonce { + err = invalidIDTokenError("refresh changed the original ID-token nonce") + } + if err != nil { + var invalid invalidIDTokenError + if errors.As(err, &invalid) { + // Retain the latest refresh token for logout, but stop retrying a rejected identity. + auth.PendingRefresh.IdentityError = invalid.Error() + auth.Revision++ + if saveErr := s.save(d); saveErr != nil { + return saveErr + } + return signInRequired(invalid.Error()) + } + return err + } + rotated.Email = identity.Email + } + rotated.PendingRefresh = nil + rotated.Revision = auth.Revision + 1 + *auth = rotated + if err := s.save(d); err != nil { + return err + } + // A validated replacement must survive cancellation after the server rotated the token. + if err := ctx.Err(); err != nil { + return err + } + if !rotated.PlanEnabled() { + return fmt.Errorf("refreshed ChatGPT grant does not permit plan usage; run ocr auth login --provider chatgpt --enable-plan --account %s", auth.ClientID) + } + now = time.Now() + if !rotated.ExpiresAt.After(now) || (recoveringPending && rotated.refreshDue(now)) { + return errAccessNeedsRefresh + } + result = &rotated + return nil + }) + return result, err +} + +func (c *Client) revokeSession(ctx context.Context, auth *Auth) error { + var err error + for n := 0; n < 3; n++ { + var config discovery + err = c.discover(ctx, &config) + if err == nil { + err = c.post(ctx, config.Revocation, url.Values{"token": {auth.RefreshToken}, "token_type_hint": {"refresh_token"}, "client_id": {auth.ClientID}}, nil) + } + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + var oe *OAuthError + var networkErr *url.Error + if !(errors.As(err, &oe) && oe.Status >= 500) && !errors.As(err, &networkErr) { + return err + } + if n < 2 { + timer := time.NewTimer(time.Duration(n+1) * 250 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + } + return err +} + +func (c *Client) Logout(ctx context.Context, s Store, account string) error { + return s.withLock(ctx, func() error { + d, err := s.load() + if err != nil { + return err + } + a, err := d.selected(account) + if err != nil { + return err + } + var revokeErr error + if a.RefreshToken != "" { + session := *a + if a.PendingRefresh != nil { + session.RefreshToken = a.PendingRefresh.Response.Refresh + } + revokeErr = c.revokeSession(ctx, &session) + } + a.clear() + if err := s.save(d); err != nil { + return err + } + if revokeErr != nil { + return fmt.Errorf("local tokens cleared; remote revocation not confirmed, disconnect the app in ChatGPT Settings: %w", revokeErr) + } + return nil + }) +} diff --git a/internal/chatgptauth/recovery_test.go b/internal/chatgptauth/recovery_test.go new file mode 100644 index 000000000..4f78cc667 --- /dev/null +++ b/internal/chatgptauth/recovery_test.go @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +type cancelBody struct { + io.Reader + cancel context.CancelFunc +} + +func (b cancelBody) Close() error { b.cancel(); return nil } + +func TestRefreshCommitAfterCancellation(t *testing.T) { + for _, valid := range []bool{true, false} { + t.Run(map[bool]string{true: "validated", false: "invalid"}[valid], func(t *testing.T) { + s := testStore(t) + a := testAuth() + a.ExpiresAt = time.Now().Add(-time.Minute) + saveTest(t, s, a) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + body := `{"access_token":"rotated-access","refresh_token":"rotated-refresh","token_type":"Bearer","expires_in":3600}` + if !valid { + body = `{"access_token":"rotated-access","token_type":"Bearer","expires_in":3600}` + } + c := NewClient() + c.http = &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: cancelBody{strings.NewReader(body), cancel}}, nil + })} + result, err := c.Credentials(ctx, s, "") + if err == nil || result != nil { + t.Fatal("canceled request returned credentials", err) + } + if valid && !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + saved, err := s.Selected("") + if err != nil { + t.Fatal(err) + } + expected := "refresh" + if valid { + expected = "rotated-refresh" + } + if saved.RefreshToken != expected { + t.Fatal("rotation persistence", saved.RefreshToken) + } + if valid { + c.http.Transport = roundTrip(func(*http.Request) (*http.Response, error) { + t.Error("saved rotation triggered another network request") + return nil, errors.New("unexpected network request") + }) + next, err := c.Credentials(context.Background(), s, "") + if err != nil || next == nil || next.AccessToken != "rotated-access" || next.RefreshToken != "rotated-refresh" { + t.Fatal("saved rotation was not reusable", err) + } + } + }) + } +} + +func localAuthorization(local string) (url.Values, error) { + c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + r, err := c.Get(local) + if err != nil { + return nil, err + } + defer r.Body.Close() + u, err := r.Location() + if err != nil { + return nil, err + } + return u.Query(), nil +} +func sendCallback(q url.Values, id string) error { + r, err := http.Get(q.Get("redirect_uri") + "?" + url.Values{"state": {q.Get("state")}, "code": {"code-" + q.Get("state")}, "client_id": {id}}.Encode()) + if err != nil { + return err + } + return r.Body.Close() +} + +func TestExplicitPlanConsent(t *testing.T) { + for _, consent := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary", true: "enable"}[consent], func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + a.Scopes = strings.Fields("openid profile email offline_access resource.invoke") + saveTest(t, s, a) + old := openBrowser + defer func() { openBrowser = old }() + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + prompt := "" + if consent { + prompt = "consent" + } + if q.Get("prompt") != prompt || q.Get("client_id") != a.ClientID || q.Get("scope") != Scope || q.Get("resource") != Resource || q.Get("agent_name_hint") != "" { + t.Error(q) + } + host, err := s.Host(context.Background()) + if err != nil || q.Get("ext_agent_host_id") != host { + t.Error("host changed", err) + } + f.claims["nonce"] = q.Get("nonce") + f.token = func(*http.Request) any { + b := f.tokens() + if !consent { + b["scope"] = strings.Join(a.Scopes, " ") + } + return b + } + return sendCallback(q, a.ClientID) + } + got, err := f.client.Login(context.Background(), s, "", false, false, consent, io.Discard) + if err != nil || got.PlanEnabled() != consent { + t.Fatal(got, err) + } + }) + } + s := testStore(t) + if _, err := NewClient().Login(context.Background(), s, "", false, true, true, io.Discard); !errors.Is(err, ErrNotFound) { + t.Fatal("consent without retained registration", err) + } + if _, err := NewClient().Login(context.Background(), s, "", true, true, true, io.Discard); err == nil { + t.Fatal("consent with new registration") + } +} + +func TestInvalidGrantAuthorizationRecovery(t *testing.T) { + for _, succeeds := range []bool{true, false} { + t.Run(map[bool]string{true: "retry-success", false: "bounded-failure"}[succeeds], func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + a.ClientID = "previous" + saveTest(t, s, a) + old := openBrowser + defer func() { openBrowser = old }() + var first url.Values + var count int + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + count++ + if count == 1 { + first = q + if q.Get("client_id") != DynamicClient { + t.Error(q) + } + } else { + if q.Get("client_id") != "oaiapp_one" || q.Get("agent_name_hint") != "" || q.Get("id_token_hint") != "" { + t.Error(q) + } + for _, key := range []string{"state", "nonce", "code_challenge"} { + if q.Get(key) == first.Get(key) { + t.Error("reused binding", key) + } + } + if q.Get("ext_agent_host_id") != first.Get("ext_agent_host_id") || q.Get("scope") != Scope || q.Get("resource") != Resource { + t.Error(q) + } + } + active, _ := s.Selected("") + if active.ClientID != "previous" || active.RefreshToken != "refresh" { + t.Error("pending registration activated") + } + f.claims["nonce"] = q.Get("nonce") + return sendCallback(q, "oaiapp_one") + } + transport := f.client.http.Transport + if transport == nil { + transport = http.DefaultTransport + } + var exchanges int + f.client.http.Transport = roundTrip(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/api/accounts/oauth/token" { + exchanges++ + r.ParseForm() + if r.Form.Get("client_id") != "oaiapp_one" { + t.Error("lost issued client") + } + r.Body = io.NopCloser(strings.NewReader(r.Form.Encode())) + if exchanges == 1 || !succeeds { + return &http.Response{StatusCode: 400, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"invalid_grant"}`))}, nil + } + } + return transport.RoundTrip(r) + }) + _, err := f.client.Login(context.Background(), s, "", true, false, false, io.Discard) + if (err == nil) != succeeds || count != 2 || exchanges != 2 { + t.Fatal("unbounded or missing recovery", count, exchanges, err) + } + prior, _ := s.Selected("previous") + if prior.RefreshToken != "refresh" { + t.Fatal("previous account changed") + } + if !succeeds { + active, _ := s.Selected("") + if active.ClientID != "previous" { + t.Fatal("failed retry activated") + } + } + }) + } +} + +func TestPendingLoginConcurrentAccountOperations(t *testing.T) { + for _, scenario := range []string{"new-read-select-logout", "new-refresh", "return-logout", "return-refresh", "return-login"} { + t.Run(scenario, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + if strings.HasSuffix(scenario, "refresh") { + a.ExpiresAt = time.Now().Add(-time.Minute) + } + saveTest(t, s, a) + old := openBrowser + defer func() { openBrowser = old }() + pending := make(chan url.Values, 1) + release := make(chan struct{}) + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + pending <- q + <-release + f.claims["nonce"] = q.Get("nonce") + id := a.ClientID + if strings.HasPrefix(scenario, "new") { + id = "oaiapp_two" + f.claims["aud"] = id + } + return sendCallback(q, id) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + finished := make(chan error, 1) + go func() { + _, err := f.client.Login(ctx, s, "", strings.HasPrefix(scenario, "new"), false, false, io.Discard) + finished <- err + }() + select { + case <-pending: + case <-ctx.Done(): + t.Fatal("no pending login") + } + // Release the browser even on a failed contention assertion. + defer func() { + select { + case <-release: + default: + close(release) + } + }() + operationCtx, operationCancel := context.WithTimeout(context.Background(), time.Second) + defer operationCancel() + if strings.HasSuffix(scenario, "refresh") { + f.token = func(*http.Request) any { + return map[string]any{"access_token": "access-rotated", "refresh_token": "refresh-rotated", "token_type": "Bearer", "expires_in": 3600} + } + got, err := f.client.Credentials(operationCtx, s, a.ClientID) + if err != nil || got.RefreshToken != "refresh-rotated" { + t.Fatal("refresh blocked", err) + } + f.token = nil + } else if scenario == "return-login" { + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + f.claims["nonce"] = q.Get("nonce") + return sendCallback(q, a.ClientID) + } + if _, err := f.client.Login(operationCtx, s, a.ClientID, false, false, false, io.Discard); err != nil { + t.Fatal("concurrent login blocked", err) + } + } else { + got, err := f.client.Credentials(operationCtx, s, a.ClientID) + if err != nil || got.AccessToken != "access" { + t.Fatal("credentials blocked", err) + } + if err := s.Select(operationCtx, a.ClientID); err != nil { + t.Fatal("selection blocked", err) + } + if err := f.client.Logout(operationCtx, s, a.ClientID); err != nil { + t.Fatal("logout blocked", err) + } + } + close(release) + err := <-finished + returning := strings.HasPrefix(scenario, "return") + if (err != nil) != returning { + t.Fatal("commit revision check", err) + } + saved, err := s.Selected(a.ClientID) + if err != nil { + t.Fatal(err) + } + switch { + case strings.HasSuffix(scenario, "refresh"): + if saved.RefreshToken != "refresh-rotated" { + t.Fatal("rotation overwritten") + } + case scenario == "return-login": + if saved.RefreshToken != "refresh-new" { + t.Fatal("login overwritten") + } + default: + if saved.AccessToken != "" || saved.RefreshToken != "" { + t.Fatal("logged-out account resurrected") + } + } + }) + } +} + +func TestModelsAPIDiagnostics(t *testing.T) { + for _, tc := range []struct{ body, shape, code, param, detail string }{ + {`{"error":{"code":"subscription_sharing_user_not_eligible","param":"model","message":"workspace not eligible"}}`, "error_object", "subscription_sharing_user_not_eligible", "model", "workspace not eligible"}, + {`{"detail":"region restriction"}`, "detail", "", "", "region restriction"}, + {`{"error":{"code":"invalid_model","param":"model","message":"Bearer access token=refresh id secret-id sk-secret123 eyJhbGciOiJSUzI1NiJ9.payload.signature"}}`, "error_object", "invalid_model", "model", "[REDACTED]"}, + {`not-json access`, "non_json", "", "", ""}, + {`{"error":"not eligible"}`, "error_string", "", "", "not eligible"}, + } { + t.Run(tc.shape+tc.code, func(t *testing.T) { + c := NewClient() + a := testAuth() + a.IDToken = "secret-id" + c.http = &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 403, Header: http.Header{"X-Request-Id": {"req-catalog"}}, Body: io.NopCloser(strings.NewReader(tc.body))}, nil + })} + _, err := c.Models(context.Background(), &a) + if err == nil { + t.Fatal("error swallowed") + } + var api *APIError + if !errors.As(err, &api) || api.Status != 403 || api.Code != tc.code || api.Param != tc.param || api.Shape != tc.shape || api.RequestID != "req-catalog" || !strings.Contains(api.Detail, tc.detail) { + t.Fatal(err) + } + for _, secret := range []string{"Bearer access", "token=refresh", "secret-id", "sk-secret123", "eyJhbGciOiJSUzI1NiJ9.payload.signature"} { + if strings.Contains(err.Error(), secret) { + t.Fatal("credential in diagnostic") + } + } + }) + } +} diff --git a/internal/chatgptauth/refresh_recovery_test.go b/internal/chatgptauth/refresh_recovery_test.go new file mode 100644 index 000000000..f2c31812c --- /dev/null +++ b/internal/chatgptauth/refresh_recovery_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestRefreshRecoveryAfterVerificationFailure(t *testing.T) { + for _, scenario := range []string{"healthy_id_token", "no_id_token", "jwks_unavailable", "jwks_missing_key", "cancel_after_token_response"} { + t.Run(scenario, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + a.ExpiresAt = time.Now().Add(-time.Minute) + saveTest(t, s, a) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rotated, recoverNetwork := false, false + refreshCalls, rejectedOld, failedJWKS := 0, 0, 0 + f.client.http = &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + if err := r.Context().Err(); err != nil { + return nil, err + } + if r.URL.Path == "/api/accounts/oauth/token" { + refreshCalls++ + if err := r.ParseForm(); err != nil { + return nil, err + } + if rotated && r.Form.Get("refresh_token") == a.RefreshToken { + rejectedOld++ + return &http.Response{StatusCode: 400, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"invalid_grant"}`))}, nil + } + rotated = true + tokens := f.tokens() + if scenario == "no_id_token" { + delete(tokens, "id_token") + } + body, err := json.Marshal(tokens) + if err != nil { + return nil, err + } + var reader io.ReadCloser = io.NopCloser(strings.NewReader(string(body))) + if scenario == "cancel_after_token_response" && !recoverNetwork { + reader = cancelBody{Reader: strings.NewReader(string(body)), cancel: cancel} + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: reader}, nil + } + if r.URL.Path == "/.well-known/jwks.json" && scenario == "jwks_unavailable" && !recoverNetwork { + failedJWKS++ + return &http.Response{StatusCode: 503, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"temporarily_unavailable"}`))}, nil + } + if r.URL.Path == "/.well-known/jwks.json" && scenario == "jwks_missing_key" && !recoverNetwork { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"keys":[]}`))}, nil + } + return http.DefaultTransport.RoundTrip(r) + })} + first, firstErr := f.client.Credentials(ctx, s, "") + needsRecovery := scenario == "jwks_unavailable" || scenario == "jwks_missing_key" || scenario == "cancel_after_token_response" + if needsRecovery { + if firstErr == nil || first != nil || !rotated { + t.Fatalf("fault did not occur after rotation: rotated=%t err=%v", rotated, firstErr) + } + if scenario == "jwks_unavailable" && failedJWKS != 1 { + t.Fatalf("expected one JWKS fault, got %d", failedJWKS) + } + if scenario == "cancel_after_token_response" && !errors.Is(firstErr, context.Canceled) { + t.Fatalf("expected canceled context, got %v", firstErr) + } + } else if firstErr != nil { + t.Fatal(firstErr) + } + saved, err := s.Selected("") + if err != nil { + t.Fatal(err) + } + t.Logf("first_error=%v stored_old_refresh=%t", firstErr, saved.RefreshToken == a.RefreshToken) + if needsRecovery { + if saved.PendingRefresh == nil || saved.PendingRefresh.Response.Refresh != "refresh-new" || saved.AccessToken != a.AccessToken { + t.Fatal("replacement was lost or activated before verification") + } + } + recoverNetwork = true + restarted := NewClient() + restarted.base, restarted.http = f.client.base, f.client.http + next, nextErr := restarted.Credentials(context.Background(), s, "") + after, err := s.Selected("") + if err != nil { + t.Fatal(err) + } + if needsRecovery && next != nil && !next.ExpiresAt.Equal(saved.PendingRefresh.ReceivedAt.Add(time.Hour)) { + t.Fatal("recovery extended the original token lifetime") + } + t.Logf("refresh_calls=%d rejected_old=%d next_error=%v tokens_cleared=%t", refreshCalls, rejectedOld, nextErr, after.AccessToken == "" && after.RefreshToken == "") + if nextErr != nil || next == nil || next.AccessToken != "access-new" || next.PendingRefresh != nil || refreshCalls != 1 || rejectedOld != 0 { + t.Fatalf("refresh must recover after the temporary validation fault without replaying the consumed token: %v", nextErr) + } + }) + } +} + +func TestPendingRefreshRejectsUnverifiedIdentity(t *testing.T) { + for _, invalid := range []string{"malformed", "signature", "subject", "nonce", "expired_at_receipt"} { + t.Run(invalid, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + if invalid == "subject" { + f.claims["sub"] = "another-account" + } + if invalid == "nonce" { + f.claims["nonce"] = "another-login" + } + if invalid == "expired_at_receipt" { + f.claims["exp"] = time.Now().Add(-time.Minute).Unix() + } + var response tokenResponse + b, err := json.Marshal(f.tokens()) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &response); err != nil { + t.Fatal(err) + } + if invalid == "signature" { + response.ID += "invalid" + } + if invalid == "malformed" { + response.ID = "malformed" + } + a.PendingRefresh = &pendingRefresh{Response: response, ReceivedAt: time.Now()} + saveTest(t, s, a) + got, err := f.client.Credentials(context.Background(), s, "") + if err == nil || got != nil || f.calls.Load() != 0 { + t.Fatal("unverified pending credentials were used", err) + } + wantRecovery := "ocr auth login --provider chatgpt --account " + a.ClientID + if !strings.Contains(err.Error(), wantRecovery) { + t.Fatalf("permanent identity failure needs sign-in guidance: %v", err) + } + firstError := err.Error() + saved, err := s.Selected("") + if err != nil || saved.AccessToken != a.AccessToken || saved.RefreshToken != a.RefreshToken || saved.PendingRefresh == nil || saved.PendingRefresh.IdentityError == "" { + t.Fatal("active identity changed", err) + } + restarted := NewClient() + requests := 0 + restarted.http.Transport = roundTrip(func(r *http.Request) (*http.Response, error) { + requests++ + return nil, errors.New("unexpected network request") + }) + if got, err := restarted.Credentials(context.Background(), s, ""); got != nil || err == nil || err.Error() != firstError || requests != 0 { + t.Fatal("permanent failure was retried after restart", requests, err) + } + + f.claims["sub"], f.claims["exp"] = a.Subject, time.Now().Add(time.Hour).Unix() + previous := openBrowser + defer func() { openBrowser = previous }() + openBrowser = func(_ context.Context, local string) error { + q, err := localAuthorization(local) + if err != nil { + return err + } + f.claims["nonce"] = q.Get("nonce") + return sendCallback(q, a.ClientID) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := f.client.Login(ctx, s, "", false, false, false, io.Discard); err != nil { + t.Fatal("could not sign in after permanent refresh failure", err) + } + if got, err := f.client.Credentials(ctx, s, ""); err != nil || got == nil || got.PendingRefresh != nil || got.AccessToken != "access-new" { + t.Fatal("sign-in did not restore usable credentials", err) + } + }) + } +} + +func TestPendingRefreshLogoutAndClear(t *testing.T) { + for _, logout := range []bool{true, false} { + t.Run(map[bool]string{true: "logout", false: "clear"}[logout], func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + a.PendingRefresh = &pendingRefresh{Response: tokenResponse{Refresh: "pending-refresh"}, ReceivedAt: time.Now(), IdentityError: "invalid ID token"} + saveTest(t, s, a) + transport := f.client.http.Transport + revoked := false + f.client.http.Transport = roundTrip(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/revoke" { + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.Form.Get("token") != "pending-refresh" { + t.Error("revocation used the consumed token") + } + revoked = true + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil + } + return transport.RoundTrip(r) + }) + var err error + if logout { + err = f.client.Logout(context.Background(), s, "") + } else { + err = s.Clear(context.Background(), "") + } + if err != nil { + t.Fatal(err) + } + saved, err := s.Selected("") + if err != nil || saved.PendingRefresh != nil || saved.AccessToken != "" || saved.RefreshToken != "" || revoked != logout { + t.Fatal("pending credentials survived clearing", err) + } + }) + } +} + +func TestPendingRefreshRecoversAfterIDAndAccessExpiry(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + received := time.Now().Add(-2 * time.Minute) + f.claims["iat"] = received.Add(-time.Minute).Unix() + f.claims["exp"] = received.Add(time.Minute).Unix() + a.PendingRefresh = &pendingRefresh{Response: tokenResponse{Access: "expired-access", Refresh: "refresh-new", ID: f.jwt(), Type: "Bearer", Expires: 60}, ReceivedAt: received} + saveTest(t, s, a) + f.token = func(r *http.Request) any { + if r.Form.Get("refresh_token") != "refresh-new" { + t.Error("replayed consumed refresh token") + } + saved, err := s.Selected("") + if err != nil || saved.PendingRefresh != nil || saved.ExpiresAt.After(time.Now()) { + t.Error("expired pending token was not committed with its original expiry", err) + } + f.claims["iat"] = time.Now().Unix() + f.claims["exp"] = time.Now().Add(time.Hour).Unix() + return f.tokens() + } + if got, err := f.client.Credentials(context.Background(), s, ""); err != nil || got == nil || got.AccessToken != "access-new" || f.calls.Load() != 1 { + t.Fatal("verified replacement could not refresh again", err) + } +} + +func TestCredentialsRefreshWindow(t *testing.T) { + for _, pending := range []bool{false, true} { + t.Run(map[bool]string{false: "verified", true: "pending"}[pending], func(t *testing.T) { + for _, tc := range []struct { + name string + remaining time.Duration + earliest time.Duration + refreshCalls int32 + refreshedLifetime int + }{ + {name: "outside_window", remaining: 10 * time.Minute}, + {name: "two_minutes", remaining: 2 * time.Minute, refreshCalls: 1}, + {name: "seconds_remaining", remaining: 15 * time.Second, refreshCalls: 1}, + {name: "before_earliest", remaining: 2 * time.Minute, earliest: time.Minute}, + {name: "after_earliest", remaining: 2 * time.Minute, earliest: -time.Minute, refreshCalls: 1}, + {name: "short_lived_replacement", remaining: 2 * time.Minute, refreshCalls: 1, refreshedLifetime: 120}, + } { + t.Run(tc.name, func(t *testing.T) { + f := fixture(t) + s := testStore(t) + a := testAuth() + now := time.Now() + expiry := now.Add(tc.remaining) + a.ExpiresAt = expiry + if tc.earliest != 0 { + a.EarliestRefreshAt = now.Add(tc.earliest).Truncate(time.Second) + } + wantAccess, wantRefresh := a.AccessToken, a.RefreshToken + if pending { + received := now.Add(-10 * time.Minute) + f.claims["iat"] = received.Unix() + response := tokenResponse{Access: "access-pending", Refresh: "refresh-pending", ID: f.jwt(), Type: "Bearer", Expires: int64(expiry.Sub(received) / time.Second)} + if tc.earliest != 0 { + response.Earliest, _ = json.Marshal(a.EarliestRefreshAt.Unix()) + } + a.PendingRefresh = &pendingRefresh{Response: response, ReceivedAt: received} + a.ExpiresAt = now.Add(-time.Minute) + wantAccess, wantRefresh = response.Access, response.Refresh + } + saveTest(t, s, a) + f.token = func(r *http.Request) any { + if r.Form.Get("refresh_token") != wantRefresh { + t.Error("refresh did not use the latest verified token") + } + stored, err := s.Selected("") + if err != nil || stored.PendingRefresh != nil || stored.AccessToken != wantAccess || !stored.ExpiresAt.Equal(expiry) { + t.Error("credentials were not persisted before refreshing", err) + } + tokens := f.tokens() + if tc.refreshedLifetime != 0 { + tokens["expires_in"] = tc.refreshedLifetime + } + return tokens + } + got, err := f.client.Credentials(context.Background(), s, "") + if err != nil || got == nil { + t.Fatal("credentials unavailable", err) + } + if calls := f.calls.Load(); calls != tc.refreshCalls { + t.Fatalf("refresh calls = %d, want %d", calls, tc.refreshCalls) + } + if tc.refreshCalls > 0 { + wantAccess, wantRefresh = "access-new", "refresh-new" + } else if !got.ExpiresAt.Equal(expiry) || !got.EarliestRefreshAt.Equal(a.EarliestRefreshAt) { + t.Fatal("recovery changed the token refresh times") + } + if got.AccessToken != wantAccess || got.RefreshToken != wantRefresh || got.PendingRefresh != nil { + t.Fatal("unexpected credentials returned") + } + stored, err := s.Selected("") + if err != nil || stored.AccessToken != got.AccessToken || stored.RefreshToken != got.RefreshToken || stored.PendingRefresh != nil { + t.Fatal("returned credentials were not persisted", err) + } + }) + } + }) + } +} diff --git a/internal/chatgptauth/store.go b/internal/chatgptauth/store.go new file mode 100644 index 000000000..9d3bf7439 --- /dev/null +++ b/internal/chatgptauth/store.go @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package chatgptauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +var ErrNotFound = errors.New("no ChatGPT registration found; run ocr auth login --provider chatgpt") + +type Auth struct { + Revision uint64 `json:"revision,omitempty"` + ClientID string `json:"client_id"` + Issuer string `json:"issuer"` + Subject string `json:"subject"` + Email string `json:"email,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + EarliestRefreshAt time.Time `json:"earliest_refresh_at,omitempty"` + Welcomed bool `json:"welcomed,omitempty"` + Nonce string `json:"nonce,omitempty"` + PendingRefresh *pendingRefresh `json:"pending_refresh,omitempty"` +} + +type pendingRefresh struct { + Response tokenResponse `json:"response"` + ReceivedAt time.Time `json:"received_at"` + IdentityError string `json:"identity_error,omitempty"` +} + +func (a Auth) PlanEnabled() bool { + var direct, invoke bool + for _, s := range a.Scopes { + direct = direct || s == "chatgpt.tokens.use.direct" + invoke = invoke || s == "resource.invoke" + } + return direct && invoke +} +func (a *Auth) clear() { + a.Revision++ + a.AccessToken = "" + a.RefreshToken = "" + a.IDToken = "" + a.Nonce = "" + a.TokenType = "" + a.Scopes = nil + a.ExpiresAt = time.Time{} + a.EarliestRefreshAt = time.Time{} + a.PendingRefresh = nil +} + +type Database struct { + Active string `json:"active,omitempty"` + Accounts []Auth `json:"accounts"` +} + +func (d *Database) selected(id string) (*Auth, error) { + if id == "" { + id = d.Active + } + for i := range d.Accounts { + if d.Accounts[i].ClientID == id { + return &d.Accounts[i], nil + } + } + return nil, ErrNotFound +} + +type Store struct{ Dir string } + +func DefaultStore() (Store, error) { + home, err := os.UserHomeDir() + if err != nil { + return Store{}, err + } + return Store{Dir: filepath.Join(home, ".opencodereview", "auth", "chatgpt")}, nil +} +func (s Store) secure() error { + if s.Dir == "" { + return errors.New("ChatGPT store directory is required") + } + if err := os.MkdirAll(s.Dir, 0700); err != nil { + return err + } + return os.Chmod(s.Dir, 0700) +} +func (s Store) load() (*Database, error) { + b, err := os.ReadFile(filepath.Join(s.Dir, "accounts.json")) + if errors.Is(err, os.ErrNotExist) { + return &Database{}, nil + } + if err != nil { + return nil, err + } + var d Database + if err := json.Unmarshal(b, &d); err != nil { + return nil, errors.New("invalid ChatGPT registration database") + } + seen := map[string]bool{} + for _, a := range d.Accounts { + if a.ClientID == "" || a.ClientID == DynamicClient || a.Issuer != Issuer || a.Subject == "" || seen[a.ClientID] { + return nil, errors.New("invalid ChatGPT account registration") + } + seen[a.ClientID] = true + } + if d.Active != "" && !seen[d.Active] { + return nil, errors.New("invalid active ChatGPT registration") + } + return &d, nil +} +func (s Store) Snapshot() (*Database, error) { return s.load() } +func (s Store) Selected(id string) (*Auth, error) { + d, err := s.load() + if err != nil { + return nil, err + } + return d.selected(id) +} + +var renameFile = os.Rename + +func (s Store) write(name string, v any) error { + if err := s.secure(); err != nil { + return err + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + f, err := os.CreateTemp(s.Dir, ".write-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + defer f.Close() + if err := f.Chmod(0600); err != nil { + return err + } + if _, err := f.Write(b); err != nil { + return err + } + if err := f.Sync(); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return renameFile(f.Name(), filepath.Join(s.Dir, name)) +} +func (s Store) save(d *Database) error { return s.write("accounts.json", d) } +func (s Store) withLock(ctx context.Context, fn func() error) error { + if err := ctx.Err(); err != nil { + return err + } + if err := s.secure(); err != nil { + return err + } + f, err := os.OpenFile(filepath.Join(s.Dir, "session.lock"), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer f.Close() + if err := f.Chmod(0600); err != nil { + return err + } + for { + acquired, err := tryLock(f) + if err != nil { + return err + } + if acquired { + defer unlock(f) + if err := ctx.Err(); err != nil { + return err + } + return fn() + } + timer := time.NewTimer(25 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} +func (s Store) Host(ctx context.Context) (string, error) { + var id string + err := s.withLock(ctx, func() error { + var record struct { + ID string `json:"id"` + } + b, err := os.ReadFile(filepath.Join(s.Dir, "host.json")) + if errors.Is(err, os.ErrNotExist) { + u, err := uuid.NewRandom() + if err != nil { + return err + } + record.ID = u.URN() + if err := s.write("host.json", record); err != nil { + return err + } + } else if err != nil { + return err + } else if json.Unmarshal(b, &record) != nil { + return errors.New("invalid ChatGPT host record") + } + u, err := uuid.Parse(record.ID) + if err != nil || u.Version() != 4 || u.Variant() != uuid.RFC4122 || !strings.HasPrefix(record.ID, "urn:uuid:") { + return errors.New("invalid ChatGPT host identifier") + } + id = record.ID + return nil + }) + return id, err +} +func (s Store) Clear(ctx context.Context, id string) error { + return s.withLock(ctx, func() error { + d, err := s.load() + if err != nil { + return err + } + a, err := d.selected(id) + if err != nil { + return err + } + a.clear() + return s.save(d) + }) +} +func (s Store) Select(ctx context.Context, id string) error { + if id == "" { + return errors.New("a ChatGPT registration client ID is required") + } + return s.withLock(ctx, func() error { + d, err := s.load() + if err != nil { + return err + } + if _, err := d.selected(id); err != nil { + return fmt.Errorf("select ChatGPT account: %w", err) + } + d.Active = id + return s.save(d) + }) +} diff --git a/internal/config/allowlist/allowed_ext_test.go b/internal/config/allowlist/allowed_ext_test.go index c3d0e0660..8e506f77d 100644 --- a/internal/config/allowlist/allowed_ext_test.go +++ b/internal/config/allowlist/allowed_ext_test.go @@ -4,6 +4,9 @@ package allowedext import ( + "encoding/json" + "os" + "strings" "testing" ) @@ -86,6 +89,8 @@ func TestIsAllowedExt(t *testing.T) { {".NIMS", true}, {".nimble", true}, {".NIMBLE", true}, + {".nu", true}, + {".NU", true}, {".ipynb", true}, {".IPYNB", true}, {".elm", true}, @@ -130,6 +135,20 @@ func TestIsAllowedExt(t *testing.T) { {".VY", true}, {".rego", true}, {".REGO", true}, + {".glsl", true}, + {".GLSL", true}, + {".hlsl", true}, + {".HLSL", true}, + {".wgsl", true}, + {".WGSL", true}, + {".metal", true}, + {".METAL", true}, + {".ps1", true}, + {".PS1", true}, + {".psm1", true}, + {".PSM1", true}, + {".psd1", true}, + {".PSD1", true}, {".txt", false}, {".md", false}, {".png", false}, @@ -145,6 +164,67 @@ func TestIsAllowedExt(t *testing.T) { } } +// TestAllowlistKeepsEveryTestedExtension ties the explicit table in +// TestIsAllowedExt to supported_file_types.json. The table above can only test +// what it lists, so on its own it keeps passing when an entry disappears from the +// file: the case simply stops being exercised, and the suite gets greener while +// the allowlist gets smaller. Dropping a listed extension from the file is the +// shape an accidental deletion takes, so that direction is asserted here. +// +// The reverse direction, an allowlisted extension with no case in the table, is +// deliberately not a failure: the table is far smaller than the allowlist, and +// requiring a case for every entry would mean touching this test for every +// language PR. What is guaranteed here is that nothing the table covers can be +// removed unnoticed. +func TestAllowlistKeepsEveryTestedExtension(t *testing.T) { + raw, err := os.ReadFile("supported_file_types.json") + if err != nil { + t.Fatalf("read supported_file_types.json: %v", err) + } + var exts []string + if err := json.Unmarshal(raw, &exts); err != nil { + t.Fatalf("parse supported_file_types.json: %v", err) + } + if len(exts) == 0 { + t.Fatal("supported_file_types.json has no extensions") + } + + allowed := make(map[string]bool, len(exts)) + for _, ext := range exts { + if ext != strings.ToLower(ext) { + t.Errorf("allowlist entry %q is not lower case; matching lowercases the input, so a mixed-case entry can never be hit", ext) + } + if !strings.HasPrefix(ext, ".") { + t.Errorf("allowlist entry %q does not start with a dot", ext) + } + if allowed[ext] { + t.Errorf("allowlist entry %q is listed more than once", ext) + } + allowed[ext] = true + } + + tested := []string{ + ".go", ".java", ".ts", ".tsx", ".astro", ".py", ".pyi", ".php", ".phtml", + ".rs", ".r", ".ets", ".json5", ".kt", ".kts", ".fs", ".fsi", ".fsx", + ".ftl", ".ftlh", ".ftlx", ".hbs", ".mustache", ".jinja2", ".j2", ".pug", ".graphql", + ".js", ".jsx", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".cs", + ".rb", ".sh", ".bash", ".zsh", ".sql", ".css", ".scss", ".html", ".xml", ".yaml", + ".yml", ".json", ".toml", ".lua", ".swift", + } + for _, ext := range tested { + if !allowed[ext] { + t.Errorf("supported_file_types.json no longer lists %q, but TestIsAllowedExt expects it to be allowed", ext) + continue + } + if !IsAllowedExt(ext) { + t.Errorf("IsAllowedExt(%q) = false for an extension listed in supported_file_types.json", ext) + } + if upper := strings.ToUpper(ext); !IsAllowedExt(upper) { + t.Errorf("IsAllowedExt(%q) = false: matching must be case-insensitive", upper) + } + } +} + func TestIsExcludedPath(t *testing.T) { tests := []struct { name string @@ -219,6 +299,14 @@ func TestIsExcludedPath(t *testing.T) { // Prisma schemas have no conventional default test-file exclusion. {"prisma schema", "prisma/schema.prisma", false}, + {"powershell script", "scripts/Deploy.ps1", false}, + {"powershell module", "modules/Deploy/Deploy.psm1", false}, + {"powershell manifest", "modules/Deploy/Deploy.psd1", false}, + {"pester root", "Deploy.Tests.ps1", false}, + {"pester nested", "tests/unit/Deploy.Tests.ps1", false}, + {"pester uppercase", "tests/unit/DEPLOY.TESTS.PS1", false}, + {"powershell fixture", "tests/fixtures/Deploy.ps1", true}, + // Handlebars/Mustache have no extension-specific test-path convention; // generic fixture directories remain excluded. {"handlebars fixture", "test/fixtures/card.hbs", true}, @@ -282,6 +370,14 @@ func TestIsExcludedPath(t *testing.T) { {"nim non-test", "src/parser.nim", false}, {"nim tests in filename", "src/tests_helper.nim", false}, + // Nushell test files + {"nushell tests directory", "tests/parser.nu", true}, + {"nushell nested tests directory", "packages/core/tests/unit/parser.nu", true}, + {"nushell test directory", "test/parser.nu", true}, + {"nushell test prefix", "src/test_parser.nu", true}, + {"nushell test hyphen prefix", "src/test-parser.nu", true}, + {"nushell test suffix", "src/parser_test.nu", true}, + {"nushell non-test", "src/parser.nu", false}, // Elm test files {"elm test directory", "tests/ParserTest.elm", true}, {"elm nested test directory", "packages/core/tests/unit/ParserTest.elm", true}, @@ -345,6 +441,12 @@ func TestIsExcludedPath(t *testing.T) { {"vyper non-test", "src/token.vy", false}, {"vyper test in filename only", "src/test_helpers.vy", false}, + // Shader languages have no conventional default test-file exclusion. + {"glsl shader", "shaders/fragment.glsl", false}, + {"hlsl shader", "shaders/lighting.hlsl", false}, + {"wgsl shader", "shaders/compute.wgsl", false}, + {"metal shader", "shaders/blur.metal", false}, + // Snapshot files {"jest snapshot dir", "src/__snapshots__/App.test.js.snap", true}, {"snap file", "src/components/Button.snap", true}, diff --git a/internal/config/allowlist/default_exclude_patterns.json b/internal/config/allowlist/default_exclude_patterns.json index 052583b84..3579488d4 100644 --- a/internal/config/allowlist/default_exclude_patterns.json +++ b/internal/config/allowlist/default_exclude_patterns.json @@ -23,6 +23,11 @@ "**/test/**/*.lhs", "**/*Spec.lhs", "**/tests/**/*.nim", + "**/tests/**/*.nu", + "**/test/**/*.nu", + "**/*_test.nu", + "**/test_*.nu", + "**/test-*.nu", "**/tests/**/*.R", "**/__snapshots__/**", "**/*.snap", diff --git a/internal/config/allowlist/supported_file_types.json b/internal/config/allowlist/supported_file_types.json index c0f413e9c..b47650faf 100644 --- a/internal/config/allowlist/supported_file_types.json +++ b/internal/config/allowlist/supported_file_types.json @@ -39,6 +39,8 @@ ".zsh", ".fish", ".ps1", + ".psm1", + ".psd1", ".sql", ".css", ".scss", @@ -79,6 +81,7 @@ ".json5", ".dart", ".tf", + ".tofu", ".graphql", ".gql", ".prisma", @@ -93,6 +96,7 @@ ".nim", ".nims", ".nimble", + ".nu", ".elm", ".properties", ".po", @@ -113,5 +117,9 @@ ".vhdl", ".sol", ".vy", - ".rego" + ".rego", + ".glsl", + ".hlsl", + ".wgsl", + ".metal" ] diff --git a/internal/config/rules/rule_docs/nushell.md b/internal/config/rules/rule_docs/nushell.md new file mode 100644 index 000000000..e52f5da06 --- /dev/null +++ b/internal/config/rules/rule_docs/nushell.md @@ -0,0 +1,220 @@ +# Nushell Script Review Checklist + +Comprehensive checklist for reviewing Nushell scripts. Check items in order of priority. + +--- + +## 1. Security (Critical) + +### Code injection + +- [ ] No `nu -c $variable` with untrusted input +- [ ] No `source $variable` with runtime paths (must be `const`) +- [ ] No `run` of untrusted `.nu` script paths; `run` targets must be parse-time constants +- [ ] No `^sh -c`, `^bash -c`, or `^cmd.exe /C` with interpolated user input +- [ ] No `run-external` with user-controlled command names + +### Path safety + +- [ ] Existing user-provided paths validated with strict expansion + `path relative-to`; no string-prefix containment checks +- [ ] No raw `open $user_input` without path traversal guard +- [ ] `..` sequences in user paths detected and rejected +- [ ] Base directory enforcement for file operations + +### Credential handling + +- [ ] No hardcoded secrets in source code +- [ ] Credentials scoped with `with-env`, not set on `$env` directly +- [ ] Secrets read from files/stdin, not passed as command-line arguments +- [ ] No credentials logged via `print` or written to non-secure files + +### Destructive operations + +- [ ] `rm` operations validate target path (not `/`, not `$nu.home-dir`) +- [ ] Glob patterns from user input are validated (no unintended expansion) +- [ ] `--depth` limits on `glob` to prevent DoS on large trees + +### Temp files + +- [ ] Temp files created with built-in `mktemp`, not predictable paths +- [ ] Temp files cleaned up in `try/catch` or equivalent +- [ ] Temp directories use `mktemp --directory` + +### Environment safety + +- [ ] `$env.PATH` not modifiable by untrusted input +- [ ] Dangerous env vars (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`) cleared before running untrusted commands +- [ ] `with-env` used for scoped environment changes in security-sensitive contexts + +--- + +## 2. Correctness + +### Type safety + +- [ ] All exported commands have type annotations on parameters +- [ ] I/O pipeline signatures (`]: type -> type {`) match actual behavior +- [ ] Complex types use proper syntax: `record`, `list`, `table` +- [ ] Optional parameters use `?` suffix: `name?: string` +- [ ] Optional params and typed named options without defaults are handled as `oneof`; boolean switch flags remain `bool` +- [ ] Rest parameters typed: `...args: string` +- [ ] Runtime assignment annotations are valid under Nu 0.114's default `enforce-runtime-annotations` + +### Error handling + +- [ ] Fallible operations wrapped in `try/catch` +- [ ] External commands checked with `complete` when exit code matters +- [ ] Tests do not assert raw nested-Nu diagnostics without normalizing ANSI, PTY wrapping, and `|` gutters +- [ ] `catch` blocks include meaningful error context (not empty) +- [ ] `finally` used for cleanup side effects, not as the returned value +- [ ] `catch` blocks read `$err.details` for structured diagnostics, not removed `$err.json` +- [ ] Custom errors include `label` with `span` for good error messages +- [ ] No bare `error make {msg: '...'}` without span when metadata is available + +### Null safety + +- [ ] Optional record fields accessed with `?`: `$rec.field?` +- [ ] `default` used for fallback values: `$val | default 'N/A'` +- [ ] No bare field access on records from external/untrusted sources +- [ ] `$in` captured early with `let` when used multiple times + +### Logic correctness + +- [ ] `for` not used as final expression (returns null, use `each`) +- [ ] `mut` variables not captured in closures (will error) +- [ ] `source`/`use` paths are `const`, not `let` +- [ ] Long `if`/`else if` chains on one value prefer `match` unless `if` is clearer +- [ ] `if`/`match` expressions have `else`/`_` fallbacks when a non-null output is required +- [ ] `each` not used on single records (use `items` or `transpose`) +- [ ] `parse` gets `lines` first when line-by-line parsing of stream input is intended +- [ ] Correct operator: `>` in non-pipeline context is comparison, not redirect +- [ ] Multiline custom command calls with named flags are one-line or wrapped in parentheses +- [ ] SemVer logic uses `into semver` / `into semver-range` / `semver bump`, not string surgery + +### External commands + +- [ ] External commands prefixed with `^` when name conflicts with builtins +- [ ] `find` (Nushell builtin) vs `^find` (Unix) distinction maintained +- [ ] `sort` (Nushell builtin) vs `^sort` (Unix) distinction maintained +- [ ] Arguments to external commands separated (not concatenated strings) +- [ ] Format strings passed to external commands use double quotes for simple escapes, or `char tab` / `char nl` when interpolation is needed + +--- + +## 3. Style & Idiom + +### Naming + +- [ ] Commands: `kebab-case` (`fetch-user`, not `fetchUser` or `fetch_user`) +- [ ] Variables/params: `snake_case` (`$user_id`, not `$userId`) +- [ ] Env vars: `SCREAMING_SNAKE_CASE` (`$env.APP_VERSION`) +- [ ] Flags: `kebab-case` (`--output-dir`, not `--output_dir`) +- [ ] Full words preferred (`$user_name`, not `$usr_nm`) + +### String format priority + +- [ ] Bare words in arrays: `[foo bar]` not `["foo" "bar"]` +- [ ] Single quotes for simple strings: `'hello'` not `"hello"` +- [ ] Single-quoted interpolation preferred: `$'val: ($x)'` not `$"val: ($x)"` +- [ ] Strings needing a **literal** `(` use `$"...\(..."`, never `$'...'` — in + `$'...'` the paren always opens an expression and `\(` does not escape it +- [ ] Double quotes only when escape sequences needed: `"\n"`, `"\t"` +- [ ] Raw strings for regex: `r#'pattern'#` +- [ ] `str uppercase` / `str lowercase` used instead of deprecated `str upcase` / `str downcase` + +### Pipeline & functional style + +- [ ] Pipelines preferred over imperative loops +- [ ] `$items | get price | math sum` instead of `mut total; for ...` +- [ ] `ls | where size > 1mb` instead of manual filtering +- [ ] `enumerate` instead of manual index counters +- [ ] `reduce` instead of `mut` accumulator + `for` +- [ ] `match` preferred for multi-branch dispatch on a single value + +### Formatting + +- [ ] No space before `|params|` in closures: `{|x| ...}` not `{ |x| ...}` +- [ ] Spaces around pipe: `cmd | cmd` not `cmd|cmd` +- [ ] Commas omitted in lists: `[1 2 3]` not `[1, 2, 3]` +- [ ] One space after `:` in records: `{x: 1}` not `{x:1}` +- [ ] Multi-line format for expressions >80 chars + +### Documentation + +- [ ] Exported commands have `#` comment above `def` +- [ ] Parameter descriptions as inline `#` comments +- [ ] `@example` attributes for non-trivial commands +- [ ] `@category` for organization when applicable + +### Modules + +- [ ] Only necessary definitions are `export`-ed +- [ ] `export def main` used when command matches module name +- [ ] Submodules are re-exported explicitly when callers need the submodule namespace in Nu 0.114+ +- [ ] Private helpers are not exported +- [ ] `export-env` for environment setup blocks + +--- + +## 4. Performance + +### Parallelism + +- [ ] `par-each` used for I/O-bound work (file reads, HTTP requests) +- [ ] `par-each` used for CPU-bound work (data processing) +- [ ] `--threads` specified when controlling concurrency matters +- [ ] `each` used only when order matters or list is tiny + +### Streaming & memory + +- [ ] `each --flatten` for streaming nested results +- [ ] Large files not loaded entirely when streaming suffices +- [ ] `lines` + pipeline for line-by-line processing of large files +- [ ] `peek` used for stream metadata/sample inspection instead of collecting +- [ ] `first N` / `take while` to limit processing early + +### Caching & computation + +- [ ] Expensive results cached in `let` bindings, not recomputed +- [ ] `glob` with `--depth` to avoid scanning huge trees +- [ ] Built-in commands preferred over external for small data +- [ ] External tools (`^rg`, `^jq`) used for large-scale operations + +--- + +## 5. Robustness + +### Input validation + +- [ ] Parameter types annotated (catches misuse at parse time) +- [ ] Range/value checks at function entry for critical params +- [ ] User-facing commands validate inputs before processing +- [ ] Consistent return types (don't mix null and value unexpectedly) + +### File operations + +- [ ] `path exists` checked before `open` when file may not exist +- [ ] `save --force` used intentionally (overwrites without warning) +- [ ] File encoding handled appropriately (`open --raw` for binary) +- [ ] `from xlsx` / `from ods` output handled as a record of sheet tables +- [ ] Spreadsheet imports use `--noheaders`, `--first-row`, or `--prefer-integers` instead of removed `--header-row` +- [ ] `mkdir -v` / `mv -v` / `rm -v` outputs treated as tables, not parsed text +- [ ] `rm` / `mkdir` partial-success behavior considered when multiple paths are passed + +### Process management + +- [ ] Long-running external processes have timeouts or cancellation +- [ ] Background jobs (`job spawn`) tracked and cleaned up +- [ ] Exit codes checked for critical external commands + +--- + +## Review Workflow + +1. **Skim the entire script** — Understand purpose, entry points, data flow +2. **Security pass** — Check Section 1 items systematically +3. **Correctness pass** — Verify types, error handling, null safety +4. **Style pass** — Naming, strings, formatting, documentation +5. **Performance pass** — Parallelism, streaming, caching opportunities +6. **Robustness pass** — Input validation, file safety, process management +7. **Summarize findings** — Group by severity, highlight security issues first \ No newline at end of file diff --git a/internal/config/rules/rule_docs/powershell.md b/internal/config/rules/rule_docs/powershell.md new file mode 100644 index 000000000..bcfe606e9 --- /dev/null +++ b/internal/config/rules/rule_docs/powershell.md @@ -0,0 +1,89 @@ +#### PowerShell Review Principles +> Favor precision over recall: report only defects that are likely to affect correctness, security, compatibility, or operational safety. Do not report formatting or naming preferences already covered by PSScriptAnalyzer. Account for the declared PowerShell version, target operating systems, privilege boundary, and whether the file is a script (`.ps1`), module (`.psm1`), or data/manifest file (`.psd1`). + +Before reporting non-local behavior, use `file_read` and `code_search` to verify callers, parameter contracts, module exports, execution context, and input trust boundaries. Do not assume every use of aliases, `Write-Host`, the call operator (`&`), or a global variable is a defect without a concrete behavioral impact. + +#### Command Construction and Injection +- Untrusted text passed to `Invoke-Expression`, `[scriptblock]::Create`, `powershell -Command`, `pwsh -Command`, `cmd /c`, `sh -c`, or another string-evaluation boundary where an attacker can introduce executable syntax. +- Commands assembled as one interpolated string where subsequent evaluation permits injection or quoting changes the intended arguments; do not report string construction alone. +- Remote content downloaded and executed without authenticity, integrity, or trust validation, including `Invoke-WebRequest ... | Invoke-Expression` patterns. +- User-controlled executable names, module names, script paths, or command paths invoked with `&`, dot-sourcing, `Start-Process`, or remoting without an allowlist or a constrained resolution boundary. +- User-controlled wildcard patterns, registry paths, certificate paths, or filesystem paths that can escape the intended scope, match additional targets, or trigger destructive operations. +- Quoting that expands variables or subexpressions earlier than intended, especially across native-command, remoting, or nested-shell boundaries. + +#### Parameters, Types, and Input Validation +- Parameters accepting external input without the type, validation attributes, or explicit checks needed before privileged, destructive, or security-sensitive use. +- A parameter declared for pipeline input but processed outside `process`, causing only the final item or no items to be handled as intended. +- Confusing `$null`, an empty collection, and a scalar after PowerShell's automatic enumeration, producing incorrect branching or skipped work. +- Relying on truthiness where valid values such as `0`, `''`, or `$false` must be distinguished from missing input. +- Unsafe casts or enum conversions that can throw after partial side effects have already occurred. +- Returning or accepting loosely shaped `PSCustomObject` values where callers rely on properties that are absent on reachable paths. + +#### Errors and Native Process Status +- Expecting `try`/`catch` to handle a non-terminating cmdlet error without `-ErrorAction Stop` or an equivalent scoped `$ErrorActionPreference` policy. +- Setting `$ErrorActionPreference` globally or suppressing errors with `SilentlyContinue`/`Ignore` in a way that hides a required failure or changes unrelated caller behavior. +- Empty or overly broad `catch` blocks that replace failure with plausible output, continue after partial state changes, or discard actionable error context. +- Assuming a native command failure throws a PowerShell exception; inspect the supported version, `$PSNativeCommandUseErrorActionPreference`, `$ErrorActionPreference`, and any native-command wrapper before requiring an explicit `$LASTEXITCODE` check. Interpret exit codes using the invoked command's contract, not a blanket nonzero-is-failure rule. +- Checking `$?` after another command has already overwritten it, or treating stderr output alone as proof that a native command failed. +- Cleanup, lock release, location restoration, session teardown, or temporary-state restoration skipped on a reachable failure or cancellation path; recognize `finally`, PowerShell 7.3+ `clean` blocks, and other disposal mechanisms appropriate to the supported version and lifetime. + +#### Pipeline and Output Semantics +- Helper expressions or cmdlets emitting unintended objects into a function's success stream and corrupting the function's documented return value. +- Assuming `return` suppresses objects emitted earlier in the function or script block. +- Accidental collection unrolling that changes a nested collection or single collection object into separate pipeline records; preserve shape explicitly when callers depend on it. +- Using formatting cmdlets such as `Format-Table` before data leaves a reusable function or module, converting structured objects into formatting instructions instead of returning data. +- Mixing host, information, warning, error, and success streams in a way that breaks redirection, automation, or structured consumers. +- Materializing an unbounded pipeline where memory exhaustion is reachable for expected input sizes. For array append costs, verify the supported version and workload: PowerShell 7.5 optimized array addition, so `+=` alone is not evidence of quadratic runtime. + +#### State Changes, Idempotence, and Cleanup +- State-changing advanced functions that claim or require `-WhatIf`/`-Confirm` behavior but omit or bypass `SupportsShouldProcess` and `$PSCmdlet.ShouldProcess()`. +- Retry or rerun behavior that duplicates resources, appends configuration repeatedly, or corrupts state instead of converging on the requested result. +- Multiple related mutations that can leave a partially applied configuration after an intermediate failure without rollback, compensation, or a clearly documented partial-state contract. +- Scripts changing process-wide location, environment variables, preference variables, default parameter values, or module state without restoring them when used as reusable components. +- Resources, jobs, runspaces, sessions, subscriptions, streams, or temporary files that remain active after success, failure, or cancellation. +- Destructive wildcard operations whose resolved targets are not checked before mutation. + +#### Credentials, Transport, and Sensitive Data +- Passwords, tokens, connection strings, private keys, or credentials embedded in source, command arguments, logs, exceptions, transcripts, or module manifests. +- Exposing plaintext credentials beyond a necessary API boundary or retaining/logging their plaintext representation. Conversion to `SecureString` alone is not a defect and does not erase the original plaintext; do not assume its contents are encrypted on non-Windows platforms. +- Accepting separate username/password strings where `PSCredential`, managed identity, or an established secret provider is required by the surrounding code. +- Disabling certificate validation, using `-SkipCertificateCheck`, weakening TLS, or suppressing host verification across a real trust boundary. +- Trusting execution policy as an authentication or authorization control; execution policy is not a security boundary. +- Logging full request headers, environment variables, process arguments, or remote-session configuration that may contain secrets. + +#### Remoting, Jobs, and Concurrency +- Assuming local variables are available remotely without `Using:`, parameters, or an explicit argument list, or expanding them in the wrong session because of quoting. +- Depending on methods, live handles, or private-key material lost during out-of-process job or remoting serialization; verify the actual type because some types are rehydrated rather than returned as deserialized property bags. +- Starting jobs, thread jobs, runspaces, or remote commands without observing completion and failures when the result affects correctness. +- Shared mutable state accessed from thread jobs, parallel pipelines, event handlers, or runspaces without synchronization or an ownership invariant. +- Event subscriptions or background jobs surviving beyond their owner and continuing unexpected side effects. +- Remoting endpoints, authentication mechanisms, or credential delegation that broaden access beyond the intended hosts or users. + +#### Modules, Scopes, and Manifests +- Module code leaking mutable variables, aliases, functions, drives, or preference changes into global scope unintentionally. +- Public commands missing from `FunctionsToExport`, or private helpers exported through wildcards when the manifest is expected to define a stable API. +- `RootModule`, `NestedModules`, `RequiredModules`, compatible editions, or PowerShell version constraints that do not match the module's actual dependencies and syntax. +- Module initialization performing irreversible side effects on import without an explicit module contract. +- Dot-sourcing or importing a path derived from untrusted input, the current directory, or an ambiguous module search path. +- Evaluating untrusted `.psd1` content as code instead of importing data safely, or adding values the actual loader rejects. Distinguish `Import-PowerShellDataFile` from module-manifest restricted language; supported manifest variables and expressions are not arbitrary code execution. + +#### Version and Platform Compatibility +- Windows PowerShell 5.1-only and PowerShell 7-only syntax, cmdlets, parameters, or .NET APIs used without matching the project's declared support range. +- Windows-only providers, registry access, drive letters, path separators, environment-variable casing, or executable names used on code paths intended for macOS or Linux. +- Native-command quoting or argument-passing assumptions that differ across Windows PowerShell, modern PowerShell, and target operating systems; inspect PowerShell 7.3+ `$PSNativeCommandArgumentPassing` modes and legacy-command exceptions. +- Text encoding assumptions that corrupt non-ASCII input or output across Windows PowerShell and PowerShell 7 defaults. +- Platform checks performed after an incompatible command or type has already been resolved or invoked. + +#### Test Correctness +- Pester mocks targeting a command name or module scope different from the one used by the code under test, allowing the real command to run or making the assertion meaningless. +- Assertions that cannot fail for the reported regression, or tests that exercise setup output instead of the function result. +- Tests sharing global variables, imported module state, environment changes, files, jobs, or sessions without isolation and cleanup. +- Destructive or privileged tests reaching real resources without an effective safety boundary. `TestDrive` isolates only file operations directed into it; mocks must intercept the actual call, and `-WhatIf` only protects operations that honor `ShouldProcess`. +- Tests depending on uncontrolled network, credentials, locale, installed modules, execution policy, or host state without declaring and isolating that dependency. + +#### Official References +- [Command evaluation](https://learn.microsoft.com/en-us/powershell/scripting/learn/deep-dives/avoid-using-invoke-expression), [native-command preferences](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_preference_variables), and [execution policy](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies). +- [Advanced function lifecycle](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_functions_advanced_methods), [return/output semantics](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_return), and [array addition](https://learn.microsoft.com/en-us/powershell/scripting/dev-cross-plat/performance/script-authoring-considerations#array-addition). +- [ShouldProcess](https://learn.microsoft.com/en-us/powershell/scripting/learn/deep-dives/everything-about-shouldprocess), [SecureString](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/convertto-securestring), and [remoting serialization](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_remote_variables). +- [Module manifests](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_module_manifests), [safe data import](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/import-powershelldatafile), and [character encoding](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_character_encoding). +- [Pester mocking](https://pester.dev/docs/usage/mocking) and [TestDrive isolation](https://pester.dev/docs/usage/testdrive); use the documentation matching the project's Pester version. diff --git a/internal/config/rules/rule_docs/shader.md b/internal/config/rules/rule_docs/shader.md new file mode 100644 index 000000000..192422fe1 --- /dev/null +++ b/internal/config/rules/rule_docs/shader.md @@ -0,0 +1,40 @@ +> Favor precision over recall: only raise an issue when you are confident it is a real defect on the target shading language and pipeline stage; stay silent when the surrounding pipeline setup (bindings, render pass, vertex layout) is defined outside this file and cannot be verified. Treat correctness and security findings as blocking, and style or naming suggestions as non-blocking. + +#### Obvious Typos or Spelling Errors +- Spelling errors in uniform/binding names, semantic names (HLSL `SV_*`), varying/interpolant names, or shader entry-point names at their declaration sites +- Typos in preprocessor macros or `#include` paths that would fail to compile or silently pull in the wrong header + +#### Precision, Range, and Numeric Correctness +- Low/`half`/`mediump` precision qualifiers used for values that need full range or precision (depth, world-space position, accumulation buffers), risking banding or z-fighting +- Division, `rsqrt`, `normalize`, `log`, or `pow` calls on values that can be zero or negative without a guard, producing `NaN`/`Inf` that propagates through the pipeline +- Implicit or explicit type conversions between `float`/`half`/`int`/`uint` that truncate or wrap in a way that changes shading results, especially in loop counters and texture indices +- Non-uniform control flow around `pow`, `log`, or matrix operations that assumes IEEE-754 behavior not guaranteed across all target GPUs/drivers + +#### Texture and Buffer Access Safety +- Texture, buffer, or resource array indices computed from vertex/instance/thread IDs or user data without a bounds check, particularly on `RWStructuredBuffer`/`RWBuffer`/`ssbo`/`storage` writes +- Implicit-derivative texture sampling (GLSL `texture`, HLSL `tex2D`/`Sample`, WGSL `textureSample`, or Metal `sample`) in non-uniform control flow where implicit derivatives are undefined; do not flag explicit-LOD sampling such as `textureLod` for this reason +- Mismatched texture format, channel count, or sRGB/linear color space assumptions between the shader and the resource binding declared on the host side +- Out-of-bounds writes to shared/groupshared/threadgroup memory in compute shaders, or missing barriers (`GroupMemoryBarrierWithGroupSync`, `barrier()`, `workgroupBarrier()`) before reading data another invocation wrote + +#### Binding, Layout, and Cross-Stage Contracts +- Uniform/constant buffer, descriptor set, or binding-slot indices that do not match the layout the host application (or a companion shader stage) expects, since mismatches fail silently at runtime rather than at compile time +- Struct memory layout (`std140`/`std430` in GLSL, constant-buffer packing in HLSL, host-shareable type alignment and member offsets in WGSL) whose field alignment or padding does not match the CPU-side struct, causing misread values +- Vertex output / fragment input (varyings, `SV_Position`, `[[stage_in]]`) whose interpolation qualifiers (`flat`, `noperspective`, `centroid`) are inconsistent with how the value is used downstream +- Shader variants driven by preprocessor defines or pipeline permutations where a new code path is not covered by all defined permutations, leaving some variants uncompiled or behaviorally inconsistent + +#### Concurrency and Compute Correctness (Compute/Kernel Shaders) +- Race conditions on shared/groupshared/threadgroup memory or storage buffers written by multiple invocations without atomics or synchronization +- Hardcoded indexing, tile-size, or shared-memory assumptions that disagree with the actual local workgroup size or dispatch coverage; distinguish threads per workgroup (`local_size_*`, `[numthreads]`, `@workgroup_size`, or Metal's host-supplied threads-per-threadgroup) from the number of workgroups dispatched +- Atomic operations used on types or backends that do not actually support atomics for that format, or atomics used where a simple reduction would be both correct and faster +- Divergent branches or early `return`/`discard` inside a compute kernel placed before a required barrier, causing a deadlock or undefined synchronization on affected lanes + +#### Performance Anti-Patterns +- Expensive operations (dynamic branching, texture-dependent reads, transcendental functions) inside tight loops that could be hoisted, precomputed on the CPU, or baked into a lookup texture +- Dynamic (non-uniform) branching on GPUs where the target hardware executes both sides of a branch per-warp/wavefront, negating the intended savings +- Redundant texture fetches or matrix multiplications recomputed per-fragment/per-thread that are invariant across the invocation and could be computed once (e.g. in the vertex stage or as a uniform) +- Overly large local/register usage (long-lived temporaries, unrolled loops) that reduces occupancy without a documented profiling justification + +#### Security-Sensitive and Portability Concerns +- Shader code paths that read attacker- or user-controlled buffer sizes/offsets (e.g. from a compute dispatch driven by untrusted input) without validating them before use as an index +- Vendor-specific intrinsics or extensions (`GL_ARB_*`, `SV_Barycentrics`, wave/subgroup intrinsics) used without a fallback or capability check, breaking portability across GPUs that lack the extension +- `discard`/`clip` used to implement alpha testing in a way that defeats early-Z/early-depth-test optimizations without a documented performance tradeoff diff --git a/internal/config/rules/system_rules.json b/internal/config/rules/system_rules.json index b57552db3..399978d6d 100644 --- a/internal/config/rules/system_rules.json +++ b/internal/config/rules/system_rules.json @@ -35,12 +35,13 @@ "**/*.prisma": "prisma.md", "**/*.jl": "julia.md", "**/*.R": "r.md", - "**/*.{tf,hcl,tfvars}": "terraform.md", + "**/*.{tf,tofu,hcl,tfvars}": "terraform.md", "**/*.bicep": "bicep.md", "**/*.nix": "nix.md", "**/*.{hs,lhs}": "haskell.md", "**/*.{nim,nims,nimble}": "nim.md", "**/*.swift": "swift.md", + "**/*.nu": "nushell.md", "**/*.elm": "elm.md", "**/*.{jsonnet,libsonnet}": "jsonnet.md", "**/*.zig": "zig.md", @@ -54,6 +55,8 @@ "**/*.mm": "objc.md", "**/*.sol": "solidity.md", "**/*.vy": "vyper.md", - "**/*.rego": "rego.md" + "**/*.rego": "rego.md", + "**/*.{glsl,hlsl,wgsl,metal}": "shader.md", + "**/*.{ps1,psm1,psd1}": "powershell.md" } } diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go index f0327d60a..bef62619a 100644 --- a/internal/config/rules/system_rules_test.go +++ b/internal/config/rules/system_rules_test.go @@ -130,6 +130,7 @@ func TestResolve_DefaultRules(t *testing.T) { {"src/model.jl", "Type Stability"}, {"MyPkg/src/solver.jl", "Type Stability"}, {"main.tf", "Hardcoded Secrets"}, + {"main.tofu", "Hardcoded Secrets"}, {"modules/network/vpc.hcl", "Overly Permissive Access"}, {"envs/prod.tfvars", "Hardcoded Secrets"}, {"infra/main.bicep", "Hardcoded Secrets"}, @@ -143,6 +144,8 @@ func TestResolve_DefaultRules(t *testing.T) { {"Sources/App/ContentView.swift", "Swift Review Principles"}, {"MyApp/Models/UserStore.swift", "Swift Review Principles"}, {"ChattyFit/ChattyFit/Views/WorkoutSessionView.swift", "SwiftUI State and Lifecycle"}, + {"scripts/setup.nu", "Nushell Script Review Checklist"}, + {"modules/user/init.nu", "Nushell Script Review Checklist"}, {"src/Main.elm", "Elm Architecture"}, {"app/Page/Home.elm", "Elm Architecture"}, {"lib/config.libsonnet", "Late Binding"}, @@ -156,6 +159,10 @@ func TestResolve_DefaultRules(t *testing.T) { {"if/common.thrift", "Field IDs and Wire Compatibility"}, {"schema/addressbook.capnp", "Ordinals and Wire Compatibility"}, {"src/rpc.capnp", "Ordinals and Wire Compatibility"}, + {"shaders/fragment.glsl", "Texture and Buffer Access Safety"}, + {"Shaders/Lighting.hlsl", "Texture and Buffer Access Safety"}, + {"shaders/compute.wgsl", "Texture and Buffer Access Safety"}, + {"Shaders/Blur.metal", "Texture and Buffer Access Safety"}, {"src/parser.ml", "Pattern Matching"}, {"lib/parser.mli", "Pattern Matching"}, {"src/Component.re", "Pattern Matching"}, @@ -174,6 +181,9 @@ func TestResolve_DefaultRules(t *testing.T) { {"src/amm.vy", "Reentrancy and `@nonreentrant`"}, {"policies/authz.rego", "Default Posture"}, {"policies/authz.REGO", "Default Posture"}, + {"scripts/deploy.ps1", "PowerShell Review Principles"}, + {"modules/Build/Build.psm1", "PowerShell Review Principles"}, + {"modules/Build/Build.psd1", "PowerShell Review Principles"}, } for _, tt := range tests { @@ -909,6 +919,32 @@ func TestResolveDetail_SystemPatternMatch(t *testing.T) { } } +func TestResolveDetail_SystemPowerShellPatternMatch(t *testing.T) { + setTestHome(t, t.TempDir()) + resolver, _, err := NewResolver(t.TempDir(), "", ResolverOptions{}) + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + dr := resolver.(DetailResolver) + + for _, name := range []string{"Deploy.ps1", "Build.psm1", "Build.psd1", "Deploy.Tests.ps1"} { + for _, path := range []string{name, "scripts/" + name, "tests/unit/" + strings.ToUpper(name)} { + t.Run(path, func(t *testing.T) { + if !allowedext.IsAllowedExt(filepath.Ext(path)) || allowedext.IsExcludedPath(path) { + t.Fatalf("PowerShell file %q must remain reviewable", path) + } + detail := dr.ResolveDetail(path) + if detail.Source != "system" || detail.Pattern != "**/*.{ps1,psm1,psd1}" { + t.Errorf("unexpected rule source/pattern: %q / %q", detail.Source, detail.Pattern) + } + if !strings.Contains(detail.Rule, "PowerShell Review Principles") { + t.Errorf("expected PowerShell rule, got %q", truncate(detail.Rule, 80)) + } + }) + } + } +} + func TestResolveDetail_SystemPrismaPatternMatch(t *testing.T) { setTestHome(t, t.TempDir()) resolver, _, err := NewResolver(t.TempDir(), "", ResolverOptions{}) diff --git a/internal/delegate/instructions_test.go b/internal/delegate/instructions_test.go new file mode 100644 index 000000000..effb60bb9 --- /dev/null +++ b/internal/delegate/instructions_test.go @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package delegate + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "testing" + "testing/fstest" + "time" +) + +type delegateRepo struct { + dir, base, commit string + env []string +} + +func delegateGitEnv() []string { + var env []string + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if !strings.HasPrefix(strings.ToUpper(key), "GIT_") { + env = append(env, entry) + } + } + return append(env, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_ATTR_NOSYSTEM=1") +} + +func (r delegateRepo) run(t *testing.T, args ...string) ([]byte, error) { + t.Helper() + // Cleanup checks run after the test context has been canceled. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir, cmd.Env = r.dir, r.env + out, err := cmd.CombinedOutput() + if ctx.Err() != nil { + return out, ctx.Err() + } + return out, err +} + +func (r delegateRepo) must(t *testing.T, args ...string) string { + t.Helper() + out, err := r.run(t, args...) + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return string(out) +} + +func newDelegateRepo(t *testing.T) delegateRepo { + t.Helper() + r := delegateRepo{dir: t.TempDir(), env: append(delegateGitEnv(), + "GIT_EXTERNAL_DIFF=ocr-forbidden-external-diff", "GIT_PAGER=ocr-forbidden-pager")} + r.must(t, "init", "-q", "--template=") + r.must(t, "config", "user.name", "Test User") + r.must(t, "config", "user.email", "test@example.com") + r.must(t, "config", "commit.gpgsign", "false") + r.must(t, "config", "core.autocrlf", "false") + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(r.dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("sample file.txt", "before\n") + write(".gitattributes", "*.txt diff=ocr-test\n") + r.must(t, "add", ".") + r.must(t, "commit", "-qm", "initial") + r.base = strings.TrimSpace(r.must(t, "rev-parse", "HEAD")) + write("sample file.txt", "committed\n") + r.must(t, "add", "sample file.txt") + r.must(t, "commit", "-qm", "change") + r.commit = strings.TrimSpace(r.must(t, "rev-parse", "HEAD")) + write("sample file.txt", "workspace\n") + r.must(t, "config", "color.ui", "always") + r.must(t, "config", "diff.ocr-test.textconv", "ocr-forbidden-textconv") + return r +} + +func delegateExamples(t *testing.T) []string { + t.Helper() + docs := []string{ + "skills/open-code-review-delegate/SKILL.md", + "plugins/open-code-review/skills/open-code-review-delegate/SKILL.md", + "plugins/open-code-review/claude-code/commands/delegate-review.md", + "plugins/open-code-review/kimi-code/commands/delegate-review.md", + } + for _, locale := range []string{"en", "zh", "ja", "ko", "ru"} { + docs = append(docs, "pages/src/content/docs/"+locale+"/integrations/delegate.md") + } + // Go runs package tests from the package directory, including with -trimpath. + examples, err := readDelegateExamples(os.DirFS(filepath.Join("..", "..")), docs) + if err != nil { + t.Fatal(err) + } + return examples +} + +func readDelegateExamples(root fs.FS, docs []string) ([]string, error) { + if len(docs) == 0 { + return nil, fmt.Errorf("no delegate documents to check") + } + exampleRE := regexp.MustCompile("git [^`\r\n]*(?: -- \"?\"?|\"?:\"?)|cat \"?\"?") + var canonical []string + for i, doc := range docs { + content, err := fs.ReadFile(root, doc) + if err != nil { + return nil, fmt.Errorf("%s: %w", doc, err) + } + set := map[string]bool{} + for _, example := range exampleRE.FindAllString(string(content), -1) { + set[example] = true + } + var examples []string + for example := range set { + examples = append(examples, example) + } + slices.Sort(examples) + if len(examples) == 0 { + return nil, fmt.Errorf("%s contains no review command examples", doc) + } + if !slices.ContainsFunc(examples, func(example string) bool { return strings.HasPrefix(example, "git ") }) { + return nil, fmt.Errorf("%s contains no Git command examples", doc) + } + if i == 0 { + canonical = examples + } else if !slices.Equal(examples, canonical) { + return nil, fmt.Errorf("%s examples differ from %s:\n%q\nwant %q", doc, docs[0], examples, canonical) + } + } + return canonical, nil +} + +func TestReadDelegateExamples(t *testing.T) { + const diff = "git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- \"\"" + const cat = "cat \"\"" + for _, tc := range []struct { + name string + contents []string + want []string + wantErr string + }{ + { + name: "matching sets with duplicates and different order", + contents: []string{diff + "\n" + cat + "\n" + diff, cat + "\n" + diff}, + want: []string{cat, diff}, + }, + {name: "empty first document", contents: []string{"# No examples", diff}, wantErr: "doc-0.md contains no review command examples"}, + {name: "empty later document", contents: []string{diff, "# No examples"}, wantErr: "doc-1.md contains no review command examples"}, + {name: "all documents empty", contents: []string{"", ""}, wantErr: "doc-0.md contains no review command examples"}, + {name: "no documents", wantErr: "no delegate documents to check"}, + {name: "no Git commands", contents: []string{cat, cat}, wantErr: "doc-0.md contains no Git command examples"}, + {name: "different command sets", contents: []string{diff, diff + "\n" + cat}, wantErr: "doc-1.md examples differ from doc-0.md"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := fstest.MapFS{} + var docs []string + for i, content := range tc.contents { + name := fmt.Sprintf("doc-%d.md", i) + docs = append(docs, name) + root[name] = &fstest.MapFile{Data: []byte(content)} + } + got, err := readDelegateExamples(root, docs) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, tc.want) { + t.Fatalf("examples = %q, want %q", got, tc.want) + } + }) + } +} + +func (r delegateRepo) args(example, patchPath string) []string { + // Split placeholders before inserting paths containing spaces as single arguments. + args := strings.Fields(example)[1:] + replace := strings.NewReplacer("\"", "", "", r.base, "", r.commit, + "", r.commit, "", r.commit, "", "sample file.txt", "", patchPath) + for i := range args { + args[i] = replace.Replace(args[i]) + } + return args +} + +func TestDelegateDiffExamples(t *testing.T) { + examples := delegateExamples(t) + r := newDelegateRepo(t) + modes := map[string]bool{} + for _, example := range examples { + t.Run(example, func(t *testing.T) { + if strings.HasPrefix(example, "cat ") { + modes["untracked"] = true + if example != "cat \"\"" { + t.Fatal("untracked file path must be quoted") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("shell execution requires Bash; path quotation was checked") + } + name := "untracked sample file.txt" + if err := os.WriteFile(filepath.Join(r.dir, name), []byte("untracked\n"), 0o644); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bash, "-c", strings.ReplaceAll(example, "", "$1"), "ocr-read", name) + cmd.Dir, cmd.Env = r.dir, r.env + if out, err := cmd.CombinedOutput(); err != nil || string(out) != "untracked\n" { + t.Fatalf("read untracked file: %v\n%s", err, out) + } + return + } + if !strings.Contains(example, " -- \"\"") && !strings.Contains(example, "\":\"") { + t.Fatal("file path placeholder must be quoted") + } + patchPath := filepath.Join(t.TempDir(), "review patch.diff") + args := r.args(example, patchPath) + if len(args) < 2 || args[0] != "--no-pager" { + t.Fatal("every Git command must disable the pager before the subcommand") + } + if args[1] == "log" || args[1] == "blame" || strings.Contains(example, ":") { + modes[args[1]] = true + want := "workspace" + if args[1] == "log" { + want = "change" + } else if args[1] == "show" { + want = "committed" + } + if out := r.must(t, args...); !strings.Contains(out, want) { + t.Fatalf("context output missing %q: %s", want, out) + } + return + } + for _, flag := range []string{"--no-ext-diff", "--no-textconv", "--no-color"} { + if !slices.Contains(args, flag) { + t.Errorf("missing %s", flag) + } + } + mode, want := "workspace", "+workspace\n" + if strings.Contains(example, "") { + mode, want = "range", "+committed\n" + } else if strings.Contains(example, "") { + mode, want = "commit", "+committed\n" + } + modes[mode] = true + if strings.Contains(example, "") { + modes["output-file"] = true + if !strings.Contains(example, "--output=\"\"") { + t.Fatal("output path placeholder must be quoted") + } + } else { + assertDelegatePatch(t, r.must(t, args...), want) + args = append(args[:2:2], append([]string{"--output=" + patchPath}, args[2:]...)...) + } + r.must(t, args...) + patch, err := os.ReadFile(patchPath) + if err != nil { + t.Fatal(err) + } + assertDelegatePatch(t, string(patch), want) + for _, failure := range []string{"invalid-ref", "output-is-directory"} { + badArgs := append([]string(nil), args...) + for i, arg := range badArgs { + if failure == "invalid-ref" { + badArgs[i] = strings.NewReplacer(r.base, "ocr-missing-ref", r.commit, "ocr-missing-ref", "HEAD", "ocr-missing-ref").Replace(arg) + } else if strings.HasPrefix(arg, "--output=") { + badArgs[i] = "--output=" + t.TempDir() + } + } + if out, err := r.run(t, badArgs...); err == nil { + t.Errorf("%s unexpectedly succeeded: %s", failure, out) + } else if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() <= 0 { + t.Errorf("%s did not return a positive Git exit code: %v", failure, err) + } + } + }) + } + for _, mode := range []string{"range", "commit", "workspace", "output-file", "untracked", "log", "blame", "show"} { + if !modes[mode] { + t.Errorf("missing %s example", mode) + } + } +} + +func TestDelegateGitIsolation(t *testing.T) { + ambient := newDelegateRepo(t) + head := ambient.must(t, "rev-parse", "HEAD") + status := ambient.must(t, "status", "--porcelain") + indexPath := filepath.Join(ambient.dir, ".git", "index") + index, err := os.ReadFile(indexPath) + if err != nil { + t.Fatal(err) + } + files := map[string][]byte{} + for _, name := range []string{"sample file.txt", ".gitattributes"} { + content, err := os.ReadFile(filepath.Join(ambient.dir, name)) + if err != nil { + t.Fatal(err) + } + files[name] = content + } + for key, value := range map[string]string{ + "GIT_DIR": filepath.Join(ambient.dir, ".git"), "GIT_WORK_TREE": ambient.dir, + "GIT_INDEX_FILE": indexPath, "GIT_OBJECT_DIRECTORY": filepath.Join(ambient.dir, ".git", "objects"), + "GIT_ALTERNATE_OBJECT_DIRECTORIES": filepath.Join(ambient.dir, ".git", "objects"), + "GIT_COMMON_DIR": filepath.Join(ambient.dir, ".git"), "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.worktree", "GIT_CONFIG_VALUE_0": ambient.dir, + } { + t.Setenv(key, value) + } + t.Cleanup(func() { + if got := ambient.must(t, "rev-parse", "HEAD"); got != head { + t.Error("ambient repository HEAD changed") + } + if got := ambient.must(t, "status", "--porcelain"); got != status { + t.Error("ambient repository worktree changed") + } + if got, err := os.ReadFile(indexPath); err != nil || !bytes.Equal(got, index) { + t.Error("ambient repository index changed") + } + for name, content := range files { + if got, err := os.ReadFile(filepath.Join(ambient.dir, name)); err != nil || !bytes.Equal(got, content) { + t.Errorf("ambient worktree file %s changed", name) + } + } + }) + isolated := newDelegateRepo(t) + if _, err := os.Stat(filepath.Join(isolated.dir, ".git")); err != nil { + t.Fatal("isolated repository was not initialized:", err) + } + got := strings.TrimSpace(isolated.must(t, "rev-parse", "--show-toplevel")) + actual, err := os.Stat(got) + if err != nil { + t.Fatal(err) + } + want, err := os.Stat(isolated.dir) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(actual, want) { + t.Fatalf("repository escaped temporary directory: %s", got) + } +} + +func TestDelegatePagerProcess(t *testing.T) { + if os.Getenv("OCR_DELEGATE_PAGER_HELPER") != "1" { + return + } + if err := os.WriteFile(os.Getenv("OCR_DELEGATE_PAGER_MARKER"), []byte("started"), 0o600); err != nil { + os.Exit(2) + } + if _, err := io.Copy(os.Stdout, os.Stdin); err != nil { + os.Exit(2) + } + os.Exit(0) +} + +func TestDelegatePagerPTY(t *testing.T) { + examples := delegateExamples(t) + unavailable := func(reason string) { + t.Helper() + if os.Getenv("OCR_DELEGATE_REQUIRE_PTY") == "1" { + t.Fatal(reason) + } + t.Skip(reason) + } + if runtime.GOOS != "linux" { + unavailable("PTY regression uses util-linux script") + } + script, err := exec.LookPath("script") + if err != nil { + unavailable("PTY regression requires util-linux script: " + err.Error()) + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + version, err := exec.CommandContext(ctx, script, "--version").CombinedOutput() + if err != nil || !bytes.Contains(version, []byte("util-linux")) { + unavailable("PTY regression requires the util-linux implementation of script") + } + r := newDelegateRepo(t) + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + run := func(t *testing.T, args []string) string { + t.Helper() + marker := filepath.Join(t.TempDir(), "pager-started") + command := []string{"git"} + for _, arg := range args { + command = append(command, quote(arg)) + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, script, "-q", "-e", "-c", strings.Join(command, " "), os.DevNull) + cmd.Dir = r.dir + cmd.Env = append(delegateGitEnv(), "TERM=xterm", "OCR_DELEGATE_PAGER_HELPER=1", "OCR_DELEGATE_PAGER_MARKER="+marker, + "GIT_PAGER="+quote(os.Args[0])+" -test.run=^TestDelegatePagerProcess$") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PTY command %v: %v\n%s", args, err, out) + } + return marker + } + if _, err := os.Stat(run(t, []string{"--paginate", "log", "--oneline"})); err != nil { + t.Fatal("positive control did not launch the pager:", err) + } + for _, example := range examples { + if !strings.HasPrefix(example, "git ") { + continue + } + t.Run(example, func(t *testing.T) { + marker := run(t, r.args(example, filepath.Join(t.TempDir(), "review patch.diff"))) + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("documented command launched the pager: %v", err) + } + }) + } +} + +func assertDelegatePatch(t *testing.T, patch, want string) { + t.Helper() + for _, part := range []string{"diff --git ", "@@ -1 +1 @@", want} { + if !strings.Contains(patch, part) { + t.Errorf("patch missing %q:\n%s", part, patch) + } + } + if strings.Contains(patch, "\x1b[") { + t.Error("patch contains ANSI color escapes") + } +} diff --git a/internal/diff/git.go b/internal/diff/git.go index babfd1e7e..37732a8eb 100644 --- a/internal/diff/git.go +++ b/internal/diff/git.go @@ -37,7 +37,6 @@ var providerDirIgnoreDirs = []string{ ".cachefile/", "_packages/", "rpm/", - "pkgs/", } // Mode defines how the diff is retrieved. diff --git a/internal/diff/git_test.go b/internal/diff/git_test.go index a45ddc40b..1e794444a 100644 --- a/internal/diff/git_test.go +++ b/internal/diff/git_test.go @@ -108,6 +108,35 @@ func TestGetDiffSetRetainsBuiltInDirectoryExclusionsForReporting(t *testing.T) { } } +func TestGetDiffSetKeepsRootPackageSourceDir(t *testing.T) { + repo := t.TempDir() + runGitTest(t, repo, "init", "-q") + runGitTest(t, repo, "config", "user.email", "test@example.com") + runGitTest(t, repo, "config", "user.name", "Test User") + + relPath := "pkgs/foo/default.nix" + path := filepath.Join(repo, filepath.FromSlash(relPath)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create pkgs directory: %v", err) + } + if err := os.WriteFile(path, []byte("{ }: { }\n"), 0o644); err != nil { + t.Fatalf("write pkgs file: %v", err) + } + runGitTest(t, repo, "add", relPath) + runGitTest(t, repo, "commit", "-q", "-m", "add tracked pkgs file") + if err := os.WriteFile(path, []byte("{ }: { }\n# change\n"), 0o644); err != nil { + t.Fatalf("modify pkgs file: %v", err) + } + + set, err := NewWorkspaceProvider(repo, gitcmd.New(0)).GetDiffSet(context.Background()) + if err != nil { + t.Fatalf("GetDiffSet returned error: %v", err) + } + if len(set.Excluded) != 0 || len(set.Included) != 1 || set.Included[0].NewPath != relPath { + t.Fatalf("GetDiffSet = %+v, want %q included and nothing excluded", set, relPath) + } +} + func TestGetDiffSetWalksChangesetOrder(t *testing.T) { repo := t.TempDir() runGitTest(t, repo, "init", "-q") diff --git a/internal/diff/gitignore_test.go b/internal/diff/gitignore_test.go index a3c7837bf..576506e10 100644 --- a/internal/diff/gitignore_test.go +++ b/internal/diff/gitignore_test.go @@ -25,6 +25,12 @@ func TestExcludedDirs(t *testing.T) { t.Error("ExcludedDirs should include .git/") } + for _, d := range dirs { + if d == "pkgs/" { + t.Error("ExcludedDirs must not include pkgs/") + } + } + dirs2 := ExcludedDirs() dirs[0] = "MUTATED" if dirs2[0] == "MUTATED" { @@ -42,6 +48,8 @@ func TestProviderDirPrefix(t *testing.T) { {"target/.pnpm/pkg/index.js", "target/"}, {"src/vendor/keep.go", ""}, {"main.go", ""}, + {"pkgs/foo/default.nix", ""}, + {"pkgs", ""}, } for _, tc := range tests { if got := ProviderDirPrefix(tc.path); got != tc.want { diff --git a/internal/llm/bedrock_test.go b/internal/llm/bedrock_test.go index bd3201664..e8592b750 100644 --- a/internal/llm/bedrock_test.go +++ b/internal/llm/bedrock_test.go @@ -295,12 +295,8 @@ func TestCustomProviderCanSelectBedrock(t *testing.T) { }) } -// TestBedrockModelOverrideIsNotGatedByThePresetList covers what the preset's -// own documentation promises: any identifier Bedrock will route. A preset's -// Models list otherwise acts as an allowlist for --model, which cannot work for -// identifiers scoped to an account and a region — an application inference -// profile ARN, the value to use when spend has to be attributed, can never -// appear in a list compiled upstream. +// TestBedrockModelOverrideIsNotGatedByThePresetList covers identifiers scoped +// to an account and region that cannot appear in a list compiled upstream. func TestBedrockModelOverrideIsNotGatedByThePresetList(t *testing.T) { path := writeConfig(t, map[string]any{ "provider": "bedrock", @@ -323,22 +319,46 @@ func TestBedrockModelOverrideIsNotGatedByThePresetList(t *testing.T) { } } -// TestModelOverrideStillGatedForKeyBasedProviders keeps the relaxation scoped to -// ambient auth: a typo against a hosted API should still be caught locally. -func TestModelOverrideStillGatedForKeyBasedProviders(t *testing.T) { +func TestCustomBedrockModelListDoesNotGateOverride(t *testing.T) { + path := writeConfig(t, map[string]any{ + "provider": "mine", + "custom_providers": map[string]any{ + "mine": map[string]any{ + "protocol": "anthropic-bedrock", + "aws_region": "us-west-2", + "models": []string{"listed-model"}, + }, + }, + }) + var ep ResolvedEndpoint + var err error + stderr := captureStderr(t, func() { + ep, err = ResolveEndpointWithModelOverride(path, "account-specific-model") + }) + if err != nil || ep.Model != "account-specific-model" || stderr != "" { + t.Errorf("endpoint = %+v, error = %v, stderr = %q", ep, err, stderr) + } +} + +func TestModelOverrideWarnsForKeyBasedProviders(t *testing.T) { path := writeConfig(t, map[string]any{ "provider": "anthropic", "model": "claude-sonnet-5", "providers": map[string]any{"anthropic": map[string]any{"api_key": "sk-test-not-a-real-key"}}, }) - if _, err := ResolveEndpointWithModelOverride(path, "claude-sonnet-5-typo"); err == nil { - t.Error("an unlisted model was accepted for a key-based provider; want an error") + var ep ResolvedEndpoint + var err error + stderr := captureStderr(t, func() { + ep, err = ResolveEndpointWithModelOverride(path, "claude-sonnet-5-typo") + }) + if err != nil || ep.Model != "claude-sonnet-5-typo" || !strings.Contains(stderr, `model "claude-sonnet-5-typo" is not in the suggested models`) { + t.Errorf("endpoint = %+v, error = %v, stderr = %q", ep, err, stderr) } } -// TestNonAmbientProviderStillRequiresAPIKey makes sure relaxing the gate for -// ambient auth did not relax it for everyone. +// TestNonAmbientProviderStillRequiresAPIKey makes sure open model lists do not +// relax credential requirements. func TestNonAmbientProviderStillRequiresAPIKey(t *testing.T) { t.Setenv("ANTHROPIC_API_KEY", "") path := writeConfig(t, map[string]any{ diff --git a/internal/llm/chatgpt.go b/internal/llm/chatgpt.go new file mode 100644 index 000000000..f045298ae --- /dev/null +++ b/internal/llm/chatgpt.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/alibaba/open-code-review/internal/chatgptauth" +) + +var chatGPTCredentials = func(ctx context.Context, account string) (*chatgptauth.Auth, error) { + s, err := chatgptauth.DefaultStore() + if err != nil { + return nil, err + } + return chatgptauth.NewClient().Credentials(ctx, s, account) +} +var chatGPTModels = func(ctx context.Context, auth *chatgptauth.Auth) ([]chatgptauth.Model, error) { + return chatgptauth.NewClient().Models(ctx, auth) +} + +func ChatGPTModels(ctx context.Context) ([]chatgptauth.Model, error) { + auth, err := chatGPTCredentials(ctx, "") + if err != nil { + return nil, err + } + return chatGPTModels(ctx, auth) +} +func resolveChatGPT(cfg configFile, entry providerEntryConfig, override string, ctx context.Context) (ResolvedEndpoint, bool, error) { + if entry.URL != "" || entry.Protocol != "" || entry.APIKey != "" || entry.APIKeyCmd != "" || entry.AuthHeader != "" { + return ResolvedEndpoint{}, false, fmt.Errorf("chatgpt provider uses only its protected SIWC credential store and public Responses endpoint; url, protocol and credential overrides are not allowed") + } + if err := validateChatGPTBody(entry.ExtraBody); err != nil { + return ResolvedEndpoint{}, false, err + } + for k := range entry.ExtraHeaders { + if reservedHeaders[strings.ToLower(k)] || strings.EqualFold(k, "Host") { + return ResolvedEndpoint{}, false, fmt.Errorf("chatgpt extra_headers cannot override %s", k) + } + } + timeout, err := ValidateTimeoutSec(entry.TimeoutSec) + if err != nil { + return ResolvedEndpoint{}, false, err + } + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + auth, err := chatGPTCredentials(ctx, "") + if err != nil { + return ResolvedEndpoint{}, false, err + } + model := cfg.Model + if entry.Model != "" { + model = entry.Model + } + if override != "" { + model = override + } + if model == "" { + models, err := chatGPTModels(ctx, auth) + if err != nil { + return ResolvedEndpoint{}, false, err + } + if len(models) == 0 { + return ResolvedEndpoint{}, false, fmt.Errorf("selected ChatGPT account catalog is empty; configure an explicit model or run ocr llm models") + } + model = models[0].Slug + } + return ResolvedEndpoint{URL: chatgptauth.Resource, Token: auth.AccessToken, Provider: "chatgpt", Source: "provider:chatgpt (Using ChatGPT plan; Manage usage: " + chatgptauth.UsageURL + ")", Protocol: ProtocolOpenAIResponses, Model: model, Timeout: timeout, ExtraBody: entry.ExtraBody, ExtraHeaders: entry.ExtraHeaders, RequiresStreaming: true, RejectsSamplingParams: true, ChatGPTPlan: true, ChatGPTAccount: auth.ClientID}, true, nil +} +func validateChatGPTBody(body map[string]any) error { + for k := range body { + switch k { + case "reasoning", "text", "prompt_cache_key": + default: + return fmt.Errorf("chatgpt preview does not accept extra_body.%s; supported overrides: reasoning, text, prompt_cache_key", k) + } + } + return nil +} diff --git a/internal/llm/chatgpt_model_test.go b/internal/llm/chatgpt_model_test.go new file mode 100644 index 000000000..8200c82a1 --- /dev/null +++ b/internal/llm/chatgpt_model_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/alibaba/open-code-review/internal/chatgptauth" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +func TestChatGPTModelSelectionContract(t *testing.T) { + oldCredentials, oldModels := chatGPTCredentials, chatGPTModels + defer func() { chatGPTCredentials = oldCredentials; chatGPTModels = oldModels }() + chatGPTCredentials = func(context.Context, string) (*chatgptauth.Auth, error) { + return &chatgptauth.Auth{ClientID: "account", AccessToken: "fixture"}, nil + } + var calls int + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { + calls++ + return []chatgptauth.Model{{Slug: "server-second"}, {Slug: "server-first"}}, nil + } + for _, tc := range []struct { + name, global, entry, override, want string + catalog bool + }{ + {name: "default-order", want: "server-second", catalog: true}, + {name: "global-explicit", global: "global-unlisted", want: "global-unlisted"}, + {name: "provider-explicit", global: "global", entry: "gpt-6-luna", want: "gpt-6-luna"}, + {name: "override-explicit", global: "global", entry: "entry", override: "user-slug", want: "user-slug"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls = 0 + ep, ok, err := resolveChatGPT(configFile{Model: tc.global}, providerEntryConfig{Model: tc.entry}, tc.override, context.Background()) + wantCalls := 0 + if tc.catalog { + wantCalls = 1 + } + if err != nil || !ok || ep.Model != tc.want || calls != wantCalls { + t.Fatal(ep, calls, err) + } + }) + } + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { return nil, nil } + if _, _, err := resolveChatGPT(configFile{}, providerEntryConfig{}, "", context.Background()); err == nil { + t.Fatal("empty default catalog accepted") + } + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { + return nil, errors.New("catalog denied") + } + if _, _, err := resolveChatGPT(configFile{}, providerEntryConfig{}, "", context.Background()); err == nil || !strings.Contains(err.Error(), "catalog denied") { + t.Fatal(err) + } + if _, err := ChatGPTModels(context.Background()); err == nil { + t.Fatal("list command bypassed live catalog") + } +} + +func TestChatGPTExplicitModelServerRejection(t *testing.T) { + oldCredentials, oldModels := chatGPTCredentials, chatGPTModels + defer func() { chatGPTCredentials = oldCredentials; chatGPTModels = oldModels }() + chatGPTCredentials = func(context.Context, string) (*chatgptauth.Auth, error) { + return &chatgptauth.Auth{ClientID: "account", AccessToken: "fixture"}, nil + } + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { + t.Fatal("explicit resolution queried catalog") + return nil, nil + } + ep, _, err := resolveChatGPT(configFile{}, providerEntryConfig{Model: "server-rejected"}, "", context.Background()) + if err != nil { + t.Fatal(err) + } + c := NewOpenAIResponsesClient(ClientConfig{URL: ep.URL, ChatGPTPlan: true, RequiresStreaming: true}) + c.chatGPTToken = func(context.Context) (string, error) { return "fixture", nil } + var calls int + c.sdk = openai.NewClient(option.WithBaseURL(ep.URL), option.WithMaxRetries(0), option.WithHTTPClient(&http.Client{Transport: resolverRoundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), `"model":"server-rejected"`) { + t.Error("changed user model", string(body)) + } + return &http.Response{StatusCode: 403, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"req-model"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"subscription_sharing_model_not_eligible","param":"model","message":"model not eligible"}}`))}, nil + })})) + _, err = c.CompletionsWithCtx(context.Background(), ChatRequest{Model: ep.Model, Messages: []Message{NewTextMessage("user", "hello")}}) + var api *openai.Error + if !errors.As(err, &api) || api.StatusCode != 403 || api.Code != "subscription_sharing_model_not_eligible" || api.Param != "model" || api.Response.Header.Get("x-request-id") != "req-model" || calls != 1 { + t.Fatal("server rejection lost or retried", calls, err) + } +} diff --git a/internal/llm/chatgpt_roundtrip_test.go b/internal/llm/chatgpt_roundtrip_test.go new file mode 100644 index 000000000..9d177487c --- /dev/null +++ b/internal/llm/chatgpt_roundtrip_test.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/alibaba/open-code-review/internal/chatgptauth" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/responses" +) + +func fixtureChatGPTClient(t *testing.T, handler http.HandlerFunc) *OpenAIResponsesClient { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + target, _ := url.Parse(server.URL) + c := NewOpenAIResponsesClient(ClientConfig{URL: chatgptauth.Resource, ChatGPTPlan: true, RequiresStreaming: true}) + c.chatGPTToken = func(context.Context) (string, error) { return "access-fixture", nil } + c.sdk = openai.NewClient(option.WithBaseURL(chatgptauth.Resource), option.WithMaxRetries(0), option.WithHTTPClient(&http.Client{Transport: resolverRoundTripFunc(func(req *http.Request) (*http.Response, error) { + r := req.Clone(req.Context()) + u := *req.URL + u.Scheme = target.Scheme + u.Host = target.Host + r.URL = &u + return http.DefaultTransport.RoundTrip(r) + })})) + return c +} +func TestChatGPTNativeToolHTTPRoundTrip(t *testing.T) { + turns := 0 + c := fixtureChatGPTClient(t, func(w http.ResponseWriter, r *http.Request) { + var raw json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + t.Error(err) + } + var body map[string]any + json.Unmarshal(raw, &body) + for _, key := range []string{"background", "conversation", "max_output_tokens", "max_tool_calls", "metadata", "moderation", "multi_agent", "prompt", "prompt_cache_retention", "safety_identifier", "temperature", "top_logprobs", "top_p", "truncation", "user", "previous_response_id"} { + if _, ok := body[key]; ok { + t.Error("unsupported field", key) + } + } + if r.URL.Path != "/v1/responses" || r.Header.Get("Authorization") != "Bearer access-fixture" { + t.Error(r.URL, r.Header) + } + if body["tools"].([]any)[0].(map[string]any)["type"] != "namespace" { + t.Error(string(raw)) + } + w.Header().Set("Content-Type", "text/event-stream") + turns++ + if turns == 1 { + fmt.Fprint(w, "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs\",\"summary\":[],\"encrypted_content\":\"ciphertext\"}}\n\n") + fmt.Fprint(w, "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"namespace\":\"ocr\",\"name\":\"file_read\",\"call_id\":\"call\",\"id\":\"fc\",\"arguments\":\"{\\\"path\\\":\\\"main.go\\\"}\",\"status\":\"completed\"}}\n\n") + fmt.Fprint(w, "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-6-luna\",\"output\":[],\"usage\":{\"input_tokens\":11,\"output_tokens\":12,\"total_tokens\":23,\"output_tokens_details\":{\"reasoning_tokens\":7}}}}\n\n") + } else { + if !strings.Contains(string(raw), `"namespace":"ocr"`) || !strings.Contains(string(raw), `"encrypted_content":"ciphertext"`) || !strings.Contains(string(raw), `"type":"function_call_output"`) || !strings.Contains(string(raw), "package main") { + t.Error("replay lost", string(raw)) + } + fmt.Fprint(w, "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r2\",\"status\":\"completed\",\"model\":\"gpt-6-luna\",\"output\":[{\"type\":\"message\",\"id\":\"m\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Reviewed\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":14,\"output_tokens\":3,\"total_tokens\":17}}}\n\n") + } + }) + messages := []Message{NewTextMessage("system", "review policy"), NewTextMessage("developer", "local tools only"), NewTextMessage("user", "review main.go")} + tools := []ToolDef{{Type: "function", Function: FunctionDef{Name: "file_read", Parameters: map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}}}}} + req := ChatRequest{Model: "gpt-6-luna", Messages: messages, Tools: tools} + first, err := c.CompletionsWithCtx(context.Background(), req) + if err != nil { + t.Fatal(err) + } + calls := first.ToolCalls() + if len(calls) != 1 || calls[0].Function.Name != "file_read" || calls[0].ID != "call" { + t.Fatal(calls) + } + if !strings.Contains(string(first.RawUsage), `"reasoning_tokens":7`) { + t.Fatal(string(first.RawUsage)) + } + req.Messages = append(req.Messages, NewToolCallMessage(first.VisibleContent(), calls, first.Native(), first.ReasoningContent()), NewToolResultMessage("call", "package main")) + final, err := c.CompletionsWithCtx(context.Background(), req) + if err != nil || final.Content() != "Reviewed" || turns != 2 { + t.Fatal(final, err, turns) + } +} +func TestChatGPTLateFailureAndWrongNamespace(t *testing.T) { + for _, scenario := range []string{"late-failure", "wrong-status", "namespace", "incomplete-param"} { + t.Run(scenario, func(t *testing.T) { + c := fixtureChatGPTClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + switch scenario { + case "late-failure": + fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[]}}\n\n") + fmt.Fprint(w, "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"subscription_sharing_usage_unavailable\",\"message\":\"unavailable\",\"param\":\"usage\"}}}\n\n") + case "wrong-status": + fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"incomplete\",\"output\":[]}}\n\n") + case "namespace": + fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[{\"type\":\"function_call\",\"name\":\"file_read\",\"namespace\":\"unknown\",\"call_id\":\"call\",\"arguments\":\"{}\"}]}}\n\n") + case "incomplete-param": + fmt.Fprint(w, "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"content_filter\"}}}\n\n") + } + }) + _, err := c.CompletionsWithCtx(context.Background(), ChatRequest{Model: "gpt-6-luna", Messages: []Message{NewTextMessage("user", "test")}}) + if err == nil { + t.Fatal("failed stream accepted") + } + if scenario == "late-failure" && (!strings.Contains(err.Error(), "subscription_sharing_usage_unavailable") || !strings.Contains(err.Error(), "param=usage")) { + t.Fatal(err) + } + if scenario == "incomplete-param" && !strings.Contains(err.Error(), "content_filter") { + t.Fatal(err) + } + }) + } +} +func TestChatGPTFallbackCallAndCredentialFailure(t *testing.T) { + c := NewOpenAIResponsesClient(ClientConfig{URL: chatgptauth.Resource, ChatGPTPlan: true, RequiresStreaming: true}) + c.chatGPTToken = func(context.Context) (string, error) { return "", errors.New("signed out") } + if _, err := c.CompletionsWithCtx(context.Background(), ChatRequest{}); err == nil { + t.Fatal("credential failure swallowed") + } + p := c.buildResponsesParams("gpt-6-luna", ChatRequest{Messages: []Message{NewToolCallMessage("", []ToolCall{{ID: "call", Function: FunctionCall{Name: "file_read", Arguments: "{}"}}}, NativeTurn{}, "")}}) + b, _ := json.Marshal(p) + if !strings.Contains(string(b), `"namespace":"ocr"`) { + t.Fatal(string(b)) + } + old, oldModels := chatGPTCredentials, chatGPTModels + defer func() { chatGPTCredentials, chatGPTModels = old, oldModels }() + chatGPTCredentials = func(context.Context, string) (*chatgptauth.Auth, error) { + return &chatgptauth.Auth{ClientID: "fixture"}, nil + } + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { + return []chatgptauth.Model{{Slug: "gpt-6-luna", DisplayName: "Luna"}}, nil + } + if m, err := ChatGPTModels(context.Background()); err != nil || len(m) != 1 { + t.Fatal(m, err) + } + chatGPTCredentials = func(context.Context, string) (*chatgptauth.Auth, error) { return nil, errors.New("fixture") } + if _, err := ChatGPTModels(context.Background()); err == nil { + t.Fatal("model credential failure") + } +} +func TestChatGPTCapabilityStatus(t *testing.T) { + var response responses.Response + if err := response.UnmarshalJSON([]byte(`{"status":"incomplete"}`)); err != nil { + t.Fatal(err) + } + if err := checkResponseStatus(&response); err != nil { + t.Fatal("ordinary Responses incomplete behavior changed") + } +} diff --git a/internal/llm/chatgpt_test.go b/internal/llm/chatgpt_test.go new file mode 100644 index 000000000..6d8b03464 --- /dev/null +++ b/internal/llm/chatgpt_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/alibaba/open-code-review/internal/chatgptauth" + "github.com/openai/openai-go/v3/responses" +) + +func TestChatGPTRequestNamespaceAndReplay(t *testing.T) { + c := NewOpenAIResponsesClient(ClientConfig{URL: "https://api.openai.com/v1", ChatGPTPlan: true, RequiresStreaming: true, RejectsSamplingParams: true}) + temperature := 0.7 + params := c.buildResponsesParams("gpt-6-luna", ChatRequest{Messages: []Message{NewTextMessage("system", "policy"), NewTextMessage("user", "hello")}, Tools: []ToolDef{{Type: "function", Function: FunctionDef{Name: "file_read", Description: "Read", Parameters: map[string]any{"type": "object"}}}}, MaxTokens: 99, Temperature: &temperature, ToolChoice: "required"}) + b, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + var body map[string]any + json.Unmarshal(b, &body) + if body["instructions"] != "policy" || body["store"] != false { + t.Fatal(string(b)) + } + tools := body["tools"].([]any) + namespace := tools[0].(map[string]any) + if namespace["type"] != "namespace" || namespace["name"] != "ocr" { + t.Fatal(string(b)) + } + if namespace["tools"].([]any)[0].(map[string]any)["name"] != "file_read" { + t.Fatal(string(b)) + } + if body["max_output_tokens"] != nil || body["temperature"] != nil { + t.Fatal(string(b)) + } + var sdk responses.Response + err = sdk.UnmarshalJSON([]byte(`{"id":"r","model":"gpt-6-luna","status":"completed","output":[{"type":"reasoning","id":"rs","summary":[],"encrypted_content":"opaque"},{"type":"function_call","namespace":"ocr","name":"file_read","call_id":"call","id":"fc","arguments":"{}","status":"completed"}],"usage":{"input_tokens":15,"output_tokens":4,"total_tokens":19,"output_tokens_details":{"reasoning_tokens":2}}}`)) + if err != nil { + t.Fatal(err) + } + out := c.mapResponsesResponse(&sdk) + if out.ToolCalls()[0].Function.Name != "file_read" || out.Usage == nil { + t.Fatal(out) + } + replay := c.buildResponsesParams("gpt-6-luna", ChatRequest{Messages: []Message{NewTextMessage("user", "hello"), NewToolCallMessage("", out.ToolCalls(), out.Native(), ""), NewToolResultMessage("call", "done")}}) + b, _ = json.Marshal(replay) + if !strings.Contains(string(b), `"namespace":"ocr"`) || !strings.Contains(string(b), `"encrypted_content":"opaque"`) || !strings.Contains(string(b), `"call_id":"call"`) { + t.Fatal(string(b)) + } +} +func TestChatGPTStreamTerminal(t *testing.T) { + for _, kind := range []string{"completed", "failed", "incomplete", "error", "interrupted"} { + t.Run(kind, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if body["stream"] != true || body["store"] != false || r.Header.Get("Authorization") != "Bearer fixture" { + t.Error(body, r.Header) + } + if body["reasoning"].(map[string]any)["effort"] != "low" { + t.Error(body) + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n") + if kind == "interrupted" { + return + } + if kind == "error" { + fmt.Fprint(w, "event: error\ndata: {\"type\":\"error\",\"code\":\"subscription_sharing_usage_limit_exceeded\",\"message\":\"limit\"}\n\n") + return + } + fmt.Fprintf(w, "event: response.%s\ndata: {\"type\":\"response.%s\",\"response\":{\"id\":\"r\",\"model\":\"gpt-6-luna\",\"status\":%q,\"error\":{\"code\":\"subscription_sharing_usage_limit_exceeded\",\"message\":\"limit\"},\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"id\":\"m\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\",\"annotations\":[]}],\"status\":\"completed\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n", kind, kind, kind) + })) + defer server.Close() + c := NewOpenAIResponsesClient(ClientConfig{URL: chatgptauth.Resource, APIKey: "fixture", ChatGPTPlan: true, RequiresStreaming: true, RejectsSamplingParams: true, ExtraBody: map[string]any{"reasoning": map[string]any{"effort": "low"}}}) + c.chatGPTToken = func(context.Context) (string, error) { return "fixture", nil } + target, _ := url.Parse(server.URL) + c.sdk = openai.NewClient(option.WithBaseURL(chatgptauth.Resource), option.WithMaxRetries(0), option.WithHTTPClient(&http.Client{Transport: resolverRoundTripFunc(func(req *http.Request) (*http.Response, error) { + copy := req.Clone(req.Context()) + u := *req.URL + u.Scheme = target.Scheme + u.Host = target.Host + copy.URL = &u + return http.DefaultTransport.RoundTrip(copy) + })})) + out, err := c.CompletionsWithCtx(context.Background(), ChatRequest{Model: "gpt-6-luna", Messages: []Message{NewTextMessage("user", "hello")}}) + if kind == "completed" { + if err != nil || out.Content() != "ok" || out.Usage.TotalTokens != 3 { + t.Fatal(out, err) + } + } else if err == nil { + t.Fatal("non-completion accepted") + } else if kind == "failed" && !strings.Contains(err.Error(), "subscription_sharing_usage_limit_exceeded") { + t.Fatal(err) + } + }) + } +} +func TestChatGPTRejectsOverrides(t *testing.T) { + for _, key := range []string{"temperature", "store", "stream", "previous_response_id", "input", "tools", "metadata", "reasoning.effort", "max_output_tokens", "background"} { + c := NewOpenAIResponsesClient(ClientConfig{URL: chatgptauth.Resource, ChatGPTPlan: true, ExtraBody: map[string]any{key: true}}) + if _, err := c.CompletionsWithCtx(context.Background(), ChatRequest{Model: "gpt-6-luna"}); err == nil { + t.Fatal(key) + } + } + c := NewOpenAIResponsesClient(ClientConfig{URL: "https://evil.invalid/v1", ChatGPTPlan: true}) + if _, err := c.CompletionsWithCtx(context.Background(), ChatRequest{Model: "gpt-6-luna"}); err == nil { + t.Fatal("override accepted") + } +} +func TestChatGPTResolveEndpoint(t *testing.T) { + clearAllEnv(t) + setTestHome(t, t.TempDir()) + path, _ := writeResolverConfig(t, configFile{Provider: "chatgpt", Providers: map[string]providerEntryConfig{"chatgpt": {Model: "gpt-6-luna"}}}) + old := chatGPTCredentials + oldModels := chatGPTModels + defer func() { chatGPTCredentials = old; chatGPTModels = oldModels }() + chatGPTCredentials = func(context.Context, string) (*chatgptauth.Auth, error) { + return &chatgptauth.Auth{ClientID: "oaiapp", AccessToken: "fixture"}, nil + } + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { + return []chatgptauth.Model{{Slug: "gpt-6-luna", DisplayName: "Luna", Visibility: "list"}}, nil + } + ep, err := ResolveEndpointWithOptions(path, ResolveOptions{Model: "gpt-6-luna"}) + if err != nil || !ep.ChatGPTPlan || ep.URL != chatgptauth.Resource || ep.Token != "fixture" { + t.Fatal(ep, err) + } + for _, entry := range []providerEntryConfig{{URL: "https://evil.invalid"}, {Protocol: "openai"}, {APIKey: "key"}, {APIKeyCmd: "exit 1"}, {ExtraHeaders: map[string]string{"Authorization": "bad"}}} { + p, _ := writeResolverConfig(t, configFile{Provider: "chatgpt", Providers: map[string]providerEntryConfig{"chatgpt": entry}}) + if _, err := ResolveEndpoint(p); err == nil { + t.Fatal(entry) + } + } + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { + t.Fatal("explicit model must not query catalog") + return nil, errors.New("catalog unavailable") + } + for _, slug := range []string{"unlisted", "unlisted[1m]"} { + if ep, err := ResolveEndpointWithOptions(path, ResolveOptions{Model: slug}); err != nil || ep.Model != slug { + t.Fatal("explicit model rejected or rewritten locally", ep, err) + } + } + if ep, err := ResolveEndpoint(path); err != nil || ep.Model != "gpt-6-luna" { + t.Fatal(ep, err) + } + chatGPTCredentials = func(context.Context, string) (*chatgptauth.Auth, error) { return nil, errors.New("signed out") } + if _, err := ResolveEndpoint(path); err == nil { + t.Fatal("missing creds accepted") + } +} diff --git a/internal/llm/chatgpt_transport_test.go b/internal/llm/chatgpt_transport_test.go new file mode 100644 index 000000000..19d5db2db --- /dev/null +++ b/internal/llm/chatgpt_transport_test.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import "net/http" + +type resolverRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f resolverRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/internal/llm/client.go b/internal/llm/client.go index a315daff9..e0b7aa983 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -324,10 +324,11 @@ type ResponseMessage struct { // ChatResponse is the parsed result of a completion request. type ChatResponse struct { - ID string `json:"-"` - Model string `json:"-"` - Choices []Choice `json:"-"` - Usage *UsageInfo `json:"-"` // Token usage extracted from API response + ID string `json:"-"` + Model string `json:"-"` + Choices []Choice `json:"-"` + Usage *UsageInfo `json:"-"` // Token usage extracted from API response + RawUsage json.RawMessage `json:"-"` } // Content extracts the text content from the first choice, falling back to reasoning content. @@ -397,14 +398,18 @@ type FunctionDef struct { // ClientConfig holds configuration for connecting to an LLM service. type ClientConfig struct { - URL string // Full API endpoint URL - APIKey string // Bearer token / API key - Model string // Default model override - AuthHeader string // Auth header name: "x-api-key", "authorization", or empty for protocol default - Timeout time.Duration // Request timeout - ExtraBody map[string]any // Vendor-specific fields merged into every request body - ExtraHeaders map[string]string // Extra HTTP headers sent with every request - RetryCodes []int // Additional HTTP status codes that trigger retry + URL string // Full API endpoint URL + APIKey string // Bearer token / API key + Model string // Default model override + AuthHeader string // Auth header name: "x-api-key", "authorization", or empty for protocol default + Timeout time.Duration // Request timeout + ExtraBody map[string]any // Vendor-specific fields merged into every request body + ExtraHeaders map[string]string // Extra HTTP headers sent with every request + RetryCodes []int // Additional HTTP status codes that trigger retry + RequiresStreaming bool + RejectsSamplingParams bool + ChatGPTPlan bool + ChatGPTAccount string // SessionKey is the fallback prompt-cache affinity key // for requests whose context carries none (see ContextWithSessionKey). // @@ -480,18 +485,22 @@ func retryCodesMiddleware(codes []int) func(*http.Request, func(*http.Request) ( // parameters rather than fields on ResolvedEndpoint. func NewLLMClient(ep ResolvedEndpoint, collector *RetryCollector, raw *RawHolder) LLMClient { cfg := ClientConfig{ - URL: ep.URL, - APIKey: ep.Token, - Model: ep.Model, - AuthHeader: ep.AuthHeader, - Timeout: ep.Timeout, - ExtraBody: ep.ExtraBody, - ExtraHeaders: ep.ExtraHeaders, - RetryCodes: ep.RetryCodes, - retryCollector: collector, - rawHolder: raw, - AWSProfile: ep.AWSProfile, - AWSRegion: ep.AWSRegion, + URL: ep.URL, + APIKey: ep.Token, + Model: ep.Model, + AuthHeader: ep.AuthHeader, + Timeout: ep.Timeout, + ExtraBody: ep.ExtraBody, + ExtraHeaders: ep.ExtraHeaders, + RetryCodes: ep.RetryCodes, + RequiresStreaming: ep.RequiresStreaming, + RejectsSamplingParams: ep.RejectsSamplingParams, + ChatGPTPlan: ep.ChatGPTPlan, + ChatGPTAccount: ep.ChatGPTAccount, + retryCollector: collector, + rawHolder: raw, + AWSProfile: ep.AWSProfile, + AWSRegion: ep.AWSRegion, } switch ep.Protocol { case ProtocolAnthropic: @@ -649,6 +658,7 @@ func (c *OpenAIClient) CompletionsWithCtx(ctx context.Context, req ChatRequest) finalizeRequest(ctx, c.cfg.retryCollector, errRequestPanicked) panic(r) } + err = describeTimeout(ctx, err) finalizeRequest(ctx, c.cfg.retryCollector, err) }() @@ -1455,6 +1465,7 @@ func (c *AnthropicClient) CompletionsWithCtx(ctx context.Context, req ChatReques finalizeRequest(ctx, c.cfg.retryCollector, errRequestPanicked) panic(r) } + err = describeTimeout(ctx, err) finalizeRequest(ctx, c.cfg.retryCollector, err) }() diff --git a/internal/llm/providers.go b/internal/llm/providers.go index b90f435f7..ea5faa4db 100644 --- a/internal/llm/providers.go +++ b/internal/llm/providers.go @@ -27,14 +27,17 @@ type Provider struct { BaseURL string AuthHeader string // Anthropic-only; empty for OpenAI-compatible EnvVar string // environment variable name for API key fallback - Models []string // AmbientAuth marks a provider whose credentials come from the // environment's own chain rather than an api_key — AWS SigV4, for // instance. The resolver skips its api_key requirement for these, because // there is no key to configure and demanding one would make the provider // impossible to use. - AmbientAuth bool + AmbientAuth bool + ExternalAuth bool + RequiresStreaming bool + RejectsSamplingParams bool + Models []string } // After modifying the built-in provider registry, run `go generate ./internal/llm` @@ -84,6 +87,16 @@ var registry = []Provider{ "global.anthropic.claude-opus-4-8", }, }, + { + Name: "chatgpt", + DisplayName: "ChatGPT plan (Sign in with ChatGPT preview)", + Protocol: ProtocolOpenAIResponses, + BaseURL: "https://api.openai.com/v1", + ExternalAuth: true, + RequiresStreaming: true, + RejectsSamplingParams: true, + Models: []string{}, + }, { Name: "openai", DisplayName: "OpenAI API", diff --git a/internal/llm/providers_test.go b/internal/llm/providers_test.go index fd8ad903b..3b8c7e442 100644 --- a/internal/llm/providers_test.go +++ b/internal/llm/providers_test.go @@ -10,7 +10,7 @@ import ( ) func TestLookupProvider_KnownProviders(t *testing.T) { - names := []string{"anthropic", "openai", "openrouter", "dashscope", "edenai"} + names := []string{"anthropic", "openai", "openrouter", "dashscope", "edenai", "chatgpt"} for _, name := range names { p, ok := LookupProvider(name) if !ok { @@ -26,12 +26,25 @@ func TestLookupProvider_KnownProviders(t *testing.T) { if p.BaseURL == "" { t.Errorf("LookupProvider(%q).BaseURL is empty", name) } - if len(p.Models) == 0 { + if len(p.Models) == 0 && name != "chatgpt" { t.Errorf("LookupProvider(%q).Models is empty", name) } } } +func TestLookupProvider_ChatGPTDetails(t *testing.T) { + if _, ok := LookupProvider("codex"); ok { + t.Fatal("undocumented Codex preset must not ship") + } + p, ok := LookupProvider("chatgpt") + if !ok { + t.Fatal("chatgpt preset not found") + } + if p.Protocol != ProtocolOpenAIResponses || p.BaseURL != "https://api.openai.com/v1" || p.EnvVar != "" || !p.ExternalAuth || !p.RequiresStreaming || !p.RejectsSamplingParams || len(p.Models) != 0 { + t.Fatalf("unexpected chatgpt preset: %+v", p) + } +} + func TestLookupProvider_MiniMaxDetails(t *testing.T) { tests := []struct { name, wantURL, wantEnvVar string @@ -76,7 +89,7 @@ func TestListProviders_Order(t *testing.T) { if len(providers) < 3 { t.Fatalf("expected at least 3 providers, got %d", len(providers)) } - expected := []string{"anthropic", "baidu-qianfan", "bedrock", "dashscope", "dashscope-tokenplan", "deepseek", "edenai", "gemini", "hy-tokenplan", "iflytek", "kimi", "kimi-global", "litellm", "mimo", "minimax", "minimax-cn", "mistral", "novita", "ollama-cloud", "openai", "openai-responses", "openrouter", "siliconflow", "siliconflow-cn", "tencent-tokenhub", "volcengine", "xai", "z-ai", "z-ai-coding"} + expected := []string{"anthropic", "baidu-qianfan", "bedrock", "chatgpt", "dashscope", "dashscope-tokenplan", "deepseek", "edenai", "gemini", "hy-tokenplan", "iflytek", "kimi", "kimi-global", "litellm", "mimo", "minimax", "minimax-cn", "mistral", "novita", "ollama-cloud", "openai", "openai-responses", "openrouter", "siliconflow", "siliconflow-cn", "tencent-tokenhub", "volcengine", "xai", "z-ai", "z-ai-coding"} if len(providers) != len(expected) { t.Fatalf("expected %d providers, got %d", len(expected), len(providers)) } diff --git a/internal/llm/request_timeout.go b/internal/llm/request_timeout.go new file mode 100644 index 000000000..9f5810cf5 --- /dev/null +++ b/internal/llm/request_timeout.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "errors" + "fmt" +) + +var ErrRequestTimeout = errors.New("LLM request timeout (configure OCR_LLM_TIMEOUT or provider timeout_sec)") + +func describeTimeout(ctx context.Context, err error) error { + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("caller deadline exceeded: %w", err) + } + return fmt.Errorf("%w: %w", ErrRequestTimeout, err) +} diff --git a/internal/llm/request_timeout_test.go b/internal/llm/request_timeout_test.go new file mode 100644 index 000000000..3b80c8c2c --- /dev/null +++ b/internal/llm/request_timeout_test.go @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/anthropics/anthropic-sdk-go/option" +) + +func TestRequestTimeoutAndSDKRetryBehavior(t *testing.T) { + for _, protocol := range []string{"openai", "responses", "anthropic"} { + for _, scenario := range []struct { + name string + wantAttempts int32 + wantError string + requestTimeout time.Duration + taskTimeout time.Duration + cancelAfter time.Duration + }{ + {name: "headers", wantAttempts: 1, wantError: "request", requestTimeout: 100 * time.Millisecond}, + {name: "body", wantAttempts: 1, wantError: "request", requestTimeout: 100 * time.Millisecond}, + {name: "task_during_request", wantAttempts: 1, wantError: "task", taskTimeout: 100 * time.Millisecond}, + {name: "cancel_during_request", wantAttempts: 1, wantError: "cancel", cancelAfter: 100 * time.Millisecond}, + {name: "request_during_backoff", wantAttempts: 1, wantError: "request", requestTimeout: 100 * time.Millisecond}, + {name: "task_during_backoff", wantAttempts: 1, wantError: "task", taskTimeout: 100 * time.Millisecond}, + {name: "cancel_during_backoff", wantAttempts: 1, wantError: "cancel", cancelAfter: 100 * time.Millisecond}, + {name: "sdk_retry_success", wantAttempts: 2}, + {name: "sdk_retry_limit", wantAttempts: 6, wantError: "provider"}, + {name: "permanent_error", wantAttempts: 1, wantError: "provider"}, + } { + t.Run(protocol+"/"+scenario.name, func(t *testing.T) { + t.Parallel() + var attempts atomic.Int32 + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + attempt := attempts.Add(1) + writer.Header().Set("Content-Type", "application/json") + switch { + case scenario.name == "permanent_error": + writer.WriteHeader(http.StatusBadRequest) + fmt.Fprint(writer, `{"error":{"message":"invalid"}}`) + return + case strings.HasSuffix(scenario.name, "_backoff"): + writer.Header().Set("Retry-After-Ms", "3000") + writer.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(writer, `{"error":{"message":"wait"}}`) + return + case scenario.name == "sdk_retry_limit" || scenario.name == "sdk_retry_success" && attempt == 1: + writer.Header().Set("Retry-After-Ms", "0") + writer.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprint(writer, `{"error":{"message":"busy"}}`) + return + case attempt == 1: + if scenario.name == "body" { + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + } + select { + case <-request.Context().Done(): + case <-release: + } + return + } + body := openAIOKBody + if protocol == "responses" { + body = responsesOKBody + } + if protocol == "anthropic" { + body = anthropicOKBody + } + fmt.Fprint(writer, body) + })) + defer server.Close() + defer close(release) + collector := NewRetryCollector() + cfg := ClientConfig{URL: server.URL, APIKey: "test-key", Model: "test-model", Timeout: 5 * time.Second, retryCollector: collector} + if scenario.requestTimeout > 0 { + cfg.Timeout = scenario.requestTimeout + } + var client LLMClient + switch protocol { + case "openai": + client = NewOpenAIClient(cfg) + case "responses": + client = NewOpenAIResponsesClient(cfg) + case "anthropic": + client = NewAnthropicClient(cfg) + } + ctx, cancel := context.WithCancel(metaCtx(testMeta())) + defer cancel() + if scenario.taskTimeout > 0 { + var deadlineCancel context.CancelFunc + ctx, deadlineCancel = context.WithTimeout(ctx, scenario.taskTimeout) + defer deadlineCancel() + } + if scenario.cancelAfter > 0 { + timer := time.AfterFunc(scenario.cancelAfter, cancel) + defer timer.Stop() + } + _, err := ping(ctx, client) + switch scenario.wantError { + case "": + if err != nil { + t.Fatalf("ping: %v", err) + } + case "request": + if !errors.Is(err, ErrRequestTimeout) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want request timeout", err) + } + case "task": + if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrRequestTimeout) || !strings.Contains(err.Error(), "caller deadline exceeded") { + t.Fatalf("error = %v, want caller deadline", err) + } + if strings.Contains(err.Error(), "--timeout") { + t.Fatalf("caller deadline attributed to a CLI flag: %v", err) + } + case "cancel": + if !errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestTimeout) { + t.Fatalf("error = %v, want cancellation", err) + } + case "provider": + if err == nil || errors.Is(err, ErrRequestTimeout) { + t.Fatalf("error = %v, want provider error", err) + } + } + if got := attempts.Load(); got != scenario.wantAttempts { + t.Fatalf("attempts = %d, want %d", got, scenario.wantAttempts) + } + report := freezeOne(t, collector) + if len(report.Attempts) != int(scenario.wantAttempts) { + t.Fatalf("unexpected report: %+v", report) + } + wantOutcome := OutcomeFailed + if scenario.wantError == "" { + wantOutcome = OutcomeRecovered + } + if scenario.wantError == "cancel" { + wantOutcome = OutcomeCancelled + } + if report.Outcome != wantOutcome { + t.Fatalf("outcome = %s, want %s", report.Outcome, wantOutcome) + } + if scenario.name == "headers" || scenario.name == "body" { + if report.Attempts[0].ErrorClass != ErrorClassTimeout { + t.Fatalf("timeout not recorded: %+v", report) + } + } + }) + } + } +} + +func TestDescribeTimeoutIndependentCallerDeadline(t *testing.T) { + reviewCtx, cancelReview := context.WithTimeout(context.Background(), time.Hour) + defer cancelReview() + for _, scenario := range []struct { + name string + parent context.Context + }{ + {name: "background_compression", parent: context.WithoutCancel(reviewCtx)}, + {name: "connection_test", parent: context.Background()}, + } { + t.Run(scenario.name, func(t *testing.T) { + callerCtx, cancelCaller := context.WithDeadline(scenario.parent, time.Now().Add(-time.Second)) + defer cancelCaller() + originalErr := fmt.Errorf("operation failed: %w", callerCtx.Err()) + err := describeTimeout(callerCtx, originalErr) + if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, originalErr) || errors.Is(err, ErrRequestTimeout) { + t.Fatalf("error = %v, want wrapped caller deadline", err) + } + if !strings.Contains(err.Error(), "caller deadline exceeded") || strings.Contains(err.Error(), "--timeout") { + t.Fatalf("incorrect caller deadline diagnostic: %v", err) + } + if reviewCtx.Err() != nil { + t.Fatalf("main review context must remain active: %v", reviewCtx.Err()) + } + }) + } +} + +func TestRequestTimeoutStreamingNoReplay(t *testing.T) { + var attempts atomic.Int32 + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + if attempts.Add(1) == 1 { + fmt.Fprint(writer, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"discarded\"}}]}\n\n") + writer.(http.Flusher).Flush() + select { + case <-request.Context().Done(): + case <-release: + } + return + } + fmt.Fprint(writer, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + })) + defer server.Close() + defer close(release) + collector := NewRetryCollector() + client := NewOpenAIClient(ClientConfig{URL: server.URL, Model: "test-model", APIKey: "test-key", Timeout: 100 * time.Millisecond, ExtraBody: map[string]any{"stream": true}, retryCollector: collector}) + response, err := ping(metaCtx(testMeta()), client) + if !errors.Is(err, ErrRequestTimeout) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want request timeout", err) + } + if response != nil { + t.Fatalf("unexpected partial response: %+v", response) + } + if attempts.Load() != 1 { + t.Fatalf("attempts = %d, want 1", attempts.Load()) + } + report := freezeOne(t, collector) + if report.Outcome != OutcomeFailed || report.Attempts[0].ErrorClass != ErrorClassTimeout { + t.Fatalf("report = %+v", report) + } +} + +func TestRequestTimeoutBedrockNoReplay(t *testing.T) { + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "credentials")) + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + t.Setenv("AWS_SESSION_TOKEN", "") + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + var attempts atomic.Int32 + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if !strings.HasPrefix(request.Header.Get("Authorization"), "AWS4-HMAC-SHA256 ") { + t.Error("request is not signed") + } + if attempts.Add(1) == 1 { + select { + case <-request.Context().Done(): + case <-release: + } + return + } + writer.Header().Set("Content-Type", "application/json") + fmt.Fprint(writer, anthropicOKBody) + })) + defer server.Close() + defer close(release) + collector := NewRetryCollector() + client := NewAnthropicBedrockClient(ClientConfig{Model: "test-model", AWSRegion: "us-east-1", Timeout: 100 * time.Millisecond, retryCollector: collector}) + client.sdk.Messages.Options = append(client.sdk.Messages.Options, option.WithBaseURL(server.URL)) + _, err := ping(metaCtx(testMeta()), client) + if !errors.Is(err, ErrRequestTimeout) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want request timeout", err) + } + if attempts.Load() != 1 { + t.Fatalf("attempts = %d, want 1", attempts.Load()) + } + if report := freezeOne(t, collector); report.Outcome != OutcomeFailed { + t.Fatalf("report = %+v", report) + } +} diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index 5edefd974..3399107c1 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -4,6 +4,7 @@ package llm import ( + "context" "encoding/json" "fmt" "math" @@ -31,8 +32,12 @@ type ResolvedEndpoint struct { // Only config file (llm/provider sections) and OCR_LLM_TIMEOUT env var can set this. // tryCCEnv and tryShellRC always leave it at 0 since those sources have no timeout // knob; users can still override via OCR_LLM_TIMEOUT. - Timeout time.Duration - RetryCodes []int // additional HTTP status codes that trigger exponential-backoff retry + Timeout time.Duration + RetryCodes []int // additional HTTP status codes that trigger exponential-backoff retry + RequiresStreaming bool + RejectsSamplingParams bool + ChatGPTPlan bool + ChatGPTAccount string // AmbientAuth marks an endpoint that carries no token and needs no base // URL, because the transport supplies both — AWS SigV4 signing derives the @@ -78,6 +83,7 @@ const ( type ResolveOptions struct { Provider string Model string + Context context.Context } // ResolveEndpoint resolves an endpoint without per-run overrides. @@ -181,7 +187,9 @@ func finalizeResolvedEndpoint(source string, ep ResolvedEndpoint, env envOverrid if ep.Source == "" { ep.Source = source } - ep.Model = stripModelSuffix(ep.Model) + if !ep.ChatGPTPlan { + ep.Model = stripModelSuffix(ep.Model) + } if env.hasTimeout { ep.Timeout = env.timeout } @@ -388,14 +396,14 @@ func tryOCRConfig(path string, opts ResolveOptions) (ResolvedEndpoint, bool, err cfg.Provider = opts.Provider } if cfg.Provider != "" { - return tryProviderConfig(cfg, opts.Model) + return tryProviderConfig(cfg, opts.Model, opts.Context) } return tryLegacyLlmConfig(cfg, opts.Model) } // tryProviderConfig resolves an endpoint from the provider-based configuration. -func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, bool, error) { +func tryProviderConfig(cfg configFile, modelOverride string, callerContext context.Context) (ResolvedEndpoint, bool, error) { preset, isPreset := LookupProvider(cfg.Provider) var entry providerEntryConfig @@ -405,6 +413,9 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, } else { entry, ok = cfg.CustomProviders[cfg.Provider] } + if isPreset && preset.Name == "chatgpt" && ok { + return resolveChatGPT(cfg, entry, modelOverride, callerContext) + } if !ok { section := "providers" if !isPreset { @@ -525,18 +536,12 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, } availableModels = append(availableModels, entry.Models...) - // A preset's Models list doubles as an allowlist for --model. For an - // ambient-auth provider it cannot: Bedrock identifiers are scoped to an - // account and a region, and an application inference profile ARN — a - // supported value, and the one to use when spend has to be attributed — can - // never appear in a list compiled upstream. The list stays a picker for - // `ocr config model`; it does not gate an override. - gateOverrideOnModelList := !ambientAuth - - // Apply model override with validation. + // Preset lists can lag provider catalogs, so they guide interactive selection + // without preventing a per-run override. A custom provider's configured list + // remains a constraint except with ambient, account-scoped authentication. if modelOverride != "" { - if gateOverrideOnModelList && len(availableModels) > 0 { - if !ModelListContains(availableModels, modelOverride) { + if len(availableModels) > 0 && !ModelListContains(availableModels, modelOverride) { + if !isPreset && !ambientAuth { return ResolvedEndpoint{}, false, fmt.Errorf( "model %q is not available for provider %q; available models: %s", modelOverride, @@ -544,6 +549,9 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, strings.Join(availableModels, ", "), ) } + if isPreset { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: model %q is not in the suggested models for provider %q; the provider will validate it\n", modelOverride, cfg.Provider) + } } model = modelOverride } diff --git a/internal/llm/resolver_keycmd_test.go b/internal/llm/resolver_keycmd_test.go index f5b742be8..b5dd79c16 100644 --- a/internal/llm/resolver_keycmd_test.go +++ b/internal/llm/resolver_keycmd_test.go @@ -10,7 +10,6 @@ package llm import ( "encoding/json" - "io" "os" "path/filepath" "strings" @@ -148,31 +147,6 @@ func TestResolveEndpoint_LegacyWhitespaceOnlyCmdIsUnset(t *testing.T) { } } -// captureStderr swaps os.Stderr for a pipe around fn and returns what was written. -// Output here is tiny, so reading after the writer is closed avoids any pipe-buffer -// deadlock without a goroutine. -func captureStderr(t *testing.T, fn func()) string { - t.Helper() - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe: %v", err) - } - orig := os.Stderr - os.Stderr = w - defer func() { os.Stderr = orig }() - - fn() - - if err := w.Close(); err != nil { - t.Fatalf("close pipe writer: %v", err) - } - out, err := io.ReadAll(r) - if err != nil { - t.Fatalf("read captured stderr: %v", err) - } - return string(out) -} - // (b2) when both api_key and api_key_cmd are set, a warning is emitted on stderr // and the resolved token is still the static api_key. func TestResolveEndpoint_BothSetWarnsAndUsesStaticKey(t *testing.T) { diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index c90699593..555d541d6 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -5,12 +5,16 @@ package llm import ( "bytes" + "context" "encoding/json" + "fmt" "os" "path/filepath" "strings" "testing" "time" + + "github.com/alibaba/open-code-review/internal/chatgptauth" ) func TestStripModelSuffix(t *testing.T) { @@ -532,12 +536,15 @@ func TestResolveEndpointWithOptions_ExplicitProviderAndModel(t *testing.T) { t.Fatalf("endpoint = %+v", ep) } - _, err = ResolveEndpointWithOptions(path, ResolveOptions{ - Provider: "anthropic", - Model: "not-a-registered-model", + var unknown ResolvedEndpoint + stderr := captureStderr(t, func() { + unknown, err = ResolveEndpointWithOptions(path, ResolveOptions{ + Provider: "anthropic", + Model: "not-a-registered-model", + }) }) - if err == nil || !strings.Contains(err.Error(), `model "not-a-registered-model" is not available for provider "anthropic"`) { - t.Fatalf("error = %v", err) + if err != nil || unknown.Model != "not-a-registered-model" || !strings.Contains(stderr, `model "not-a-registered-model" is not in the suggested models for provider "anthropic"`) { + t.Fatalf("endpoint = %+v, error = %v, stderr = %q", unknown, err, stderr) } } @@ -1146,7 +1153,7 @@ func TestResolveEndpointWithModelOverride_ValidModelInPresetList(t *testing.T) { } } -func TestResolveEndpointWithModelOverride_InvalidModelInPresetList(t *testing.T) { +func TestResolveEndpointWithModelOverride_UnlistedPresetModelWarns(t *testing.T) { clearAllEnv(t) cfg := configFile{ @@ -1161,24 +1168,22 @@ func TestResolveEndpointWithModelOverride_InvalidModelInPresetList(t *testing.T) t.Fatalf("write config: %v", err) } - _, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6") - if err == nil { - t.Fatal("expected error for invalid model override") - } - if !strings.Contains(err.Error(), "not available for provider") { - t.Errorf("error message should mention model unavailability, got: %v", err) + var ep ResolvedEndpoint + var err error + stderr := captureStderr(t, func() { + ep, err = ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6") + }) + if err != nil || ep.Model != "claude-opsu-4-6" { + t.Fatalf("endpoint = %+v, error = %v", ep, err) } - if !strings.Contains(err.Error(), "available models:") { - t.Errorf("error message should list available models, got: %v", err) + if !strings.Contains(stderr, `model "claude-opsu-4-6" is not in the suggested models for provider "anthropic"`) || !strings.Contains(stderr, "the provider will validate it") { + t.Errorf("stderr = %q, want model warning and provider validation guidance", stderr) } } -func TestResolveEndpointWithModelOverride_InvalidModelDoesNotRunAPIKeyCmd(t *testing.T) { +func TestResolveEndpointWithModelOverride_UnlistedModelStillRunsAPIKeyCmd(t *testing.T) { clearAllEnv(t) - // The command is guaranteed to fail, so the error it would produce doubles as - // a witness that it ran: a bad --model must fail on validation instead, with - // no secret-manager prompt. cfg := configFile{ Provider: "anthropic", Providers: map[string]providerEntryConfig{ @@ -1191,23 +1196,22 @@ func TestResolveEndpointWithModelOverride_InvalidModelDoesNotRunAPIKeyCmd(t *tes t.Fatalf("write config: %v", err) } - _, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6") - if err == nil { - t.Fatal("expected error for invalid model override") - } - if !strings.Contains(err.Error(), "not available for provider") { - t.Errorf("error message should mention model unavailability, got: %v", err) + var err error + stderr := captureStderr(t, func() { + _, err = ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6") + }) + if err == nil || !strings.Contains(err.Error(), "api_key_cmd") { + t.Fatalf("error = %v, want api_key_cmd failure", err) } - if strings.Contains(err.Error(), "api_key_cmd") { - t.Errorf("api_key_cmd ran before model validation, got: %v", err) + if !strings.Contains(stderr, `model "claude-opsu-4-6" is not in the suggested models`) { + t.Errorf("stderr = %q, want warning before api_key_cmd runs", stderr) } } -// A bad global env override must be rejected before any strategy runs, for the -// same reason as the model check above: OCR_LLM_TIMEOUT="30s" (the field wants a -// bare integer) used to be parsed only after an endpoint resolved, so the user -// authenticated to 1Password/Touch ID and then got a config error. Same witness -// trick: the command cannot succeed, so its error proves it ran. +// A bad global env override must be rejected before any strategy runs: +// OCR_LLM_TIMEOUT="30s" (the field wants a bare integer) used to be parsed only +// after an endpoint resolved, so the user authenticated to 1Password/Touch ID +// and then got a config error. A failing command proves it ran when it should not. func TestResolveEndpointWithModelOverride_BadEnvOverrideDoesNotRunAPIKeyCmd(t *testing.T) { tests := []struct { name string @@ -1364,6 +1368,105 @@ func TestResolveEndpointWithModelOverride_NoValidationWhenNoModelList(t *testing } } +func TestResolveEndpointWithModelOverride_OpenRouterAcceptsUnlistedModel(t *testing.T) { + clearAllEnv(t) + + cfg := configFile{ + Provider: "openrouter", + Providers: map[string]providerEntryConfig{ + "openrouter": { + APIKey: "test-key", + Models: []string{"custom-picker-suggestion"}, + }, + }, + } + data, _ := json.Marshal(cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + const model = "deepseek/deepseek-v4.1-flash" + var ep ResolvedEndpoint + var err error + stderr := captureStderr(t, func() { + ep, err = ResolveEndpointWithOptions(cfgPath, ResolveOptions{Provider: "openrouter", Model: model}) + }) + if err != nil { + t.Fatalf("OpenRouter rejected an unlisted model: %v", err) + } + if ep.Model != model || ep.Provider != "openrouter" { + t.Fatalf("resolved model/provider = %q/%q, want %q/openrouter", ep.Model, ep.Provider, model) + } + if !strings.Contains(stderr, `model "`+model+`" is not in the suggested models for provider "openrouter"`) { + t.Errorf("stderr = %q, want unlisted model warning", stderr) + } +} + +func TestResolveEndpointWithModelOverride_AllBuiltInProvidersAcceptUnlistedModel(t *testing.T) { + clearAllEnv(t) + const model = "unlisted-model-for-test" + for _, provider := range ListProviders() { + t.Run(provider.Name, func(t *testing.T) { + entry := providerEntryConfig{APIKey: "test-key", AWSRegion: "us-west-2"} + var credentialCalls int + if provider.Name == "chatgpt" { + entry = providerEntryConfig{} + oldCredentials, oldModels := chatGPTCredentials, chatGPTModels + t.Cleanup(func() { chatGPTCredentials, chatGPTModels = oldCredentials, oldModels }) + chatGPTCredentials = func(context.Context, string) (*chatgptauth.Auth, error) { + credentialCalls++ + return &chatgptauth.Auth{ClientID: "account-fixture", AccessToken: "siwc-fixture"}, nil + } + chatGPTModels = func(context.Context, *chatgptauth.Auth) ([]chatgptauth.Model, error) { + t.Fatal("explicit ChatGPT model queried the catalog") + return nil, nil + } + } + path, _ := writeResolverConfig(t, configFile{ + Provider: provider.Name, + Providers: map[string]providerEntryConfig{ + provider.Name: entry, + }, + }) + var ep ResolvedEndpoint + var err error + stderr := captureStderr(t, func() { + ep, err = ResolveEndpointWithModelOverride(path, model) + }) + if err != nil || ep.Model != model || ep.Provider != provider.Name { + t.Fatalf("endpoint = %+v, error = %v", ep, err) + } + if provider.Name == "chatgpt" { + if credentialCalls != 1 || !ep.ChatGPTPlan || !ep.RequiresStreaming || !ep.RejectsSamplingParams || ep.URL != chatgptauth.Resource || ep.Token != "siwc-fixture" || ep.ChatGPTAccount != "account-fixture" || stderr != "" { + t.Fatalf("endpoint = %+v, credential calls = %d, stderr = %q", ep, credentialCalls, stderr) + } + } else if strings.Count(stderr, "[ocr] WARNING: model") != 1 || !strings.Contains(stderr, fmt.Sprintf("for provider %q", provider.Name)) { + t.Errorf("stderr = %q, want one warning for %s", stderr, provider.Name) + } + }) + } +} + +func TestResolveEndpoint_UnlistedConfiguredModelDoesNotWarn(t *testing.T) { + clearAllEnv(t) + path, _ := writeResolverConfig(t, configFile{ + Provider: "anthropic", + Model: "unlisted-configured-model", + Providers: map[string]providerEntryConfig{ + "anthropic": {APIKey: "test-key"}, + }, + }) + var ep ResolvedEndpoint + var err error + stderr := captureStderr(t, func() { + ep, err = ResolveEndpoint(path) + }) + if err != nil || ep.Model != "unlisted-configured-model" || stderr != "" { + t.Fatalf("endpoint = %+v, error = %v, stderr = %q", ep, err, stderr) + } +} + func TestResolveEndpointWithModelOverride_MergesPresetAndEntryModels(t *testing.T) { clearAllEnv(t) @@ -1382,27 +1485,42 @@ func TestResolveEndpointWithModelOverride_MergesPresetAndEntryModels(t *testing. t.Fatalf("write config: %v", err) } - // Should accept both preset models and entry models. - ep1, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8") + var ep1 ResolvedEndpoint + var err error + stderr := captureStderr(t, func() { + ep1, err = ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8") + }) if err != nil { t.Fatalf("unexpected error for preset model: %v", err) } - if ep1.Model != "claude-opus-4-8" { - t.Errorf("Model = %q, want %q", ep1.Model, "claude-opus-4-8") + if ep1.Model != "claude-opus-4-8" || stderr != "" { + t.Errorf("endpoint = %+v, stderr = %q, want listed model without warning", ep1, stderr) } - ep2, err := ResolveEndpointWithModelOverride(cfgPath, "custom-model-1") + var ep2 ResolvedEndpoint + stderr = captureStderr(t, func() { + ep2, err = ResolveEndpointWithModelOverride(cfgPath, "custom-model-1") + }) if err != nil { t.Fatalf("unexpected error for entry model: %v", err) } - if ep2.Model != "custom-model-1" { - t.Errorf("Model = %q, want %q", ep2.Model, "custom-model-1") + if ep2.Model != "custom-model-1" || stderr != "" { + t.Errorf("endpoint = %+v, stderr = %q, want user-added model without warning", ep2, stderr) } - // Should reject models not in either list. - _, err = ResolveEndpointWithModelOverride(cfgPath, "invalid-model") - if err == nil { - t.Fatal("expected error for model not in preset or entry lists") + var ep3 ResolvedEndpoint + stderr = captureStderr(t, func() { + ep3, err = ResolveEndpointWithModelOverride(cfgPath, "unknown-model") + }) + if err != nil || ep3.Model != "unknown-model" || !strings.Contains(stderr, `model "unknown-model" is not in the suggested models`) { + t.Fatalf("endpoint = %+v, error = %v, stderr = %q", ep3, err, stderr) + } + + stderr = captureStderr(t, func() { + _, err = ResolveEndpoint(cfgPath) + }) + if err == nil || !strings.Contains(err.Error(), "has no model configured") || stderr != "" { + t.Fatalf("error = %v, stderr = %q, want no warning without --model", err, stderr) } } @@ -2773,3 +2891,49 @@ func TestResolveEndpoint_InvalidEnvURLNamesSourceAndVariable(t *testing.T) { }) } } + +func TestResolveEndpointWithOptions_DefaultRequestTimeout(t *testing.T) { + clearAllEnv(t) + t.Setenv("OCR_LLM_URL", "https://env.example.com/v1") + t.Setenv("OCR_LLM_TOKEN", "env-token") + path, _ := writeResolverConfig(t, configFile{Llm: llmFileConfig{Model: "config-model", URL: "https://config.example.com/v1", AuthToken: "config-token"}}) + + ep, err := ResolveEndpointWithOptions(path, ResolveOptions{Model: "config-model"}) + if err != nil { + t.Fatalf("ResolveEndpointWithOptions: %v", err) + } + if ep.Timeout != 0 { + t.Fatalf("ep.Timeout = %v, want 0 (client default)", ep.Timeout) + } +} + +func TestResolveEndpointWithOptions_EnvRequestTimeout(t *testing.T) { + clearAllEnv(t) + t.Setenv("OCR_LLM_URL", "https://env.example.com/v1") + t.Setenv("OCR_LLM_TOKEN", "env-token") + t.Setenv("OCR_LLM_TIMEOUT", "45") + path, _ := writeResolverConfig(t, configFile{Llm: llmFileConfig{Model: "config-model", URL: "https://config.example.com/v1", AuthToken: "config-token"}}) + + ep, err := ResolveEndpointWithOptions(path, ResolveOptions{}) + if err != nil { + t.Fatalf("ResolveEndpointWithOptions: %v", err) + } + if ep.Timeout != 45*time.Second { + t.Fatalf("ep.Timeout = %v, want env's 45s", ep.Timeout) + } +} + +func TestResolveEndpointWithOptions_ConfigRequestTimeout(t *testing.T) { + clearAllEnv(t) + t.Setenv("OCR_LLM_URL", "https://env.example.com/v1") + t.Setenv("OCR_LLM_TOKEN", "env-token") + path, _ := writeResolverConfig(t, configFile{Llm: llmFileConfig{Model: "config-model", URL: "https://config.example.com/v1", AuthToken: "config-token", TimeoutSec: 60}}) + + ep, err := ResolveEndpointWithOptions(path, ResolveOptions{}) + if err != nil { + t.Fatalf("ResolveEndpointWithOptions: %v", err) + } + if ep.Timeout != 60*time.Second { + t.Fatalf("ep.Timeout = %v, want config's 60s", ep.Timeout) + } +} diff --git a/internal/llm/responses_client.go b/internal/llm/responses_client.go index a9272cb63..4cd8d41bf 100644 --- a/internal/llm/responses_client.go +++ b/internal/llm/responses_client.go @@ -5,14 +5,20 @@ package llm import ( "context" + "encoding/json" + "errors" "fmt" + "net/http" + "sort" "strings" "time" + "github.com/alibaba/open-code-review/internal/chatgptauth" openai "github.com/openai/openai-go/v3" openaiopt "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/packages/param" "github.com/openai/openai-go/v3/responses" + "github.com/tidwall/sjson" ) // --- OpenAIResponsesClient --- @@ -22,8 +28,9 @@ import ( // history (no previous_response_id), so the agent loop does not need to track // server-side response IDs. See DESIGN_STATE_CACHE_PHASE.md for the rationale. type OpenAIResponsesClient struct { - cfg ClientConfig - sdk openai.Client + cfg ClientConfig + sdk openai.Client + chatGPTToken func(context.Context) (string, error) } // NewOpenAIResponsesClient creates a client for the OpenAI Responses API. @@ -42,13 +49,19 @@ func NewOpenAIResponsesClient(cfg ClientConfig) *OpenAIResponsesClient { ensureResponsesEndpoint(&cfg) sdkBaseURL := strings.TrimSuffix(strings.TrimRight(cfg.URL, "/"), "/responses") + httpClient := httpClientWithHeaderTimeout(cfg.Timeout) + retries := 5 + if cfg.ChatGPTPlan { + retries = 0 + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + } opts := []openaiopt.RequestOption{ openaiopt.WithAPIKey(cfg.APIKey), openaiopt.WithBaseURL(sdkBaseURL), - openaiopt.WithMaxRetries(5), + openaiopt.WithMaxRetries(retries), openaiopt.WithHeader("User-Agent", userAgent("")), openaiopt.WithRequestTimeout(cfg.Timeout), - openaiopt.WithHTTPClient(httpClientWithHeaderTimeout(cfg.Timeout)), + openaiopt.WithHTTPClient(httpClient), } if mw := retryCodesMiddleware(cfg.RetryCodes); mw != nil { opts = append(opts, openaiopt.WithMiddleware(mw)) @@ -61,10 +74,20 @@ func NewOpenAIResponsesClient(cfg ClientConfig) *OpenAIResponsesClient { opts = append(opts, openaiopt.WithMiddleware(newRetryObserver(cfg.retryCollector))) } - return &OpenAIResponsesClient{ + client := &OpenAIResponsesClient{ cfg: cfg, sdk: openai.NewClient(opts...), } + if cfg.ChatGPTPlan { + client.chatGPTToken = func(ctx context.Context) (string, error) { + auth, err := chatGPTCredentials(ctx, cfg.ChatGPTAccount) + if err != nil { + return "", err + } + return auth.AccessToken, nil + } + } + return client } // ensureResponsesEndpoint normalizes cfg.URL to end with /responses. The @@ -98,9 +121,28 @@ func (c *OpenAIResponsesClient) CompletionsWithCtx(ctx context.Context, req Chat finalizeRequest(ctx, c.cfg.retryCollector, errRequestPanicked) panic(r) } + err = describeTimeout(ctx, err) finalizeRequest(ctx, c.cfg.retryCollector, err) }() + var planToken string + if c.cfg.ChatGPTPlan { + if c.cfg.URL != chatgptauth.Resource+"/responses" || !c.cfg.RequiresStreaming { + return nil, errors.New("chatgpt preview requires the public OpenAI Responses endpoint and streaming") + } + if err := validateChatGPTBody(c.cfg.ExtraBody); err != nil { + return nil, err + } + for key := range c.cfg.ExtraHeaders { + if reservedHeaders[strings.ToLower(key)] || strings.EqualFold(key, "Host") { + return nil, fmt.Errorf("chatgpt cannot override header %s", key) + } + } + planToken, err = c.chatGPTToken(ctx) + if err != nil { + return nil, err + } + } model := req.Model if model == "" { model = c.cfg.Model @@ -114,49 +156,238 @@ func (c *OpenAIResponsesClient) CompletionsWithCtx(ctx context.Context, req Chat } var opts []openaiopt.RequestOption + if c.cfg.ChatGPTPlan { + opts = append(opts, openaiopt.WithAPIKey(planToken)) + } for k, v := range expandSessionKeyInHeaders(c.cfg.ExtraHeaders, sessionKey) { opts = append(opts, openaiopt.WithHeader(k, v)) } for k, v := range expandSessionKeyInBody(c.cfg.ExtraBody, sessionKey) { - // This client is non-streaming: it calls Responses.New, which expects a - // single JSON body. If a provider config sets extra_body.stream=true - // (valid for the Chat Completions client, which switches to a streaming - // path), forwarding it here makes the API answer with SSE and every - // call fails to decode. Drop the key rather than forward it. + // Streaming is selected only by the resolved provider. Forwarding an + // extra_body.stream setting could conflict with that selection or make an + // ordinary Responses.New call receive SSE, so it is always dropped here. if k == "stream" { continue } opts = append(opts, openaiopt.WithJSONSet(k, v)) } - sdkResp, err := c.sdk.Responses.New(ctx, params, opts...) + var sdkResp *responses.Response + if c.cfg.RequiresStreaming { + sdkResp, err = c.responsesStreaming(ctx, params, opts...) + } else { + sdkResp, err = c.sdk.Responses.New(ctx, params, opts...) + } if err != nil { return nil, withProviderErrorBody(err) } - // The Responses API returns HTTP 200 even when the response object is in a - // terminal failure state (failed/cancelled) or a non-terminal background - // state (queued/in_progress). The SDK therefore returns a nil Go error in - // those cases. Surface them as real errors so callers (ocr llm test, the - // review loop) that branch on err != nil actually fail instead of treating - // a dead response as success. - switch sdkResp.Status { + if err = checkResponseStatus(sdkResp); err != nil { + reviseAttempt(ctx, c.cfg.retryCollector, ErrorClassProvider, FailurePhaseResponseStatus) + return nil, err + } + + if c.cfg.ChatGPTPlan { + for _, item := range sdkResp.Output { + if item.Type == "function_call" && item.AsFunctionCall().Namespace != "ocr" { + return nil, errors.New("chatgpt returned a function call outside the offered OCR namespace") + } + } + } + return c.mapResponsesResponse(sdkResp), nil +} + +type responseStatusError struct { + message string +} + +func (e *responseStatusError) Error() string { return e.message } + +// checkResponseStatus turns unsuccessful response objects into errors. The +// Responses API can return these states with HTTP 200, so the SDK does not +// reject them itself. +func checkResponseStatus(resp *responses.Response) error { + switch resp.Status { case responses.ResponseStatusFailed, responses.ResponseStatusCancelled: - err = fmt.Errorf("openai-responses request did not complete: status=%s", sdkResp.Status) + return &responseStatusError{message: fmt.Sprintf("openai-responses request did not complete: status=%s", resp.Status)} case responses.ResponseStatusQueued, responses.ResponseStatusInProgress: - err = fmt.Errorf("openai-responses returned non-terminal status=%s (background/async mode is not supported)", sdkResp.Status) + return &responseStatusError{message: fmt.Sprintf("openai-responses returned non-terminal status=%s (background/async mode is not supported)", resp.Status)} + default: + return nil } - if err != nil { - // Correct the attempt here, where the status is known. The observer saw - // only the HTTP 200 that carried this dead response object, and nothing - // downstream would catch the omission: a request whose outcome is failed is - // listed with no error attempt at all, producing self-consistent counts - // over a record that misstates what happened. +} + +type responseStreamEventError struct { + code string + message string + param string +} + +func (e *responseStreamEventError) Error() string { + message := "openai-responses stream error" + if e.code != "" { + message += ": code=" + e.code + } + if e.message != "" { + message += ": " + e.message + } + if e.param != "" { + message += " (param=" + e.param + ")" + } + return message +} + +func (c *OpenAIResponsesClient) responsesStreaming(ctx context.Context, params responses.ResponseNewParams, opts ...openaiopt.RequestOption) (*responses.Response, error) { + resp, err := c.responsesStreamingInner(ctx, params, opts...) + if err == nil { + return resp, nil + } + + var statusErr *responseStatusError + if errors.As(err, &statusErr) { reviseAttempt(ctx, c.cfg.retryCollector, ErrorClassProvider, FailurePhaseResponseStatus) + } else { + class, phase := classifyStreamError(err) + reviseAttempt(ctx, c.cfg.retryCollector, class, phase) + } + return nil, err +} + +func (c *OpenAIResponsesClient) responsesStreamingInner(ctx context.Context, params responses.ResponseNewParams, opts ...openaiopt.RequestOption) (*responses.Response, error) { + stream := c.sdk.Responses.NewStreaming(ctx, params, opts...) + defer stream.Close() + + accumulator := responseStreamAccumulator{requireCompleted: c.cfg.ChatGPTPlan} + for stream.Next() { + if err := accumulator.add(stream.Current()); err != nil { + return nil, err + } + } + if err := stream.Err(); err != nil { return nil, err } + return accumulator.response() +} - return c.mapResponsesResponse(sdkResp), nil +type indexedResponseOutputItem struct { + outputIndex int64 + raw json.RawMessage +} + +const maxResponseStreamOutputBytes = 16 << 20 + +type responseStreamAccumulator struct { + items []indexedResponseOutputItem + outputBytes int + terminal *responses.Response + requireCompleted bool + terminalType string +} + +func (a *responseStreamAccumulator) add(event responses.ResponseStreamEventUnion) error { + switch event.Type { + case "response.output_item.done": + done := event.AsResponseOutputItemDone() + raw := done.Item.RawJSON() + // Include per-item bookkeeping so a stream of tiny items is bounded too. + const itemOverhead = 64 + if len(raw)+itemOverhead > maxResponseStreamOutputBytes-a.outputBytes { + return &responseStreamEventError{code: "output_limit_exceeded", message: "output exceeds 16 MiB stream limit"} + } + a.outputBytes += len(raw) + itemOverhead + a.items = append(a.items, indexedResponseOutputItem{ + outputIndex: done.OutputIndex, + raw: json.RawMessage(raw), + }) + case "response.completed", "response.failed", "response.incomplete": + if a.requireCompleted && event.Type != "response.completed" { + var envelope struct { + Error struct { + Param string `json:"param"` + } `json:"error"` + Incomplete json.RawMessage `json:"incomplete_details"` + } + if err := json.Unmarshal([]byte(event.Response.RawJSON()), &envelope); err != nil { + return err + } + message := fmt.Sprintf("%s: %s", event.Type, event.Response.Error.Message) + if len(envelope.Incomplete) > 0 && string(envelope.Incomplete) != "null" { + message += " " + string(envelope.Incomplete) + } + message += "; Manage usage: " + chatgptauth.UsageURL + return &responseStreamEventError{code: string(event.Response.Error.Code), param: envelope.Error.Param, message: message} + } + a.terminalType = event.Type + response := event.Response + if len(response.RawJSON()) > maxResponseStreamOutputBytes-a.outputBytes { + return &responseStreamEventError{code: "output_limit_exceeded", message: "output exceeds 16 MiB stream limit"} + } + a.terminal = &response + case "error": + streamErr := event.AsError() + return &responseStreamEventError{ + code: streamErr.Code, + message: streamErr.Message, + param: streamErr.Param, + } + } + return nil +} + +func (a *responseStreamAccumulator) response() (*responses.Response, error) { + if a.terminal == nil { + return nil, &streamIntegrityError{reason: "ended before a terminal event"} + } + if a.requireCompleted { + if a.terminalType != "response.completed" || a.terminal.Status != responses.ResponseStatusCompleted { + return nil, &responseStatusError{message: "chatgpt stream did not reach response.completed"} + } + if len(a.terminal.Output) > 0 { + return a.terminal, nil + } + } + + sort.SliceStable(a.items, func(i, j int) bool { + return a.items[i].outputIndex < a.items[j].outputIndex + }) + rawItems := make([]json.RawMessage, len(a.items)) + for i := range a.items { + rawItems[i] = a.items[i].raw + } + itemsJSON, err := json.Marshal(rawItems) + if err != nil { + return nil, fmt.Errorf("marshal openai-responses stream output: %w", err) + } + + merged, err := sjson.SetRawBytes([]byte(a.terminal.RawJSON()), "output", itemsJSON) + if err != nil { + return nil, fmt.Errorf("merge openai-responses stream output: %w", err) + } + var full responses.Response + if err := full.UnmarshalJSON(merged); err != nil { + return nil, fmt.Errorf("unmarshal accumulated openai-responses stream: %w", err) + } + if err := checkResponseStatus(&full); err != nil { + return nil, err + } + if full.Status != responses.ResponseStatusCompleted && full.Status != responses.ResponseStatusIncomplete { + return nil, &responseStatusError{message: fmt.Sprintf( + "openai-responses terminal event carried unexpected status=%q", full.Status)} + } + return &full, nil +} + +// accumulateResponseStream rebuilds a complete response from output-item done +// events and the terminal response envelope, including streams whose terminal +// output array is empty after emitting complete output items. +func accumulateResponseStream(events []responses.ResponseStreamEventUnion) (*responses.Response, error) { + var accumulator responseStreamAccumulator + for _, event := range events { + if err := accumulator.add(event); err != nil { + return nil, err + } + } + return accumulator.response() } // buildResponsesParams converts the shared ChatRequest into Responses API @@ -185,6 +416,12 @@ func (c *OpenAIResponsesClient) buildResponsesParams(model string, req ChatReque if content != "" { systemParts = append(systemParts, content) } + case "developer": + role := responses.EasyInputMessageRoleUser + if c.cfg.ChatGPTPlan { + role = responses.EasyInputMessageRoleDeveloper + } + input = append(input, responses.ResponseInputItemParamOfMessage(content, role)) case "user": input = append(input, responses.ResponseInputItemParamOfMessage(content, responses.EasyInputMessageRoleUser)) case "assistant": @@ -197,7 +434,11 @@ func (c *OpenAIResponsesClient) buildResponsesParams(model string, req ChatReque input = append(input, responses.ResponseInputItemParamOfMessage(content, responses.EasyInputMessageRoleAssistant)) } for _, tc := range msg.ToolCalls { - input = append(input, responses.ResponseInputItemParamOfFunctionCall(tc.Function.Arguments, tc.ID, tc.Function.Name)) + item := responses.ResponseInputItemParamOfFunctionCall(tc.Function.Arguments, tc.ID, tc.Function.Name) + if c.cfg.ChatGPTPlan { + item.OfFunctionCall.Namespace = openai.String("ocr") + } + input = append(input, item) } case "tool": input = append(input, responses.ResponseInputItemParamOfFunctionCallOutput(msg.ToolCallID, content)) @@ -228,6 +469,15 @@ func (c *OpenAIResponsesClient) buildResponsesParams(model string, req ChatReque Include: []responses.ResponseIncludable{responses.ResponseIncludableReasoningEncryptedContent}, } + if c.cfg.ChatGPTPlan && len(req.Tools) > 0 { + var functions []responses.NamespaceToolToolUnionParam + for _, t := range req.Tools { + functions = append(functions, responses.NamespaceToolToolUnionParam{OfFunction: &responses.NamespaceToolToolFunctionParam{ + Name: t.Function.Name, Parameters: t.Function.Parameters, Strict: openai.Bool(false), Description: openai.String(t.Function.Description), + }}) + } + tools = []responses.ToolUnionParam{{OfNamespace: &responses.NamespaceToolParam{Name: "ocr", Description: "OpenCodeReview local review tools", Tools: functions}}} + } if instructions != "" { params.Instructions = openai.String(instructions) } @@ -242,11 +492,13 @@ func (c *OpenAIResponsesClient) buildResponsesParams(model string, req ChatReque } } } - if req.MaxTokens > 0 { - params.MaxOutputTokens = openai.Int(int64(req.MaxTokens)) - } - if req.Temperature != nil { - params.Temperature = openai.Float(*req.Temperature) + if !c.cfg.RejectsSamplingParams && !c.cfg.ChatGPTPlan { + if req.MaxTokens > 0 { + params.MaxOutputTokens = openai.Int(int64(req.MaxTokens)) + } + if req.Temperature != nil { + params.Temperature = openai.Float(*req.Temperature) + } } return params @@ -332,9 +584,19 @@ func (c *OpenAIResponsesClient) mapResponsesResponse(sdkResp *responses.Response } } + var rawUsageJSON json.RawMessage + if c.cfg.ChatGPTPlan { + var envelope struct { + Usage json.RawMessage `json:"usage"` + } + if json.Unmarshal([]byte(sdkResp.RawJSON()), &envelope) == nil { + rawUsageJSON = envelope.Usage + } + } return &ChatResponse{ - ID: sdkResp.ID, - Model: string(sdkResp.Model), + ID: sdkResp.ID, + Model: string(sdkResp.Model), + RawUsage: rawUsageJSON, Choices: []Choice{{ Message: ResponseMessage{ Role: "assistant", diff --git a/internal/llm/responses_client_test.go b/internal/llm/responses_client_test.go index a0953300f..fd055d72c 100644 --- a/internal/llm/responses_client_test.go +++ b/internal/llm/responses_client_test.go @@ -6,9 +6,11 @@ package llm import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/openai/openai-go/v3/responses" @@ -335,7 +337,7 @@ func TestBuildResponsesParams_MaxTokensAndTemperature(t *testing.T) { }) temp := 0.7 - req := ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}, Temperature: &temp} + req := ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}, Temperature: &temp, MaxTokens: 512} params := client.buildResponsesParams("gpt-5.4", req) if !params.Temperature.Valid() { t.Fatal("expected Temperature to be set") @@ -344,6 +346,18 @@ func TestBuildResponsesParams_MaxTokensAndTemperature(t *testing.T) { if gotTemp != 0.7 { t.Errorf("Temperature = %v, want 0.7", gotTemp) } + + plan := NewOpenAIResponsesClient(ClientConfig{ + URL: "https://api.openai.com/v1", + RejectsSamplingParams: true, + }) + planParams := plan.buildResponsesParams("gpt-5.6-luna", req) + if planParams.Temperature.Valid() { + t.Errorf("ChatGPT plan Temperature = %v, want unset", planParams.Temperature.Value) + } + if planParams.MaxOutputTokens.Valid() { + t.Errorf("ChatGPT plan MaxOutputTokens = %v, want unset", planParams.MaxOutputTokens.Value) + } } // --- mapResponsesResponse tests --- @@ -610,6 +624,33 @@ func TestOpenAIResponsesClient_EndToEnd(t *testing.T) { } } +func TestOpenAIResponsesClient_StatuslessResponseSucceeds(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "id":"resp_statusless", + "object":"response", + "model":"gateway-model", + "output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}], + "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2} + }`) + })) + defer server.Close() + + client := NewOpenAIResponsesClient(ClientConfig{ + URL: server.URL + "/v1", APIKey: "test-key", Model: "gateway-model", + }) + resp, err := client.CompletionsWithCtx(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("CompletionsWithCtx: %v", err) + } + if resp.Content() != "ok" || resp.Choices[0].FinishReason != "stop" { + t.Errorf("response = %+v, want content ok and finish reason stop", resp) + } +} + // TestOpenAIResponsesClient_ExtraBodyStreamDropped verifies that an // extra_body.stream=true (valid for the Chat Completions client) is NOT // forwarded to the Responses API. Forwarding it makes the API answer with SSE @@ -702,8 +743,243 @@ func TestOpenAIResponsesClient_NonSuccessStatusReturnsError(t *testing.T) { } } +func TestAccumulateResponseStream(t *testing.T) { + t.Run("sorts output items and preserves terminal raw JSON", func(t *testing.T) { + events := responseStreamEvents(t, + `{"type":"response.output_item.done","output_index":1,"sequence_number":2,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"inspect","arguments":"{}","status":"completed"}}`, + `{"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"ciphertext","status":"completed"}}`, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_stream","object":"response","model":"gpt-5.6-luna","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14,"cache_creation_input_tokens":3}}}`, + ) + + resp, err := accumulateResponseStream(events) + if err != nil { + t.Fatalf("accumulateResponseStream: %v", err) + } + if len(resp.Output) != 2 { + t.Fatalf("output items = %d, want 2", len(resp.Output)) + } + if resp.Output[0].Type != "reasoning" || resp.Output[1].Type != "function_call" { + t.Errorf("output order = [%s, %s], want [reasoning, function_call]", resp.Output[0].Type, resp.Output[1].Type) + } + if !strings.Contains(resp.RawJSON(), `"cache_creation_input_tokens":3`) { + t.Errorf("RawJSON() did not preserve terminal usage: %s", resp.RawJSON()) + } + }) + + t.Run("returns a mid-stream error event", func(t *testing.T) { + events := responseStreamEvents(t, + `{"type":"response.created","sequence_number":1,"response":{"id":"resp_error","status":"in_progress"}}`, + `{"type":"error","sequence_number":2,"code":"rate_limit","message":"quota exhausted","param":"model"}`, + ) + _, err := accumulateResponseStream(events) + if err == nil || !strings.Contains(err.Error(), "quota exhausted") { + t.Fatalf("error = %v, want legible stream error", err) + } + }) + + t.Run("rejects a truncated stream", func(t *testing.T) { + events := responseStreamEvents(t, + `{"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[]}}`, + ) + _, err := accumulateResponseStream(events) + if err == nil || !strings.Contains(err.Error(), "terminal event") { + t.Fatalf("error = %v, want missing terminal event error", err) + } + }) + + t.Run("rejects a failed terminal response", func(t *testing.T) { + events := responseStreamEvents(t, + `{"type":"response.failed","sequence_number":1,"response":{"id":"resp_failed","object":"response","model":"gpt-5.6-luna","status":"failed","output":[],"usage":{"input_tokens":1,"output_tokens":0,"total_tokens":1}}}`, + ) + _, err := accumulateResponseStream(events) + if err == nil || !strings.Contains(err.Error(), "status=failed") { + t.Fatalf("error = %v, want failed response status", err) + } + }) + + t.Run("rejects an unexpected terminal status", func(t *testing.T) { + events := responseStreamEvents(t, + `{"type":"response.completed","sequence_number":1,"response":{"id":"resp_unknown","object":"response","model":"gpt-5.6-luna","status":"vendor_done","output":[]}}`, + ) + _, err := accumulateResponseStream(events) + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("error = %v, want unexpected terminal status", err) + } + }) +} + +func TestAccumulateResponseStreamRejectsExcessOutput(t *testing.T) { + item := `{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"` + strings.Repeat("x", 1024) + `"}]}` + event := responseStreamEvents(t, `{"type":"response.output_item.done","output_index":0,"item":`+item+`}`)[0] + var accumulator responseStreamAccumulator + for accumulator.outputBytes+len(event.AsResponseOutputItemDone().Item.RawJSON())+64 <= maxResponseStreamOutputBytes { + if err := accumulator.add(event); err != nil { + t.Fatalf("add item below limit: %v", err) + } + } + before := len(accumulator.items) + err := accumulator.add(event) + if err == nil || !strings.Contains(err.Error(), "output exceeds") { + t.Fatalf("add item above limit = %v, want output limit error", err) + } + if len(accumulator.items) != before { + t.Errorf("items after rejected item = %d, want %d", len(accumulator.items), before) + } + if class, phase := classifyStreamError(err); class != ErrorClassProvider || phase != FailurePhaseStream { + t.Errorf("classification = %s/%s, want provider/stream", class, phase) + } +} + +func TestAccumulateResponseStreamRejectsOversizedItem(t *testing.T) { + item := `{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"` + strings.Repeat("x", maxResponseStreamOutputBytes) + `"}]}` + _, err := accumulateResponseStream(responseStreamEvents(t, `{"type":"response.output_item.done","output_index":0,"item":`+item+`}`)) + if err == nil || !strings.Contains(err.Error(), "output exceeds") { + t.Fatalf("oversized output error = %v, want output limit error", err) + } +} + +func TestAccumulateResponseStreamRejectsTerminalBeyondLimit(t *testing.T) { + terminal := responseStreamEvents(t, `{"type":"response.completed","response":{"id":"resp_stream","status":"completed","output":[]}}`)[0] + accumulator := responseStreamAccumulator{outputBytes: maxResponseStreamOutputBytes - 1} + if err := accumulator.add(terminal); err == nil || !strings.Contains(err.Error(), "output exceeds") { + t.Fatalf("terminal error = %v, want output limit error", err) + } + if accumulator.terminal != nil { + t.Fatal("oversized terminal was retained") + } +} + +func TestChatGPTStreamFailureDetails(t *testing.T) { + for _, tc := range []struct { + name, eventType, details, want string + }{ + {"failed_missing", "failed", "", "response.failed: rate limited; Manage usage: "}, + {"failed_null", "failed", `,"incomplete_details":null`, "response.failed: rate limited; Manage usage: "}, + {"incomplete_null", "incomplete", `,"incomplete_details":null`, "response.incomplete: rate limited; Manage usage: "}, + {"incomplete_reason", "incomplete", `,"incomplete_details":{"reason":"max_output_tokens"}`, `response.incomplete: rate limited {"reason":"max_output_tokens"}; Manage usage: `}, + } { + t.Run(tc.name, func(t *testing.T) { + events := responseStreamEvents(t, `{"type":"response.`+tc.eventType+`","response":{"id":"resp_failed","status":"`+tc.eventType+`","error":{"code":"rate_limit_exceeded","message":"rate limited","param":"model"}`+tc.details+`}}`) + accumulator := responseStreamAccumulator{requireCompleted: true} + err := accumulator.add(events[0]) + var streamErr *responseStreamEventError + if !errors.As(err, &streamErr) || streamErr.code != "rate_limit_exceeded" || streamErr.param != "model" { + t.Fatal("structured error details were lost", err) + } + if streamErr.message != tc.want+"https://chatgpt.com/settings/usage" { + t.Fatalf("unexpected error message: %s", streamErr.message) + } + }) + } +} + +func TestOpenAIResponsesClient_StreamingIncompleteContract(t *testing.T) { + for _, status := range []string{"incomplete", "failed"} { + t.Run(status, func(t *testing.T) { + events := responseStreamEvents(t, + `{"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_partial","role":"assistant","status":"completed","content":[{"type":"output_text","text":"partial","annotations":[]}]}}`, + `{"type":"response.`+status+`","response":{"id":"resp_partial","status":"`+status+`","output":[]}}`, + ) + accumulated, err := accumulateResponseStream(events) + if (err != nil) != (status == "failed") { + t.Fatal("ordinary accumulator terminal contract changed", err) + } + if status == "incomplete" && (accumulated == nil || accumulated.Status != responses.ResponseStatusIncomplete || accumulated.OutputText() != "partial") { + t.Fatal("incomplete accumulator discarded output") + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + for _, event := range events { + io.WriteString(w, "data: "+event.RawJSON()+"\n\n") + } + io.WriteString(w, "data: [DONE]\n\n") + })) + defer server.Close() + client := NewOpenAIResponsesClient(ClientConfig{URL: server.URL + "/v1", APIKey: "fixture-key", Model: "fixture-model", RequiresStreaming: true}) + resp, err := client.CompletionsWithCtx(context.Background(), ChatRequest{Messages: []Message{NewTextMessage("user", "test")}}) + if status == "failed" { + if err == nil || resp != nil { + t.Fatal("failed ordinary Responses stream returned success") + } + return + } + if err != nil || resp == nil || len(resp.Choices) != 1 || resp.Choices[0].FinishReason != "length" || resp.Choices[0].Message.Content == nil || *resp.Choices[0].Message.Content != "partial" { + t.Fatal("ordinary incomplete stream lost the length finish reason or partial output", err) + } + }) + } +} + +func TestOpenAIResponsesClient_StreamingSSEPreservesNativeReasoning(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotBody) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.done\",\"output_index\":1,\"sequence_number\":2,\"item\":{\"type\":\"function_call\",\"id\":\"fc_1\",\"call_id\":\"call_1\",\"name\":\"inspect\",\"arguments\":\"{}\",\"status\":\"completed\"}}\n\n") + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"sequence_number\":1,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"summary\":[],\"encrypted_content\":\"ciphertext\",\"status\":\"completed\"}}\n\n") + _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"sequence_number\":3,\"response\":{\"id\":\"resp_sse\",\"object\":\"response\",\"model\":\"gpt-5.6-luna\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":4,\"total_tokens\":14,\"cache_creation_input_tokens\":3}}}\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer server.Close() + + temperature := 0.7 + client := NewOpenAIResponsesClient(ClientConfig{ + URL: server.URL + "/v1", + APIKey: "subscription-token", + Model: "gpt-5.6-luna", + RequiresStreaming: true, + RejectsSamplingParams: true, + }) + resp, err := client.CompletionsWithCtx(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "inspect"}}, + MaxTokens: 2048, + Temperature: &temperature, + }) + if err != nil { + t.Fatalf("CompletionsWithCtx: %v", err) + } + if gotBody["stream"] != true { + t.Errorf("stream = %v, want true", gotBody["stream"]) + } + if _, ok := gotBody["temperature"]; ok { + t.Errorf("ChatGPT plan request included temperature: %v", gotBody["temperature"]) + } + if _, ok := gotBody["max_output_tokens"]; ok { + t.Errorf("ChatGPT plan request included max_output_tokens: %v", gotBody["max_output_tokens"]) + } + if resp.Usage == nil || resp.Usage.CacheWriteTokens != 3 { + t.Fatalf("usage = %+v, want raw cache_creation_input_tokens=3", resp.Usage) + } + payload, ok := resp.Native().Payload.([]responses.ResponseInputItemUnionParam) + if !ok { + t.Fatalf("Native payload type = %T, want []responses.ResponseInputItemUnionParam", resp.Native().Payload) + } + if len(payload) != 2 || payload[0].OfReasoning == nil || payload[1].OfFunctionCall == nil { + t.Fatalf("Native payload = %#v, want ordered reasoning and function call", payload) + } + rawPayload, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal Native payload: %v", err) + } + if !strings.Contains(string(rawPayload), `"encrypted_content":"ciphertext"`) { + t.Errorf("Native payload lost encrypted_content: %s", rawPayload) + } +} + // --- helpers --- +func responseStreamEvents(t *testing.T, bodies ...string) []responses.ResponseStreamEventUnion { + t.Helper() + events := make([]responses.ResponseStreamEventUnion, len(bodies)) + for i, body := range bodies { + if err := events[i].UnmarshalJSON([]byte(body)); err != nil { + t.Fatalf("unmarshal stream event %d: %v", i, err) + } + } + return events +} + func unmarshalResponsesBody(t *testing.T, body string) *responses.Response { t.Helper() var r responses.Response diff --git a/internal/llm/retry_boundary.go b/internal/llm/retry_boundary.go index a29166117..0dc832dcb 100644 --- a/internal/llm/retry_boundary.go +++ b/internal/llm/retry_boundary.go @@ -79,9 +79,10 @@ func classifyBoundaryError(err error) (ErrorClass, FailurePhase, bool) { // transport. func classifyStreamError(err error) (ErrorClass, FailurePhase) { var integrityErr *streamIntegrityError + var responseEventErr *responseStreamEventError var streamErr *ssestream.StreamError switch { - case errors.As(err, &integrityErr), errors.As(err, &streamErr): + case errors.As(err, &integrityErr), errors.As(err, &responseEventErr), errors.As(err, &streamErr): return ErrorClassProvider, FailurePhaseStream case errors.Is(err, context.Canceled): return ErrorClassCancelled, FailurePhaseContext diff --git a/internal/llm/test_stderr_test.go b/internal/llm/test_stderr_test.go new file mode 100644 index 000000000..c1e1482cc --- /dev/null +++ b/internal/llm/test_stderr_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package llm + +import ( + "io" + "os" + "testing" +) + +// captureStderr swaps os.Stderr for a pipe around fn and returns what was written. +// Output here is tiny, so reading after the writer is closed avoids any pipe-buffer +// deadlock without a goroutine. +func captureStderr(test *testing.T, fn func()) string { + test.Helper() + reader, writer, err := os.Pipe() + if err != nil { + test.Fatalf("os.Pipe: %v", err) + } + originalStderr := os.Stderr + os.Stderr = writer + defer func() { os.Stderr = originalStderr }() + + fn() + + if err := writer.Close(); err != nil { + test.Fatalf("close pipe writer: %v", err) + } + output, err := io.ReadAll(reader) + if err != nil { + test.Fatalf("read captured stderr: %v", err) + } + return string(output) +} diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go index 88d056b74..131a224ed 100644 --- a/internal/llmloop/loop.go +++ b/internal/llmloop/loop.go @@ -280,12 +280,15 @@ func (r *Runner) CollectPendingComments() []model.LlmComment { // classification instead of guessing from free text: only a configured limit // (max tool-request rounds) is a budget stop; the empty-round and compression // exits are genuine but unclassifiable, so they map to the unknown catch-all. -// StopNone means the loop returned via task_done (completed) or via an error. +// StopNone means the loop returned via task_done in a normal round or via an +// error. A task_done carried by the grace round is the exception: it completes +// the run while the stop keeps naming the budget exit that reached that round, +// so the completed flag, not the stop, is what separates success from failure. type MainLoopStop int const ( - // StopNone — RunMainTask completed via task_done or returned an error; the - // stop cause carries no additional meaning. + // StopNone — RunMainTask completed via a task_done in a normal round, or + // returned an error; the stop cause carries no additional meaning. StopNone MainLoopStop = iota // StopMaxRounds — the configured MaxToolRequestTimes round budget was // exhausted before task_done. This is a declared budget limit. @@ -358,9 +361,12 @@ func (s MainLoopStop) Reason() string { // returned by the model, and collects review comments until task_done is // called or limits are reached. Token usage and warnings are aggregated on the // Runner across every call. The returned bool is true only when the model -// explicitly calls task_done with a successful state. The MainLoopStop return -// classifies a non-completed, non-error stop at its trigger point so the caller -// never has to infer the cause from text or context state. +// explicitly calls task_done with a successful state, including one issued in +// the grace round that follows an exhausted budget. That grace round also +// records a WarningReviewLimitReached warning, so a spent limit stays visible +// even when the completion it produced is the only other trace of it. The +// MainLoopStop return classifies a non-completed, non-error stop at its trigger +// point so the caller never has to infer the cause from text or context state. // // taskKey identifies the subtask this conversation belongs to, and is not // necessarily a file path: scan passes one file's path, while review passes the @@ -520,16 +526,27 @@ func (r *Runner) RunMainTask(ctx context.Context, messages []llm.Message, taskKe } } + // The grace round after a spent budget is the last chance to finish the + // task, so a task_done it carries counts as completion. The caller records + // the subtask as incomplete otherwise, and a resume re-runs a file the + // model already declared done. + completed := false switch stop { case StopMaxRounds: fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", taskKey) - r.runGraceRound(ctx, messages, taskKey, sessionID) + completed = r.runGraceRound(ctx, messages, taskKey, sessionID) case StopTokenBudget: fmt.Fprintf(stdout.Writer(), "[ocr] Token budget exceeded (used %d > budget %d) for %s.\n", r.TotalTokensUsed(), r.deps.MaxTokensBudget, taskKey) - r.runGraceRound(ctx, messages, taskKey, sessionID) + completed = r.runGraceRound(ctx, messages, taskKey, sessionID) } - return false, stop, nil + // Both budget stops above ran the grace round, and completion is the only + // other trace it leaves: without this warning a caller would see a + // completed task and never learn that its budget ran out mid-review. + if stop == StopMaxRounds || stop == StopTokenBudget { + r.recordGraceRoundLimit(taskKey, stop, completed) + } + return completed, stop, nil } // tokenBudgetExceeded reports whether the run's aggregate token usage is past @@ -541,11 +558,13 @@ func (r *Runner) tokenBudgetExceeded() bool { // runGraceRound performs one final LLM call after the tool-request budget is // exhausted, giving the model a chance to submit any findings it identified -// but did not yet report via code_comment. -func (r *Runner) runGraceRound(ctx context.Context, messages []llm.Message, taskKey string, sessionID string) { +// but did not yet report via code_comment. It reports whether the model called +// task_done with a successful state in that round: the grace round is a regular +// main-task round, so its verdict on the subtask has to count. +func (r *Runner) runGraceRound(ctx context.Context, messages []llm.Message, taskKey string, sessionID string) bool { graceDefs := graceRoundToolDefs(r.deps.MainToolDefs) if len(graceDefs) == 0 { - return + return false } messages = append(messages, llm.NewTextMessage("user", @@ -556,7 +575,7 @@ func (r *Runner) runGraceRound(ctx context.Context, messages []llm.Message, task if ctx.Err() != nil { fmt.Fprintf(stdout.Writer(), "[ocr] Grace round skipped for %s: context cancelled\n", taskKey) - return + return false } fs := r.deps.Session.GetOrCreateFileSession(taskKey) @@ -579,7 +598,7 @@ func (r *Runner) runGraceRound(ctx context.Context, messages []llm.Message, task llmSpan.End() telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error") fmt.Fprintf(stdout.Writer(), "[ocr] Grace round LLM error for %s: %v\n", taskKey, err) - return + return false } rec.SetResponse(resp, duration) @@ -597,13 +616,41 @@ func (r *Runner) runGraceRound(ctx context.Context, messages []llm.Message, task calls := resp.ToolCalls() if len(calls) == 0 { - return + return false } + // Only task_done reports completion; code_comment records findings. A FAILED + // task_done is neither, so the round stays incomplete. + completed := false + failed := false thinking := resp.ReasoningContent() for _, call := range calls { - r.executeToolCall(ctx, taskKey, call, rec, thinking) + checkpoint := r.executeToolCall(ctx, taskKey, call, rec, thinking) + completed = completed || checkpoint.Completed + failed = failed || checkpoint.Failed } + return completed && !failed +} + +// WarningReviewLimitReached is the warning type RunMainTask records when a +// declared budget — the tool-request rounds (StopMaxRounds) or the aggregate +// token budget (StopTokenBudget) — spends the main loop's last round and sends +// the task into its grace round. Callers publish warnings in their JSON and +// terminal output, and the warning keeps a spent limit visible even when the +// grace round completes the task: by design the completion alone would look +// like an untruncated review. +const WarningReviewLimitReached = "review_limit_reached" + +// recordGraceRoundLimit records which declared budget reached its grace round, +// and the round's verdict on the task. The verdict is part of the message +// because completion and its absence send a reader — or a JSON consumer — to +// opposite conclusions about the same limit. +func (r *Runner) recordGraceRoundLimit(taskKey string, stop MainLoopStop, completed bool) { + outcome := "the grace round did not complete the task" + if completed { + outcome = "the grace round completed the task" + } + r.RecordWarning(WarningReviewLimitReached, taskKey, stop.Reason()+"; "+outcome) } // graceRoundToolDefs returns the subset of tool definitions containing only @@ -684,6 +731,10 @@ func (r *Runner) executeToolCall(ctx context.Context, taskKey string, call llm.T // repair would absorb an unbounded number of them unobserved. r.RecordWarning("comment_args_repaired", taskKey, repair.Message()) } + // Report failure so the model can retry instead of assuming its findings were saved. + if errMsg == "" && len(comments) == 0 { + errMsg = tool.CommentNoneUsableMsg + } if errMsg != "" { dur := time.Since(startTime) toolErr := fmt.Errorf("%s", errMsg) diff --git a/internal/llmloop/loop_test.go b/internal/llmloop/loop_test.go index a75410f76..681e0ad44 100644 --- a/internal/llmloop/loop_test.go +++ b/internal/llmloop/loop_test.go @@ -106,6 +106,19 @@ func newTestDeps(client llm.LLMClient) Deps { } } +// graceLimitWarnings returns the review-limit warnings recorded on the runner. +// A limit is recorded once per grace round, however that round ended, so the +// tests below assert on this filtered slice rather than on the full list. +func graceLimitWarnings(r *Runner) []AgentWarning { + var out []AgentWarning + for _, w := range r.Warnings() { + if w.Type == WarningReviewLimitReached { + out = append(out, w) + } + } + return out +} + func TestRunMainTask_TaskDoneImmediately(t *testing.T) { client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}} deps := newTestDeps(client) @@ -638,6 +651,44 @@ func TestExecuteToolCall_CodeCommentUsesPerItemPath(t *testing.T) { } } +func TestExecuteToolCall_CodeCommentWithNoUsableEntryIsReported(t *testing.T) { + collector := tool.NewCommentCollector() + reg := tool.NewRegistry() + reg.Register(&tool.CodeCommentProvider{Collector: collector}) + reg.Freeze() + + r := NewRunner(Deps{ + Tools: reg, + CommentCollector: collector, + }) + + argsJSON, err := json.Marshal(map[string]any{ + "comments": []any{ + map[string]any{"path": "main.go", "message": "nil dereference"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + cp := r.executeToolCall(context.Background(), "main.go", llm.ToolCall{ + Function: llm.FunctionCall{ + Name: "code_comment", + Arguments: string(argsJSON), + }, + }, nil, "") + + if cp.Data != tool.CommentNoneUsableMsg { + t.Fatalf("result = %q, want %q", cp.Data, tool.CommentNoneUsableMsg) + } + if len(collector.Comments()) != 0 { + t.Fatalf("collected %d comments, want 0", len(collector.Comments())) + } + if failures := r.ToolFailures(); len(failures) != 1 || failures[0].ToolName != "code_comment" { + t.Fatalf("ToolFailures() = %+v, want one code_comment failure", failures) + } +} + func graceRoundCommentResponse() *llm.ChatResponse { content := "" return &llm.ChatResponse{ @@ -719,6 +770,198 @@ func TestRunMainTask_GraceRoundSubmitsComment(t *testing.T) { if runner.TotalInputTokens() != 70 { t.Errorf("TotalInputTokens = %d, want 70 (20+50)", runner.TotalInputTokens()) } + // The spent round budget is recorded even though the grace round did not + // complete the task. + if limit := graceLimitWarnings(runner); len(limit) != 1 || + limit[0].File != "main.go" || !strings.Contains(limit[0].Message, "did not complete the task") { + t.Fatalf("limit warnings = %+v, want one incomplete verdict on main.go", limit) + } +} + +// The grace round is still a main-task round, so a task_done it carries is the +// model declaring the subtask finished. Discarding it recorded a file the model +// completed as incomplete, and a resume then re-ran that file. +func TestRunMainTask_GraceRoundTaskDoneCompletes(t *testing.T) { + client := &fakeClient{responses: []*llm.ChatResponse{ + fileReadToolCallResponse("call_1", `{"path":"main.go"}`), + taskDoneResponseWithArguments(`{"state":"DONE"}`), + }} + deps := newTestDeps(client) + deps.Template.MaxToolRequestTimes = 1 + deps.MainToolDefs = []llm.ToolDef{ + {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}}, + {Type: "function", Function: llm.FunctionDef{Name: "task_done"}}, + {Type: "function", Function: llm.FunctionDef{Name: "file_read"}}, + } + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + completed, stop, err := runner.RunMainTask(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunMainTask: %v", err) + } + if !completed { + t.Fatal("task_done DONE in the grace round must complete RunMainTask") + } + if stop != StopMaxRounds { + t.Fatalf("stop = %v, want StopMaxRounds", stop) + } + if client.calls != 2 { + t.Fatalf("LLM calls = %d, want 2 (1 main + 1 grace)", client.calls) + } + // The spent round budget must survive a successful grace round: the + // completion alone would otherwise be the only trace of it. + if limit := graceLimitWarnings(runner); len(limit) != 1 || + limit[0].File != "main.go" || !strings.Contains(limit[0].Message, "the grace round completed the task") { + t.Fatalf("limit warnings = %+v, want one completed verdict on main.go", limit) + } +} + +// The aggregate token budget is the other declared limit that earns a grace +// round, and a task it completes must stay warn-visible the same way. +func TestRunMainTask_TokenBudgetGraceRoundTaskDoneCompletes(t *testing.T) { + client := &fakeClient{responses: []*llm.ChatResponse{ + withUsage(fileReadToolCallResponse("call_1", `{"path":"main.go"}`), 600), + taskDoneResponseWithArguments(`{"state":"DONE"}`), + }} + deps := newTestDeps(client) + // Zero tokens admit round 1; its 600 then exceed the 500 budget, so the + // next round is refused and the grace round decides the task. + deps.MaxTokensBudget = 500 + deps.MainToolDefs = []llm.ToolDef{ + {Type: "function", Function: llm.FunctionDef{Name: "file_read", Description: "read"}}, + {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}}, + {Type: "function", Function: llm.FunctionDef{Name: "task_done"}}, + } + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + completed, stop, err := runner.RunMainTask(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunMainTask: %v", err) + } + if !completed { + t.Fatal("task_done DONE in the token-budget grace round must complete RunMainTask") + } + if stop != StopTokenBudget { + t.Fatalf("stop = %v, want StopTokenBudget", stop) + } + if client.calls != 2 { + t.Fatalf("LLM calls = %d, want 2 (1 round + 1 grace)", client.calls) + } + if limit := graceLimitWarnings(runner); len(limit) != 1 || + !strings.Contains(limit[0].Message, "token budget") || + !strings.Contains(limit[0].Message, "the grace round completed the task") { + t.Fatalf("limit warnings = %+v, want one completed token-budget verdict", limit) + } +} + +// Only task_done DONE reports completion, exactly as in the main loop. A FAILED +// verdict in the grace round leaves the subtask incomplete. +func TestRunMainTask_GraceRoundTaskDoneFailedStaysIncomplete(t *testing.T) { + client := &fakeClient{responses: []*llm.ChatResponse{ + fileReadToolCallResponse("call_1", `{"path":"main.go"}`), + taskDoneResponseWithArguments(`{"state":"FAILED"}`), + }} + deps := newTestDeps(client) + deps.Template.MaxToolRequestTimes = 1 + deps.MainToolDefs = []llm.ToolDef{ + {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}}, + {Type: "function", Function: llm.FunctionDef{Name: "task_done"}}, + } + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + completed, stop, err := runner.RunMainTask(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunMainTask: %v", err) + } + if completed { + t.Fatal("task_done FAILED in the grace round must not complete RunMainTask") + } + if stop != StopMaxRounds { + t.Fatalf("stop = %v, want StopMaxRounds", stop) + } +} + +func TestRunMainTask_GraceRoundCompletionStates(t *testing.T) { + limits := []struct { + name string + maxToolRequests int + tokenBudget int64 + wantStop MainLoopStop + }{ + {name: "max_tool_requests", maxToolRequests: 1, wantStop: StopMaxRounds}, + {name: "token_budget", maxToolRequests: 5, tokenBudget: 500, wantStop: StopTokenBudget}, + } + cases := []struct { + name string + states []string + wantCompleted bool + }{ + {name: "done", states: []string{"DONE"}, wantCompleted: true}, + {name: "failed", states: []string{"FAILED"}}, + {name: "done_then_failed", states: []string{"DONE", "FAILED"}}, + {name: "failed_then_done", states: []string{"FAILED", "DONE"}}, + {name: "no_task_done"}, + } + for _, limit := range limits { + for _, testCase := range cases { + t.Run(limit.name+"/"+testCase.name, func(t *testing.T) { + var calls []llm.ToolCall + for _, state := range testCase.states { + calls = append(calls, llm.ToolCall{ + ID: state, Type: "function", + Function: llm.FunctionCall{Name: "task_done", Arguments: `{"state":"` + state + `"}`}, + }) + } + response := graceRoundCommentResponse() + response.Choices[0].Message.ToolCalls = append(calls, response.ToolCalls()...) + client := &fakeClient{responses: []*llm.ChatResponse{ + withUsage(fileReadToolCallResponse("read", `{"path":"main.go"}`), 600), + response, + }} + deps := newTestDeps(client) + deps.Template.MaxToolRequestTimes = limit.maxToolRequests + deps.MaxTokensBudget = limit.tokenBudget + deps.Tools.Register(&tool.CodeCommentProvider{Collector: deps.CommentCollector}) + deps.MainToolDefs = []llm.ToolDef{ + {Type: "function", Function: llm.FunctionDef{Name: "file_read"}}, + {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}}, + {Type: "function", Function: llm.FunctionDef{Name: "task_done"}}, + } + runner := NewRunner(deps) + + completed, stop, err := runner.RunMainTask(context.Background(), + []llm.Message{llm.NewTextMessage("user", "review")}, "main.go") + if err != nil { + t.Fatalf("RunMainTask: %v", err) + } + if completed != testCase.wantCompleted { + t.Errorf("completed = %v, want %v", completed, testCase.wantCompleted) + } + if stop != limit.wantStop { + t.Errorf("stop = %v, want %v", stop, limit.wantStop) + } + if client.calls != 2 { + t.Errorf("LLM calls = %d, want 2 (1 main + 1 grace)", client.calls) + } + comments := deps.CommentCollector.Comments() + if len(comments) != 1 || comments[0].Path != "main.go" || comments[0].Content != "found a bug" { + t.Errorf("comments = %+v, want the finding submitted after task_done", comments) + } + outcome := "the grace round did not complete the task" + if testCase.wantCompleted { + outcome = "the grace round completed the task" + } + warnings := graceLimitWarnings(runner) + if len(warnings) != 1 || warnings[0].File != "main.go" || + warnings[0].Message != limit.wantStop.Reason()+"; "+outcome { + t.Errorf("limit warnings = %+v, want one warning with verdict %q", warnings, outcome) + } + }) + } + } } func TestRunMainTask_GraceRoundSkippedWhenContextCancelled(t *testing.T) { @@ -1040,6 +1283,9 @@ func TestRunMainTask_ZeroTokenBudgetNeverStops(t *testing.T) { if !completed || stop != StopNone { t.Fatalf("expected completion with StopNone, got completed=%v stop=%v", completed, stop) } + if limit := graceLimitWarnings(runner); len(limit) != 0 { + t.Fatalf("completion in a normal round must not record a limit warning, got %+v", limit) + } } func withUsage(resp *llm.ChatResponse, prompt int64) *llm.ChatResponse { diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 3994896ae..adf9e95b2 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -65,7 +65,7 @@ func NewClient(ctx context.Context, name, command string, args, env []string, di } }() - toolsResult, err := session.ListTools(ctx, nil) + tools, err := listAllTools(ctx, session) if err != nil { return nil, fmt.Errorf("list tools from MCP server %q: %w", name, err) } @@ -74,7 +74,7 @@ func NewClient(ctx context.Context, name, command string, args, env []string, di return &Client{ name: name, session: session, - tools: toolsResult.Tools, + tools: tools, }, nil } @@ -121,7 +121,7 @@ func NewRemoteClient(ctx context.Context, name, url string, headers map[string]s } }() - toolsResult, err := session.ListTools(ctx, nil) + tools, err := listAllTools(ctx, session) if err != nil { return nil, fmt.Errorf("list tools from remote MCP server %q: %w", name, err) } @@ -130,10 +130,27 @@ func NewRemoteClient(ctx context.Context, name, url string, headers map[string]s return &Client{ name: name, session: session, - tools: toolsResult.Tools, + tools: tools, }, nil } +// listAllTools returns every tool the server publishes. tools/list is a +// paginated method: a result carries at most one page plus a nextCursor, and a +// client that issues a single ListTools call sees only that first page. The +// SDK's iterator follows the cursor to exhaustion, so a server that pages its +// catalog (any explicit PageSize, or more tools than the SDK's default page) +// does not silently lose the tools past the first page. +func listAllTools(ctx context.Context, session *mcp.ClientSession) ([]*mcp.Tool, error) { + var tools []*mcp.Tool + for tool, err := range session.Tools(ctx, nil) { + if err != nil { + return nil, err + } + tools = append(tools, tool) + } + return tools, nil +} + // headerTransport injects custom headers into every HTTP request and surfaces // clear authentication errors for 401/403 responses. type headerTransport struct { diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go index a5a932782..97964c204 100644 --- a/internal/mcp/client_test.go +++ b/internal/mcp/client_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "strings" "testing" @@ -376,3 +377,48 @@ func TestNewClient_Integration(t *testing.T) { } }) } + +// tools/list is paginated, so a server that pages its catalog returns one page +// plus a nextCursor per call. Reading a single page must not truncate the +// catalog: listAllTools has to follow the cursor to the last page. +func TestListAllTools_FollowsPagination(t *testing.T) { + ctx := context.Background() + + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := newPagedTestMCPServer(3).Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("connect server: %v", err) + } + defer func() { _ = serverSession.Close() }() + + client := mcp.NewClient(&mcp.Implementation{Name: "open-code-review", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("connect client: %v", err) + } + defer func() { _ = session.Close() }() + + // Pin the fixture: the server must actually paginate, otherwise this test + // would pass against a client that reads only the first page. + firstPage, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(firstPage.Tools) != 1 || firstPage.NextCursor == "" { + t.Fatalf("fixture does not paginate: %d tool(s) on the first page, nextCursor %q", + len(firstPage.Tools), firstPage.NextCursor) + } + + tools, err := listAllTools(ctx, session) + if err != nil { + t.Fatalf("listAllTools: %v", err) + } + var names []string + for _, tool := range tools { + names = append(names, tool.Name) + } + want := []string{"paged_tool_1", "paged_tool_2", "paged_tool_3"} + if !slices.Equal(names, want) { + t.Errorf("listAllTools names = %v, want %v", names, want) + } +} diff --git a/internal/mcp/stdio_test.go b/internal/mcp/stdio_test.go index 5e3aca467..e33907a2f 100644 --- a/internal/mcp/stdio_test.go +++ b/internal/mcp/stdio_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" "encoding/json" + "fmt" "log" "os" "os/signal" @@ -22,6 +23,7 @@ const ( runAsHangingServerEnv = "_OCR_MCP_TEST_HANG_SERVER" runAsExit3ServerEnv = "_OCR_MCP_TEST_EXIT3_SERVER" runAsSlowExitServerEnv = "_OCR_MCP_TEST_SLOW_EXIT_SERVER" + runAsPagedServerEnv = "_OCR_MCP_TEST_PAGED_SERVER" ) func TestMain(m *testing.M) { @@ -42,6 +44,8 @@ func TestMain(m *testing.M) { runExit3MCPServer() case os.Getenv(runAsSlowExitServerEnv) != "": runSlowExitMCPServer() + case os.Getenv(runAsPagedServerEnv) != "": + runPagedTestMCPServer() default: os.Exit(m.Run()) } @@ -79,6 +83,31 @@ func newTestMCPServer() *mcp.Server { return server } +// newPagedTestMCPServer returns a server that publishes toolCount tools one +// page at a time, so a client reading a single page sees only the first one. +func newPagedTestMCPServer(toolCount int) *mcp.Server { + server := mcp.NewServer( + &mcp.Implementation{Name: "paged-server", Version: "v0.0.1"}, + &mcp.ServerOptions{PageSize: 1}, + ) + for i := 1; i <= toolCount; i++ { + name := fmt.Sprintf("paged_tool_%d", i) + server.AddTool( + &mcp.Tool{ + Name: name, + Description: "Paged tool " + name, + InputSchema: map[string]any{"type": "object"}, + }, + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: name}}, + }, nil + }, + ) + } + return server +} + func runTestMCPServer() { if err := newTestMCPServer().Run(context.Background(), &mcp.StdioTransport{}); err != nil { log.Fatal(err) @@ -89,6 +118,13 @@ func runTestMCPServer() { os.Exit(0) } +func runPagedTestMCPServer() { + if err := newPagedTestMCPServer(3).Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } + os.Exit(0) +} + // runHangingMCPServer serves MCP normally but never exits: it ignores SIGTERM // and blocks forever once stdin closes, so only the SDK's SIGKILL escalation // can reclaim it. This is the unresponsive-server case from issue #1141. @@ -170,6 +206,35 @@ func TestNewClient_Stdio(t *testing.T) { } } +func TestNewClient_Stdio_PaginatedTools(t *testing.T) { + requireExec(t) + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + + c, err := NewClient(context.Background(), "paged-srv", exe, nil, []string{runAsPagedServerEnv + "=1"}, "", "v0.0.1") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer func() { _ = c.Close() }() + + var names []string + for _, tool := range c.Tools() { + names = append(names, tool.Name) + } + want := []string{"paged_tool_1", "paged_tool_2", "paged_tool_3"} + if len(names) != len(want) { + t.Fatalf("Tools() = %v, want all %d paginated tools %v", names, len(want), want) + } + for i, name := range want { + if names[i] != name { + t.Errorf("Tools()[%d] = %q, want %q", i, names[i], name) + } + } +} + func TestNewClient_Stdio_BadCommand(t *testing.T) { requireExec(t) diff --git a/internal/scan/agent.go b/internal/scan/agent.go index 8865bb124..6c62de862 100644 --- a/internal/scan/agent.go +++ b/internal/scan/agent.go @@ -106,8 +106,8 @@ type Agent struct { runner *llmloop.Runner resumeInfo *session.ResumeInfo scanFingerprints map[string]string - projectSummary string // populated post-run by maybeRunProjectSummary - budgetExceeded bool // set when the token budget gate stopped dispatch; written only by dispatchBatch's loop + projectSummary string // populated post-run by maybeRunProjectSummary + budgetExceeded atomic.Bool // set when the aggregate token budget stops dispatch or an in-flight task } // ProjectSummary returns the markdown project-level summary produced after @@ -235,7 +235,7 @@ func (a *Agent) ToolFailures() []llmloop.ToolFailureDetail { return a.runner.Too // its partial comments and a nil error, and the value reaches output solely as // summary.budget_exceeded. Per-file MaxToolRequestTimes exhaustion does NOT set // it — that is an item-level outcome, not a run-level budget stop. -func (a *Agent) BudgetExceeded() bool { return a.budgetExceeded } +func (a *Agent) BudgetExceeded() bool { return a.budgetExceeded.Load() } func (a *Agent) recordWarning(warningType, file, message string) { a.runner.RecordWarning(warningType, file, message) @@ -565,7 +565,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error batches := groupBatches(a.items, strategy, a.args.Template.BatchSize) fmt.Fprintf(stdout.Writer(), "[ocr] scan dispatch: %d batch(es) by %s strategy\n", len(batches), strategy) - var dispatched int64 + var dispatched, reused int64 for bi, batch := range batches { if err := ctx.Err(); err != nil { return a.args.CommentCollector.Comments(), err @@ -576,6 +576,11 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error n, budgetHit, checkpoints, err := a.dispatchBatch(ctx, bi, batch) dispatched += n + for _, checkpoint := range checkpoints { + if checkpoint.reused { + reused++ + } + } if err != nil { // ctx cancelled mid-batch: stop scheduling further batches but // still return whatever we've collected so far. @@ -604,8 +609,8 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error } failed := atomic.LoadInt64(&a.subtaskFailed) - if failed > 0 && failed == dispatched { - return nil, fmt.Errorf("all %d file scan(s) failed — check your LLM configuration and API key", dispatched) + if failed > 0 && failed == dispatched && reused == 0 { + return a.args.CommentCollector.Comments(), fmt.Errorf("all %d file scan(s) failed — check your LLM configuration and API key", dispatched) } return a.args.CommentCollector.Comments(), nil } @@ -724,7 +729,7 @@ func (a *Agent) dispatchBatch(ctx context.Context, batchIdx int, batch []model.S budgetHit = true // budgetHit is per-batch and dies with this call; the field is // the run-level signal emitRunResult reads after Run returns. - a.budgetExceeded = true + a.budgetExceeded.Store(true) break } } @@ -819,7 +824,7 @@ func (a *Agent) executeSubtask(ctx context.Context, it model.ScanItem) (bool, st telemetry.AnyToAttr("file.path", it.Path), telemetry.AnyToAttr("tokens", tokenCount), telemetry.AnyToAttr("max_tokens", maxAllowed)) - return false, "", nil + return false, msg, nil } completed, stop, err := a.runner.RunMainTask(ctx, messages, it.Path) @@ -827,6 +832,15 @@ func (a *Agent) executeSubtask(ctx context.Context, it model.ScanItem) (bool, st return false, "", err } if !completed { + // Mirror the review path: an in-flight token-budget stop must surface + // on the run-level BudgetExceeded() / token_budget_reached warning, + // not only on the per-item failure record. Without this, --format json + // reports budget_exceeded: false after llmloop's StopTokenBudget fired + // and the stderr "budget_exceeded=..." line lies too. + if stop == llmloop.StopTokenBudget && a.budgetExceeded.CompareAndSwap(false, true) { + a.recordWarning("token_budget_reached", it.Path, + fmt.Sprintf("stopped mid-task: used %d tokens exceeds budget %d", a.runner.TotalTokensUsed(), a.args.MaxTokensBudget)) + } // Scan sessions opt out of the run manifest, so this one string is the // whole diagnostic: it feeds both RecordReviewItemFailed and the // scan_subtask_error warning, and under --format json the [ocr] progress diff --git a/internal/scan/coverage_test.go b/internal/scan/coverage_test.go index 0bcc6f91f..3c4c279d9 100644 --- a/internal/scan/coverage_test.go +++ b/internal/scan/coverage_test.go @@ -863,17 +863,310 @@ func TestDispatchSubtasks_WithoutTaskDoneIsAllFailed(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "all 1 file scan(s) failed") { t.Fatalf("expected all-failed error, got %v", err) } - warnings := a.Warnings() - if len(warnings) != 1 || warnings[0].Type != "scan_subtask_error" || - !strings.Contains(warnings[0].Message, "main_task did not complete") { - t.Fatalf("warnings = %+v, want one incomplete scan subtask error", warnings) + // The runner also records the spent round budget, with its incomplete + // verdict; the scan_subtask_error is the failure diagnostic asserted here. + var subtaskErrors []llmloop.AgentWarning + for _, w := range a.Warnings() { + if w.Type == "scan_subtask_error" { + subtaskErrors = append(subtaskErrors, w) + } + } + if len(subtaskErrors) != 1 || !strings.Contains(subtaskErrors[0].Message, "main_task did not complete") { + t.Fatalf("subtask errors = %+v, want one incomplete scan subtask error", subtaskErrors) } // Scan opts out of the run manifest and --format json discards the progress // lines, so this warning is the whole diagnostic: it must name the trigger // (here the exhausted MaxToolRequestTimes budget), not just report that the // task did not finish. - if want := llmloop.StopMaxRounds.Reason(); !strings.Contains(warnings[0].Message, want) { - t.Errorf("warning %q does not name the stop trigger %q", warnings[0].Message, want) + if want := llmloop.StopMaxRounds.Reason(); !strings.Contains(subtaskErrors[0].Message, want) { + t.Errorf("warning %q does not name the stop trigger %q", subtaskErrors[0].Message, want) + } +} + +// A file whose tool budget runs out mid-review but which the model then marks +// done in the grace round must be checkpointed as completed. Treating it as +// failed both misreports the scan and makes the next resume re-run a file that +// was already finished. +func TestDispatchSubtasks_GraceRoundTaskDoneCheckpointed(t *testing.T) { + testHome := t.TempDir() + t.Setenv("HOME", testHome) + t.Setenv("USERPROFILE", testHome) + repoDir := t.TempDir() + items := []model.ScanItem{{Path: "a.go", Content: "package a\nfunc f() {}\n", LineCount: 2}} + + tpl := makeTemplateWithFullScan() + tpl.MaxTokens = 100000 + tpl.MaxToolRequestTimes = 1 + + collector := tool.NewCommentCollector() + registry := tool.NewRegistry() + registry.Register(&tool.CodeCommentProvider{Collector: collector}) + registry.Freeze() + + // Round 1 spends the only tool-request round on a finding; the grace round + // then declares the file done. + client := &fakeScanClient{responses: []*llm.ChatResponse{ + scanCodeCommentResponse("found something"), + scanTaskDoneResponse(), + }} + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + Tools: registry, + MainToolDefs: []llm.ToolDef{ + {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}}, + {Type: "function", Function: llm.FunctionDef{Name: "task_done"}}, + }, + CommentCollector: collector, + MaxConcurrency: 1, + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + RepoDir: repoDir, + Session: session.New(repoDir, "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + a.items = items + a.currentDate = "2026-06-26" + + comments, err := a.dispatchSubtasks(context.Background()) + if err != nil { + t.Fatalf("dispatchSubtasks: %v", err) + } + if len(comments) != 1 || comments[0].Content != "found something" { + t.Fatalf("comments = %+v, want the round 1 finding", comments) + } + var limitWarnings []llmloop.AgentWarning + for _, w := range a.Warnings() { + if w.Type == "scan_subtask_error" { + t.Fatalf("a file completed in the grace round must not warn as failed: %+v", w) + } + if w.Type == llmloop.WarningReviewLimitReached { + limitWarnings = append(limitWarnings, w) + } + } + if len(limitWarnings) != 1 || limitWarnings[0].File != items[0].Path || + !strings.Contains(limitWarnings[0].Message, "the grace round completed the task") { + t.Fatalf("limit warnings = %+v, want one completed verdict on %s", limitWarnings, items[0].Path) + } + if err := a.Session().Finalize(); err != nil { + t.Fatalf("finalize session: %v", err) + } + state, err := session.LoadResumeState(repoDir, a.SessionID()) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if _, ok := state.Item(scanItemFingerprint(items[0])); !ok { + t.Fatal("a grace-round completion must persist a checkpoint that resume can reuse") + } +} + +// The same completion, but with zero findings and nothing after the file, is +// the case where the limit would otherwise vanish entirely: no scan_subtask_error +// fires (the file finished) and no later work can warn again. Both declared +// limits must leave exactly one visible warning while the file stays completed +// and reusable for resume. +func TestDispatchSubtasks_GraceRoundLimitVisibleWithZeroFindings(t *testing.T) { + const tokenBudget = 30_000 + cases := []struct { + name string + maxToolRequests int + tokenBudget int64 + roundOneUsage int64 + }{ + {name: "max_tool_requests", maxToolRequests: 1}, + {name: "token_budget", maxToolRequests: 5, tokenBudget: tokenBudget, roundOneUsage: tokenBudget + 10_000}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testHome := t.TempDir() + t.Setenv("HOME", testHome) + t.Setenv("USERPROFILE", testHome) + repoDir := t.TempDir() + items := []model.ScanItem{{Path: "a.go", Content: "package a\nfunc f() {}\n", LineCount: 2}} + + tpl := makeTemplateWithFullScan() + tpl.MaxTokens = 100000 + tpl.MaxToolRequestTimes = tc.maxToolRequests + + collector := tool.NewCommentCollector() + registry := tool.NewRegistry() + registry.Register(&tool.CodeCommentProvider{Collector: collector}) + registry.Register(tool.NewFileReadDiff(tool.DiffMap{})) + registry.Freeze() + + // Round 1 spends the limit on a read and reports no finding; the + // grace round then declares the file done. + client := &fakeScanClient{responses: []*llm.ChatResponse{ + { + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{ToolCalls: []llm.ToolCall{{ + ID: "read", Type: "function", + Function: llm.FunctionCall{Name: "file_read_diff", Arguments: `{"path_array":["a.go"]}`}, + }}}, + }}, + Usage: &llm.UsageInfo{PromptTokens: tc.roundOneUsage}, + }, + scanTaskDoneResponse(), + }} + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + Tools: registry, + MainToolDefs: []llm.ToolDef{ + {Type: "function", Function: llm.FunctionDef{Name: "file_read_diff"}}, + {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}}, + {Type: "function", Function: llm.FunctionDef{Name: "task_done"}}, + }, + CommentCollector: collector, + MaxTokensBudget: tc.tokenBudget, + MaxConcurrency: 1, + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + RepoDir: repoDir, + Session: session.New(repoDir, "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + a.items = items + a.currentDate = "2026-06-26" + a.injectScanContentMap() + + comments, err := a.dispatchSubtasks(context.Background()) + if err != nil { + t.Fatalf("dispatchSubtasks: %v", err) + } + if len(comments) != 0 { + t.Fatalf("comments = %+v, want none", comments) + } + if client.calls != 2 { + t.Fatalf("LLM calls = %d, want 2 (1 round + 1 grace)", client.calls) + } + // The file completed, so the run-level budget flag stays false — + // the warning must not redefine budget_exceeded. + if a.BudgetExceeded() { + t.Fatal("a limit the grace round completed must not set BudgetExceeded") + } + var limitWarnings []llmloop.AgentWarning + for _, w := range a.Warnings() { + if w.Type == "scan_subtask_error" { + t.Fatalf("a file completed in the grace round must not warn as failed: %+v", w) + } + if w.Type == llmloop.WarningReviewLimitReached { + limitWarnings = append(limitWarnings, w) + } + } + if len(limitWarnings) != 1 || limitWarnings[0].File != items[0].Path || + !strings.Contains(limitWarnings[0].Message, "the grace round completed the task") { + t.Fatalf("limit warnings = %+v, want one completed verdict on %s", limitWarnings, items[0].Path) + } + if err := a.Session().Finalize(); err != nil { + t.Fatalf("finalize session: %v", err) + } + state, err := session.LoadResumeState(repoDir, a.SessionID()) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if _, ok := state.Item(scanItemFingerprint(items[0])); !ok { + t.Fatal("the completed checkpoint must stay reusable for resume") + } + }) + } +} + +func TestDispatchSubtasks_GraceRoundMixedTaskDoneNotCheckpointed(t *testing.T) { + limits := []struct { + name string + maxToolRequests int + tokenBudget int64 + }{ + {name: "max_tool_requests", maxToolRequests: 1}, + {name: "token_budget", maxToolRequests: 5, tokenBudget: 30_000}, + } + for _, limit := range limits { + for _, states := range [][]string{{"DONE", "FAILED"}, {"FAILED", "DONE"}} { + t.Run(limit.name+"/"+strings.Join(states, "_then_"), func(t *testing.T) { + testHome := t.TempDir() + t.Setenv("HOME", testHome) + t.Setenv("USERPROFILE", testHome) + repoDir := t.TempDir() + item := model.ScanItem{Path: "a.go", Content: "package a\nfunc f() {}\n", LineCount: 2} + tpl := makeTemplateWithFullScan() + tpl.MaxTokens = 100000 + tpl.MaxToolRequestTimes = limit.maxToolRequests + + collector := tool.NewCommentCollector() + registry := tool.NewRegistry() + registry.Register(&tool.CodeCommentProvider{Collector: collector}) + registry.Register(tool.NewFileReadDiff(tool.DiffMap{})) + registry.Freeze() + + var calls []llm.ToolCall + for _, state := range states { + calls = append(calls, llm.ToolCall{ + ID: state, Type: "function", + Function: llm.FunctionCall{Name: "task_done", Arguments: `{"state":"` + state + `"}`}, + }) + } + client := &fakeScanClient{responses: []*llm.ChatResponse{ + { + Choices: []llm.Choice{{Message: llm.ResponseMessage{ToolCalls: []llm.ToolCall{{ + ID: "read", Type: "function", + Function: llm.FunctionCall{Name: "file_read_diff", Arguments: `{"path_array":["a.go"]}`}, + }}}}}, + Usage: &llm.UsageInfo{PromptTokens: 40_000}, + }, + {Choices: []llm.Choice{{Message: llm.ResponseMessage{ToolCalls: calls}}}}, + }} + agent := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + Tools: registry, + MainToolDefs: []llm.ToolDef{ + {Type: "function", Function: llm.FunctionDef{Name: "file_read_diff"}}, + {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}}, + {Type: "function", Function: llm.FunctionDef{Name: "task_done"}}, + }, + CommentCollector: collector, + MaxTokensBudget: limit.tokenBudget, + MaxConcurrency: 1, + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + RepoDir: repoDir, + Session: session.New(repoDir, "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + agent.items = []model.ScanItem{item} + agent.currentDate = "2026-06-26" + agent.injectScanContentMap() + + if _, err := agent.dispatchSubtasks(context.Background()); err == nil || !strings.Contains(err.Error(), "all 1 file scan(s) failed") { + t.Errorf("dispatchSubtasks error = %v, want the single file to fail", err) + } + if client.calls != 2 { + t.Errorf("LLM calls = %d, want 2 (1 main + 1 grace)", client.calls) + } + if err := agent.Session().Finalize(); err != nil { + t.Fatalf("finalize session: %v", err) + } + resumeState, err := session.LoadResumeState(repoDir, agent.SessionID()) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if _, ok := resumeState.Item(scanItemFingerprint(item)); ok { + t.Error("a failed grace round must not leave a reusable completion checkpoint") + } + }) + } } } diff --git a/internal/scan/prompt_budget_test.go b/internal/scan/prompt_budget_test.go new file mode 100644 index 000000000..e98a3b465 --- /dev/null +++ b/internal/scan/prompt_budget_test.go @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +package scan + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/alibaba/open-code-review/internal/config/template" + "github.com/alibaba/open-code-review/internal/llm" + "github.com/alibaba/open-code-review/internal/llmloop" + "github.com/alibaba/open-code-review/internal/model" + "github.com/alibaba/open-code-review/internal/session" +) + +func TestRun_PromptOverflowRecordsFailure(t *testing.T) { + for _, tc := range []struct { + name string + systemWords int + content string + withSmallFile bool + reuseSmall bool + withFinding bool + batchSize int + }{ + {name: "all files overflow", systemWords: 100, content: "package main\n"}, + {name: "one file completes", systemWords: 20, content: "package main\n// " + strings.Repeat("test", 70) + "\n", withSmallFile: true}, + {name: "one file reused with finding", systemWords: 100, content: "package main\n", withSmallFile: true, reuseSmall: true, withFinding: true}, + {name: "one file reused without findings", systemWords: 100, content: "package main\n", withSmallFile: true, reuseSmall: true}, + {name: "reused finding in later batch", systemWords: 100, content: "package main\n", withSmallFile: true, reuseSmall: true, withFinding: true, batchSize: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := initTestRepo(t) + writeFile(t, repo, "overflow.go", []byte(tc.content)) + if tc.withSmallFile { + writeFile(t, repo, "small.go", []byte("package main\n")) + } + gitCommit(t, repo, "init") + tpl := budgetTestTemplate() + tpl.MaxTokens = 100 + if tc.batchSize > 0 { + tpl.BatchStrategy = string(BatchByLanguage) + tpl.BatchSize = tc.batchSize + } + tpl.MainTask.Messages = []template.ChatMessage{ + {Role: "system", Content: strings.Repeat("review ", tc.systemWords)}, + {Role: "user", Content: "review {{file_content}}"}, + } + client := &fakeBudgetClient{} + var resume *session.ResumeState + if tc.reuseSmall { + item := model.ScanItem{Path: "small.go", Content: "package main\n", LineCount: 1} + fingerprint := scanItemFingerprint(item) + var comments []model.LlmComment + if tc.withFinding { + comments = []model.LlmComment{{Path: item.Path, Content: "saved finding", StartLine: 1, EndLine: 1}} + } + resume = &session.ResumeState{ + SessionID: "previous-scan", + Items: map[string]session.ResumeItem{fingerprint: { + FilePath: item.Path, OldPath: item.Path, NewPath: item.Path, + Fingerprint: fingerprint, Comments: comments, + }}, + } + } + a := NewAgent(Args{ + RepoDir: repo, + Template: tpl, + LLMClient: client, + MaxConcurrency: 1, + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + Resume: resume, + Session: session.New(repo, "main", "test", session.SessionOptions{ReviewMode: session.ReviewModeFullScan}), + }) + comments, err := a.Run(context.Background()) + wantCalls := int64(0) + wantItems := 0 + if tc.withSmallFile { + wantItems = 1 + if !tc.reuseSmall { + wantCalls = 1 + } + if err != nil { + t.Fatalf("mixed scan: %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), "all 1 file scan(s) failed") { + t.Fatalf("expected all-files-failed error, got %v", err) + } + if calls := atomic.LoadInt64(&client.calls); calls != wantCalls { + t.Fatalf("MAIN_TASK calls = %d, want %d", calls, wantCalls) + } + if tc.withFinding && (len(comments) != 1 || comments[0].Content != "saved finding" || comments[0].Path != "small.go") { + t.Fatalf("reused finding lost: %+v", comments) + } + if !tc.withFinding && len(comments) != 0 { + t.Fatalf("unexpected findings: %+v", comments) + } + var foundWarning bool + for _, warning := range a.Warnings() { + if warning.Type == "scan_subtask_error" && warning.File == "overflow.go" && strings.Contains(warning.Message, "prompt tokens") { + foundWarning = true + } + } + if !foundWarning { + t.Fatalf("missing prompt-overflow failure warning: %+v", a.Warnings()) + } + state, err := session.LoadResumeState(repo, a.SessionID()) + if err != nil { + t.Fatal(err) + } + if len(state.Items) != wantItems { + t.Fatalf("reusable items = %d, want %d", len(state.Items), wantItems) + } + for _, item := range state.Items { + if item.FilePath != "small.go" { + t.Errorf("incomplete file was marked reusable: %s", item.FilePath) + } + if tc.withFinding && (len(item.Comments) != 1 || item.Comments[0].Content != "saved finding") { + t.Fatalf("reused checkpoint lost finding: %+v", item) + } + } + path, err := session.SessionFilePath(repo, a.SessionID()) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var foundFailure bool + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + var record struct { + Type string `json:"type"` + FilePath string `json:"filePath"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatal(err) + } + if record.Type == "review_item_failed" && record.FilePath == "overflow.go" && strings.Contains(record.Error, "prompt tokens") { + foundFailure = true + } + } + if !foundFailure { + t.Fatal("session omitted the prompt-overflow failure record") + } + }) + } +} + +// TestRun_TokenBudgetStopSurfacesAsBudgetExceeded pins the gap the dispatch +// look-ahead cannot close: a single scan item admitted while usage is still +// low can outgrow the budget on its own, and only an in-conversation check +// (llmloop.RunMainTask's per-round check) can stop it. The resulting +// StopTokenBudget must surface on the agent's BudgetExceeded() flag and +// emit a token_budget_reached warning so --format json and the stderr +// usage line agree with the run-level state. +// +// Without this propagation, --format json reports budget_exceeded: false +// after a token-budget stop fired and the stderr "budget_exceeded=..." +// line lies too, masking the partial stop from CI consumers. +// +// The fake client returns no tool calls, so the main loop retries rather +// than completing. Its first response consumes 26K tokens, exceeding the +// 25K budget; the next round's pre-flight check trips StopTokenBudget. +// The dispatch look-ahead admits the file because its estimated total +// cost is about 19K tokens, below the 25K budget. +func TestRun_TokenBudgetStopSurfacesAsBudgetExceeded(t *testing.T) { + perCall := int64(26_000) + budget := int64(25_000) // > per-file estimate (~19K) so the look-ahead admits the file + client := &emptyRetryClient{perCallTokens: perCall} + + repo := initTestRepo(t) + writeFile(t, repo, "single.go", []byte("package main\n")) + gitCommit(t, repo, "init") + + tpl := budgetTestTemplate() + tpl.MaxTokens = 100_000 + tpl.MaxToolRequestTimes = 3 + tpl.MainTask = template.LlmConversation{ + Messages: []template.ChatMessage{ + {Role: "user", Content: "review {{file_content}}"}, + }, + } + + a := NewAgent(Args{ + RepoDir: repo, + Template: tpl, + LLMClient: client, + MaxConcurrency: 1, + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + MaxTokensBudget: budget, + Session: session.New(repo, "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + _, err := a.Run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "all 1 file scan(s) failed") { + t.Fatalf("expected all-files-failed error, got %v", err) + } + if !a.BudgetExceeded() { + t.Error("BudgetExceeded() = false, want true after an in-flight token-budget stop") + } + var budgetWarnings int + for _, w := range a.Warnings() { + if w.Type == "token_budget_reached" { + budgetWarnings++ + } + } + if budgetWarnings == 0 { + t.Errorf("expected a token_budget_reached warning; got %+v", a.Warnings()) + } + // The per-item scan_subtask_error warning also names the same stop + // trigger via stop.Reason(); llmloop's per-round check tripped + // StopTokenBudget, so that string is the diagnostic that survives + // --format json's discarded [ocr] progress lines. + var sawStopReason bool + for _, w := range a.Warnings() { + if w.Type == "scan_subtask_error" && strings.Contains(w.Message, llmloop.StopTokenBudget.Reason()) { + sawStopReason = true + } + } + if !sawStopReason { + t.Errorf("scan_subtask_error warning does not name %q: %+v", llmloop.StopTokenBudget.Reason(), a.Warnings()) + } +} + +// emptyRetryClient answers every request with an empty assistant content and +// no tool calls, so llmloop.RunMainTask takes the no-tool-calls retry branch +// instead of completing on task_done. Used to drive the per-round +// token-budget stop on a scan path that otherwise completes in one round. +type emptyRetryClient struct { + perCallTokens int64 + calls int64 // atomic +} + +func (f *emptyRetryClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) { + atomic.AddInt64(&f.calls, 1) + empty := "" + return &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{Role: "assistant", Content: &empty}, + }}, + Model: "fake", + Usage: &llm.UsageInfo{ + PromptTokens: f.perCallTokens, + CompletionTokens: 0, + TotalTokens: f.perCallTokens, + }, + }, nil +} diff --git a/internal/scan/provider.go b/internal/scan/provider.go index a0471ed3b..4dc0f69ec 100644 --- a/internal/scan/provider.go +++ b/internal/scan/provider.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -53,6 +54,7 @@ type Provider struct { // back to DefaultMaxFileSizeBytes. func NewProvider(repoDir string, paths []string, runner *gitcmd.Runner, maxFileSizeBytes int64) *Provider { cleaned := make([]string, 0, len(paths)) + rootSelected := false for _, p := range paths { p = strings.TrimSpace(p) if p == "" { @@ -60,9 +62,27 @@ func NewProvider(repoDir string, paths []string, runner *gitcmd.Runner, maxFileS } // Normalize: strip leading "./" and trailing "/" so prefix matching // against `git ls-files` output (which never has leading "./") works. - p = strings.TrimPrefix(p, "./") + // ToSlash runs first: on Windows `.\dir` only becomes `./dir` here, and + // trimming the prefix before the conversion leaves it in place to match + // nothing. + p = filepath.ToSlash(p) + for strings.HasPrefix(p, "./") { + p = strings.TrimPrefix(p, "./") + } p = strings.TrimSuffix(p, "/") - cleaned = append(cleaned, filepath.ToSlash(p)) + // "." and "./" name the repository root, which is what an omitted --path + // already means. Kept as a selector they match nothing, because + // `git ls-files` prints `main.go` and never `./main.go`. + if p == "" || p == "." { + rootSelected = true + continue + } + cleaned = append(cleaned, p) + } + // A root selector widens the scan to the whole repository, so any narrower + // selector alongside it is already covered. + if rootSelected { + cleaned = nil } if maxFileSizeBytes <= 0 { maxFileSizeBytes = DefaultMaxFileSizeBytes @@ -84,7 +104,10 @@ func (p *Provider) Enumerate(ctx context.Context) ([]model.ScanItem, error) { } if len(p.paths) > 0 { - files = filterByPaths(files, p.paths) + files, err = p.filterByPaths(files) + if err != nil { + return nil, err + } } gitignorePatterns := diff.LoadGitignorePatterns(p.repoDir) @@ -274,19 +297,137 @@ func (p *Provider) gitLs(ctx context.Context, args ...string) ([]string, error) return files, nil } -// filterByPaths keeps only entries whose path equals a user-supplied path -// (for exact files) or lies under it (for directories). -func filterByPaths(all []string, paths []string) []string { - var out []string - for _, f := range all { - for _, want := range paths { - if f == want || strings.HasPrefix(f, want+"/") { - out = append(out, f) - break +// filterByPaths keeps only entries named by a --path selector: the file +// itself, or everything under it for a directory. +// +// The selector's own spelling is tried first, so on a case-sensitive +// filesystem `--path src` names `src/` alone even when a sibling `SRC/` +// exists. A selector that names nothing in its own spelling is then resolved +// against the working tree, which decides what the spelling names: symlinks +// are followed and, on a case-insensitive filesystem, the answer carries the +// on-disk case, so `--path SRC` names the tracked `src/` on Windows and +// macOS. Only the resolved path is matched, and it is matched exactly, so a +// selector can never widen to a case-variant sibling that the filesystem did +// not name -- an empty `src/docs/` selects nothing even while `src/DOCS/` +// holds tracked files. +// +// A selector that resolves but matches no enumerated entry (an ignored path, +// say) stays a clean, empty selection. One the working tree does not resolve +// is an error -- `--path` names a path, not a search pattern, so a mistyped or +// wrongly-cased selector must not pass for a clean scan of zero files. +func (p *Provider) filterByPaths(all []string) ([]string, error) { + keep := make([]bool, len(all)) + for _, want := range p.paths { + exact := false + for i, f := range all { + if pathMatches(f, want) { + keep[i] = true + exact = true } } + if exact { + continue + } + resolved, err := p.resolveSelector(want) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("--path %q does not exist in the working tree", want) + } + return nil, fmt.Errorf("--path %q: %w", want, err) + } + for i, f := range all { + if pathMatches(f, resolved) { + keep[i] = true + } + } + } + var out []string + for i, f := range all { + if keep[i] { + out = append(out, f) + } + } + return out, nil +} + +// resolveSelector returns the repo-relative path the working tree holds at +// the selector's spelling, or an error wrapping fs.ErrNotExist when nothing +// is there. The filesystem decides what resolves: symlinks are followed, and +// a case-insensitive filesystem answers with the on-disk spelling, so `SRC` +// names `src/` on Windows and macOS while a case-sensitive filesystem keeps +// the two apart. The result feeds exact matching, never folding, so a +// resolved selector cannot also drag in a case-variant sibling. +func (p *Provider) resolveSelector(want string) (string, error) { + root, err := filepath.EvalSymlinks(p.repoDir) + if err != nil { + return "", err + } + real, err := filepath.EvalSymlinks(filepath.Join(root, filepath.FromSlash(want))) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, real) + if err != nil { + return "", err + } + rel = filepath.ToSlash(rel) + // A selector can leave the repository through `..` or an absolute path; + // nothing tracked can match, and the clean skip stays. + if rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { + return rel, nil + } + // EvalSymlinks normalises case on Windows but not on macOS, where the + // on-disk spelling of every component still has to come from the + // directory listing. + resolved := "" + for _, part := range strings.Split(rel, "/") { + name, err := onDiskName(filepath.Join(root, filepath.FromSlash(resolved)), part) + if err != nil { + return "", err + } + if resolved == "" { + resolved = name + continue + } + resolved += "/" + name } - return out + return resolved, nil +} + +// onDiskName returns the name of the directory entry under dir that the +// filesystem resolves as name, preferring an entry spelled exactly. The +// listing is consulted because on macOS EvalSymlinks keeps the caller's case +// even where the filesystem matched a differently-cased entry. +func onDiskName(dir, name string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + for _, e := range entries { + if e.Name() == name { + return name, nil + } + } + info, err := os.Lstat(filepath.Join(dir, name)) + if err != nil { + return "", err + } + for _, e := range entries { + ei, err := e.Info() + if err != nil { + continue + } + if os.SameFile(ei, info) { + return e.Name(), nil + } + } + return "", fmt.Errorf("%s: no directory entry resolves %q", dir, name) +} + +// pathMatches reports whether f is want itself or lies under it as a +// directory, so `src` also matches `src/loader.go` but never `srcx.go`. +func pathMatches(f, want string) bool { + return f == want || strings.HasPrefix(f, want+"/") } // countLines returns the number of lines in content. A file without a diff --git a/internal/scan/provider_paths_test.go b/internal/scan/provider_paths_test.go index 681f4dc1d..39e695966 100644 --- a/internal/scan/provider_paths_test.go +++ b/internal/scan/provider_paths_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "runtime" "sort" + "strings" "testing" "github.com/alibaba/open-code-review/internal/gitcmd" @@ -120,3 +121,342 @@ func requireWhitespaceFilenames(t *testing.T) { t.Skip("windows normalises away the filenames these fixtures depend on") } } + +// enumeratePaths returns the sorted paths NewProvider selects for the given +// --path selectors, so a case reads as selector in, file list out. +func enumeratePaths(t *testing.T, repo string, selectors []string) []string { + t.Helper() + got, err := enumeratePathsErr(t, repo, selectors) + if err != nil { + t.Fatalf("Enumerate(%q): %v", selectors, err) + } + return got +} + +// enumeratePathsErr is enumeratePaths for selectors expected to fail, such as +// a path that names nothing in the working tree. +func enumeratePathsErr(t *testing.T, repo string, selectors []string) ([]string, error) { + t.Helper() + got, err := NewProvider(repo, selectors, gitcmd.New(2), 0).Enumerate(context.Background()) + paths := make([]string, 0, len(got)) + for _, it := range got { + paths = append(paths, it.Path) + } + sort.Strings(paths) + return paths, err +} + +// fsIsCaseInsensitive reports whether the filesystem holding dir folds case, +// by asking it: one probe name spelled two ways must resolve to one file. +// Windows and default macOS answer true; a case-sensitive filesystem answers +// false. Tests that depend on either rule skip on the other side rather than +// assume an OS, because a case-sensitive macOS volume or a case-sensitive +// Windows directory would silently invert them. +func fsIsCaseInsensitive(t *testing.T, dir string) bool { + t.Helper() + probe := filepath.Join(dir, "case-probe") + if err := os.WriteFile(probe, nil, 0o644); err != nil { + t.Fatalf("write case probe: %v", err) + } + // Removed with the helper, before a test can enumerate: a probe left in + // the repository would be listed by git ls-files and fail the selection + // expectations it exists to guard. + defer func() { _ = os.Remove(probe) }() + _, err := os.Stat(filepath.Join(dir, "CASE-PROBE")) + return err == nil +} + +func pathSelectorRepo(t *testing.T) string { + t.Helper() + repo := initTestRepo(t) + writeFile(t, repo, "main.go", []byte("package main\n")) + writeFile(t, repo, filepath.Join("internal", "scan", "provider.go"), []byte("package scan\n")) + gitCommit(t, repo, "init") + return repo +} + +// TestProvider_Enumerate_RootSelectorScansRepository pins the root selector. +// +// `git ls-files` prints repository-relative names such as `main.go`, never +// `./main.go`, and filterByPaths matches an exact path or a `/` +// prefix. A selector left as "." therefore matches nothing, so `ocr scan +// --path .` reported an empty selection and exited 0 while the same repository +// scanned fine with --path omitted. The failure is silent, which is what makes +// it costly: a review that covered no files still looks like a clean run. +func TestProvider_Enumerate_RootSelectorScansRepository(t *testing.T) { + repo := pathSelectorRepo(t) + want := enumeratePaths(t, repo, nil) + if len(want) == 0 { + t.Fatal("baseline scan enumerated no files") + } + + for _, selector := range []string{".", "./", " . ", "././"} { + got := enumeratePaths(t, repo, []string{selector}) + if len(got) != len(want) { + t.Errorf("--path %q enumerated %q, want the full scan %q", selector, got, want) + continue + } + for i := range want { + if got[i] != want[i] { + t.Errorf("--path %q file %d = %q, want %q", selector, i, got[i], want[i]) + } + } + } +} + +// TestProvider_Enumerate_RootSelectorWidensANarrowerOne pins the union rule: a +// root selector covers the whole repository, so a subdirectory named beside it +// must not narrow the scan back down. +func TestProvider_Enumerate_RootSelectorWidensANarrowerOne(t *testing.T) { + repo := pathSelectorRepo(t) + want := enumeratePaths(t, repo, nil) + + got := enumeratePaths(t, repo, []string{"internal/scan", "."}) + if len(got) != len(want) { + t.Fatalf("enumerated %q, want the full scan %q", got, want) + } +} + +// TestProvider_Enumerate_DotSlashPrefixSelectsSubdirectory pins the normalization +// order. filepath.ToSlash has to run before the "./" trim, or a selector that +// only becomes "./dir" during conversion keeps its prefix and matches nothing. +// This case exercises the order on any platform; the backslash spelling itself +// is covered below, on Windows only. +func TestProvider_Enumerate_DotSlashPrefixSelectsSubdirectory(t *testing.T) { + repo := pathSelectorRepo(t) + + for _, selector := range []string{"./internal/scan", "internal/scan/", "./internal/scan/"} { + got := enumeratePaths(t, repo, []string{selector}) + want := []string{filepath.ToSlash(filepath.Join("internal", "scan", "provider.go"))} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("--path %q enumerated %q, want %q", selector, got, want) + } + } +} + +// TestProvider_Enumerate_WindowsBackslashSelector pins the Windows spelling. +// filepath.ToSlash only rewrites separators on Windows, and a backslash is a +// legal filename byte on POSIX, so this case cannot run anywhere else. +func TestProvider_Enumerate_WindowsBackslashSelector(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("filepath.ToSlash only rewrites separators on Windows") + } + repo := pathSelectorRepo(t) + + got := enumeratePaths(t, repo, []string{`.\internal\scan`}) + want := []string{"internal/scan/provider.go"} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf(`--path '.\internal\scan' enumerated %q, want %q`, got, want) + } +} + +// TestProvider_Enumerate_UnknownPathIsAnError pins the difference between an +// empty selection and a mistyped selector. `--path` names a path, not a search +// pattern, so a selector the filesystem cannot resolve must fail loudly: the +// old silent "No files changed." exit 0 made a review that covered nothing +// look like a clean run. +func TestProvider_Enumerate_UnknownPathIsAnError(t *testing.T) { + repo := pathSelectorRepo(t) + + // `internal/sc` is merely a prefix of `internal/scan/provider.go`, and + // `internal/scan/provider` lacks the extension; neither names a path. + for _, selector := range []string{"does/not/exist", "internal/sc", "internal/scan/provider"} { + got, err := enumeratePathsErr(t, repo, []string{selector}) + if err == nil { + t.Errorf("--path %q enumerated %q, want an error for a path that names nothing", selector, got) + continue + } + if !strings.Contains(err.Error(), selector) { + t.Errorf("--path %q error %q should name the selector", selector, err) + } + if !strings.Contains(err.Error(), "does not exist in the working tree") { + t.Errorf("--path %q error %q should say the path does not exist", selector, err) + } + } + + // A path that exists but has nothing reviewable stays a clean skip: an + // empty directory holds no entries, and git tracks files, not directories. + if err := os.MkdirAll(filepath.Join(repo, "empty"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if got, err := enumeratePathsErr(t, repo, []string{"empty"}); err != nil || len(got) != 0 { + t.Errorf("--path empty over an existing empty directory = %q, %v; want no files and no error", got, err) + } +} + +// TestProvider_Enumerate_CaseVariantSelectorResolvesOnCaseInsensitiveFS is +// the Windows/macOS repair: `--path SRC` names `src/` because the filesystem +// resolves the spelling, so the scan selects the tracked spelling instead of +// reporting "No files changed.". +func TestProvider_Enumerate_CaseVariantSelectorResolvesOnCaseInsensitiveFS(t *testing.T) { + repo := initTestRepo(t) + if !fsIsCaseInsensitive(t, repo) { + t.Skip("case-variant spellings name different paths on this filesystem") + } + writeFile(t, repo, "main.go", []byte("package main\n")) + writeFile(t, repo, filepath.Join("SRC", "Keep.go"), []byte("package keep\n")) + gitCommit(t, repo, "init") + + want := []string{"SRC/Keep.go"} + for _, selector := range []string{"src", "SRC", "src/keep.go", "SRC/KEEP.GO"} { + got := enumeratePaths(t, repo, []string{selector}) + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("--path %q enumerated %q, want %q", selector, got, want) + } + } + + // Resolution is not fuzziness: a selector the filesystem cannot resolve is + // still an error, not an empty selection. + if got, err := enumeratePathsErr(t, repo, []string{"src/keep"}); err == nil { + t.Errorf("--path src/keep enumerated %q, want an error", got) + } +} + +// TestProvider_Enumerate_ExactSpellingWinsOnCaseSensitiveFS pins the rule +// where case is meaningful: `src` and `SRC` are two paths, the spelled path +// wins, and a spelling that names neither is an error rather than a folded +// match of both -- widening to both would silently grow the review and its +// cost. +func TestProvider_Enumerate_ExactSpellingWinsOnCaseSensitiveFS(t *testing.T) { + repo := initTestRepo(t) + if fsIsCaseInsensitive(t, repo) { + t.Skip("case-variant spellings are one path on this filesystem") + } + writeFile(t, repo, "src/lower.go", []byte("package lower\n")) + writeFile(t, repo, filepath.Join("SRC", "Keep.go"), []byte("package keep\n")) + gitCommit(t, repo, "init") + + for _, tc := range []struct{ selector, want string }{ + {"src", "src/lower.go"}, + {"SRC", "SRC/Keep.go"}, + } { + got := enumeratePaths(t, repo, []string{tc.selector}) + if len(got) != 1 || got[0] != tc.want { + t.Errorf("--path %q enumerated %q, want [%q]", tc.selector, got, tc.want) + } + } + + if got, err := enumeratePathsErr(t, repo, []string{"srC"}); err == nil { + t.Errorf("--path srC enumerated %q, want an error, not a folded match of both directories", got) + } +} + +// TestProvider_Enumerate_CaseVariantFileNameIsNotFoldedOnCaseSensitiveFS is +// the same rule at file level: `src/KEEP.go` names no file where `src/keep.go` +// and `src/Keep.go` both exist, and pooling both cases into one match would +// review two files the user did not name. +func TestProvider_Enumerate_CaseVariantFileNameIsNotFoldedOnCaseSensitiveFS(t *testing.T) { + repo := initTestRepo(t) + if fsIsCaseInsensitive(t, repo) { + t.Skip("case-variant spellings are one path on this filesystem") + } + writeFile(t, repo, "src/keep.go", []byte("package keep\n")) + writeFile(t, repo, "src/Keep.go", []byte("package keep\n")) + gitCommit(t, repo, "init") + + if got, err := enumeratePathsErr(t, repo, []string{"src/KEEP.go"}); err == nil { + t.Errorf("--path src/KEEP.go enumerated %q, want an error", got) + } +} + +// TestProvider_Enumerate_EmptyCaseVariantDirectoryStaysEmpty pins the +// follow-up from review: `src/docs/` exists as an empty directory while +// `src/DOCS/` holds tracked files. A selector resolves to the path the +// working tree holds at its spelling, and that path is matched exactly, so +// the empty directory selects nothing -- folding the string would have +// pulled in the sibling the user did not name. +func TestProvider_Enumerate_EmptyCaseVariantDirectoryStaysEmpty(t *testing.T) { + repo := initTestRepo(t) + if fsIsCaseInsensitive(t, repo) { + t.Skip("case-variant directories are one directory on this filesystem") + } + writeFile(t, repo, filepath.Join("src", "DOCS", "main.go"), []byte("package docs\n")) + gitCommit(t, repo, "init") + if err := os.MkdirAll(filepath.Join(repo, "src", "docs"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + got, err := enumeratePathsErr(t, repo, []string{"src/docs"}) + if err != nil || len(got) != 0 { + t.Errorf("--path src/docs over an empty directory = %q, %v; want no files and no error", got, err) + } + if got := enumeratePaths(t, repo, []string{"src/DOCS"}); len(got) != 1 || got[0] != "src/DOCS/main.go" { + t.Errorf("--path src/DOCS enumerated %q, want [src/DOCS/main.go]", got) + } +} + +// TestProvider_Enumerate_ResolvedAliasSelectsTrackedSpelling covers +// resolution through a symlink on a case-sensitive filesystem: `src` is a +// symlink to the tracked `SRC/`, so resolving the selector follows it to the +// tracked spelling even though no byte-exact entry matches. An empty result +// for a path the user can see would read as "nothing to review". +func TestProvider_Enumerate_ResolvedAliasSelectsTrackedSpelling(t *testing.T) { + repo := initTestRepo(t) + if fsIsCaseInsensitive(t, repo) { + t.Skip("the alias would collide with the tracked directory on this filesystem") + } + writeFile(t, repo, ".gitignore", []byte("src\n")) + writeFile(t, repo, filepath.Join("SRC", "Keep.go"), []byte("package keep\n")) + gitCommit(t, repo, "init") + + if err := os.Symlink("SRC", filepath.Join(repo, "src")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + got := enumeratePaths(t, repo, []string{"src"}) + want := []string{"SRC/Keep.go"} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("--path src through the alias enumerated %q, want %q", got, want) + } +} + +// TestProvider_ResolveSelector_RepositoryBoundary covers selectors that +// resolve to the repository root or outside it: no tracked entry can match +// the result, so the scan stays a clean skip rather than widening to whatever +// the spelling points at. +func TestProvider_ResolveSelector_RepositoryBoundary(t *testing.T) { + dir := t.TempDir() + repo := filepath.Join(dir, "repo") + outside := filepath.Join(dir, "outside") + for _, d := range []string{filepath.Join(repo, "src"), outside} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + p := &Provider{repoDir: repo} + for _, tc := range []struct{ selector, want string }{ + {selector: ".", want: "."}, + {selector: "src/..", want: "."}, + {selector: "../outside", want: "../outside"}, + } { + got, err := p.resolveSelector(tc.selector) + if err != nil || got != tc.want { + t.Errorf("resolveSelector(%q) = %q, %v; want %q and no error", tc.selector, got, err, tc.want) + } + } +} + +// TestOnDiskName pins the two lookups resolution depends on: an exactly +// spelled entry is returned as is, and a spelling the filesystem resolves to +// a differently-cased entry takes that entry's name -- the arm macOS needs, +// since Windows is already normalised inside EvalSymlinks. On a +// case-sensitive filesystem the variant names nothing and must error instead +// of guessing. +func TestOnDiskName(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "MixedCase"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if got, err := onDiskName(dir, "MixedCase"); err != nil || got != "MixedCase" { + t.Errorf("onDiskName(exact) = %q, %v; want %q and no error", got, err, "MixedCase") + } + if fsIsCaseInsensitive(t, dir) { + if got, err := onDiskName(dir, "mixedcase"); err != nil || got != "MixedCase" { + t.Errorf("onDiskName(case variant) = %q, %v; want %q and no error", got, err, "MixedCase") + } + return + } + if got, err := onDiskName(dir, "mixedcase"); err == nil { + t.Errorf("onDiskName(case variant) = %q, nil; want an error on a case-sensitive filesystem", got) + } +} diff --git a/internal/scan/provider_test.go b/internal/scan/provider_test.go index 8cfeb5cc5..ce6186557 100644 --- a/internal/scan/provider_test.go +++ b/internal/scan/provider_test.go @@ -38,30 +38,25 @@ func TestCountLines(t *testing.T) { } } -func TestFilterByPaths(t *testing.T) { - all := []string{ - "cmd/main.go", - "internal/agent/agent.go", - "internal/agent/fullscan.go", - "internal/scan/provider.go", - "README.md", - } +func TestPathMatches(t *testing.T) { tests := []struct { name string - paths []string - want []string + f string + want string + match bool }{ - {"exact file", []string{"README.md"}, []string{"README.md"}}, - {"dir prefix", []string{"internal/agent"}, []string{"internal/agent/agent.go", "internal/agent/fullscan.go"}}, - {"multi", []string{"cmd/main.go", "internal/scan"}, []string{"cmd/main.go", "internal/scan/provider.go"}}, - {"prefix not at boundary", []string{"internal/age"}, nil}, - {"no match", []string{"does/not/exist"}, nil}, + {name: "exact file", f: "README.md", want: "README.md", match: true}, + {name: "directory itself", f: "internal/agent", want: "internal/agent", match: true}, + {name: "file under directory", f: "internal/agent/agent.go", want: "internal/agent", match: true}, + {name: "prefix not at boundary", f: "internal/agent/agent.go", want: "internal/age"}, + {name: "partial file name", f: "internal/scan/provider.go", want: "internal/scan/provider"}, + {name: "other path", f: "README.md", want: "docs"}, + {name: "case variant is a different path", f: "SRC/Keep.go", want: "src"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := filterByPaths(all, tt.paths) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("filterByPaths() = %v, want %v", got, tt.want) + if got := pathMatches(tt.f, tt.want); got != tt.match { + t.Errorf("pathMatches(%q, %q) = %v, want %v", tt.f, tt.want, got, tt.match) } }) } diff --git a/internal/tool/code_comment.go b/internal/tool/code_comment.go index f26555d61..6b7a3ad5d 100644 --- a/internal/tool/code_comment.go +++ b/internal/tool/code_comment.go @@ -47,6 +47,9 @@ var validCodeCommentSeverities = map[string]struct{}{ codeCommentSeverityLow: {}, } +// CommentNoneUsableMsg reports a parsed batch with no usable comments. +const CommentNoneUsableMsg = "Error: none of the 'comments' entries could be filed; each entry needs a non-empty 'content' and a 'path' (or the reviewed file's path). Re-send the finding with those fields." + // CodeCommentProvider submits review comments to the per-Agent CommentCollector. type CodeCommentProvider struct { Collector *CommentCollector @@ -63,6 +66,9 @@ func (p *CodeCommentProvider) Execute(_ context.Context, args map[string]any) (s if errMsg != "" { return errMsg, nil } + if len(comments) == 0 { + return CommentNoneUsableMsg, nil + } for i := range comments { p.Collector.Add(comments[i]) diff --git a/internal/tool/code_comment_test.go b/internal/tool/code_comment_test.go index 68cc9d7fe..d01c83a57 100644 --- a/internal/tool/code_comment_test.go +++ b/internal/tool/code_comment_test.go @@ -310,6 +310,45 @@ func TestCodeCommentProvider_Execute(t *testing.T) { } }) + t.Run("all entries dropped returns error message", func(t *testing.T) { + collector := NewCommentCollector() + p := &CodeCommentProvider{Collector: collector} + result, err := p.Execute(context.Background(), map[string]any{ + "comments": []any{ + map[string]any{"path": "main.go", "message": "nil dereference"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result != CommentNoneUsableMsg { + t.Errorf("result = %q, want %q", result, CommentNoneUsableMsg) + } + if len(collector.Comments()) != 0 { + t.Errorf("collector has %d comments, want 0", len(collector.Comments())) + } + }) + + t.Run("partially dropped batch still succeeds", func(t *testing.T) { + collector := NewCommentCollector() + p := &CodeCommentProvider{Collector: collector} + result, err := p.Execute(context.Background(), map[string]any{ + "comments": []any{ + map[string]any{"content": "real finding", "path": "main.go"}, + map[string]any{"path": "main.go", "message": "dropped: no content"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result != CommentSucceed { + t.Errorf("result = %q, want %q", result, CommentSucceed) + } + if len(collector.Comments()) != 1 { + t.Errorf("collector has %d comments, want 1", len(collector.Comments())) + } + }) + t.Run("nil collector returns error message", func(t *testing.T) { p := &CodeCommentProvider{Collector: nil} result, err := p.Execute(context.Background(), map[string]any{ diff --git a/package.json b/package.json index a9cf671cb..d7de64900 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ ], "scripts": { "postinstall": "node scripts/install.js", - "test:github-actions": "node scripts/github-actions/post-review-comments.test.js && node scripts/github-actions/check-translation-sync.test.js && node scripts/github-actions/action-contract.test.js && node scripts/github-actions/check-plugin-contract.test.js", + "test:github-actions": "node scripts/github-actions/post-review-comments.test.js && node scripts/github-actions/check-translation-sync.test.js && node scripts/github-actions/action-contract.test.js && node scripts/github-actions/check-plugin-contract.test.js && node scripts/github-actions/release-contract.test.js && node scripts/github-actions/check-pr-commit-policy.test.js", "test:update": "node scripts/version.test.js", "test:launcher": "node bin/ocr.test.js" }, diff --git a/pages/go.mod b/pages/go.mod index 6d269ce02..bcbc60245 100644 --- a/pages/go.mod +++ b/pages/go.mod @@ -13,4 +13,4 @@ // becomes a real module and should be treated as one. module github.com/alibaba/open-code-review/pages -go 1.25.5 +go 1.26.9 diff --git a/pages/src/content/docs/en/chatgpt-preview.md b/pages/src/content/docs/en/chatgpt-preview.md new file mode 100644 index 000000000..282188393 --- /dev/null +++ b/pages/src/content/docs/en/chatgpt-preview.md @@ -0,0 +1,85 @@ +--- +title: Sign in with ChatGPT preview +--- + +OpenCodeReview can use eligible ChatGPT plan usage through OpenAI's public Responses API. This optional preview is available in the open-source CLI. OpenCodeReview does not charge a separate subscription for it. Account, workspace, region and model eligibility are determined by OpenAI. + +The `chatgpt` provider uses the documented Sign in with ChatGPT flow. Sign in with ChatGPT is the default for `ocr auth login`, `status` and `logout`. Explicit `--provider chatgpt` remains supported. + +OAuth requests the full documented OpenID Connect and OpenAI scope set: `openid` verifies identity; `profile` and `email` provide account claims; `offline_access` permits refresh-token renewal while you are away; `resource.invoke` authorizes resource invocation; and `chatgpt.tokens.use.direct` permits direct use of ChatGPT plan capacity. The initial dynamic registration includes `agent_name_hint=OpenCodeReview`. The flow requests no connector grants and does not reuse the Codex CLI client registration. It requests only these documented scopes, not arbitrary additional permissions. + +The client registration is issued by OpenAI during first sign-in and is retained for that account. The app sends its actual name as the initial registration hint, uses the same name across installations, then omits that hint on returning sign-in. It also sends a stable, opaque `ext_agent_host_id` for this local installation on every sign-in. The same installation keeps the same host ID. + +Authorization uses fresh state, nonce and PKCE values. The callback state is checked before code exchange. The ID token signature is verified with the issuer's RSA JWKS; issuer, audience, subject, lifetime and nonce are validated before the returned identity or granted scopes are trusted. + +## Sign in and select a model + +```bash +ocr auth login --provider chatgpt +ocr auth status --provider chatgpt +ocr llm models +ocr config set provider chatgpt +ocr config set providers.chatgpt.model gpt-6-luna +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +The catalog command shows account-specific suggestions and display names in server order. Without a configured model, the provider selects the first list-visible model. An explicitly configured or `--model` slug is sent unchanged to OpenAI, even if it is absent from the catalog. OpenAI determines whether the selected account can use that model. OpenCodeReview has no compiled model restrictions or aliases for this provider. + +The reasoning effort in `extra_body` is an API parameter. The separate review `--effort` flag controls OpenCodeReview's review-round and tool budgets. + +Sign-in opens the system browser and listens only on `127.0.0.1`. Use `--no-browser` to print a one-time local URL instead. The CLI never prints an authorization URL containing a retained ID-token hint. Device-code authentication is not supported for this preview. + +This preview is intended for local use by one person with an eligible ChatGPT account. Do not use it for pooled or hosted resale, shared CI with personal credentials, or as a fallback to API billing. OpenAI determines account and usage eligibility; this documentation makes no promise of savings or availability. + +If sign-in succeeds without ChatGPT plan permission, OpenCodeReview retains the account identity and reports that plan usage is disabled. Request a consent screen for the active retained registration with: + +```bash +ocr auth login --provider chatgpt --enable-plan +# For another retained registration: +ocr auth login --provider chatgpt --enable-plan --account +``` + +This explicit action sends `prompt=consent`, the full requested scopes and resource, and the saved client and host IDs. It cannot be combined with `--new-account`. Ordinary returning sign-in does not force consent. Inference remains disabled until the returned grant permits plan usage. You can instead configure an API-key provider; OpenCodeReview does not switch billing paths automatically. + +## Accounts and sign-out + +```bash +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth login --provider chatgpt --account +ocr auth logout --provider chatgpt --account +``` + +Each issued client ID remains associated with its verified issuer and subject, even if multiple registrations share an email. A returning sign-in reuses that client ID. After a code-exchange `invalid_grant`, sign-in restarts authorization once with the callback-issued client ID and fresh state, nonce and PKCE. This pending registration is never activated before signed identity validation. A second failure stops sign-in and leaves previous credentials intact. + +The credential database is `~/.opencodereview/auth/chatgpt/accounts.json`. The separate `host.json` stores a stable installation host ID. Credential files are replaced atomically. + +On Unix, credential files use owner-only `0600` permissions and the store directory uses `0700`. On Windows, access depends on ACLs inherited from the local user profile; OpenCodeReview does not establish or verify a DACL. Go's [`os.Chmod`](https://pkg.go.dev/os#Chmod) changes only the read-only attribute on Windows. Use a user profile whose ACLs restrict access to your account, check those permissions before signing in, and avoid shared or broadly accessible storage locations. A directory under the user profile is not a guarantee of private access. + +Store mutations and refresh rotation share an operating-system file lock, but browser authorization runs outside it so other accounts remain usable. Completion reloads the store and checks the retained registration's revision. Logout, refresh or another completed login on that registration invalidates the pending result. Unrelated account updates are preserved. Access tokens are refreshed before expiry with rotating refresh tokens. + +If a refresh returns new tokens but verification is interrupted by cancellation or a temporary failure to fetch public keys for ID-token verification, OCR keeps the received tokens. Run the original command again. The next request resumes verification, even after restarting the CLI. + +If the refreshed ID token fails signature or identity validation, OCR stops retrying and asks you to sign in again. Run the command shown in the error, using the saved client ID: + +```bash +ocr auth login --provider chatgpt --account +``` + +A successful sign-in replaces the rejected credentials. + +Logout attempts to revoke the renewable session using the newest refresh token, including a replacement awaiting ID-token verification. It clears access, refresh and ID tokens, and retains the account registration and host ID. Later sign-in omits `id_token_hint` after logout. If remote revocation cannot be confirmed, the CLI reports that local tokens were cleared and directs you to disconnect the app in ChatGPT settings. + +## Transport and usage + +This provider sends only to `https://api.openai.com/v1/responses`. Endpoint, protocol and credential overrides are rejected. It uses streaming with `store: false`, sends full history, groups local functions in the `ocr` namespace, and preserves call IDs and encrypted reasoning for subsequent turns. Only `response.completed` counts as success. Failed, incomplete and interrupted streams stop the request, including usage-limit errors received after output starts. + +Preview request fields are constrained to OpenAI's supported subset. `extra_body` accepts `reasoning`, `text` and `prompt_cache_key`; it cannot replace the model, history, tools, stream mode or storage policy. Raw provider usage remains available on the response, and the existing opt-in raw review capture records the stream without credential headers. + +**Using ChatGPT plan:** eligible requests use the selected account's ChatGPT plan. [Manage usage in ChatGPT settings](https://chatgpt.com/settings/usage). A usage-limit error may reflect a plan-wide or app-specific limit; the CLI does not infer a reset time. + +See OpenAI's [SIWC open-source overview](https://developers.openai.com/siwc/token-sharing-open-source), [sign-in contract](https://developers.openai.com/siwc/token-sharing-open-source/sign-in) and [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations) for current requirements. + +See [CLI Reference](../cli-reference/) for the `ocr auth` and `ocr llm models` commands, and [Configuration](../configuration/) for provider settings. diff --git a/pages/src/content/docs/en/cli-reference.md b/pages/src/content/docs/en/cli-reference.md index 6a489b493..6db183be7 100644 --- a/pages/src/content/docs/en/cli-reference.md +++ b/pages/src/content/docs/en/cli-reference.md @@ -123,7 +123,7 @@ staged + unstaged + untracked changes in the current directory's repo. | `--background-file ` | `-B` | — | Path to a Markdown file used as review background. Takes precedence over `--background` when both are set. | | `--exclude ` | — | — | Comma-separated gitignore-style patterns to exclude; merged with the `excludes` section of `rule.json` | | `--concurrency ` | — | `8` | Maximum number of subtasks reviewed in parallel. | -| `--timeout ` | — | `15` | Per-subtask deadline. `0` disables the timeout. Scaled linearly by the number of effort review rounds (e.g. 15/30/45 min for low/medium/high). | +| `--timeout ` | — | `15` | Per-subtask budget, including LLM calls, tools, and retry waits. `0` disables the task deadline, not request timeouts. Independent request timeout: `OCR_LLM_TIMEOUT` or provider `timeout_sec`, in seconds (default `300`). Scaled linearly by the number of effort review rounds (e.g. 15/30/45 min for low/medium/high). | | `--effort ` | — | `medium` | Review effort preset: `low` (1 review round), `medium` (2 rounds), `high` (3 rounds). More rounds improve recall at proportionally higher cost. Overrides the saved `effort` setting for this run. | | `--rule ` | — | — | Path to a custom JSON review rule file. Overrides the project-level and global `rule.json`. | | `--max-tools ` | — | template default | Max tool-call rounds per subtask. `0` uses the template default (`100`); values 1–49 are clamped up to `50`. The flag only ever *raises* the cap — a value below the template default is ignored. | @@ -158,6 +158,11 @@ does not change that source order. Incomplete strategies fall through without being mixed. A selected built-in provider's credentials may still come from its supported environment variable. +For built-in providers, `--model` accepts IDs outside the suggested models in +`ocr config model`. If the ID is absent from both the built-in list and +`providers..models`, OCR warns on stderr and leaves validation to the +provider. Custom providers retain their existing `--model` validation rules. + ### Modes #### Workspace mode (default) @@ -371,7 +376,7 @@ With no `--path`, the whole repository is scanned. | Flag | Short | Default | Description | |---|---|---|---| -| `--path ` | - | whole repo | Comma-separated repo-relative directories or files to scan (e.g., `internal/agent`, `internal/llm/client.go`). | +| `--path ` | - | whole repo | Comma-separated repo-relative directories or files to scan (e.g., `internal/agent`, `internal/llm/client.go`). Selectors resolve against the working tree: the spelled path wins over a case-variant sibling, a differently-cased spelling that the filesystem resolves (Windows, default macOS) still selects the tracked files, and a selector that names no existing path is an error rather than an empty scan. | | `--exclude ` | - | - | Comma-separated gitignore-style patterns to skip (e.g., `**/generated/*,*.pb.go`); merged with `rule.json` excludes. | | `--output ` | `-o` | stdout | Write scan results to a UTF-8 file (`-` means stdout). Lazily created on first write so failed runs leave existing files untouched. Text format automatically strips ANSI color codes. | | `--preview` | `-p` | `false` | Enumerate and filter files without calling the LLM. Prints the file list, reviewable/excluded counts, total lines, and per-file exclusion reasons. Honors `--format json`; `--format sarif` is not supported. | @@ -591,9 +596,30 @@ ocr config model Interactive model selection See [Configuration](../configuration/) for the full key reference, schemas, and examples. + + +## `ocr auth` + +Sign in with ChatGPT is the default for `ocr auth login`, `status` and `logout`. Explicit `--provider chatgpt` remains supported. + +Use the documented Sign in with ChatGPT flow with the `chatgpt` provider. See the [ChatGPT preview guide](../chatgpt-preview/). + +```bash +ocr auth login --provider chatgpt +ocr auth login --provider chatgpt --no-browser +ocr auth login --provider chatgpt --enable-plan +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth logout --provider chatgpt --account +ocr llm models +``` + +`ocr auth` accepts `--provider chatgpt` and `--account `; `login` also accepts `--no-browser`, `--new-account` and `--enable-plan`. ChatGPT does not support `--device`. `--enable-plan` requests consent for a saved registration and cannot be combined with `--new-account`. The model list is account-specific; an explicit model is accepted even if the list omits it. This provider accepts `extra_body` only for `reasoning`, `text` and `prompt_cache_key`. See the [ChatGPT preview guide](../chatgpt-preview/) for account handling, permissions and restrictions. + ## `ocr llm` -LLM utility commands. Two subcommands: +LLM utility commands. Three subcommands: ```text ocr llm @@ -601,6 +627,7 @@ ocr llm Sub-commands: test Send a test conversation to the configured LLM model providers List all built-in LLM providers + models List current account-specific ChatGPT models ``` ### `ocr llm test` diff --git a/pages/src/content/docs/en/configuration.md b/pages/src/content/docs/en/configuration.md index 7f4a37b9f..6115ac6d4 100644 --- a/pages/src/content/docs/en/configuration.md +++ b/pages/src/content/docs/en/configuration.md @@ -38,14 +38,15 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ### Built-in providers The following providers ship with OCR, with the Base URL and protocol -preset — once selected, you only need to fill in the API key. If -`providers..api_key` is unset, OCR falls back to the corresponding -environment variable. +preset. Most require an API key. If `providers..api_key` is unset, +OCR falls back to the corresponding environment variable. The `chatgpt` +provider instead uses the ChatGPT credentials created by `ocr auth login`. | Name | Protocol | Base URL | API key env var | |---|---|---|---| | `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | `bedrock` | anthropic-bedrock | derived from `aws_region` | — (AWS credential chain) | +| `chatgpt` | openai-responses | `https://api.openai.com/v1` | not applicable (Sign in with ChatGPT) | | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | | `openai-responses` | openai-responses | `https://api.openai.com/v1` | `OPENAI_RESPONSES_API_KEY` | | `openrouter` | openai | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | @@ -69,6 +70,28 @@ environment variable. | `novita` | openai | `https://api.novita.ai/openai` | `NOVITA_API_KEY` | | `xai` | openai | `https://api.x.ai/v1` | `XAI_API_KEY` | +Built-in providers' model lists are suggestions for `ocr config model`, not +restrictions on `--model`. An override absent from both the built-in list and +`providers..models` produces a warning on stderr; the provider validates +the model when the request is sent. Custom providers retain their existing +`--model` validation rules. + +## Sign in with ChatGPT preview + +This preview adds the `chatgpt` built-in provider for the public Responses API. It has no API-key environment variable. See the [ChatGPT preview guide](../chatgpt-preview/) for sign-in, consent and usage restrictions. + +Configure the provider and optional supported request fields with `ocr config set`. Choose a model from `ocr llm models` or set an explicit model slug: + +```bash +ocr auth login --provider chatgpt +ocr config set provider chatgpt +ocr config set providers.chatgpt.model +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +The provider accepts explicit model values even if the account-specific catalog omits them. It accepts `extra_body` fields `reasoning`, `text` and `prompt_cache_key`; it rejects endpoint, protocol and credential overrides. + ### Overriding a built-in provider's Base URL Every built-in provider has a preset Base URL (shown in the table above). @@ -76,6 +99,8 @@ To point a built-in provider at a different endpoint — for example a self-hosted LiteLLM gateway that is rarely at the preset default `http://localhost:4000/v1` — set `providers..url`: +The `chatgpt` preview is an exception: its public endpoint is fixed and URL overrides are rejected. + ```bash ocr config set provider litellm ocr config set model openai/gpt-5.4 @@ -89,7 +114,7 @@ preset default — so you only need to set it when your endpoint differs. ### AWS Bedrock -`bedrock` speaks the same Messages API as `anthropic`, but requests are +With its default `anthropic-bedrock` protocol, `bedrock` uses the same Messages API as `anthropic`, but requests are SigV4-signed from the standard AWS credential chain instead of carrying an API key, and the region decides the host. There is no `api_key` to set, and none is accepted as a substitute for a signature: @@ -101,6 +126,8 @@ ocr config set providers.bedrock.aws_region us-west-2 ocr config set providers.bedrock.aws_profile example-profile ``` +If you set `providers.bedrock.protocol` to `openai` for an OpenAI-compatible gateway, configure the gateway URL in `providers.bedrock.url` and supply its API key through `providers.bedrock.api_key` or `providers.bedrock.api_key_cmd`. Selecting another model in `ocr config provider` preserves the saved API key. + | Field | Meaning | |---|---| | `providers.bedrock.aws_region` | Region whose `bedrock-runtime` host serves the request. Falls back to `AWS_REGION` or the active profile. | @@ -118,8 +145,8 @@ when absent from it. Run `aws bedrock list-inference-profiles --region ` to see what an account offers; a version suffix such as `-v1:0` is invalid for the newer families. -`ocr llm test` reports the region and profile in place of a URL, because bedrock -has no configured URL — the region decides the host: +With the default `anthropic-bedrock` protocol, `ocr llm test` reports the region +and profile in place of a URL. The region determines the host: ``` Source: provider:bedrock @@ -136,6 +163,14 @@ rather than accepted and ignored. ### Custom providers +Names such as `mimo-v2.6-flash` and `foo.api_key` are supported. Before +setting fields on a new name containing dots, create and select it with +`ocr config set provider `; this changes the active provider. Existing +dotted entries can be edited without selecting them, through either +`providers..` or `custom_providers..`. Names must +not start or end with a dot, contain consecutive dots, or have empty or +whitespace-only segments. Dot-free names still support implicit creation. + Any provider name not in the table above is treated as custom and must supply at least `url` and `protocol` (`protocol` is `anthropic`, `openai`, `openai-responses`, or `anthropic-bedrock`): @@ -193,6 +228,16 @@ in the FAQ before picking one. ### Timeouts +Task and request timeouts are independent: + +- `ocr review --timeout` and `ocr scan --timeout` set the concurrent-task + budget in **minutes** (default **15**; `0` disables the task deadline). + This budget includes all LLM calls, tool work, and retry waits for the task. + Review scales it by the number of effort rounds. +- The per-request HTTP timeout is in **seconds** (default **300**). + Raising `--timeout` does not change it; disabling the task deadline does + not disable request timeouts. A shorter remaining task budget always wins. + Each LLM request has an HTTP timeout, defaulting to **300 seconds**. Slow local models (or large files) can need more. Three knobs, in increasing scope: @@ -213,6 +258,25 @@ Both `timeout_sec` keys can be set with `ocr config set`: } ``` +For example, `OCR_LLM_TIMEOUT=900 ocr review --timeout 30` allows an +individual request up to 15 minutes within the task budget. Without the +environment variable or a configured `timeout_sec`, requests still time +out after 5 minutes even with `--timeout 30`. + +An expired per-request deadline ends the call without automatically replaying +it, including when reading a response body or stream. For consistently slow +requests, increase the request timeout rather than retrying with the same +limit. The existing SDK retry policy is unchanged: retryable connection and +HTTP failures use up to 5 retries with backoff and provider `Retry-After` +hints. The SDK's per-attempt deadline also bounds its retry wait. Cancellation +or an exhausted task deadline stops both requests and retry waits. + +Diagnostics distinguish `LLM request timeout` (check `OCR_LLM_TIMEOUT` or +provider `timeout_sec`) from `caller deadline exceeded`, which means the +calling operation's context has expired. For a review/scan task deadline, +check `--timeout`; other operations, such as background memory compression +and `ocr llm test`, have their own deadlines. + ### API key from a command Instead of storing a key in the config file, `api_key_cmd` fetches it at diff --git a/pages/src/content/docs/en/contributing.md b/pages/src/content/docs/en/contributing.md index 3360518e5..c47453b83 100644 --- a/pages/src/content/docs/en/contributing.md +++ b/pages/src/content/docs/en/contributing.md @@ -28,7 +28,7 @@ You don't have to write Go to be useful: ### Prerequisites -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -115,39 +115,55 @@ refactor(viewer): extract task-card rendering into helper The same format is used for **PR titles** so they show up cleanly in the generated changelog. +`PR Commit Policy` checks the PR title and every commit's full message, including +the body, case-insensitively. Do not include `[skip ci]`, `[ci skip]`, `[no ci]`, +`[skip actions]`, `[actions skip]`, or a `skip-checks: true` / `skip-checks:true` line. + +To fix failures, edit the PR title or rewrite affected commit messages on your +contribution branch. Adding a clean commit does not remove earlier violations. +PR descriptions may discuss these instructions and are not checked. + ## Project layout ``` open-code-review/ +├── acp/ # Independent ACP (Agent Client Protocol) adapter for the CLI ├── cmd/opencodereview/ # CLI entry point — flag parsing, dispatch +├── docs/ # Localized README and contributing guides +├── imgs/ # README images and logos ├── internal/ │ ├── agent/ # Review agent logic, sub-agent dispatch +│ ├── chatgptauth/ # ChatGPT OAuth and credential management │ ├── config/ # Template, rules, allowlist, embedded JSON -│ ├── diff/ # Git diff parsing, three modes +│ ├── delegate/ # Review specification generation without LLM calls +│ ├── diff/ # Git diff parsing │ ├── gitcmd/ # Git subprocess runner -│ ├── llm/ # LLM client (Anthropic & OpenAI), endpoint resolver +│ ├── llm/ # LLM clients and endpoint resolution +│ ├── llmloop/ # LLM tool-use loop shared by review and scan +│ ├── mcp/ # MCP clients and tool adapters │ ├── model/ # Data structs (LlmComment, Diff, …) │ ├── pathutil/ # Path utilities -│ ├── release/ # Release-notes generation +│ ├── release/ # Release artifact naming consistency checks +│ ├── scan/ # Full-file code review │ ├── session/ # JSONL session writer │ ├── stdout/ # Quiet-able stdout writer │ ├── suggestdiff/ # Suggestion diff rendering │ ├── telemetry/ # OpenTelemetry config + helpers │ ├── tool/ # Tool registry + provider impls -│ └── viewer/ # Embedded HTTP UI -├── pages/ # WebUI marketing page (separate React app) -├── plugins/ # Claude Code slash command -├── extensions/ # Editor extensions (VS Code) +│ └── viewer/ # Embedded HTTP UI for browsing review sessions +├── pages/ # Website landing page and docs (separate React app) +├── plugins/ # Coding agent integrations (Claude Code, Codex, OpenCode, …) +├── extensions/ # VS Code and IntelliJ IDEA extensions, shared frontend ├── examples/ # CI recipes (GitHub Actions, GitLab CI) -├── skills/ # Agent SDK skill manifest -├── scripts/ # NPM postinstall + cross-build scripts +├── skills/ # Review and delegation skill definitions +├── scripts/ # Install, publish, and repository check scripts ├── npm/ # Per-platform optional dependency packages └── bin/ # NPM wrapper (Node) ``` Most contributions touch `internal/agent/`, `internal/tool/`, or `internal/llm/`. The CLI surface in `cmd/opencodereview/` is -intentionally thin — flag parsing then dispatch to the agent package. +intentionally thin — flag parsing and command dispatch. ## AI-Assisted Development diff --git a/pages/src/content/docs/en/installation.md b/pages/src/content/docs/en/installation.md index b97482be5..cb51c585f 100644 --- a/pages/src/content/docs/en/installation.md +++ b/pages/src/content/docs/en/installation.md @@ -163,7 +163,7 @@ without a pre-built binary. #### Prerequisites -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) diff --git a/pages/src/content/docs/en/integrations/acp.md b/pages/src/content/docs/en/integrations/acp.md new file mode 100644 index 000000000..803ce04b5 --- /dev/null +++ b/pages/src/content/docs/en/integrations/acp.md @@ -0,0 +1,165 @@ +--- +title: ACP +sidebar: + order: 6 +--- + +**Experimental feature:** The ACP adapter is currently unstable and is only supported when compiled locally from source. Update the source and rebuild when upgrading. + +## Prerequisites {#prerequisites} + +- Install the [OCR CLI](../../installation/), configure the [review model](../../configuration/), and check the connection with `ocr llm test`. +- Prepare a source checkout containing `acp/`, Git, and Go 1.23+ to build the adapter. Building the OCR CLI from source requires Go 1.25.5+. +- Use a client that supports ACP v1 stdio and custom agent launch commands. + +The adapter and OCR CLI are built separately. The instructions below use a local build. + +## Build and connect your client {#build-and-connect-your-client} + +Run from the repository root: + +```bash +make -C acp build +command -v ocr +``` + +The build output is `dist/ocr-acp`. Put the absolute path printed by `command -v ocr` in `--ocr-binary` below. + +Add the following configuration to the client's Agent settings. Replace every `/absolute/path` placeholder and the parser model values with your own settings: + +```json +{ + "agent_servers": { + "OpenCodeReview": { + "type": "custom", + "command": "/absolute/path/open-code-review/dist/ocr-acp", + "args": [ + "--ocr-binary", + "/absolute/path/ocr" + ], + "env": { + "OCR_ACP_PARSER_PROVIDER": "anthropic", + "OCR_ACP_PARSER_MODEL": "deepseek-flash", + "OCR_ACP_PARSER_BASE_URL": "https://your-gateway.example", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" + } + } + } +} +``` + +Field names vary by client; consult its ACP configuration guide. + +## Run a review or scan {#run-a-review-or-scan} + +Send the following in an OpenCodeReview conversation: + +| Request | Command | +| --- | --- | +| Review staged, unstaged, and untracked changes | `/review` | +| Run at most one review round | `/review --effort low` | +| Review the latest checked-out commit | `/review --commit HEAD` | +| Compare two existing refs | `/review --from main --to HEAD` | +| Scan a directory | `/scan --path internal/agent` | +| Scan multiple paths | `/scan --path internal/agent,internal/config` | + +Review requires the working directory to be a Git repository and resolves result paths from its root. Scan uses the session working directory as its root. + +`--effort low` runs at most one review round, `medium` at most two, and `high` at most three. A review can stop early when a round finds nothing new. Omitting the option uses OCR's configured value. Fewer rounds can miss findings, so choose the tradeoff you need. + +In clients that support ACP command suggestions, type `/` to see available commands and options. + +If the client attaches local file links, send them with `/scan` and omit `--path`. File links cannot be combined with `/review` or an explicit scan path; unsupported or ambiguous links are rejected instead of expanding the scan scope. + +The adapter accepts only the documented CLI options: + +- Review: `--commit`, `--from`, `--to`, `--effort`, `--no-filter`, `--background`, `--background-file`. +- Scan: `--path`, `--batch`, `--no-plan`, `--no-dedup`, `--no-summary`, `--background`. + +`/review` does not support `--staged`. Conversation commands also reject output options such as `--format` and `--audience`, and override options such as `--repo`, `--model`, and `--provider`. Configure the review model through OCR itself. + +## Enable natural-language requests {#enable-natural-language-requests} + +The parsing model converts natural-language requests into review or scan commands, or asks for clarification when information is missing. It is configured separately from OCR's review model; the client's own model selection does not configure either model. + +Add these settings to the Agent's `env` object and replace the model and key placeholders: + +```json +{ + "OCR_ACP_PARSER_PROVIDER": "openai", + "OCR_ACP_PARSER_MODEL": "YOUR_TOOL_CALLING_MODEL", + "OCR_ACP_PARSER_BASE_URL": "https://your-gateway.example", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" +} +``` + +This example uses an OpenAI-compatible Chat Completions protocol. Set the provider to `anthropic` for the Anthropic Messages protocol. For a compatible gateway, set `OCR_ACP_PARSER_BASE_URL` to its API base URL. + +Base URL path rules depend on the protocol: + +| Protocol | `OCR_ACP_PARSER_BASE_URL` example | Actual request URL | +| --- | --- | --- | +| `openai` | `https://gateway.example/v1` | `https://gateway.example/v1/chat/completions` | +| `anthropic` | `https://gateway.example` | `https://gateway.example/v1/messages` | + +You may also provide a complete URL ending in `/chat/completions` or `/v1/messages`; the adapter will not append the suffix again. For Anthropic, a base URL ending in `/v1` produces `/v1/v1/messages`; use the path required by the gateway. + +| Environment variable | Startup flag | Purpose | +| --- | --- | --- | +| `OCR_ACP_PARSER_PROVIDER` | `--parser-provider` | `openai` or `anthropic` | +| `OCR_ACP_PARSER_MODEL` | `--parser-model` | Model with tool-calling support | +| `OCR_ACP_PARSER_BASE_URL` | `--parser-base-url` | Optional API base URL override | +| `OCR_ACP_PARSER_API_KEY` | None | Parser model key, supplied only through the environment | + +The supported parser protocols are `openai` and `anthropic`; `openai-responses` and `anthropic-bedrock` are not supported. + +Anthropic parser requests explicitly disable Thinking so that the required `submit_intent` tool call works. This does not change Thinking settings for the OCR review model. + +After configuring the parser, start a new conversation and try: + +```text +Review the changes in my current workspace. +Review the latest checked-out commit. +Scan internal/agent for potential issues. +``` + +When a request is ambiguous, answer the clarification before execution. The model is instructed to ask and explain in your language; fixed validation messages and report labels remain in English. Slash commands continue to work when the parser is unavailable. Incomplete parser configuration produces a clear startup error. + +The phrase “latest checked-out commit” maps to `/review --commit HEAD`. A session keeps one pending clarification request. Compatible known fields can accumulate across clarifications, and an explicit new value replaces the old one; fields from another review type are not mixed in. Completion or cancellation clears the state, which is not restored across sessions. + +## Read results and cancel work {#read-results-and-cancel-work} + +Progress uses the OCR CLI's existing logs and final JSON. It shows the latest reported activity and total elapsed time, without inferring file-group stages, stage durations, or model-wait time. + +A single **OCR review** or **OCR scan** entry shows progress and locally measured runtime. Expand it to see **Command:**, the working directory, and bounded plain-text logs. Diagnostics remain in the details and do not replace the progress title; commands and logs are not repeated in the conversation body. The client controls the initial expansion state. + +The final report first shows completion status, file and finding counts, and the severity distribution. Each finding has a numbered heading with severity and file location, followed by its category, explanation, and a language-tagged code block. Suggested fixes are clearly marked as unapplied. Findings appear only once. Valid locations link to files; missing files, invalid line numbers, and paths outside the working root remain plain text. Exact rendering and click behavior depend on the client version. + +Partial-failure details and budget-limit notices are retained. The report footer shows available cumulative tokens and OCR-reported runtime. Execution details also show OCR's tool-call count and input, output, and cached tokens when available. Missing data is omitted. The live timer measures local execution and may differ from OCR's reported runtime; token totals include all model requests, not only newly generated answer tokens. + +Use the client's cancel control to stop a request. The adapter cancels the task and waits for managed processes to clean up. To limit the whole turn, including parsing, add `"--turn-timeout", "10m"` to the Agent's `args` array. The default is `0` (no whole-turn limit); parser requests have their own 15-second default. After a timeout the adapter asks you to retry. + +## Troubleshooting {#troubleshooting} + +| Symptom | Check | +| --- | --- | +| Agent cannot start | Check both executable paths and permissions. Run `ocr llm test` for OCR configuration; remove incomplete parser settings or provide all required values. | +| Slash commands work but natural language does not | Configure the separate `OCR_ACP_PARSER_*` environment variables and check the provider, model, key, and gateway URL. | +| Natural-language parsing times out | Parsing has a separate 15-second default limit and OCR has not started yet. Use the diagnostic request phase, HTTP status, and provider response to locate the cause; increasing `--turn-timeout` does not extend the parser limit. Retry or use `/review` and `/scan` to bypass parsing. | +| Review fails or returns partial results | Expand the OCR review/scan details for diagnostics and read the final failure message. Check OCR model configuration and provider availability. | +| A task takes too long | Cancel it, inspect progress details, or set a whole-turn timeout. For review, `--effort low` reduces the number of rounds. | +| Rebuilding still shows old behavior | Check the configured binary path and start a new OpenCodeReview conversation. Restart the Agent or client if it reuses an old process. | + +For connection errors, inspect the client's ACP logs and the adapter's stderr diagnostics. Remove keys and private source code before sharing logs. + +## Upgrade and support boundaries {#upgrade-and-support-boundaries} + +After updating the source, run `make -C acp build` again and start a new OpenCodeReview conversation. Running processes and old messages do not load the update. Session restoration is not supported; a new conversation does not retain an earlier clarification request. + +The adapter currently uses local stdio transport. HTTP, multiple workspace roots, automatic file edits, and image or audio requests are not supported. Other ACP clients require separate compatibility checks; passing protocol tests does not verify every client's interface behavior. + +## See also {#see-also} + +- [Configuration](../../configuration/) — OCR review model and provider settings. +- [CLI Reference](../../cli-reference/) — details for review and scan options. +- [ACP Introduction](https://agentclientprotocol.com/get-started/introduction) — the official Agent Client Protocol introduction. diff --git a/pages/src/content/docs/en/integrations/delegate.md b/pages/src/content/docs/en/integrations/delegate.md index a87949305..1c67fb511 100644 --- a/pages/src/content/docs/en/integrations/delegate.md +++ b/pages/src/content/docs/en/integrations/delegate.md @@ -95,24 +95,42 @@ repetition. ### Step 3: Get diffs -Use git directly, based on the mode/ref info from Step 1: +Choose the diff command using the mode and refs from Step 1. Use `git --no-pager` for every Git call during the review. + +The diff commands disable external diff programs, text conversion, and color to produce plain patches. **Range mode** (merge\_base provided): ```bash -git diff .. -- +git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- "" ``` **Commit mode**: ```bash -git show -- +git --no-pager show --no-ext-diff --no-textconv --no-color -- "" ``` **Workspace mode**: ```bash -git diff HEAD -- # tracked files -cat # new untracked files +git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- "" # tracked files +cat "" # new untracked files +``` + +To read context: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" ``` +For large diffs, add `--output=""` to the command for the selected mode. Choose a unique absolute path outside the repository and create its parent directory. Use the same path in file-reading tool calls: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +After Git exits with code 0, read and review the entire file in chunks, then remove it. If Git fails or times out, retry or record `skipped` with the error. Check unexpectedly empty output against a fresh preview. + ### Step 4: Review each file For each reviewable file: @@ -121,6 +139,8 @@ For each reviewable file: 2. Consult the matching Rule Group (Step 2) as the review checklist 3. Conduct a thorough review, using context exploration as needed +Record each previewed `(path, status)` as `reviewed` or `skipped`. Give a reason for every skipped entry, and report `total_files`, `reviewed_files`, `skipped_files`, and `coverage_rate`. + ### Step 5: Report Classify each finding by severity: diff --git a/pages/src/content/docs/en/review-rules.md b/pages/src/content/docs/en/review-rules.md index 088450975..3673c8f4c 100644 --- a/pages/src/content/docs/en/review-rules.md +++ b/pages/src/content/docs/en/review-rules.md @@ -219,6 +219,11 @@ snapshots, and generated code: - `**/test/**/*.lhs` - `**/*Spec.lhs` - `**/tests/**/*.nim` +- `**/tests/**/*.nu` +- `**/test/**/*.nu` +- `**/*_test.nu` +- `**/test_*.nu` +- `**/test-*.nu` - `**/tests/**/*.R` - `**/__snapshots__/**` - `**/*.snap` @@ -326,12 +331,14 @@ matching order: | `**/*.{graphql,gql}` | `graphql.md` — GraphQL schema and operations. | | `**/*.prisma` | `prisma.md` — Prisma schema. | | `**/*.jl` | `julia.md` — Julia source. | -| `**/*.{tf,hcl,tfvars}` | `terraform.md` — Terraform / HCL. | +| `**/*.{tf,tofu,hcl,tfvars}` | `terraform.md` — Terraform / HCL. | | `**/*.bicep` | `bicep.md` — Bicep (Azure) templates. | +| `**/*.nu` | `nushell.md` — Nushell scripts. | | `**/*.elm` | `elm.md` - Elm source. | | `**/*.{jsonnet,libsonnet}` | `jsonnet.md` — Jsonnet configuration templates and libraries. | | `**/*.thrift` | `thrift.md` — Apache Thrift IDL wire compatibility. | | `**/*.capnp` | `capnp.md` — Cap'n Proto schema wire compatibility. | +| `**/*.{glsl,hlsl,wgsl,metal}` | `shader.md` — GLSL, HLSL, WGSL, and Metal shaders. | | `**/*.{v,sv,vh}` | `verilog.md` — Verilog and SystemVerilog RTL. | | `**/*.{vhd,vhdl}` | `vhdl.md` — VHDL RTL. | | `**/*.m` | `matlab.md` (or `objc.md` via [content sniffing](#content-sniffing-for-m-files)) | @@ -339,6 +346,7 @@ matching order: | `**/*.sol` | `solidity.md` — Solidity smart contracts. | | `**/*.vy` | `vyper.md` — Vyper smart contracts. | | `**/*.rego` | `rego.md` — Rego policy (OPA). | +| `**/*.{ps1,psm1,psd1}` | `powershell.md` — PowerShell scripts, modules, and data/manifest files. | | _(fallback)_ | `default.md` | The resolved rule body becomes the `{{system_rule}}` placeholder in the diff --git a/pages/src/content/docs/index.ts b/pages/src/content/docs/index.ts index 44180e7bd..2e61a4398 100644 --- a/pages/src/content/docs/index.ts +++ b/pages/src/content/docs/index.ts @@ -9,6 +9,7 @@ import type { Language } from '../../i18n/types'; import enQuickstart from './en/quickstart.md'; import enInstallation from './en/installation.md'; import enConfiguration from './en/configuration.md'; +import enChatGPTPreview from './en/chatgpt-preview.md'; import enCliReference from './en/cli-reference.md'; import enReviewRules from './en/review-rules.md'; import enArchitecture from './en/architecture.md'; @@ -20,6 +21,7 @@ import enAgentSkill from './en/integrations/agent-skill.md'; import enClaudeCode from './en/integrations/claude-code.md'; import enCicd from './en/integrations/ci.md'; import enDelegate from './en/integrations/delegate.md'; +import enAcp from './en/integrations/acp.md'; import enContributing from './en/contributing.md'; import enFaq from './en/faq.md'; @@ -27,6 +29,7 @@ import enFaq from './en/faq.md'; import zhQuickstart from './zh/quickstart.md'; import zhInstallation from './zh/installation.md'; import zhConfiguration from './zh/configuration.md'; +import zhChatGPTPreview from './zh/chatgpt-preview.md'; import zhCliReference from './zh/cli-reference.md'; import zhReviewRules from './zh/review-rules.md'; import zhArchitecture from './zh/architecture.md'; @@ -38,6 +41,7 @@ import zhAgentSkill from './zh/integrations/agent-skill.md'; import zhClaudeCode from './zh/integrations/claude-code.md'; import zhCicd from './zh/integrations/ci.md'; import zhDelegate from './zh/integrations/delegate.md'; +import zhAcp from './zh/integrations/acp.md'; import zhContributing from './zh/contributing.md'; import zhFaq from './zh/faq.md'; @@ -45,6 +49,7 @@ import zhFaq from './zh/faq.md'; import jaQuickstart from './ja/quickstart.md'; import jaInstallation from './ja/installation.md'; import jaConfiguration from './ja/configuration.md'; +import jaChatGPTPreview from './ja/chatgpt-preview.md'; import jaCliReference from './ja/cli-reference.md'; import jaReviewRules from './ja/review-rules.md'; import jaArchitecture from './ja/architecture.md'; @@ -56,6 +61,7 @@ import jaAgentSkill from './ja/integrations/agent-skill.md'; import jaClaudeCode from './ja/integrations/claude-code.md'; import jaCicd from './ja/integrations/ci.md'; import jaDelegate from './ja/integrations/delegate.md'; +import jaAcp from './ja/integrations/acp.md'; import jaContributing from './ja/contributing.md'; import jaFaq from './ja/faq.md'; @@ -63,6 +69,7 @@ import jaFaq from './ja/faq.md'; import koQuickstart from './ko/quickstart.md'; import koInstallation from './ko/installation.md'; import koConfiguration from './ko/configuration.md'; +import koChatGPTPreview from './ko/chatgpt-preview.md'; import koCliReference from './ko/cli-reference.md'; import koReviewRules from './ko/review-rules.md'; import koArchitecture from './ko/architecture.md'; @@ -74,6 +81,7 @@ import koAgentSkill from './ko/integrations/agent-skill.md'; import koClaudeCode from './ko/integrations/claude-code.md'; import koCicd from './ko/integrations/ci.md'; import koDelegate from './ko/integrations/delegate.md'; +import koAcp from './ko/integrations/acp.md'; import koContributing from './ko/contributing.md'; import koFaq from './ko/faq.md'; @@ -81,6 +89,7 @@ import koFaq from './ko/faq.md'; import ruQuickstart from './ru/quickstart.md'; import ruInstallation from './ru/installation.md'; import ruConfiguration from './ru/configuration.md'; +import ruChatGPTPreview from './ru/chatgpt-preview.md'; import ruCliReference from './ru/cli-reference.md'; import ruReviewRules from './ru/review-rules.md'; import ruArchitecture from './ru/architecture.md'; @@ -92,6 +101,7 @@ import ruAgentSkill from './ru/integrations/agent-skill.md'; import ruClaudeCode from './ru/integrations/claude-code.md'; import ruCicd from './ru/integrations/ci.md'; import ruDelegate from './ru/integrations/delegate.md'; +import ruAcp from './ru/integrations/acp.md'; import ruContributing from './ru/contributing.md'; import ruFaq from './ru/faq.md'; @@ -110,8 +120,10 @@ export type DocSlug = | 'claude-code' | 'cicd' | 'delegate' + | 'acp' | 'contributing' - | 'faq'; + | 'faq' + | 'chatgpt-preview'; type LocalizedDocs = Partial>; @@ -119,6 +131,7 @@ const enDocs: Record = { 'quickstart': enQuickstart, 'installation': enInstallation, 'configuration': enConfiguration, + 'chatgpt-preview': enChatGPTPreview, 'cli-reference': enCliReference, 'review-rules': enReviewRules, 'architecture': enArchitecture, @@ -130,6 +143,7 @@ const enDocs: Record = { 'claude-code': enClaudeCode, 'cicd': enCicd, 'delegate': enDelegate, + 'acp': enAcp, 'contributing': enContributing, 'faq': enFaq, }; @@ -138,6 +152,7 @@ const zhDocs: Record = { 'quickstart': zhQuickstart, 'installation': zhInstallation, 'configuration': zhConfiguration, + 'chatgpt-preview': zhChatGPTPreview, 'cli-reference': zhCliReference, 'review-rules': zhReviewRules, 'architecture': zhArchitecture, @@ -149,6 +164,7 @@ const zhDocs: Record = { 'claude-code': zhClaudeCode, 'cicd': zhCicd, 'delegate': zhDelegate, + 'acp': zhAcp, 'contributing': zhContributing, 'faq': zhFaq, }; @@ -157,6 +173,7 @@ const jaDocs: Record = { 'quickstart': jaQuickstart, 'installation': jaInstallation, 'configuration': jaConfiguration, + 'chatgpt-preview': jaChatGPTPreview, 'cli-reference': jaCliReference, 'review-rules': jaReviewRules, 'architecture': jaArchitecture, @@ -168,6 +185,7 @@ const jaDocs: Record = { 'claude-code': jaClaudeCode, 'cicd': jaCicd, 'delegate': jaDelegate, + 'acp': jaAcp, 'contributing': jaContributing, 'faq': jaFaq, }; @@ -176,6 +194,7 @@ const koDocs: Record = { 'quickstart': koQuickstart, 'installation': koInstallation, 'configuration': koConfiguration, + 'chatgpt-preview': koChatGPTPreview, 'cli-reference': koCliReference, 'review-rules': koReviewRules, 'architecture': koArchitecture, @@ -187,6 +206,7 @@ const koDocs: Record = { 'claude-code': koClaudeCode, 'cicd': koCicd, 'delegate': koDelegate, + 'acp': koAcp, 'contributing': koContributing, 'faq': koFaq, }; @@ -195,6 +215,7 @@ const ruDocs: LocalizedDocs = { 'quickstart': ruQuickstart, 'installation': ruInstallation, 'configuration': ruConfiguration, + 'chatgpt-preview': ruChatGPTPreview, 'cli-reference': ruCliReference, 'review-rules': ruReviewRules, 'architecture': ruArchitecture, @@ -206,6 +227,7 @@ const ruDocs: LocalizedDocs = { 'claude-code': ruClaudeCode, 'cicd': ruCicd, 'delegate': ruDelegate, + 'acp': ruAcp, 'contributing': ruContributing, 'faq': ruFaq, }; diff --git a/pages/src/content/docs/ja/chatgpt-preview.md b/pages/src/content/docs/ja/chatgpt-preview.md new file mode 100644 index 000000000..76fe7014d --- /dev/null +++ b/pages/src/content/docs/ja/chatgpt-preview.md @@ -0,0 +1,83 @@ +--- +title: ChatGPT サインインのプレビュー +--- + +OpenCodeReview は OpenAI の公開 Responses API を通じて、対象となる ChatGPT プランの利用枠を使用できます。これはオープンソース CLI の任意のプレビュー機能で、OpenCodeReview が別途サブスクリプション料金を請求することはありません。アカウント、ワークスペース、地域、モデルの利用資格は OpenAI が決定します。 + +`chatgpt` provider は公開されている Sign in with ChatGPT フローを使います。 `ocr auth login`、`status`、`logout` は既定で Sign in with ChatGPT を使用します。明示的な `--provider chatgpt` も引き続き使用できます。 + +## サインインしてモデルを選択 + +```bash +ocr auth login --provider chatgpt +ocr auth status --provider chatgpt +ocr llm models +ocr config set provider chatgpt +ocr config set providers.chatgpt.model gpt-6-luna +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +OAuth 登録では、OpenID Connect と OpenAI の次の scope をすべて要求します: `openid`(本人確認)、`profile` と `email`(アカウント情報)、`offline_access`(リフレッシュトークンによるオフライン更新)、`resource.invoke`(リソース呼び出し)、`chatgpt.tokens.use.direct`(ChatGPT プラン利用枠の直接使用)。初回登録では `agent_name_hint=OpenCodeReview` を送信します。コネクター権限は要求せず、Codex CLI のクライアント登録も再利用しません。この文書化されたフローに必要な scope のみを要求し、任意の追加権限は要求しません。 + +初回サインイン時に OpenAI がクライアント登録を発行し、そのアカウント用に保存します。アプリ名を初回登録時のヒントとして送信し、インストール間で同じ名前を使い、再サインインでは省略します。ローカルインストールを示す安定した不透明な `ext_agent_host_id` は毎回送信し、同じインストールでは同じ ID を使います。 + +認証ごとに新しい state、nonce、PKCE 値を生成します。コード交換前に callback の state を検証します。issuer の RSA JWKS で ID token の署名を検証し、issuer、audience、subject、有効期間、nonce を確認してから ID と付与 scope を信頼します。 + +モデル一覧には、現在のアカウントで表示可能な候補と表示名がサーバーの順序で表示されます。モデルを設定していない場合、provider は一覧の最初の表示モデルを選びます。明示的に設定したモデルまたは `--model` の slug は一覧になくてもそのまま OpenAI に送られます。アカウントで利用できるかどうかは OpenAI が判定します。この provider に固定モデル一覧や別名は組み込まれていません。 + +`extra_body` の推論 effort は API パラメーターです。別の review `--effort` フラグは OpenCodeReview のレビューラウンドとツール予算を制御します。 + +サインインではシステムブラウザーを開き、`127.0.0.1` のみで待ち受けます。`--no-browser` を指定すると一度限りのローカル URL を表示します。CLI は保存済み ID token hint を含む認証 URL を表示しません。このプレビューではデバイスコード認証をサポートしません。 + +サインインできても ChatGPT プラン権限がない場合、OpenCodeReview はアカウント ID を保存し、プラン利用が無効であることを表示します。現在保存されている登録で同意画面を要求するには、次を実行します: + +```bash +ocr auth login --provider chatgpt --enable-plan +# 別の保存済み登録の場合: +ocr auth login --provider chatgpt --enable-plan --account +``` + +この明示的な操作は `prompt=consent`、完全な要求 scope と resource、および保存済みのクライアント ID とホスト ID を送信します。`--new-account` とは併用できません。通常の再ログインでは同意を強制しません。返された grant がプラン利用を許可するまで推論は無効です。代わりに API key provider を設定できます。OpenCodeReview が課金経路を自動で切り替えることはありません。 + +## アカウントとサインアウト + +```bash +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth login --provider chatgpt --account +ocr auth logout --provider chatgpt --account +``` + +発行された各クライアント ID は検証済み issuer と subject に結び付けられます。同じメールアドレスを使う複数の登録も区別されます。再ログインでは同じクライアント ID を再利用します。コード交換で `invalid_grant` が返ると、コールバックで発行されたクライアント ID を使い、新しい state、nonce、PKCE で認証を一度だけ再開します。署名付き ID の検証が終わるまで、この保留中の登録は有効化されません。2 回目も失敗した場合はログインを停止し、既存資格情報を保持します。 + +資格情報データベースは `~/.opencodereview/auth/chatgpt/accounts.json` です。別の `host.json` には安定したインストールホスト ID を保存します。資格情報ファイルはアトミックに置き換えます。 + +Unix では資格情報ファイルに所有者のみ読み書き可能な `0600`、保存ディレクトリに `0700` の権限を設定します。Windows でのアクセス権はローカルユーザープロファイルから継承する ACL に依存し、OpenCodeReview は DACL を設定も検証もしません。Go の [`os.Chmod`](https://pkg.go.dev/os#Chmod) は Windows では読み取り専用属性だけを変更します。ACL で自分のアカウントにアクセスを制限したユーザープロファイルを使い、サインイン前に権限を確認し、共有または広くアクセス可能な保存場所を避けてください。ユーザープロファイル内のディレクトリであるだけでは、非公開のアクセスは保証されません。 + +保存内容の変更と refresh token のローテーションには OS ファイルロックを使いますが、ブラウザー認証はロックの外で実行するため、他のアカウントを利用できます。完了時に保存内容を読み直し、登録の revision を確認します。その登録のログアウト、refresh、または別のログイン完了が保留結果を無効にします。別アカウントの更新は保持されます。access token は期限前にローテーション refresh token で更新します。 + +更新で新しいトークンを受信した後、リクエストのキャンセルや署名検証用の公開鍵を一時的に取得できないことにより検証が中断された場合、OCR は受信済みのトークンを保持します。元のコマンドを再実行してください。CLI を再起動した場合も、次のリクエストで検証を再開します。 + +更新時に返された ID トークンの署名やアカウントの同一性の検証に失敗した場合、OCR は再試行を停止し、再ログインを求めます。エラーに表示されたクライアント ID を使い、次のコマンドを実行してください。 + +```bash +ocr auth login --provider chatgpt --account +``` + +ログインに成功すると、検証に失敗した資格情報が新しい資格情報に置き換わります。 + +ログアウトでは最新の refresh token を使って更新可能なセッションの失効を試みます。ID トークンの検証待ちで保持している新しい refresh token も対象です。その後、access、refresh、ID token を消去し、アカウント登録とホスト ID は保持します。ログアウト後の再ログインでは `id_token_hint` を送信しません。サーバー側の失効を確認できない場合、CLI はローカル token を消去したことを表示し、ChatGPT 設定からアプリを切断するよう案内します。 + +## 通信と利用状況 + +この provider は `https://api.openai.com/v1/responses` のみに送信します。エンドポイント、プロトコル、資格情報の上書きは拒否されます。`store: false` でストリーミングし、履歴全体を送信し、ローカル関数を `ocr` 名前空間にまとめ、後続ターン用に呼び出し ID と暗号化された推論内容を保持します。`response.completed` のみが成功を示します。出力開始後の利用制限エラーを含め、失敗、不完全、中断したストリームではリクエストを停止します。 + +プレビューのリクエストフィールドは OpenAI がサポートする範囲に制限されます。`extra_body` で指定できるのは `reasoning`、`text`、`prompt_cache_key` です。モデル、履歴、ツール、ストリーム方式、保存ポリシーは変更できません。応答では provider の生の利用量データを利用できます。既存の任意の生レビュー記録は認証ヘッダーを含めずにストリームを記録します。 + +**ChatGPT プランの利用:** 対象リクエストは選択したアカウントの ChatGPT プランを使用します。[ChatGPT 設定で利用状況を管理](https://chatgpt.com/settings/usage)。利用制限エラーはプラン全体またはアプリ固有の制限を示す場合があります。CLI はリセット時刻を推測しません。 + +現在の要件は OpenAI の[オープンソース SIWC 概要](https://developers.openai.com/siwc/token-sharing-open-source)、[サインイン仕様](https://developers.openai.com/siwc/token-sharing-open-source/sign-in)、[プレビュー制限](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations)を参照してください。 + +`ocr auth` と `ocr llm models` のコマンドは [CLI リファレンス](../cli-reference/)を、provider 設定は[設定ガイド](../configuration/)を参照してください。 diff --git a/pages/src/content/docs/ja/cli-reference.md b/pages/src/content/docs/ja/cli-reference.md index 50c3c2d2f..30c75bcdb 100644 --- a/pages/src/content/docs/ja/cli-reference.md +++ b/pages/src/content/docs/ja/cli-reference.md @@ -117,7 +117,7 @@ ocr r [flags] (alias) | `--background-file ` | `-B` | — | レビューの背景として使用する Markdown ファイルのパス。`--background` も指定した場合は両方を結合します。 | | `--exclude ` | — | — | 除外する gitignore 形式のパターン(カンマ区切り)。`rule.json` の excludes とマージされます。 | | `--concurrency ` | — | `8` | 並行してレビューするサブタスクの最大数。 | -| `--timeout ` | — | `15` | サブタスクごとの締め切り時間。`0` でタイムアウトを無効化します。effort ラウンド数に応じて線形にスケールします(例: low/medium/high で 15/30/45 分)。 | +| `--timeout ` | — | `15` | LLM 呼び出し、ツール実行、再試行の待機を含むサブタスクごとの制限時間。`0` でタスクの期限を無効化しますが、リクエストのタイムアウトは無効化しません。リクエスト単位のタイムアウトは `OCR_LLM_TIMEOUT` または provider の `timeout_sec` で独立して設定し、秒単位です(デフォルト `300`)。effort のレビューラウンド数に応じて線形にスケールします(例: low/medium/high で 15/30/45 分)。 | | `--rule ` | — | — | カスタム JSON レビュールールファイルのパス。プロジェクトレベルおよびグローバルの `rule.json` を上書きします。 | | `--max-tools ` | — | テンプレートのデフォルト | サブタスクごとの最大ツール呼び出し回数。`0` はテンプレートのデフォルト(`100`)を使用します。1〜49 は `50` に引き上げられます。解決後の値はテンプレートのデフォルトを**上回る場合にのみ**適用されます(引き上げのみ可能で、引き下げはできません)。 | | `--max-tokens ` | — | 設定またはテンプレートのデフォルト | サブタスクごとの**プロンプト**トークン上限(review のデフォルトは `200000`)。この実行で保存済みの `max_tokens` 設定を上書きします。出力の上限には影響しません。そちらは `MAX_COMPLETION_TOKENS`(`16384`)が個別に制御します。 | @@ -149,6 +149,11 @@ ocr scan --provider openai --model gpt-5.4 --format json ソース順序は変更しません。不完全な戦略は別の戦略と混合されず、次へフォールバックします。 選択された組み込み provider の認証情報は、対応する環境変数から引き続き取得できます。 +組み込み provider の `--model` は、`ocr config model` の選択候補以外も受け付けます。 +組み込み一覧にも `providers..models` にもないモデルを指定すると、OCR は +stderr に警告を出し、検証は provider に任せます。カスタム provider には +従来の `--model` 検証ルールが適用されます。 + ### モード #### ワークスペースモード(デフォルト) @@ -347,7 +352,7 @@ ocr s [flags] (alias) | 引数 | 短縮形 | デフォルト | 説明 | |---|---|---|---| -| `--path ` | - | リポジトリ全体 | スキャン対象のリポジトリ相対ディレクトリまたはファイル(カンマ区切り、例: `internal/agent`、`internal/llm/client.go`)。 | +| `--path ` | - | リポジトリ全体 | スキャン対象のリポジトリ相対ディレクトリまたはファイル(カンマ区切り、例: `internal/agent`、`internal/llm/client.go`)。セレクターは作業ツリー上の実際のパスとして解決されます。綴りどおりの一致を優先するため `src` が兄弟ディレクトリ `SRC/` を選ぶことはなく、大文字小文字を区別しないファイルシステム(Windows、既定の macOS)では、ファイルシステムが解決できる別の綴りでも対応するファイルを選びます。存在しないパスを指定した場合は、空のスキャンではなくエラーになります。 | | `--exclude ` | - | - | 除外する gitignore 形式のパターン(カンマ区切り、例: `**/generated/*,*.pb.go`)。`rule.json` の excludes とマージされます。 | | `--output ` | `-o` | 標準出力 | スキャン結果を UTF-8 ファイルに書き込みます(`-` は標準出力を表します)。初回書き込み時に遅延作成されるため、実行が失敗しても既存のファイルは変更されません。テキスト形式では ANSI カラーコードが自動的に削除されます。 | | `--preview` | `-p` | `false` | LLM を呼び出さずにファイルを列挙・フィルタリングします。ファイルリスト、レビュー対象/除外数、総行数、ファイルごとの除外理由を出力します。`--format json` に対応しています。`--format sarif` はサポートされていません。 | @@ -553,9 +558,30 @@ ocr config model Interactive model selection key の完全なリファレンス、schema、例は[設定](../configuration/)を参照してください。 + + +## `ocr auth` + +`ocr auth login`、`status`、`logout` は既定で Sign in with ChatGPT を使用します。明示的な `--provider chatgpt` も引き続き使用できます。 + +`chatgpt` provider では公開された Sign in with ChatGPT フローを使用します。[ChatGPT プレビューガイド](../chatgpt-preview/)を参照してください。 + +```bash +ocr auth login --provider chatgpt +ocr auth login --provider chatgpt --no-browser +ocr auth login --provider chatgpt --enable-plan +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth logout --provider chatgpt --account +ocr llm models +``` + +`ocr auth` は `--provider chatgpt` と `--account ` を受け付けます。`login` はさらに `--no-browser`、`--new-account`、`--enable-plan` を受け付けます。ChatGPT では `--device` をサポートしません。`--enable-plan` は保存済み登録の同意を要求し、`--new-account` とは併用できません。モデル一覧はアカウントごとに異なります。一覧にないモデルも明示すれば使用できます。この provider の `extra_body` は `reasoning`、`text`、`prompt_cache_key` のみ対応します。アカウント管理、権限、制限は[ChatGPT プレビューガイド](../chatgpt-preview/)を参照してください。 + ## `ocr llm` -LLM ユーティリティコマンドです。2 つのサブコマンドがあります: +LLM ユーティリティコマンドです。3 つのサブコマンドがあります: ```text ocr llm @@ -563,6 +589,7 @@ ocr llm Sub-commands: test Send a test conversation to the configured LLM model providers List all built-in LLM providers + models List current account-specific ChatGPT models ``` ### `ocr llm test` diff --git a/pages/src/content/docs/ja/configuration.md b/pages/src/content/docs/ja/configuration.md index 1533b435b..2ae44c016 100644 --- a/pages/src/content/docs/ja/configuration.md +++ b/pages/src/content/docs/ja/configuration.md @@ -37,13 +37,15 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ### 組み込み provider 以下の provider が OCR に同梱されており、Base URL とプロトコルがプリセット -されています——選択後は API key を入力するだけです。`providers..api_key` -が未設定の場合は、対応する環境変数に自動的にフォールバックします。 +されています。多くは API key が必要です。`providers..api_key` が未設定の +場合は対応する環境変数を使用します。`chatgpt` は代わりに `ocr auth login` で +作成した ChatGPT 認証情報を使用します。 | 名称 | プロトコル | Base URL | API key 環境変数 | |---|---|---|---| | `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | `bedrock` | anthropic-bedrock | `aws_region` から決定 | —(AWS 認証情報チェーン) | +| `chatgpt` | openai-responses | `https://api.openai.com/v1` | 該当なし(Sign in with ChatGPT) | | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | | `openai-responses` | openai-responses | `https://api.openai.com/v1` | `OPENAI_RESPONSES_API_KEY` | | `openrouter` | openai | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | @@ -67,6 +69,27 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx | `novita` | openai | `https://api.novita.ai/openai` | `NOVITA_API_KEY` | | `xai` | openai | `https://api.x.ai/v1` | `XAI_API_KEY` | +組み込み provider のモデル一覧は `ocr config model` の選択候補であり、`--model` +の制限には使われません。組み込み一覧にも `providers..models` にもないモデルを +指定すると、OCR は stderr に警告を出します。モデルはリクエスト送信時に provider が +検証します。カスタム provider には従来の `--model` 検証ルールが適用されます。 + +## ChatGPT サインインのプレビュー + +このプレビューでは公開 Responses API 用の組み込み `chatgpt` provider が追加されます。API key 環境変数はありません。 サインイン、同意、利用制限は[ChatGPT プレビューガイド](../chatgpt-preview/)を参照してください。 + +`ocr config set` で provider と任意のリクエストフィールドを設定します。`ocr llm models` から選ぶか、モデル slug を明示します: + +```bash +ocr auth login --provider chatgpt +ocr config set provider chatgpt +ocr config set providers.chatgpt.model +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +アカウント別一覧にないモデルも明示すれば送信されます。`extra_body` は `reasoning`、`text`、`prompt_cache_key` のみ受け付けます。エンドポイント、プロトコル、資格情報の上書きは拒否されます。 + ### 組み込み provider の Base URL を上書きする 各組み込み provider にはプリセット Base URL があります(上表を参照)。 @@ -74,6 +97,8 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx デフォルト `http://localhost:4000/v1` とは異なることが多い自前 LiteLLM ゲートウェイなど——`providers..url` を設定します: +`chatgpt` プレビューは例外です。公開エンドポイントは固定されており、URL の上書きは拒否されます。 + ```bash ocr config set provider litellm ocr config set model openai/gpt-5.4 @@ -87,7 +112,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1 ### AWS Bedrock -`bedrock` は `anthropic` と同じ Messages API を話しますが、API key を持たせる +`bedrock` は既定の `anthropic-bedrock` プロトコルで `anthropic` と同じ Messages API を使いますが、API key を持たせる 代わりに標準の AWS 認証情報チェーンから SigV4 署名を行い、ホストはリージョンが 決めます。設定すべき `api_key` はなく、署名の代わりとして受け付けることも ありません。 @@ -99,6 +124,8 @@ ocr config set providers.bedrock.aws_region us-west-2 ocr config set providers.bedrock.aws_profile example-profile ``` +OpenAI 互換ゲートウェイに接続するために `providers.bedrock.protocol` を `openai` に設定する場合は、`providers.bedrock.url` にゲートウェイの URL を設定し、`providers.bedrock.api_key` または `providers.bedrock.api_key_cmd` でその API キーを指定してください。`ocr config provider` でモデルを変更しても、保存済みの API キーは保持されます。 + | フィールド | 意味 | |---|---| | `providers.bedrock.aws_region` | リクエストを処理する `bedrock-runtime` ホストのリージョン。未設定なら `AWS_REGION` または有効なプロファイルにフォールバックします。 | @@ -115,7 +142,7 @@ ocr config set providers.bedrock.aws_profile example-profile そのアカウントが提供するものを確認できます。なお新しいファミリーでは `-v1:0` のようなバージョンサフィックスは無効です。 -bedrock には設定された URL がなく、ホストはリージョンが決めるため、 +既定の `anthropic-bedrock` プロトコルではホストがリージョンによって決まるため、 `ocr llm test` は URL の代わりにリージョンとプロファイルを表示します。 ``` @@ -133,6 +160,13 @@ Model: claude-sonnet-5 ### カスタム provider +`mimo-v2.6-flash` や `foo.api_key` のような名前を使用できます。ドットを含む新しい +名前のフィールドを設定する前に、`ocr config set provider ` で作成して選択してください。 +この操作は現在の provider を変更します。既存のエントリは選択せずに +`providers..` または `custom_providers..` で編集できます。 +名前の先頭・末尾のドット、連続したドット、空または空白のみの区切り部分は使用できません。 +ドットを含まない名前は引き続きフィールドの設定時に自動作成されます。 + 上記の表にない provider 名はすべてカスタムとみなされ、少なくとも `url` と `protocol` を指定する必要があります(`protocol` は `anthropic`、`openai`、 `openai-responses`、または `anthropic-bedrock`)。 @@ -190,6 +224,16 @@ Ollama は API key を無視しますが、カスタム provider は空でない ### タイムアウト(Timeouts) +タスクの制限時間とリクエスト単位のタイムアウトは独立しています。 + +- `ocr review --timeout` と `ocr scan --timeout` は並行タスクの制限時間を + **分**単位で設定します(デフォルトは **15**、`0` でタスクの期限を無効化)。 + この時間には、タスク内のすべての LLM 呼び出し、ツール実行、再試行の待機が含まれます。 + review では effort に応じたレビューラウンド数を掛けた時間になります。 +- HTTP リクエスト単位のタイムアウトは**秒**単位です(デフォルトは **300**)。 + `--timeout` を増やしても変更されず、タスクの期限を無効にしてもリクエストの + タイムアウトは無効になりません。タスクの残り時間の方が短い場合は、その時間が優先されます。 + 各 LLM リクエストには HTTP タイムアウトがあり、デフォルトは **300 秒**です。 遅いローカルモデル(あるいは大きなファイル)では、それ以上の時間が必要になることがあります。 スコープの狭い順に、3 つの設定があります。 @@ -210,6 +254,25 @@ Ollama は API key を無視しますが、カスタム provider は空でない } ``` +例えば、`OCR_LLM_TIMEOUT=900 ocr review --timeout 30` では、タスクの制限時間内で +単一のリクエストを最大 15 分間実行できます。この環境変数も `timeout_sec` も +設定しなければ、`--timeout 30` を指定してもリクエストは 5 分でタイムアウトします。 + +リクエスト単位の期限を超えると、自動的に再送せずに呼び出しを終了します。 +レスポンス本文やストリームの読み取り中にタイムアウトした場合も同様です。 +常に時間がかかるリクエストでは、同じ制限時間で再試行するのではなく、リクエストの +タイムアウトを延ばしてください。既存の SDK の再試行ポリシーは変わりません。 +再試行可能な接続エラーや HTTP エラーに対しては、バックオフとプロバイダーの +`Retry-After` 指示に従って最大 5 回再試行します。SDK の試行ごとの期限は、 +再試行の待機時間にも適用されます。キャンセルまたはタスクの期限切れにより、 +リクエストと再試行の待機の両方が停止します。 + +診断メッセージでは `LLM request timeout`(`OCR_LLM_TIMEOUT` または provider の +`timeout_sec` を確認)と `caller deadline exceeded` を区別します。後者は呼び出し元の +処理のコンテキストの期限が切れたことを示します。review/scan タスクの期限切れなら +`--timeout` を確認してください。バックグラウンドのメモリ圧縮や `ocr llm test` などの +処理には、それぞれ独立した期限があります。 + ### API key をコマンドで取得する key を設定ファイルに保存する代わりに、`api_key_cmd` で実行時にシークレット diff --git a/pages/src/content/docs/ja/contributing.md b/pages/src/content/docs/ja/contributing.md index 17d8c1395..8e38e9f31 100644 --- a/pages/src/content/docs/ja/contributing.md +++ b/pages/src/content/docs/ja/contributing.md @@ -25,7 +25,7 @@ Go を書かなくても貢献できます。 ### 前提条件 -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -110,39 +110,56 @@ refactor(viewer): extract task-card rendering into helper **PR タイトル**も同じフォーマットを使うと、生成される changelog に整然と表示されます。 +`PR Commit Policy` は、PR タイトルと本文を含むすべてのコミットメッセージを、 +大文字・小文字を区別せずに検査します。`[skip ci]`、`[ci skip]`、`[no ci]`、 +`[skip actions]`、`[actions skip]`、または `skip-checks: true` / `skip-checks:true` +だけの行を含めないでください。 + +検査に失敗した場合は、PR タイトルを編集するか、自分の作業ブランチで該当するコミットメッセージを書き換えてください。 +これらの指示を含まないコミットを追加しても、過去のコミットの違反は解消されません。 +PR の説明ではこれらの指示について議論でき、検査の対象にはなりません。 + ## プロジェクト構成 ``` open-code-review/ +├── acp/ # CLI 向けの独立した ACP(Agent Client Protocol)アダプター ├── cmd/opencodereview/ # CLI エントリーポイント——引数解析、ディスパッチ +├── docs/ # 各言語の README と貢献ガイド +├── imgs/ # README 用の画像とロゴ ├── internal/ │ ├── agent/ # レビューエージェントのロジック、サブエージェントのディスパッチ +│ ├── chatgptauth/ # ChatGPT OAuth と認証情報の管理 │ ├── config/ # テンプレート、ルール、ホワイトリスト、埋め込み JSON -│ ├── diff/ # Git diff の解析、3 つのモード +│ ├── delegate/ # LLM を呼び出さないレビュー仕様の生成 +│ ├── diff/ # Git diff の解析 │ ├── gitcmd/ # Git サブプロセスランナー -│ ├── llm/ # LLM client(Anthropic と OpenAI)、エンドポイント解決 +│ ├── llm/ # LLM クライアントとエンドポイント解決 +│ ├── llmloop/ # Review と Scan で共有する LLM ツール呼び出しループ +│ ├── mcp/ # MCP クライアントとツールアダプター │ ├── model/ # データ構造(LlmComment、Diff……) │ ├── pathutil/ # パスユーティリティ -│ ├── release/ # Release notes の生成 +│ ├── release/ # リリース成果物の命名整合性チェック +│ ├── scan/ # ファイル全体のコードレビュー │ ├── session/ # JSONL セッションライター │ ├── stdout/ # ミュート可能な stdout writer │ ├── suggestdiff/ # 提案 diff のレンダリング │ ├── telemetry/ # OpenTelemetry の設定 + ヘルパー │ ├── tool/ # ツールレジストリ + provider の実装 -│ └── viewer/ # 埋め込み HTTP UI -├── pages/ # WebUI マーケティングページ(独立した React app) -├── plugins/ # Claude Code slash コマンド -├── extensions/ # エディタ拡張(VS Code) +│ └── viewer/ # レビューセッションを閲覧する埋め込み HTTP UI +├── pages/ # 公式サイトのランディングページとドキュメント(独立した React app) +├── plugins/ # コーディングエージェントとの連携(Claude Code、Codex、OpenCode など) +├── extensions/ # VS Code と IntelliJ IDEA の拡張、共有フロントエンド ├── examples/ # CI レシピ(GitHub Actions、GitLab CI) -├── skills/ # Agent SDK skill manifest -├── scripts/ # NPM postinstall + クロスプラットフォームビルドスクリプト +├── skills/ # レビューと委譲のスキル定義 +├── scripts/ # インストール、公開、リポジトリチェック用スクリプト ├── npm/ # 各プラットフォーム向け optional dependency パッケージ └── bin/ # NPM wrapper(Node) ``` ほとんどの貢献は `internal/agent/`、`internal/tool/`、`internal/llm/` に触れます。 -`cmd/opencodereview/` の CLI レイヤーは意図的に薄く保たれています——引数を解析してから -agent パッケージへディスパッチします。 +`cmd/opencodereview/` の CLI レイヤーは意図的に薄く保たれています——引数の解析と +コマンドのディスパッチを担当します。 ## AI支援開発 diff --git a/pages/src/content/docs/ja/installation.md b/pages/src/content/docs/ja/installation.md index 8a8ad9ed9..bc29c0b16 100644 --- a/pages/src/content/docs/ja/installation.md +++ b/pages/src/content/docs/ja/installation.md @@ -147,7 +147,7 @@ OCR 自体を変更する場合、またはプリコンパイル済みバイナ #### 前提条件 -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) diff --git a/pages/src/content/docs/ja/integrations/acp.md b/pages/src/content/docs/ja/integrations/acp.md new file mode 100644 index 000000000..e4484f793 --- /dev/null +++ b/pages/src/content/docs/ja/integrations/acp.md @@ -0,0 +1,216 @@ +--- +title: ACP +sidebar: + order: 6 +--- + +## 前提条件 {#prerequisites} + +- [OCR CLI](../../installation/) をインストールし、[レビューモデル](../../configuration/) + を設定してください。`ocr llm test` で接続を確認できます。 +- アダプターのビルドには、`acp/` ディレクトリを含むソースのチェックアウト、Git、 + Go 1.23 以降が必要です。親プロジェクトの OCR CLI のビルドには Go 1.25.5 以降が必要です。 +- ACP v1 stdio とカスタム Agent 起動コマンドに対応したクライアントを使用してください。 + +アダプターは OCR CLI とは別にビルドします。以下の手順ではローカルでビルドした +ものを使用します。 + +## ビルドとクライアントへの接続 {#build-and-connect-your-client} + +リポジトリのルートで実行します。 + +```bash +make -C acp build +command -v ocr +``` + +ビルド成果物は `dist/ocr-acp` です。`command -v ocr` が出力した絶対パスを `--ocr-binary` に指定してください。 + +クライアントの Agent 設定に次の構成を追加してください。すべての `/absolute/path` と解析モデルの値を実際の値に置き換えます。 + +```json +{ + "agent_servers": { + "OpenCodeReview": { + "type": "custom", + "command": "/absolute/path/open-code-review/dist/ocr-acp", + "args": ["--ocr-binary", "/absolute/path/ocr"], + "env": { + "OCR_ACP_PARSER_PROVIDER": "anthropic", + "OCR_ACP_PARSER_MODEL": "deepseek-flash", + "OCR_ACP_PARSER_BASE_URL": "https://your-gateway.example", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" + } + } + } +} +``` + +フィールド名はクライアントごとに異なるため、ACP 設定手順を参照してください。 + +## レビューとスキャンの実行 {#run-a-review-or-scan} + +OpenCodeReview の会話で、以下のコマンドを送信します。 + +| リクエスト | コマンド | +| --- | --- | +| ステージ済み、未ステージ、未追跡の変更をレビュー | `/review` | +| レビューを最大 1 ラウンド実行 | `/review --effort low` | +| 現在チェックアウトしている最新コミットをレビュー | `/review --commit HEAD` | +| 既存の 2 つの参照を比較 | `/review --from main --to HEAD` | +| ディレクトリをスキャン | `/scan --path internal/agent` | +| 複数のパスをスキャン | `/scan --path internal/agent,internal/config` | + +例の参照とパスは、プロジェクト内のものに置き換えてください。レビューには Git +リポジトリが必要で、指摘のパスはそのルートを基準に解決します。スキャンではセッションの +作業ディレクトリを使用します。`--effort low` は最大 1 ラウンド、`medium` は最大 2 ラウンド、 +`high` は最大 3 ラウンドのレビューを実行します。新しい指摘がなければ、早期に終了する場合があります。 +省略すると OCR の設定を維持します。 +ラウンドを減らすと問題を見逃す可能性があるため、速度と品質のバランスを考慮してください。 + +ACP のコマンド候補に対応したクライアントでは、`/` を入力するとコマンドと引数の +ヒントを確認できます。ブランチやパスの動的補完は提供しません。クライアントが +ローカルファイルへのリンクを添付する場合は、`/scan` と一緒に送り、`--path` を +省略してください。リンクと `/review` または明示的なスキャンパスは併用できません。 +未対応または曖昧なリンクは拒否し、スキャン範囲を自動で広げることはありません。 + +アダプターが受け付ける CLI オプションは以下に限定されており、任意のオプションを +そのまま渡すことはできません。 + +- レビュー:`--commit`、`--from`、`--to`、`--effort`、`--no-filter`、 + `--background`、`--background-file`。 +- スキャン:`--path`、`--batch`、`--no-plan`、`--no-dedup`、`--no-summary`、 + `--background`。 + +変更全体をレビューするには `/review` を使用してください。`--staged` は未対応です。 +会話内のコマンドでは、`--format` や `--audience` などの出力オプション、 +`--repo`、`--model`、`--provider` などの上書きオプションは受け付けません。 +レビューモデルは OCR 側で設定してください。 + +## 自然言語のリクエストを有効にする {#enable-natural-language-requests} + +解析用モデルは、自然言語のリクエストをレビューやスキャンのコマンドに変換するか、 +不明点を確認します。OCR のレビューモデルとは別に設定します。 +クライアント自体のモデルを選択しても、この 2 つのモデルは設定されません。 + +agent の `env` オブジェクトに以下の環境設定を追加し、モデルとキーのプレースホルダーを +プロバイダーの値に置き換えてください。 + +```json +{ + "OCR_ACP_PARSER_PROVIDER": "openai", + "OCR_ACP_PARSER_MODEL": "YOUR_TOOL_CALLING_MODEL", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" +} +``` + +この例では OpenAI 互換の Chat Completions プロトコルを使用します。Anthropic Messages +プロトコルには `anthropic` を指定します。互換ゲートウェイを使用する場合は、 +`OCR_ACP_PARSER_BASE_URL` に API のベース URL も設定してください。認証情報は +ローカル設定に保存するか、agent プロセスの環境変数で渡してください。共有する +プロジェクト設定に含めてコミットしないでください。GUI アプリは、ターミナルで +エクスポートした環境変数を引き継がない場合があります。 + +ベース URL のパス規則はプロトコルによって異なります。 + +| プロトコル | `OCR_ACP_PARSER_BASE_URL` の例 | リクエスト先 | +| --- | --- | --- | +| `openai` | `https://gateway.example/v1` | `https://gateway.example/v1/chat/completions` | +| `anthropic` | `https://gateway.example` | `https://gateway.example/v1/messages` | + +それぞれ `/chat/completions` または `/v1/messages` で終わる完全なエンドポイントも使用できます。 +その場合、同じ接尾辞は追加しません。Anthropic のベース URL が `/v1` で終わると +`/v1/v1/messages` になるため、ゲートウェイの実際の API パスに合わせて指定してください。 + +| 環境変数 | 起動フラグ | 用途 | +| --- | --- | --- | +| `OCR_ACP_PARSER_PROVIDER` | `--parser-provider` | `openai` または `anthropic` | +| `OCR_ACP_PARSER_MODEL` | `--parser-model` | ツール呼び出しに対応したモデル | +| `OCR_ACP_PARSER_BASE_URL` | `--parser-base-url` | API ベース URL の上書き(任意) | +| `OCR_ACP_PARSER_API_KEY` | なし | 解析用 API キー。環境変数のみ | + +起動フラグは、対応する環境変数より優先されます。解析用モデルは OCR の設定や +`OCR_LLM_*` 変数を読み取りません。対応するプロトコルは `openai` と `anthropic` +であり、ここでは `openai-responses` と `anthropic-bedrock` は使用できません。 + +Anthropic の解析リクエストでは、必須の `submit_intent` ツール呼び出しを使用するため、 +Thinking を明示的に無効にします。OCR のレビューモデルの Thinking 設定は変更しません。 + +解析用モデルを設定したら、新しい会話を開始して試してください。 + +```text +作業ディレクトリの変更をレビューしてください。 +現在チェックアウトしている最新コミットをレビューしてください。 +internal/agent をスキャンして、潜在的なバグを探してください。 +``` + +リクエストが曖昧な場合は、実行前に確認への回答が必要です。モデルには、質問や案内を +ユーザーと同じ言語で返すよう指示しています。固定の検証メッセージとレポートのラベルは +英語のままです。解析用モデルが利用できなくても、スラッシュコマンドは使えます。 +解析用モデルの設定が不完全な場合は、起動時に明示的なエラーが発生します。 + +「現在チェックアウトしている最新コミット」は `/review --commit HEAD` に対応します。 +セッションは確認待ちのリクエストを 1 件だけ保持します。連続した確認では互換性のある複数の既知の +フィールドを保持し、明示された新しい値を優先します。異なるレビュー種別のフィールドは混在させません。 +完了またはキャンセルで状態を消去し、別のセッションには復元しません。 + +## 結果の確認とキャンセル {#read-results-and-cancel-work} + +進捗には既存の OCR CLI ログと最終 JSON を使用し、CLI の変更は不要です。最後に報告された処理と総経過時間を表示します。グループの段階、段階ごとの時間、モデル応答待ち時間は推測して表示しません。 + +単一の **OCR review** または **OCR scan** 項目に、進捗とローカルで計測した実行時間が +表示されます。展開すると **Command:**、作業ディレクトリ、サイズ制限付きのプレーン +テキストログを確認できます。診断情報は詳細に保持され、進捗タイトルを置き換えません。 +コマンドとログは会話本文に繰り返し表示されません。初期の展開状態はクライアントが決定します。 + +最終レポートは完了状態、ファイル数・指摘数、重要度別の件数から始まります。各指摘の +見出しには番号、重要度、ファイル位置を表示し、その下に分類、説明、言語指定付きの +コードブロックを配置します。提案コードには未適用であることを明記します。指摘は最終本文に +一度だけ表示されます。有効なファイル位置はリンクになり、存在しないファイル、範囲外の行、 +作業ルート外のパスは通常のテキストになります。表示とクリック動作はクライアントのバージョンに依存します。 + +一部失敗の詳細と予算上限の通知は保持されます。末尾には取得できた累計トークン数と OCR が +報告した所要時間を表示します。実行詳細には、OCR が提供したツール呼び出し合計と入力・出力・ +キャッシュのトークン数も表示し、欠けた内訳を補完しません。ライブタイマーはローカルの実行時間を +計測するため、OCR の報告時間と異なる場合があります。トークン合計はすべてのモデルリクエストを +含み、新しく生成された回答だけのトークン数ではありません。 + +クライアントのキャンセル操作で停止できます。アダプターは処理を +キャンセルし、管理対象プロセスの終了処理が完了するまで待機します。解析を含むターン全体に +制限時間を設けるには、agent の `args` 配列に `"--turn-timeout", "10m"` を追加します。 +デフォルトは `0` で、ターン全体のタイムアウトは無効です。解析には個別にデフォルト +15 秒の制限があります。タイムアウト時は、アダプターが再試行を案内します。 + +## トラブルシューティング {#troubleshooting} + +| 症状 | 確認すること | +| --- | --- | +| agent が起動しない | 2 つの実行ファイルの絶対パスと実行権限を確認してください。`ocr llm test` で OCR の設定を確認し、不完全な解析用モデルの設定は削除するか、必要な値をすべて指定してください。 | +| スラッシュコマンドは動くが、自然言語は使えない | 別途 `OCR_ACP_PARSER_*` 環境変数を設定してください。プロバイダー、モデル、キー、ゲートウェイ URL を確認してください。 | +| 自然言語の解析がタイムアウトする | 解析のデフォルト制限は 15 秒で、この段階では OCR は未起動です。診断のリクエスト段階、HTTP ステータス、プロバイダーの応答から原因を調べてください。`--turn-timeout` を延長しても解析自体の制限は延びません。再試行するか、`/review`、`/scan` で解析用モデルを迂回できます。 | +| コマンドやフラグが拒否される | 上記の対応オプションを使用してください。`/review --path` の代わりに `/scan --path` を使用し、コミットや範囲のレビューには既存の Git 参照を指定してください。 | +| レビューが失敗する、または一部の結果しか返らない | OCR review/scan を展開して診断情報を確認し、最終結果の失敗詳細を読んでください。OCR のモデル設定とプロバイダーの稼働状況を確認してください。 | +| 結果の位置をクリックできない | ファイルが処理対象のルート内に存在し、行範囲が有効か確認してください。テキストだけを表示するフォールバックは意図した動作です。 | +| 処理に時間がかかりすぎる | キャンセルするか、進捗の詳細を確認するか、ターン全体のタイムアウトを設定してください。レビューでは `--effort low` でラウンド数を減らせます。 | +| 再ビルド後も古い動作のまま | 設定したバイナリのパスを確認し、新しい agent 会話を開始してください。古いプロセスを再利用している場合は、agent またはクライアントを再起動してください。 | + +接続に問題がある場合は、クライアントの ACP ログとアダプターの stderr を確認します。 +共有する前に、認証情報と非公開のソース情報を削除してください。 + +## 更新とサポート範囲 {#upgrade-and-support-boundaries} + +ソースのチェックアウトを更新し、`make -C acp build` を再実行して、新しい +OpenCodeReview の会話を開始してください。実行中のプロセスや過去のメッセージは +再読み込みされません。セッションの復元には対応していません。新しい会話は、回答待ちの +確認状態を引き継がずに開始します。 + +アダプターは現在、ローカルの stdio トランスポートを使用します。HTTP トランスポート、 +追加のワークスペースルート、ファイルの自動編集、画像や音声のプロンプトには対応していません。 +ほかの ACP クライアントは、それぞれ互換性の確認が必要です。プロトコルテストの成功だけで、 +すべてのクライアントの画面上の動作が確認できるわけではありません。 + +## 関連項目 {#see-also} + +- [設定](../../configuration/) — OCR のレビューモデルとプロバイダーの設定。 +- [CLI リファレンス](../../cli-reference/) — レビューとスキャンのオプション詳細。 +- [ACP の紹介](https://agentclientprotocol.com/get-started/introduction) — Agent Client Protocol の公式紹介。 diff --git a/pages/src/content/docs/ja/integrations/delegate.md b/pages/src/content/docs/ja/integrations/delegate.md index f0899285a..0792bd7db 100644 --- a/pages/src/content/docs/ja/integrations/delegate.md +++ b/pages/src/content/docs/ja/integrations/delegate.md @@ -81,24 +81,42 @@ ocr delegate rule ... ### ステップ 3:diff の取得 -ステップ 1 の mode/ref 情報に基づき、git を直接使用: +ステップ 1 のモードと参照情報に応じて diff コマンドを選びます。レビュー中のすべての Git 呼び出しで `git --no-pager` を使います。 + +以下の diff コマンドは外部 diff プログラム、テキスト変換、カラー出力を無効にして、プレーンテキストのパッチを取得します。 **Range モード**(merge\_base あり): ```bash -git diff .. -- +git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- "" ``` **Commit モード**: ```bash -git show -- +git --no-pager show --no-ext-diff --no-textconv --no-color -- "" ``` **Workspace モード**: ```bash -git diff HEAD -- # 追跡ファイル -cat # 新規未追跡ファイル +git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- "" # 追跡ファイル +cat "" # 新規未追跡ファイル +``` + +コンテキストを取得するには: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" ``` +大きな diff では、選択したモードのコマンドに `--output=""` を追加します。リポジトリ外の一意な絶対パスを選び、親ディレクトリを作成します。ファイル読み取りツールでも同じパスを使います: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +Git が終了コード 0 で終了したら、ファイル全体を分割して読み取り、レビューを終えてから削除します。コマンドが失敗またはタイムアウトした場合は、再試行するか、エラーを記録して `skipped` にします。出力が予期せず空の場合は、preview を再実行して照合します。 + ### ステップ 4:各ファイルのレビュー レビュー可能な各ファイルについて: @@ -107,6 +125,8 @@ cat # 新規未追跡ファイル 2. 対応するルールグループ(ステップ 2)をレビューチェックリストとして参照 3. コンテキスト探索を必要に応じて行い、徹底的にレビュー +preview の各 `(path, status)` を `reviewed` または `skipped` として記録し、スキップした項目には理由を付けます。レポートには `total_files`、`reviewed_files`、`skipped_files`、`coverage_rate` を含めます。 + ### ステップ 5:レポート 重要度で分類: diff --git a/pages/src/content/docs/ja/review-rules.md b/pages/src/content/docs/ja/review-rules.md index 7c5b6e19d..d435e585f 100644 --- a/pages/src/content/docs/ja/review-rules.md +++ b/pages/src/content/docs/ja/review-rules.md @@ -165,6 +165,11 @@ OCR は [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest - `**/test/**/*.lhs` - `**/*Spec.lhs` - `**/tests/**/*.nim` +- `**/tests/**/*.nu` +- `**/test/**/*.nu` +- `**/*_test.nu` +- `**/test_*.nu` +- `**/test-*.nu` - `**/tests/**/*.R` - `**/__snapshots__/**` - `**/*.snap` @@ -249,7 +254,7 @@ OCR は [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest | `**/*.astro` | `astro.md`: Astro コンポーネントと islands。 | | `**/*.{ts,js,tsx,jsx,mjs,cjs}` | `ts_js_tsx_jsx.md` | | `**/*.{kt,kts}` | `kotlin.md` | -| `**/*.{fs,fsi,fsx}` | `fsharp.md`: F# の実装、シグネチャ、スクリプトファイル。 | +| `**/*.{fs,fsi,fsx}` | `fsharp.md`: F# の実装、シグネチャ、スクリプトファイル。 | | `**/*.rs` | `rust.md` | | `**/*.R` | `r.md` | | `**/*.{cpp,cc,cxx,hpp,hxx}` | `cpp.md` | @@ -262,12 +267,14 @@ OCR は [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest | `**/*.{graphql,gql}` | `graphql.md`: GraphQL スキーマと操作。 | | `**/*.prisma` | `prisma.md`: Prisma スキーマ。 | | `**/*.jl` | `julia.md`: Julia ソースコード。 | -| `**/*.{tf,hcl,tfvars}` | `terraform.md`: Terraform / HCL。 | +| `**/*.{tf,tofu,hcl,tfvars}` | `terraform.md`: Terraform / HCL。 | | `**/*.bicep` | `bicep.md`: Bicep(Azure)テンプレート。 | +| `**/*.nu` | `nushell.md`: Nushell スクリプト。 | | `**/*.elm` | `elm.md` - Elm ソースコード。 | | `**/*.{jsonnet,libsonnet}` | `jsonnet.md`: Jsonnet の設定テンプレートとライブラリ。 | | `**/*.thrift` | `thrift.md`: Apache Thrift IDL のワイヤ互換性。 | | `**/*.capnp` | `capnp.md`: Cap'n Proto スキーマのワイヤ互換性。 | +| `**/*.{glsl,hlsl,wgsl,metal}` | `shader.md` - GLSL、HLSL、WGSL、Metal シェーダー。 | | `**/*.{v,sv,vh}` | `verilog.md`: Verilog および SystemVerilog の RTL。 | | `**/*.{vhd,vhdl}` | `vhdl.md`: VHDL の RTL。 | | `**/*.m` | `matlab.md`(または[コンテンツスニッフィング](#content-sniffing-for-m-files)により `objc.md`) | @@ -275,6 +282,7 @@ OCR は [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest | `**/*.sol` | `solidity.md`: Solidity スマートコントラクト。 | | `**/*.vy` | `vyper.md`: Vyper スマートコントラクト。 | | `**/*.rego` | `rego.md`: Rego ポリシー(OPA)。 | +| `**/*.{ps1,psm1,psd1}` | `powershell.md`: PowerShell スクリプト、モジュール、データ/マニフェストファイル。 | | _(fallback)_ | `default.md` | 解決されたルール本文は、plan および main task prompt 内の `{{system_rule}}` プレースホルダーの内容になります。 diff --git a/pages/src/content/docs/ko/chatgpt-preview.md b/pages/src/content/docs/ko/chatgpt-preview.md new file mode 100644 index 000000000..602db04c1 --- /dev/null +++ b/pages/src/content/docs/ko/chatgpt-preview.md @@ -0,0 +1,83 @@ +--- +title: ChatGPT 로그인 미리보기 +--- + +OpenCodeReview는 OpenAI 공개 Responses API를 통해 자격이 있는 ChatGPT 요금제 사용량을 이용할 수 있습니다. 오픈 소스 CLI의 선택형 미리보기 기능이며 OpenCodeReview가 별도 구독료를 부과하지 않습니다. 계정, 워크스페이스, 지역, 모델 사용 자격은 OpenAI가 결정합니다. + +`chatgpt` provider는 공개된 Sign in with ChatGPT 흐름을 사용합니다. `ocr auth login`, `status`, `logout`은 기본적으로 Sign in with ChatGPT를 사용합니다. 명시적인 `--provider chatgpt`도 계속 지원합니다. + +## 로그인 및 모델 선택 + +```bash +ocr auth login --provider chatgpt +ocr auth status --provider chatgpt +ocr llm models +ocr config set provider chatgpt +ocr config set providers.chatgpt.model gpt-6-luna +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +OAuth 등록은 OpenID Connect 및 OpenAI 범위를 모두 요청합니다. `openid`는 신원을 확인하고, `profile` 및 `email`은 계정 정보를 제공하며, `offline_access`는 갱신 토큰을 통한 오프라인 갱신에 사용됩니다. `resource.invoke`는 리소스 호출 권한이고 `chatgpt.tokens.use.direct`는 ChatGPT 요금제 사용량을 직접 사용하기 위한 권한입니다. 최초 등록 요청에는 `agent_name_hint=OpenCodeReview`가 포함됩니다. 커넥터 권한을 요청하지 않으며 Codex CLI 클라이언트 등록을 재사용하지 않습니다. 문서화된 흐름에 필요한 범위만 요청하며 임의 권한은 요청하지 않습니다. + +첫 로그인 때 OpenAI가 클라이언트 등록을 발급하며 해당 계정에 보관합니다. 앱의 실제 이름을 최초 등록 힌트로 보내며 설치 간 같은 이름을 사용하고 재로그인 때는 생략합니다. 이 로컬 설치에 연결된 안정적이고 식별 불가능한 `ext_agent_host_id`를 로그인할 때마다 보내며, 같은 설치에는 같은 호스트 ID를 사용합니다. + +인증마다 새 state, nonce, PKCE 값을 생성합니다. 코드를 교환하기 전에 callback state를 확인합니다. issuer의 RSA JWKS로 ID 토큰 서명을 검증하고 issuer, audience, subject, 유효 기간, nonce를 확인한 뒤에만 신원과 허용 범위를 신뢰합니다. + +모델 목록은 현재 계정에서 볼 수 있는 제안과 표시 이름을 서버 순서로 보여줍니다. 모델을 설정하지 않으면 provider가 목록에서 첫 번째로 표시되는 모델을 선택합니다. 명시적으로 설정한 모델 또는 `--model` slug는 목록에 없어도 그대로 OpenAI에 전송됩니다. 계정에서 모델을 사용할 수 있는지는 OpenAI가 결정합니다. 이 provider에는 컴파일된 모델 제한이나 별칭이 없습니다. + +`extra_body`의 reasoning effort는 API 매개변수입니다. 별도의 review `--effort` 플래그는 OpenCodeReview 검토 라운드와 도구 예산을 제어합니다. + +로그인하면 시스템 브라우저가 열리고 `127.0.0.1`에서만 대기합니다. `--no-browser`는 한 번 사용하는 로컬 URL을 출력합니다. CLI는 저장된 ID 토큰 힌트가 포함된 인증 URL을 출력하지 않습니다. 이 미리보기는 장치 코드 인증을 지원하지 않습니다. + +로그인에 성공했지만 ChatGPT 요금제 권한이 없으면 OpenCodeReview는 계정 신원을 보존하고 요금제 사용이 비활성화되었다고 알립니다. 현재 저장된 등록에서 동의 화면을 요청하려면 다음을 실행합니다. + +```bash +ocr auth login --provider chatgpt --enable-plan +# 다른 저장 등록의 경우: +ocr auth login --provider chatgpt --enable-plan --account +``` + +이 명시적 작업은 `prompt=consent`, 전체 요청 범위와 리소스, 저장된 클라이언트 및 호스트 ID를 전송합니다. `--new-account`와 함께 사용할 수 없습니다. 일반 재로그인은 동의를 강제하지 않습니다. 반환된 grant가 요금제 사용을 허용할 때까지 추론은 비활성 상태입니다. 대신 API 키 provider를 설정할 수 있으며 OpenCodeReview가 결제 경로를 자동 전환하지 않습니다. + +## 계정 및 로그아웃 + +```bash +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth login --provider chatgpt --account +ocr auth logout --provider chatgpt --account +``` + +발급된 각 클라이언트 ID는 검증된 issuer와 subject에 연결됩니다. 여러 등록이 같은 이메일을 사용해도 서로 구분됩니다. 다시 로그인하면 같은 클라이언트 ID를 재사용합니다. 코드 교환에서 `invalid_grant`가 발생하면 콜백에서 발급된 클라이언트 ID와 새 state, nonce, PKCE로 인증을 한 번 다시 시작합니다. 서명된 신원을 검증하기 전에 대기 중 등록을 활성화하지 않습니다. 두 번째도 실패하면 기존 자격 증명을 보존하고 로그인을 중단합니다. + +자격 증명 데이터베이스는 `~/.opencodereview/auth/chatgpt/accounts.json`입니다. 별도의 `host.json`은 안정적인 설치 호스트 ID를 저장합니다. 자격 증명 파일은 원자적으로 교체됩니다. + +Unix에서는 자격 증명 파일에 소유자만 읽고 쓸 수 있는 `0600`, 저장소 디렉터리에 `0700` 권한을 설정합니다. Windows의 접근 권한은 로컬 사용자 프로필에서 상속된 ACL에 따라 결정되며, OpenCodeReview는 DACL을 설정하거나 검증하지 않습니다. Go의 [`os.Chmod`](https://pkg.go.dev/os#Chmod)는 Windows에서 읽기 전용 속성만 변경합니다. ACL로 본인 계정에만 접근을 제한한 사용자 프로필을 사용하고 로그인 전에 권한을 확인하세요. 공유되거나 광범위한 접근이 허용되는 저장 위치는 피하세요. 사용자 프로필 안에 있는 디렉터리라는 이유만으로 비공개 접근이 보장되지는 않습니다. + +저장소 변경 및 갱신 토큰 회전에는 OS 파일 잠금을 사용하지만 브라우저 인증은 잠금 바깥에서 진행되어 다른 계정을 계속 사용할 수 있습니다. 완료 시 저장소를 다시 읽고 등록 revision을 확인합니다. 해당 등록의 로그아웃, 갱신 또는 다른 로그인 완료는 대기 중 결과를 무효화합니다. 다른 계정의 업데이트는 보존됩니다. 만료 전에 회전형 갱신 토큰으로 액세스 토큰을 갱신합니다. + +갱신으로 새 토큰을 받은 뒤 요청 취소나 일시적인 서명 검증용 공개 키 조회 실패로 검증이 중단되면 OCR은 받은 토큰을 보관합니다. 원래 명령을 다시 실행하세요. CLI를 재시작했더라도 다음 요청에서 검증을 이어갑니다. + +갱신 응답의 ID 토큰이 서명 또는 계정 신원 검증에 실패하면 OCR은 재시도를 멈추고 다시 로그인하도록 안내합니다. 오류 메시지에 표시된 클라이언트 ID로 다음 명령을 실행하세요. + +```bash +ocr auth login --provider chatgpt --account +``` + +로그인에 성공하면 검증에 실패한 자격 증명이 새 자격 증명으로 교체됩니다. + +로그아웃은 최신 갱신 토큰으로 갱신 가능한 세션의 폐기를 시도합니다. ID 토큰 검증을 기다리는 동안 보관한 새 갱신 토큰도 사용합니다. 이후 액세스, 갱신 및 ID 토큰을 지우며 계정 등록과 호스트 ID는 보존합니다. 로그아웃 후 로그인은 `id_token_hint`를 보내지 않습니다. 원격 폐기를 확인할 수 없으면 CLI가 로컬 토큰을 지웠음을 알리고 ChatGPT 설정에서 앱 연결을 해제하도록 안내합니다. + +## 전송 및 사용량 + +이 provider는 `https://api.openai.com/v1/responses`로만 요청을 보냅니다. 엔드포인트, 프로토콜 및 자격 증명 재정의는 거부됩니다. `store: false`로 스트리밍하고 전체 기록을 전송하며 로컬 함수를 `ocr` 네임스페이스로 묶고 후속 턴에 필요한 호출 ID와 암호화된 reasoning을 보존합니다. `response.completed`만 성공으로 처리합니다. 출력이 시작된 뒤 발생한 사용량 제한 오류를 포함해 실패, 미완료, 중단된 스트림은 요청을 중지합니다. + +미리보기 요청 필드는 OpenAI 지원 하위 집합으로 제한됩니다. `extra_body`는 `reasoning`, `text`, `prompt_cache_key`를 받으며 모델, 기록, 도구, 스트림 모드, 저장 정책을 바꿀 수 없습니다. 응답에서 provider 원본 사용량을 확인할 수 있고, 기존 선택형 원시 검토 기록은 자격 증명 헤더 없이 스트림을 기록합니다. + +**ChatGPT 요금제 사용:** 자격이 있는 요청은 선택한 계정의 ChatGPT 요금제를 사용합니다. [ChatGPT 설정에서 사용량 관리](https://chatgpt.com/settings/usage). 사용량 제한 오류는 요금제 전체 또는 앱별 한도를 반영할 수 있습니다. CLI는 재설정 시각을 추측하지 않습니다. + +현재 요구 사항은 OpenAI의 [오픈 소스 SIWC 개요](https://developers.openai.com/siwc/token-sharing-open-source), [로그인 계약](https://developers.openai.com/siwc/token-sharing-open-source/sign-in), [미리보기 제한](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations)을 참조하세요. + +`ocr auth` 및 `ocr llm models` 명령은 [CLI 레퍼런스](../cli-reference/)를, provider 설정은 [구성 안내](../configuration/)를 참조하세요. diff --git a/pages/src/content/docs/ko/cli-reference.md b/pages/src/content/docs/ko/cli-reference.md index b46adcf3e..72abb6773 100644 --- a/pages/src/content/docs/ko/cli-reference.md +++ b/pages/src/content/docs/ko/cli-reference.md @@ -122,7 +122,7 @@ ocr r [flags] (alias) | `--background-file ` | `-B` | — | 리뷰 배경으로 쓸 Markdown 파일 경로. `--background`와 함께 지정하면 이쪽이 우선합니다. | | `--exclude ` | — | — | 제외할 gitignore 형식 패턴(쉼표 구분). `rule.json`의 `excludes` 항목과 합쳐집니다. | | `--concurrency ` | — | `8` | 병렬로 리뷰할 서브태스크의 최대 개수. | -| `--timeout ` | — | `15` | 서브태스크당 제한 시간. `0`이면 타임아웃을 끕니다. effort 라운드 수에 비례해 선형 확장됩니다(예: low/medium/high에서 15/30/45분). | +| `--timeout ` | — | `15` | LLM 호출, 도구 실행, 재시도 대기를 포함한 서브태스크별 시간 예산. `0`이면 태스크 기한을 비활성화하지만 요청 타임아웃은 끄지 않습니다. 개별 요청 타임아웃은 `OCR_LLM_TIMEOUT` 또는 프로바이더의 `timeout_sec`로 독립적으로 설정하며 초 단위입니다(기본값 `300`). effort의 리뷰 라운드 수에 비례해 선형 확장됩니다(예: low/medium/high에서 15/30/45분). | | `--effort ` | — | `medium` | 리뷰 강도 프리셋: `low`(라운드 1회), `medium`(2회), `high`(3회). 라운드를 늘리면 놓치는 지적이 줄지만 비용도 그만큼 늘어납니다. 이 실행에 한해 저장된 `effort` 설정을 덮어씁니다. | | `--rule ` | — | — | 커스텀 JSON 리뷰 규칙 파일 경로. 프로젝트 수준과 전역 `rule.json`을 덮어씁니다. | | `--max-tools ` | — | 템플릿 기본값 | 서브태스크당 최대 도구 호출 라운드 수. `0`이면 템플릿 기본값(`100`)을 쓰고, 1~49는 `50`으로 올려 맞춥니다. 이 플래그는 상한을 *올리기만* 합니다. 템플릿 기본값보다 낮은 값은 무시됩니다. | @@ -155,6 +155,11 @@ ocr scan --provider openai --model gpt-5.4 --format json 섞이지 않고 그대로 다음으로 넘어갑니다. 내장 프로바이더를 골랐다면 자격 증명은 여전히 해당 프로바이더가 지원하는 환경 변수에서 올 수 있습니다. +내장 프로바이더에서는 `--model`로 `ocr config model`의 제안 목록에 없는 모델도 +지정할 수 있습니다. 모델이 내장 목록과 `providers..models` 모두에 없으면 +OCR은 stderr에 경고를 출력하고 검증은 프로바이더에 맡깁니다. 사용자 정의 +프로바이더에는 기존 `--model` 검증 규칙이 적용됩니다. + ### 모드 {#modes} #### 워크스페이스 모드(기본값) {#workspace-mode-default} @@ -364,7 +369,7 @@ ocr s [flags] (alias) | 플래그 | 단축 | 기본값 | 설명 | |---|---|---|---| -| `--path ` | - | 저장소 전체 | 스캔할 저장소 기준 상대 디렉터리나 파일(쉼표 구분). 예: `internal/agent`, `internal/llm/client.go`. | +| `--path ` | - | 저장소 전체 | 스캔할 저장소 기준 상대 디렉터리나 파일(쉼표 구분). 예: `internal/agent`, `internal/llm/client.go`. 선택자는 작업 트리의 실제 경로로 해석됩니다. 입력한 철자의 일치를 우선하므로 `src`가 같은 위치의 `SRC/`까지 선택하지 않으며, 대소문자를 구분하지 않는 파일 시스템(Windows, 기본 macOS)에서는 파일 시스템이 해석할 수 있는 다른 철자로도 해당 파일을 선택합니다. 존재하지 않는 경로를 지정하면 빈 스캔이 아니라 오류가 됩니다. | | `--exclude ` | - | - | 건너뛸 gitignore 형식 패턴(쉼표 구분). 예: `**/generated/*,*.pb.go`. `rule.json`의 excludes와 합쳐집니다. | | `--output ` | `-o` | stdout | 스캔 결과를 UTF-8 파일로 씁니다(`-`는 stdout). 첫 쓰기 시점에 파일을 만들므로 실패한 실행은 기존 파일을 건드리지 않습니다. text 형식에서는 ANSI 색 코드를 자동으로 제거합니다. | | `--preview` | `-p` | `false` | LLM을 호출하지 않고 파일을 나열하고 필터링합니다. 파일 목록, 리뷰 대상과 제외 대상 개수, 전체 라인 수, 파일별 제외 사유를 출력합니다. `--format json`은 지원하지만 `--format sarif`는 지원하지 않습니다. | @@ -575,9 +580,30 @@ ocr config model Interactive model selection 키 전체 목록과 스키마, 예시는 [설정](../configuration/)을 참고하세요. + + +## `ocr auth` {#ocr-auth} + +`ocr auth login`, `status`, `logout`은 기본적으로 Sign in with ChatGPT를 사용합니다. 명시적인 `--provider chatgpt`도 계속 지원합니다. + +`chatgpt` provider는 공개 Sign in with ChatGPT 흐름을 사용합니다. [ChatGPT 미리보기 안내](../chatgpt-preview/)를 참조하세요. + +```bash +ocr auth login --provider chatgpt +ocr auth login --provider chatgpt --no-browser +ocr auth login --provider chatgpt --enable-plan +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth logout --provider chatgpt --account +ocr llm models +``` + +`ocr auth`는 `--provider chatgpt` 및 `--account `를 받으며 `login`은 `--no-browser`, `--new-account`, `--enable-plan`도 받습니다. ChatGPT는 `--device`를 지원하지 않습니다. `--enable-plan`은 저장된 등록에 대한 동의를 요청하며 `--new-account`와 함께 사용할 수 없습니다. 모델 목록은 계정별로 다르며 목록에 없어도 명시적 모델을 사용할 수 있습니다. 이 provider의 `extra_body`는 `reasoning`, `text`, `prompt_cache_key`만 지원합니다. 계정 관리, 권한 및 제한은 [ChatGPT 미리보기 안내](../chatgpt-preview/)를 참조하세요. + ## `ocr llm` {#ocr-llm} -LLM 유틸리티 명령입니다. 하위 명령은 두 가지입니다: +LLM 유틸리티 명령입니다. 하위 명령은 세 가지입니다: ```text ocr llm @@ -585,6 +611,7 @@ ocr llm Sub-commands: test Send a test conversation to the configured LLM model providers List all built-in LLM providers + models List current account-specific ChatGPT models ``` ### `ocr llm test` {#ocr-llm-test} diff --git a/pages/src/content/docs/ko/configuration.md b/pages/src/content/docs/ko/configuration.md index 87f341edd..2aac65936 100644 --- a/pages/src/content/docs/ko/configuration.md +++ b/pages/src/content/docs/ko/configuration.md @@ -36,12 +36,13 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ### 내장 프로바이더 {#built-in-providers} -다음 프로바이더는 Base URL과 프로토콜이 미리 설정된 채 OCR에 내장되어 있습니다. 선택한 뒤 API 키만 채우면 됩니다. `providers..api_key`가 비어 있으면 OCR은 해당 환경 변수로 대체합니다. +다음 프로바이더는 Base URL과 프로토콜이 미리 설정된 채 OCR에 내장되어 있습니다. 대부분은 API 키가 필요하며, `providers..api_key`가 비어 있으면 OCR은 해당 환경 변수로 대체합니다. `chatgpt`는 대신 `ocr auth login`이 만든 ChatGPT 자격 증명을 사용합니다. | 이름 | 프로토콜 | Base URL | API 키 환경 변수 | |---|---|---|---| | `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | `bedrock` | anthropic-bedrock | `aws_region`에서 결정 | — (AWS 자격 증명 체인) | +| `chatgpt` | openai-responses | `https://api.openai.com/v1` | 해당 없음 (Sign in with ChatGPT) | | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | | `openai-responses` | openai-responses | `https://api.openai.com/v1` | `OPENAI_RESPONSES_API_KEY` | | `openrouter` | openai | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | @@ -65,10 +66,34 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx | `novita` | openai | `https://api.novita.ai/openai` | `NOVITA_API_KEY` | | `xai` | openai | `https://api.x.ai/v1` | `XAI_API_KEY` | +내장 프로바이더의 모델 목록은 `ocr config model`에서 선택할 때 제안하는 목록이며 +`--model`을 제한하지 않습니다. 지정한 모델이 내장 목록과 +`providers..models` 모두에 없으면 OCR은 stderr에 경고를 출력합니다. +모델의 유효성은 요청을 보낼 때 프로바이더가 확인합니다. 사용자 정의 프로바이더에는 +기존 `--model` 검증 규칙이 적용됩니다. + +## ChatGPT 로그인 미리보기 + +이 미리보기는 공개 Responses API용 내장 `chatgpt` provider를 추가합니다. API 키 환경 변수는 없습니다. 로그인, 동의 및 사용 제한은 [ChatGPT 미리보기 안내](../chatgpt-preview/)를 참조하세요. + +`ocr config set`으로 provider와 선택적 요청 필드를 설정합니다. `ocr llm models`에서 모델을 선택하거나 모델 slug를 직접 지정할 수 있습니다. + +```bash +ocr auth login --provider chatgpt +ocr config set provider chatgpt +ocr config set providers.chatgpt.model +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +계정별 목록에 없는 모델도 명시하면 전송됩니다. `extra_body`는 `reasoning`, `text`, `prompt_cache_key`만 받으며 엔드포인트, 프로토콜, 자격 증명 재정의는 거부됩니다. + ### 내장 프로바이더의 Base URL 재정의 {#overriding-a-built-in-provider-s-base-url} 모든 내장 프로바이더에는 미리 설정된 Base URL이 있습니다(위 표 참고). 내장 프로바이더를 다른 엔드포인트로 보내려면 `providers..url`을 설정합니다(예: 자체 호스팅 LiteLLM 게이트웨이는 미리 설정된 기본값 `http://localhost:4000/v1`에 있는 경우가 드뭅니다): +`chatgpt` 미리보기는 예외입니다. 공개 엔드포인트는 고정되어 있으며 URL 재정의를 거부합니다. + ```bash ocr config set provider litellm ocr config set model openai/gpt-5.4 @@ -80,7 +105,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1 ### AWS Bedrock {#aws-bedrock} -`bedrock`은 `anthropic`과 같은 Messages API를 사용하지만, 요청에 API 키를 싣는 대신 표준 AWS 자격 증명 체인으로 SigV4 서명을 하며 호스트는 리전이 결정합니다. 설정할 `api_key`가 없고, 서명을 대신할 키도 받지 않습니다: +`bedrock`은 기본 `anthropic-bedrock` 프로토콜에서 `anthropic`과 같은 Messages API를 사용하지만, 요청에 API 키를 싣는 대신 표준 AWS 자격 증명 체인으로 SigV4 서명을 하며 호스트는 리전이 결정합니다. 설정할 `api_key`가 없고, 서명을 대신할 키도 받지 않습니다: ```bash ocr config set provider bedrock @@ -89,6 +114,8 @@ ocr config set providers.bedrock.aws_region us-west-2 ocr config set providers.bedrock.aws_profile example-profile ``` +OpenAI 호환 게이트웨이에 연결하려고 `providers.bedrock.protocol`을 `openai`로 설정했다면, `providers.bedrock.url`에 게이트웨이 URL을 설정하고 `providers.bedrock.api_key` 또는 `providers.bedrock.api_key_cmd`로 게이트웨이의 API 키를 제공하세요. `ocr config provider`에서 모델을 변경해도 저장된 API 키는 유지됩니다. + | 필드 | 의미 | |---|---| | `providers.bedrock.aws_region` | 요청을 처리할 `bedrock-runtime` 호스트의 리전. 비어 있으면 `AWS_REGION`이나 활성 프로필로 대체합니다. | @@ -98,7 +125,7 @@ ocr config set providers.bedrock.aws_profile example-profile 모델 식별자는 계정 **과** 리전 단위로 정해지므로 OCR이 제공하는 목록은 닫힌 집합이 아니라 출발점입니다. 목록에 없더라도 계정에서 유효한 추론 프로필 ID나 애플리케이션 추론 프로필 ARN이면 그대로 받습니다. 계정에서 무엇을 쓸 수 있는지는 `aws bedrock list-inference-profiles --region `으로 확인하세요. 최신 계열에서는 `-v1:0` 같은 버전 접미사가 유효하지 않습니다. -bedrock에는 설정된 URL이 없고 호스트를 리전이 결정하므로, `ocr llm test`는 URL 대신 리전과 프로필을 표시합니다: +기본 `anthropic-bedrock` 프로토콜에서는 리전이 호스트를 결정하므로, `ocr llm test`는 URL 대신 리전과 프로필을 표시합니다: ``` Source: provider:bedrock @@ -112,6 +139,8 @@ Bedrock은 `llm.protocol`이나 `OCR_LLM_PROTOCOL`로는 사용할 수 **없습 ### 커스텀 프로바이더 {#custom-providers} +`mimo-v2.6-flash`, `foo.api_key`처럼 점이 포함된 이름을 사용할 수 있습니다. 새로운 이름에 점이 있다면 필드를 설정하기 전에 `ocr config set provider `으로 생성하고 선택하세요. 이 명령은 현재 프로바이더를 변경합니다. 기존 항목은 선택하지 않고도 `providers..` 또는 `custom_providers..`로 수정할 수 있습니다. 이름의 처음이나 끝에 점이 있거나, 점이 연속되거나, 구분된 부분이 비어 있거나 공백으로만 구성된 이름은 허용되지 않습니다. 점이 없는 이름은 필드 설정 시 계속 자동으로 생성됩니다. + 위 표에 없는 프로바이더 이름은 커스텀으로 취급하며 최소한 `url`과 `protocol`을 지정해야 합니다(`protocol`은 `anthropic`, `openai`, `openai-responses`, `anthropic-bedrock` 중 하나): ```bash @@ -158,6 +187,16 @@ Ollama는 API 키를 무시하지만 커스텀 프로바이더에는 비어 있 ### 타임아웃 {#timeouts} +태스크 타임아웃과 개별 요청 타임아웃은 서로 독립적입니다: + +- `ocr review --timeout`과 `ocr scan --timeout`은 동시 실행 태스크의 시간 예산을 + **분** 단위로 설정합니다(기본값 **15**, `0`이면 태스크 기한을 비활성화). + 이 예산에는 태스크의 모든 LLM 호출, 도구 실행, 재시도 대기가 포함됩니다. + review에서는 effort의 리뷰 라운드 수만큼 예산을 늘립니다. +- 개별 HTTP 요청 타임아웃은 **초** 단위입니다(기본값 **300**). + `--timeout`을 늘려도 요청 타임아웃은 바뀌지 않으며, 태스크 기한을 비활성화해도 + 요청 타임아웃은 비활성화되지 않습니다. 태스크의 남은 예산이 더 짧으면 그 예산이 우선합니다. + LLM 요청마다 HTTP 타임아웃이 있으며 기본값은 **300초**입니다. 느린 로컬 모델(또는 큰 파일)에는 더 필요할 수 있습니다. 범위가 좁은 것부터 세 가지 설정이 있습니다: - `providers..timeout_sec` / `custom_providers..timeout_sec` — 프로바이더별, 초 단위. @@ -174,6 +213,23 @@ LLM 요청마다 HTTP 타임아웃이 있으며 기본값은 **300초**입니다 } ``` +예를 들어 `OCR_LLM_TIMEOUT=900 ocr review --timeout 30`은 태스크 예산 내에서 +개별 요청을 최대 15분까지 허용합니다. 이 환경 변수나 `timeout_sec`를 설정하지 않으면 +`--timeout 30`을 지정해도 요청은 5분 후에 타임아웃됩니다. + +개별 요청 기한이 만료되면 요청을 자동으로 다시 보내지 않고 호출을 종료합니다. +응답 본문이나 스트림을 읽는 도중 타임아웃된 경우도 마찬가지입니다. 지속적으로 느린 +요청은 같은 제한 시간으로 재시도하기보다 요청 타임아웃을 늘려야 합니다. +기존 SDK 재시도 정책은 바뀌지 않습니다. 재시도 가능한 연결 및 HTTP 오류는 백오프와 +프로바이더의 `Retry-After` 지시에 따라 최대 5회 재시도합니다. SDK의 각 시도 기한은 +재시도 대기 시간에도 적용됩니다. 취소되거나 태스크 기한이 만료되면 요청과 재시도 대기가 +모두 중단됩니다. + +진단 메시지는 `LLM request timeout`(`OCR_LLM_TIMEOUT` 또는 프로바이더의 `timeout_sec` 확인)과 +`caller deadline exceeded`를 구분합니다. 후자는 호출한 작업의 컨텍스트 기한이 만료되었다는 +뜻입니다. review/scan 태스크 기한이 만료된 경우에는 `--timeout`을 확인하세요. +백그라운드 메모리 압축이나 `ocr llm test` 같은 다른 작업에는 각각 독립적인 기한이 있습니다. + ### 명령으로 API 키 가져오기 {#api-key-from-a-command} 키를 설정 파일에 저장하는 대신 `api_key_cmd`가 실행 시점에 비밀 관리자(1Password, `pass`, `gopass` 등)에서 가져옵니다. 앞뒤 공백을 제거한 한 줄짜리 stdout이 키가 됩니다. 레거시 `llm` 블록에서는 같은 옵션을 `auth_token_cmd`로 사용할 수 있습니다. diff --git a/pages/src/content/docs/ko/contributing.md b/pages/src/content/docs/ko/contributing.md index dbdb43e97..f9c95d0bb 100644 --- a/pages/src/content/docs/ko/contributing.md +++ b/pages/src/content/docs/ko/contributing.md @@ -28,7 +28,7 @@ Go를 쓰지 않아도 도울 수 있습니다. ### 사전 요구 사항 {#prerequisites} -- [Go 1.25 이상](https://go.dev/dl/) +- [Go 1.26.9 이상](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -115,39 +115,55 @@ refactor(viewer): extract task-card rendering into helper **PR 제목**에도 같은 형식을 씁니다. 그래야 생성되는 변경 로그에 깔끔하게 들어갑니다. +`PR Commit Policy`는 PR 제목과 본문을 포함한 모든 커밋 메시지를 대소문자 구분 없이 검사합니다. +`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, `[actions skip]` 또는 +`skip-checks: true` / `skip-checks:true`만 있는 줄을 포함하지 마세요. + +검사에 실패하면 PR 제목을 수정하거나 자신의 기여 브랜치에서 해당 커밋 메시지를 다시 작성하세요. +이러한 지시가 없는 새 커밋을 추가해도 이전 커밋의 위반 사항은 해결되지 않습니다. +PR 설명에서는 이러한 지시를 논의할 수 있으며 검사 대상이 아닙니다. + ## 프로젝트 구조 {#project-layout} ``` open-code-review/ +├── acp/ # CLI용 독립 ACP(Agent Client Protocol) 어댑터 ├── cmd/opencodereview/ # CLI 진입점 — 플래그 파싱, 디스패치 +├── docs/ # 현지화된 README와 기여 가이드 +├── imgs/ # README 이미지와 로고 ├── internal/ │ ├── agent/ # 리뷰 Agent 로직, 서브 Agent 디스패치 +│ ├── chatgptauth/ # ChatGPT OAuth와 자격 증명 관리 │ ├── config/ # 템플릿, 규칙, 허용 목록, 내장 JSON -│ ├── diff/ # Git diff 파싱, 세 가지 모드 +│ ├── delegate/ # LLM 호출 없이 리뷰 명세 생성 +│ ├── diff/ # Git diff 파싱 │ ├── gitcmd/ # Git 하위 프로세스 실행기 -│ ├── llm/ # LLM 클라이언트(Anthropic, OpenAI), 엔드포인트 해석기 +│ ├── llm/ # LLM 클라이언트와 엔드포인트 해석 +│ ├── llmloop/ # Review와 Scan이 공유하는 LLM 도구 호출 루프 +│ ├── mcp/ # MCP 클라이언트와 도구 어댑터 │ ├── model/ # 데이터 구조체(LlmComment, Diff 등) │ ├── pathutil/ # 경로 유틸리티 -│ ├── release/ # 릴리스 노트 생성 +│ ├── release/ # 릴리스 산출물 이름의 일관성 검사 +│ ├── scan/ # 전체 파일 코드 리뷰 │ ├── session/ # JSONL 세션 기록기 │ ├── stdout/ # 조용히 만들 수 있는 stdout 기록기 │ ├── suggestdiff/ # 제안 diff 렌더링 │ ├── telemetry/ # OpenTelemetry 설정과 헬퍼 │ ├── tool/ # 도구 레지스트리와 프로바이더 구현 -│ └── viewer/ # 내장 HTTP UI -├── pages/ # WebUI 소개 페이지(별도 React 앱) -├── plugins/ # Claude Code 슬래시 명령 -├── extensions/ # 에디터 확장(VS Code) +│ └── viewer/ # 리뷰 세션을 탐색하는 내장 HTTP UI +├── pages/ # 공식 웹사이트 랜딩 페이지와 문서(별도 React 앱) +├── plugins/ # 코딩 에이전트 통합(Claude Code, Codex, OpenCode 등) +├── extensions/ # VS Code 및 IntelliJ IDEA 확장과 공유 프런트엔드 ├── examples/ # CI 레시피(GitHub Actions, GitLab CI) -├── skills/ # Agent SDK skill 매니페스트 -├── scripts/ # NPM postinstall과 크로스 빌드 스크립트 +├── skills/ # 리뷰 및 위임 스킬 정의 +├── scripts/ # 설치, 배포 및 저장소 검사 스크립트 ├── npm/ # 플랫폼별 optional dependency 패키지 └── bin/ # NPM 래퍼(Node) ``` 기여는 대개 `internal/agent/`, `internal/tool/`, `internal/llm/`을 건드립니다. -`cmd/opencodereview/`의 CLI 표면은 일부러 얇게 두었습니다. 플래그를 파싱한 뒤 -agent 패키지로 넘기는 것이 전부입니다. +`cmd/opencodereview/`의 CLI 표면은 일부러 얇게 두었습니다. 플래그 파싱과 +명령 디스패치를 담당합니다. ## AI 지원 개발 diff --git a/pages/src/content/docs/ko/installation.md b/pages/src/content/docs/ko/installation.md index 6fad43bab..1d38fad91 100644 --- a/pages/src/content/docs/ko/installation.md +++ b/pages/src/content/docs/ko/installation.md @@ -135,7 +135,7 @@ OCR 자체를 수정하거나 사전 빌드 바이너리가 없는 플랫폼에 #### 사전 요구 사항 {#prerequisites} -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) diff --git a/pages/src/content/docs/ko/integrations/acp.md b/pages/src/content/docs/ko/integrations/acp.md new file mode 100644 index 000000000..b328dd750 --- /dev/null +++ b/pages/src/content/docs/ko/integrations/acp.md @@ -0,0 +1,213 @@ +--- +title: ACP +sidebar: + order: 6 +--- + +## 사전 요구 사항 {#prerequisites} + +- [OCR CLI](../../installation/)를 설치하고 [리뷰 모델](../../configuration/)을 + 설정하세요. `ocr llm test`로 연결을 확인할 수 있습니다. +- 어댑터를 빌드하려면 `acp/` 디렉터리가 포함된 소스 체크아웃, Git, Go 1.23 이상이 + 필요합니다. 상위 프로젝트의 OCR CLI를 빌드하려면 Go 1.25.5 이상이 필요합니다. +- ACP v1 stdio와 사용자 지정 Agent 시작 명령을 지원하는 클라이언트를 사용하세요. + +어댑터는 OCR CLI와 별도로 빌드합니다. 아래 안내는 로컬 빌드를 사용합니다. + +## 빌드 및 클라이언트 연결 {#build-and-connect-your-client} + +저장소 루트에서 실행하세요. + +```bash +make -C acp build +command -v ocr +``` + +빌드 결과는 `dist/ocr-acp`입니다. `command -v ocr`가 출력한 OCR의 절대 경로를 `--ocr-binary`에 넣으세요. + +클라이언트의 Agent 설정에 다음 구성을 추가하세요. 모든 `/absolute/path`와 파서 모델 값을 실제 값으로 바꾸세요. + +```json +{ + "agent_servers": { + "OpenCodeReview": { + "type": "custom", + "command": "/absolute/path/open-code-review/dist/ocr-acp", + "args": ["--ocr-binary", "/absolute/path/ocr"], + "env": { + "OCR_ACP_PARSER_PROVIDER": "anthropic", + "OCR_ACP_PARSER_MODEL": "deepseek-flash", + "OCR_ACP_PARSER_BASE_URL": "https://your-gateway.example", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" + } + } + } +} +``` + +필드 이름은 클라이언트마다 다르므로 해당 ACP 설정 안내를 참고하세요. + +## 리뷰 및 스캔 실행 {#run-a-review-or-scan} + +OpenCodeReview 대화에서 다음 명령을 보내세요: + +| 요청 | 명령 | +| --- | --- | +| 스테이징된 변경, 스테이징되지 않은 변경, 추적되지 않는 변경 리뷰 | `/review` | +| 리뷰를 최대 한 라운드 실행 | `/review --effort low` | +| 현재 체크아웃한 최신 커밋 리뷰 | `/review --commit HEAD` | +| 기존 참조 두 개 비교 | `/review --from main --to HEAD` | +| 디렉터리 스캔 | `/scan --path internal/agent` | +| 여러 경로 스캔 | `/scan --path internal/agent,internal/config` | + +예시의 참조와 경로는 프로젝트에 있는 값으로 바꾸세요. 리뷰에는 Git 저장소가 +필요하며, 발견 사항의 경로는 저장소 루트를 기준으로 해석합니다. 스캔은 세션의 작업 +디렉터리를 사용합니다. `--effort low`는 최대 한 라운드, `medium`은 최대 두 라운드, +`high`는 최대 세 라운드의 리뷰를 실행합니다. 새로운 발견 사항이 없으면 일찍 종료할 수 있습니다. +생략하면 OCR 설정을 유지합니다. +라운드 수를 줄이면 문제를 놓칠 수 있으므로 속도와 품질을 고려해 선택하세요. + +ACP 명령 제안을 지원하는 클라이언트에서 `/`를 입력하면 명령과 인수 힌트를 +볼 수 있습니다. 동적 브랜치 및 경로 자동 완성은 제공하지 않습니다. 클라이언트가 +로컬 파일 링크를 첨부한다면 `/scan`과 함께 보내고 `--path`는 생략하세요. +링크를 `/review` 또는 명시적인 스캔 경로와 함께 사용할 수는 없습니다. +지원하지 않거나 모호한 링크는 거부하며 스캔 범위를 자동으로 넓히지 않습니다. + +어댑터가 허용하는 CLI 옵션은 정해져 있으며, 임의의 옵션을 그대로 전달할 수는 없습니다: + +- 리뷰: `--commit`, `--from`, `--to`, `--effort`, `--no-filter`, + `--background`, `--background-file`. +- 스캔: `--path`, `--batch`, `--no-plan`, `--no-dedup`, `--no-summary`, + `--background`. + +전체 변경 사항을 리뷰하려면 `/review`를 사용하세요. `--staged`는 지원하지 않습니다. +대화 명령에서는 `--format`, `--audience` 같은 출력 옵션과 `--repo`, `--model`, +`--provider` 같은 재정의 옵션을 사용할 수 없습니다. 리뷰 모델은 OCR 자체 설정을 +통해 구성하세요. + +## 자연어 요청 활성화 {#enable-natural-language-requests} + +파싱 모델은 자연어 요청을 리뷰 또는 스캔 명령으로 변환하거나, 불명확한 부분을 +질문합니다. OCR의 리뷰 모델과 별도로 설정해야 합니다. 클라이언트 자체의 모델을 선택해도 +이 두 모델이 설정되지는 않습니다. + +Agent의 `env` 객체에 다음 환경 설정을 추가하고, 모델과 키 자리표시자를 사용 중인 +제공자의 값으로 바꾸세요: + +```json +{ + "OCR_ACP_PARSER_PROVIDER": "openai", + "OCR_ACP_PARSER_MODEL": "YOUR_TOOL_CALLING_MODEL", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" +} +``` + +이 예시는 OpenAI 호환 Chat Completions 프로토콜을 사용합니다. Anthropic Messages +프로토콜은 `anthropic`을 사용하세요. 호환 게이트웨이를 쓴다면 +`OCR_ACP_PARSER_BASE_URL`에 API 기본 URL도 설정하세요. 자격 증명은 로컬 설정에 +보관하거나 Agent 프로세스의 환경 변수로 전달하세요. 공유 프로젝트 설정에 넣어 +커밋하지 마세요. GUI 앱은 터미널에서 내보낸 환경 변수를 상속하지 않을 수 있습니다. + +기본 URL의 경로 규칙은 프로토콜마다 다릅니다: + +| 프로토콜 | `OCR_ACP_PARSER_BASE_URL` 예시 | 실제 요청 주소 | +| --- | --- | --- | +| `openai` | `https://gateway.example/v1` | `https://gateway.example/v1/chat/completions` | +| `anthropic` | `https://gateway.example` | `https://gateway.example/v1/messages` | + +각각 `/chat/completions` 또는 `/v1/messages`로 끝나는 전체 엔드포인트도 사용할 수 있으며, +이 경우 해당 접미사를 다시 붙이지 않습니다. Anthropic 기본 URL이 `/v1`로 끝나면 +`/v1/v1/messages`가 되므로 게이트웨이의 실제 API 경로에 맞춰 지정하세요. + +| 환경 변수 | 시작 플래그 | 용도 | +| --- | --- | --- | +| `OCR_ACP_PARSER_PROVIDER` | `--parser-provider` | `openai` 또는 `anthropic` | +| `OCR_ACP_PARSER_MODEL` | `--parser-model` | 도구 호출을 지원하는 모델 | +| `OCR_ACP_PARSER_BASE_URL` | `--parser-base-url` | API 기본 URL 재정의(선택 사항) | +| `OCR_ACP_PARSER_API_KEY` | 없음 | 파싱 API 키. 환경 변수로만 제공 | + +시작 플래그가 대응하는 환경 변수보다 우선합니다. 파서는 OCR 설정이나 `OCR_LLM_*` +변수를 읽지 않습니다. 지원하는 프로토콜은 `openai`와 `anthropic`이며, +여기서는 `openai-responses`와 `anthropic-bedrock`을 지원하지 않습니다. + +Anthropic 파싱 요청은 필수 `submit_intent` 도구 호출을 위해 Thinking을 명시적으로 +비활성화합니다. OCR 리뷰 모델의 Thinking 설정은 변경하지 않습니다. + +파서를 설정한 뒤 새 대화를 시작하고 다음과 같이 요청해 보세요: + +```text +작업 디렉터리의 변경 사항을 리뷰해 주세요. +현재 체크아웃한 최신 커밋을 리뷰해 주세요. +internal/agent를 스캔해서 잠재적인 버그를 찾아 주세요. +``` + +요청이 모호하면 실행 전에 확인 질문에 답하세요. 모델은 질문과 안내를 사용자의 언어에 +맞추도록 지시받습니다. 고정된 검증 메시지와 보고서 라벨은 영어로 유지됩니다. +파싱 모델을 사용할 수 없어도 슬래시 명령은 작동합니다. 파서 설정이 불완전하면 +시작 시 명시적인 오류가 발생합니다. + +“현재 체크아웃한 최신 커밋”은 `/review --commit HEAD`에 해당합니다. 세션은 확인을 기다리는 +요청 하나만 유지하며, 여러 호환 가능한 필드를 저장할 수 있습니다. 연속된 확인 질문에서는 +알려진 값을 유지하고 명시적인 새 값으로 이전 값을 덮어씁니다. 서로 다른 리뷰 유형의 필드는 +섞지 않습니다. 완료 또는 취소 후 상태를 지우며 다른 세션에서 복원하지 않습니다. + +## 결과 확인 및 취소 {#read-results-and-cancel-work} + +진행 상황은 기존 OCR CLI 로그와 최종 JSON을 사용하므로 CLI 수정이 필요하지 않습니다. 최근에 보고된 작업과 전체 경과 시간을 표시하며, 그룹 단계나 단계별 소요 시간, 모델 응답 대기 시간은 추정하여 표시하지 않습니다. + +하나의 **OCR review** 또는 **OCR scan** 항목에 진행 상황과 로컬에서 측정한 실행 시간이 +표시됩니다. 펼치면 **Command:**, 작업 디렉터리, 크기가 제한된 일반 텍스트 로그를 볼 수 +있습니다. 진단 정보는 상세 내용에 유지되며 진행 제목을 덮어쓰지 않습니다. 명령과 로그는 +대화 본문에 반복되지 않습니다. 처음 펼쳐지는지는 클라이언트가 결정합니다. + +최종 보고서는 완료 상태, 파일 및 발견 사항 수, 심각도별 개수로 시작합니다. 각 발견 사항의 +제목에는 번호, 심각도, 파일 위치가 있고, 그 아래에 분류, 설명, 언어가 지정된 코드 블록이 +표시됩니다. 제안 코드는 아직 적용되지 않았음을 명시합니다. 발견 사항은 최종 본문에 한 번만 +나타납니다. 유효한 파일 위치는 링크로 제공하며, 없는 파일, 범위를 벗어난 줄, 작업 루트 밖의 +경로는 일반 텍스트로 유지합니다. 렌더링과 클릭 동작은 클라이언트 버전에 따라 다릅니다. + +부분 실패 상세 정보와 예산 한도 알림은 유지됩니다. 보고서 끝에는 제공된 누적 토큰 수와 +OCR이 보고한 소요 시간이 표시됩니다. 실행 상세에는 OCR이 제공하는 도구 호출 합계와 +입력·출력·캐시 토큰 수도 표시하며, 없는 내역을 만들어 넣지 않습니다. 실시간 타이머는 로컬 +실행 시간을 측정하므로 OCR 보고 시간과 다를 수 있습니다. 토큰 합계는 모든 모델 요청을 +포함하며 새로 생성된 답변의 토큰만 세는 값이 아닙니다. + +클라이언트의 취소 기능으로 중지하세요. 어댑터는 작업을 취소하고 +관리 대상 프로세스의 정리가 끝날 때까지 기다립니다. 파싱을 포함한 전체 턴에 시간 +제한을 두려면 Agent의 `args` 배열에 `"--turn-timeout", "10m"`을 추가하세요. +기본값은 `0`이며, 전체 턴 제한 시간을 비활성화합니다. 파싱에는 별도로 기본 15초의 +제한이 있습니다. 시간 초과 시 어댑터가 재시도 안내를 표시합니다. + +## 문제 해결 {#troubleshooting} + +| 증상 | 확인할 사항 | +| --- | --- | +| Agent가 시작되지 않음 | 실행 파일 두 개의 절대 경로와 실행 권한을 확인하세요. `ocr llm test`로 OCR 설정을 확인하고, 불완전한 파서 설정을 제거하거나 필수 값을 모두 제공하세요. | +| 슬래시 명령은 되지만 자연어는 안 됨 | 별도의 `OCR_ACP_PARSER_*` 환경을 설정하세요. 제공자, 모델, 키, 게이트웨이 URL을 확인하세요. | +| 자연어 파싱 시간 초과 | 기본 파싱 제한은 15초이며, 이 단계에서는 OCR이 아직 시작되지 않았습니다. 진단의 요청 단계, HTTP 상태, 제공자 응답으로 원인을 확인하세요. `--turn-timeout`을 늘려도 파싱 자체의 제한은 연장되지 않습니다. 재시도하거나 `/review`, `/scan`으로 파싱 모델을 건너뛸 수 있습니다. | +| 명령이나 플래그가 거부됨 | 위의 지원 옵션을 사용하세요. `/review --path` 대신 `/scan --path`를 사용하고, 커밋 또는 범위 리뷰에는 기존 Git 참조를 지정하세요. | +| 리뷰가 실패하거나 일부 결과만 반환됨 | OCR review/scan을 펼쳐 진단 정보를 확인하고 최종 결과의 실패 상세 정보를 읽으세요. OCR의 모델 설정과 제공자 가용성을 확인하세요. | +| 결과 위치를 클릭할 수 없음 | 파일이 작업 루트 안에 존재하고 행 범위가 유효한지 확인하세요. 텍스트만 표시하는 대체 동작은 의도된 것입니다. | +| 작업이 너무 오래 걸림 | 취소하거나 진행 상황의 상세 정보를 확인하거나 전체 턴 제한 시간을 설정하세요. 리뷰에서는 `--effort low`로 라운드 수를 줄일 수 있습니다. | +| 다시 빌드해도 이전 동작이 유지됨 | 설정된 바이너리 경로를 확인하고 새 Agent 대화를 시작하세요. 이전 프로세스를 계속 재사용한다면 Agent 또는 클라이언트를 다시 시작하세요. | + +연결에 문제가 있으면 클라이언트의 ACP 로그와 어댑터의 stderr를 확인하세요. +공유하기 전에 자격 증명과 비공개 소스 정보를 제거하세요. + +## 업그레이드 및 지원 범위 {#upgrade-and-support-boundaries} + +소스 체크아웃을 업데이트하고 `make -C acp build`를 다시 실행한 뒤 새 +OpenCodeReview 대화를 시작하세요. 실행 중인 프로세스와 이전 메시지는 다시 로드되지 +않습니다. 세션 복원은 지원하지 않습니다. 새 대화는 답변을 기다리던 확인 질문의 +상태를 이어받지 않고 시작합니다. + +어댑터는 현재 로컬 stdio 전송을 사용합니다. HTTP 전송, 추가 작업 공간 루트, +자동 파일 편집, 이미지 또는 오디오 프롬프트는 지원하지 않습니다. 다른 ACP +클라이언트는 각각 호환성을 확인해야 합니다. 프로토콜 테스트가 통과했다고 모든 +클라이언트의 화면 동작까지 확인된 것은 아닙니다. + +## 관련 문서 {#see-also} + +- [설정](../../configuration/) — OCR의 리뷰 모델 및 제공자 설정. +- [CLI 레퍼런스](../../cli-reference/) — 리뷰 및 스캔 옵션 상세 정보. +- [ACP 소개](https://agentclientprotocol.com/get-started/introduction) — Agent Client Protocol 공식 소개. diff --git a/pages/src/content/docs/ko/integrations/delegate.md b/pages/src/content/docs/ko/integrations/delegate.md index 6f819a12c..6d045b519 100644 --- a/pages/src/content/docs/ko/integrations/delegate.md +++ b/pages/src/content/docs/ko/integrations/delegate.md @@ -91,24 +91,42 @@ ocr delegate rule ... ### 3단계: diff 가져오기 {#step-3-get-diffs} -1단계에서 얻은 모드와 ref 정보를 바탕으로 git을 직접 씁니다. +1단계의 모드와 ref 정보에 맞는 diff 명령을 선택하세요. 리뷰 중 모든 Git 호출에 `git --no-pager`를 사용하세요. + +아래 diff 명령은 외부 diff 프로그램, 텍스트 변환, 색상 출력을 꺼서 일반 텍스트 패치를 가져옵니다. **range 모드**(merge\_base가 제공됨): ```bash -git diff .. -- +git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- "" ``` **commit 모드**: ```bash -git show -- +git --no-pager show --no-ext-diff --no-textconv --no-color -- "" ``` **workspace 모드**: ```bash -git diff HEAD -- # 추적 중인 파일 -cat # 새로 추가된 추적되지 않은 파일 +git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- "" # 추적 중인 파일 +cat "" # 새로 추가된 추적되지 않은 파일 +``` + +컨텍스트를 조회하려면: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" ``` +큰 diff는 선택한 모드의 명령에 `--output=""`를 추가하세요. 저장소 밖의 고유한 절대 경로를 선택하고 상위 디렉터리를 만드세요. 파일 읽기 도구에서도 같은 경로를 사용하세요: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +Git이 종료 코드 0으로 끝나면 파일 전체를 나누어 읽고 리뷰한 뒤 삭제하세요. 명령이 실패하거나 시간 초과가 발생하면 재시도하거나 오류를 기록하고 `skipped`로 표시하세요. 출력이 예상과 달리 비어 있으면 preview를 다시 실행해 대조하세요. + ### 4단계: 파일별 리뷰 {#step-4-review-each-file} 리뷰 대상 파일마다 다음을 수행합니다. @@ -117,6 +135,8 @@ cat # 새로 추가된 추적되지 않은 파일 2. 대응하는 Rule Group(2단계)을 리뷰 체크리스트로 삼습니다. 3. 필요한 만큼 맥락을 탐색하며 꼼꼼히 리뷰합니다. +preview의 각 `(path, status)`를 `reviewed` 또는 `skipped`로 기록하고, 건너뛴 항목에는 사유를 적으세요. 보고서에는 `total_files`, `reviewed_files`, `skipped_files`, `coverage_rate`를 포함하세요. + ### 5단계: 보고 {#step-5-report} 발견한 문제를 심각도로 분류합니다. diff --git a/pages/src/content/docs/ko/review-rules.md b/pages/src/content/docs/ko/review-rules.md index 350f42dd6..dbb0dc2bb 100644 --- a/pages/src/content/docs/ko/review-rules.md +++ b/pages/src/content/docs/ko/review-rules.md @@ -203,6 +203,11 @@ OCR은 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublesta - `**/test/**/*.lhs` - `**/*Spec.lhs` - `**/tests/**/*.nim` +- `**/tests/**/*.nu` +- `**/test/**/*.nu` +- `**/*_test.nu` +- `**/test_*.nu` +- `**/test-*.nu` - `**/tests/**/*.R` - `**/__snapshots__/**` - `**/*.snap` @@ -294,7 +299,7 @@ OCR은 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublesta | `**/*.astro` | `astro.md` — Astro 컴포넌트와 아일랜드. | | `**/*.{ts,js,tsx,jsx,mjs,cjs}` | `ts_js_tsx_jsx.md` | | `**/*.{kt,kts}` | `kotlin.md` | -| `**/*.{fs,fsi,fsx}` | `fsharp.md` — F# 구현, 시그니처, 스크립트 파일. | +| `**/*.{fs,fsi,fsx}` | `fsharp.md` — F# 구현, 시그니처, 스크립트 파일. | | `**/*.rs` | `rust.md` | | `**/*.R` | `r.md` | | `**/*.{cpp,cc,cxx,hpp,hxx}` | `cpp.md` | @@ -307,12 +312,14 @@ OCR은 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublesta | `**/*.{graphql,gql}` | `graphql.md` — GraphQL 스키마와 오퍼레이션. | | `**/*.prisma` | `prisma.md` — Prisma 스키마. | | `**/*.jl` | `julia.md` — Julia 소스. | -| `**/*.{tf,hcl,tfvars}` | `terraform.md` — Terraform / HCL. | +| `**/*.{tf,tofu,hcl,tfvars}` | `terraform.md` — Terraform / HCL. | | `**/*.bicep` | `bicep.md` — Bicep(Azure) 템플릿. | +| `**/*.nu` | `nushell.md` — Nushell 스크립트. | | `**/*.elm` | `elm.md` — Elm 소스. | | `**/*.{jsonnet,libsonnet}` | `jsonnet.md` — Jsonnet 설정 템플릿과 라이브러리. | | `**/*.thrift` | `thrift.md` — Apache Thrift IDL 통신 호환성. | | `**/*.capnp` | `capnp.md` — Cap'n Proto 스키마 통신 호환성. | +| `**/*.{glsl,hlsl,wgsl,metal}` | `shader.md` — GLSL, HLSL, WGSL 및 Metal 셰이더. | | `**/*.{v,sv,vh}` | `verilog.md` — Verilog 및 SystemVerilog RTL. | | `**/*.{vhd,vhdl}` | `vhdl.md` — VHDL RTL. | | `**/*.m` | `matlab.md`(또는 [내용 탐지](#content-sniffing-for-m-files)로 `objc.md`) | @@ -320,6 +327,7 @@ OCR은 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublesta | `**/*.sol` | `solidity.md` — Solidity 스마트 컨트랙트. | | `**/*.vy` | `vyper.md` — Vyper 스마트 컨트랙트. | | `**/*.rego` | `rego.md` — Rego 정책 (OPA). | +| `**/*.{ps1,psm1,psd1}` | `powershell.md` — PowerShell 스크립트, 모듈 및 데이터/매니페스트 파일. | | _(대체값)_ | `default.md` | 해석된 규칙 본문은 plan과 main 작업 프롬프트에서 `{{system_rule}}` 자리에 들어갑니다. diff --git a/pages/src/content/docs/ru/chatgpt-preview.md b/pages/src/content/docs/ru/chatgpt-preview.md new file mode 100644 index 000000000..5a8188c3e --- /dev/null +++ b/pages/src/content/docs/ru/chatgpt-preview.md @@ -0,0 +1,83 @@ +--- +title: Предварительная версия входа через ChatGPT +--- + +OpenCodeReview может использовать доступный лимит тарифа ChatGPT через публичный API Responses от OpenAI. Эта необязательная функция предварительной версии доступна в CLI с открытым исходным кодом. OpenCodeReview не взимает отдельную плату за подписку. Доступность зависит от аккаунта, рабочего пространства, региона и модели и определяется OpenAI. + +Провайдер `chatgpt` использует документированный процесс Sign in with ChatGPT. `ocr auth login`, `status` и `logout` по умолчанию используют Sign in with ChatGPT. Явный параметр `--provider chatgpt` также поддерживается. + +## Вход и выбор модели + +```bash +ocr auth login --provider chatgpt +ocr auth status --provider chatgpt +ocr llm models +ocr config set provider chatgpt +ocr config set providers.chatgpt.model gpt-6-luna +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +Регистрация OAuth запрашивает полный набор областей OpenID Connect и OpenAI: `openid` (проверка личности), `profile` и `email` (данные аккаунта), `offline_access` (обновление токена без присутствия пользователя), `resource.invoke` (вызов ресурса) и `chatgpt.tokens.use.direct` (прямое использование лимита тарифа ChatGPT). При первой регистрации отправляется `agent_name_hint=OpenCodeReview`. Права для коннекторов не запрашиваются, регистрация клиента Codex CLI не используется повторно. Запрашиваются только области, нужные для документированного процесса, без произвольных дополнительных прав. + +При первом входе OpenAI выдает регистрацию клиента, которую приложение сохраняет для этого аккаунта. При первой регистрации отправляется фактическое имя приложения, одинаковое для всех установок, а при повторном входе подсказка опускается. При каждом входе приложение отправляет постоянный непрозрачный `ext_agent_host_id` для этой локальной установки. Одна установка сохраняет один и тот же идентификатор хоста. + +Для каждой авторизации создаются новые state, nonce и значения PKCE. Состояние callback проверяется до обмена кода. Подпись ID-токена проверяется по RSA JWKS издателя; до принятия личности и выданных областей проверяются issuer, audience, subject, срок действия и nonce. + +Список моделей показывает предложения и отображаемые имена, доступные выбранному аккаунту, в порядке сервера. Если модель не задана, провайдер выбирает первую видимую модель. Явно заданный идентификатор модели или параметр `--model` отправляется в OpenAI без изменений, даже если его нет в списке. Доступность модели для аккаунта определяет OpenAI. В этот провайдер не встроены ограничения или псевдонимы моделей. + +Параметр reasoning effort в `extra_body` относится к API. Отдельный флаг review `--effort` задаёт число раундов проверки и бюджет инструментов OpenCodeReview. + +Вход открывает системный браузер и слушает только `127.0.0.1`. Параметр `--no-browser` выводит одноразовый локальный URL. CLI не печатает URL авторизации с сохраненной подсказкой ID-токена. В этой предварительной версии вход по коду устройства не поддерживается. + +Если вход выполнен без разрешения на использование тарифа ChatGPT, OpenCodeReview сохраняет личность аккаунта и сообщает, что использование тарифа выключено. Чтобы запросить страницу согласия для сохраненной регистрации, выполните: + +```bash +ocr auth login --provider chatgpt --enable-plan +# Для другой сохраненной регистрации: +ocr auth login --provider chatgpt --enable-plan --account +``` + +Это явное действие отправляет `prompt=consent`, полный набор запрашиваемых областей и ресурс, а также сохраненные идентификаторы клиента и хоста. Параметр нельзя использовать вместе с `--new-account`. Обычный повторный вход не требует согласия заново. Запросы остаются выключенными, пока выданное разрешение не допускает использование тарифа. Можно настроить провайдер с API-ключом; OpenCodeReview не переключает способ оплаты автоматически. + +## Аккаунты и выход + +```bash +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth login --provider chatgpt --account +ocr auth logout --provider chatgpt --account +``` + +Каждый выданный идентификатор клиента связан с проверенными issuer и subject, поэтому регистрации различаются даже при одинаковом адресе электронной почты. Повторный вход использует тот же идентификатор клиента. После ошибки `invalid_grant` при обмене кода процесс авторизации однократно начинается заново с идентификатором клиента из callback и новыми state, nonce и PKCE. Ожидающая регистрация не активируется до проверки подписанной личности. При повторной ошибке вход прекращается, прежние учетные данные сохраняются. + +База учетных данных находится в `~/.opencodereview/auth/chatgpt/accounts.json`. Отдельный `host.json` хранит постоянный идентификатор установки. Файлы учетных данных заменяются атомарно. + +В Unix файлы учетных данных имеют права `0600`, доступные только владельцу, а каталог хранилища имеет права `0700`. В Windows доступ зависит от ACL, унаследованных от локального профиля пользователя; OpenCodeReview не устанавливает и не проверяет DACL. Функция Go [`os.Chmod`](https://pkg.go.dev/os#Chmod) в Windows изменяет только атрибут «только чтение». Используйте профиль пользователя, ACL которого ограничивают доступ вашей учетной записью, проверяйте права перед входом и избегайте общих или широко доступных мест хранения. Нахождение каталога внутри профиля пользователя само по себе не гарантирует закрытый доступ. + +Изменения хранилища и ротация refresh-токена используют блокировку ОС, но браузерная авторизация выполняется вне блокировки, чтобы другие аккаунты оставались доступны. При завершении хранилище перечитывается и проверяется версия регистрации. Выход, обновление токена или другой завершенный вход для этой регистрации делают ожидающий результат недействительным. Изменения других аккаунтов сохраняются. Access-токены обновляются до истечения срока с помощью ротируемых refresh-токенов. + +Если при обновлении получены новые токены, но проверка прервалась из-за отмены запроса или временной недоступности открытых ключей проверки подписи, OCR сохраняет полученные токены. Повторите исходную команду. Проверка продолжится при следующем запросе, в том числе после перезапуска CLI. + +Если подпись или данные учетной записи в новом ID-токене не проходят проверку, OCR прекращает повторные попытки и предлагает войти снова. Выполните команду из сообщения об ошибке с сохраненным идентификатором клиента: + +```bash +ocr auth login --provider chatgpt --account +``` + +После успешного входа новые учетные данные заменят те, которые не прошли проверку. + +При выходе OCR пытается отозвать обновляемую сессию с помощью самого нового refresh-токена, включая токен, полученный до завершения проверки ID-токена. Затем access-, refresh- и ID-токены удаляются, а регистрация аккаунта и идентификатор хоста сохраняются. После выхода при следующем входе `id_token_hint` не отправляется. Если удаленный отзыв подтвердить нельзя, CLI сообщает об удалении локальных токенов и предлагает отключить приложение в настройках ChatGPT. + +## Передача запросов и лимиты + +Этот провайдер отправляет запросы только на `https://api.openai.com/v1/responses`. Переопределение конечной точки, протокола и учетных данных отклоняется. Используется потоковая передача с `store: false`, отправляется полная история, локальные функции группируются в пространстве имен `ocr`, а идентификаторы вызовов и зашифрованное reasoning сохраняются для следующих ходов. Успехом считается только `response.completed`. При ошибке, неполном или прерванном потоке запрос прекращается, включая ошибку лимита после начала вывода. + +Поля запросов предварительной версии ограничены поддерживаемым OpenAI набором. `extra_body` принимает `reasoning`, `text` и `prompt_cache_key`; менять модель, историю, инструменты, режим потока или политику хранения нельзя. Ответ сохраняет исходные данные об использовании провайдера. Существующая необязательная запись исходного потока проверки не включает заголовки учетных данных. + +**Использование тарифа ChatGPT:** подходящие запросы используют тариф выбранного аккаунта. [Управление использованием в настройках ChatGPT](https://chatgpt.com/settings/usage). Ошибка лимита может относиться ко всему тарифу или отдельному приложению; CLI не пытается определить время сброса. + +Текущие требования описаны в [обзоре SIWC для открытого исходного кода](https://developers.openai.com/siwc/token-sharing-open-source), [контракте входа](https://developers.openai.com/siwc/token-sharing-open-source/sign-in) и [ограничениях предварительной версии](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations) OpenAI. + +Команды `ocr auth` и `ocr llm models` описаны в [справочнике CLI](../cli-reference/), а настройки провайдера описаны в [руководстве по конфигурации](../configuration/). diff --git a/pages/src/content/docs/ru/cli-reference.md b/pages/src/content/docs/ru/cli-reference.md index e28d3eee0..a10dec268 100644 --- a/pages/src/content/docs/ru/cli-reference.md +++ b/pages/src/content/docs/ru/cli-reference.md @@ -123,7 +123,7 @@ ocr r [flags] (alias) | `--background-file ` | `-B` | — | Путь к Markdown-файлу с контекстом ревью. Если также задан `--background`, используются оба источника. | | `--exclude ` | — | — | Разделённые запятыми шаблоны исключения в стиле gitignore; объединяются с excludes из `rule.json`. | | `--concurrency ` | — | `8` | Максимальное число подзадач, проверяемых параллельно. | -| `--timeout ` | — | `15` | Срок выполнения для каждой подзадачи. `0` отключает тайм-аут. Масштабируется линейно по числу раундов effort (например, 15/30/45 мин для low/medium/high). | +| `--timeout ` | — | `15` | Бюджет времени каждой подзадачи, включая вызовы LLM, инструменты и ожидание повторов. `0` отключает срок выполнения задачи, но не тайм-ауты запросов. Независимый тайм-аут запроса задаётся через `OCR_LLM_TIMEOUT` или `timeout_sec` провайдера, в секундах (по умолчанию `300`). Масштабируется линейно по числу раундов проверки effort (например, 15/30/45 мин для low/medium/high). | | `--rule ` | — | — | Путь к пользовательскому JSON-файлу правил ревью. Переопределяет проектный и глобальный `rule.json`. | | `--max-tools ` | — | значение шаблона | Максимальное число раундов вызова инструментов для каждой подзадачи. `0` использует значение шаблона (`100`); значения 1–49 повышаются до `50`; итоговое значение применяется только если оно **больше** значения шаблона (предел можно лишь повысить, но не понизить). | | `--max-tokens ` | — | значение конфигурации или шаблона | Предел токенов **промпта** для каждой подзадачи (по умолчанию `200000` для review). Переопределяет сохранённое значение `max_tokens` для этого запуска. На предел вывода не влияет — он задаётся отдельно параметром `MAX_COMPLETION_TOKENS` (`16384`). | @@ -139,6 +139,19 @@ ocr r [flags] (alias) > Их сочетание приводит к ошибке. `--resume` поддерживает только ревью > диапазона или коммита и несовместим с `--preview`. +### Выбор LLM для одного запуска + +Команды `review` и `scan` принимают `--provider` и `--model` для текущего +запуска, не изменяя сохранённую конфигурацию. `--provider` выбирает запись из +`providers` или `custom_providers`; `--model` переопределяет модель выбранного +источника. + +Для встроенных провайдеров `--model` принимает модели вне списка предложений +`ocr config model`. Если модель отсутствует и во встроенном списке, и в +`providers..models`, OCR выводит предупреждение в stderr и оставляет +проверку провайдеру. Для пользовательских провайдеров действуют прежние правила +проверки `--model`. + ### Режимы #### Режим рабочей области (по умолчанию) @@ -350,7 +363,7 @@ ocr s [flags] (alias) | Флаг | Краткая форма | По умолчанию | Описание | |---|---|---|---| -| `--path ` | - | весь репозиторий | Относительные каталоги или файлы репозитория для сканирования (через запятую, например `internal/agent`, `internal/llm/client.go`). | +| `--path ` | - | весь репозиторий | Относительные каталоги или файлы репозитория для сканирования (через запятую, например `internal/agent`, `internal/llm/client.go`). Селекторы разрешаются по рабочему дереву: точное написание имеет приоритет, поэтому `src` не выберет соседний каталог `SRC/`; в регистронезависимых файловых системах (Windows, macOS по умолчанию) другое написание, которое файловая система может разрешить, всё равно выберет соответствующие файлы; селектор, не указывающий ни на один существующий путь, приводит к ошибке, а не к пустому сканированию. | | `--exclude ` | - | - | Шаблоны исключения в стиле gitignore (через запятую, например `**/generated/*,*.pb.go`); объединяются с excludes из `rule.json`. | | `--output ` | `-o` | stdout | Запись результатов сканирования в файл UTF-8 (`-` означает stdout). Файл создаётся лениво при первой записи, поэтому неудачные запуски не затрагивают существующие файлы. В текстовом формате цветовые коды ANSI удаляются автоматически. | | `--preview` | `-p` | `false` | Перечисляет и фильтрует файлы без вызова LLM. Выводит список файлов, количество проверяемых/исключённых, общее число строк и причины исключения для каждого файла. Учитывает `--format json`; `--format sarif` не поддерживается. | @@ -574,9 +587,30 @@ ocr config model Interactive model selection Полный справочник по ключам, схемы и примеры приведены в разделе [Конфигурация](../configuration/). + + +## `ocr auth` + +`ocr auth login`, `status` и `logout` по умолчанию используют Sign in with ChatGPT. Явный параметр `--provider chatgpt` также поддерживается. + +Провайдер `chatgpt` использует документированный процесс Sign in with ChatGPT. См. [руководство по предварительной версии ChatGPT](../chatgpt-preview/). + +```bash +ocr auth login --provider chatgpt +ocr auth login --provider chatgpt --no-browser +ocr auth login --provider chatgpt --enable-plan +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth logout --provider chatgpt --account +ocr llm models +``` + +`ocr auth` принимает `--provider chatgpt` и `--account `, а `login` также принимает `--no-browser`, `--new-account` и `--enable-plan`. Для ChatGPT параметр `--device` не поддерживается. `--enable-plan` запрашивает согласие для сохраненной регистрации и несовместим с `--new-account`. Список моделей зависит от аккаунта; явно заданная модель допустима, даже если ее нет в списке. Этот провайдер принимает в `extra_body` только `reasoning`, `text` и `prompt_cache_key`. Управление аккаунтами, права и ограничения описаны в [руководстве по предварительной версии ChatGPT](../chatgpt-preview/). + ## `ocr llm` -Служебные команды LLM. Доступны две подкоманды: +Служебные команды LLM. Доступны три подкоманды: ```text ocr llm @@ -584,6 +618,7 @@ ocr llm Sub-commands: test Send a test conversation to the configured LLM model providers List all built-in LLM providers + models List current account-specific ChatGPT models ``` ### `ocr llm test` diff --git a/pages/src/content/docs/ru/configuration.md b/pages/src/content/docs/ru/configuration.md index bc0c90a6b..a9f05dd70 100644 --- a/pages/src/content/docs/ru/configuration.md +++ b/pages/src/content/docs/ru/configuration.md @@ -41,14 +41,16 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ### Встроенные провайдеры Перечисленные ниже провайдеры поставляются вместе с OCR; для них заранее -заданы базовый URL и протокол, поэтому после выбора достаточно указать -API-ключ. Если `providers..api_key` не задан, OCR использует -соответствующую переменную окружения. +заданы базовый URL и протокол. Большинству нужен API-ключ. Если +`providers..api_key` не задан, OCR использует соответствующую переменную +окружения. Провайдер `chatgpt` вместо этого использует учётные данные ChatGPT, +созданные командой `ocr auth login`. | Имя | Протокол | Базовый URL | Переменная окружения для API-ключа | |---|---|---|---| | `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | `bedrock` | anthropic-bedrock | определяется `aws_region` | — (цепочка учётных данных AWS) | +| `chatgpt` | openai-responses | `https://api.openai.com/v1` | не применяется (Sign in with ChatGPT) | | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | | `openai-responses` | openai-responses | `https://api.openai.com/v1` | `OPENAI_RESPONSES_API_KEY` | | `openrouter` | openai | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | @@ -72,6 +74,28 @@ API-ключ. Если `providers..api_key` не задан, OCR испо | `novita` | openai | `https://api.novita.ai/openai` | `NOVITA_API_KEY` | | `xai` | openai | `https://api.x.ai/v1` | `XAI_API_KEY` | +Списки моделей встроенных провайдеров служат подсказкой для `ocr config model` и +не ограничивают `--model`. Если модель отсутствует и во встроенном списке, и в +`providers..models`, OCR выводит предупреждение в stderr; провайдер +проверит модель при отправке запроса. Для пользовательских провайдеров действуют +прежние правила проверки `--model`. + +## Предварительная версия входа через ChatGPT + +В предварительной версии добавлен встроенный провайдер `chatgpt` для публичного API Responses. Переменной окружения API-ключа нет. Условия входа, согласия и использования описаны в [руководстве по предварительной версии ChatGPT](../chatgpt-preview/). + +Настройте провайдер и необязательные поля запроса с помощью `ocr config set`. Выберите модель через `ocr llm models` или задайте ее идентификатор явно: + +```bash +ocr auth login --provider chatgpt +ocr config set provider chatgpt +ocr config set providers.chatgpt.model +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +Явно заданная модель отправляется, даже если ее нет в списке аккаунта. `extra_body` принимает только `reasoning`, `text` и `prompt_cache_key`; переопределение URL, протокола и учетных данных отклоняется. + ### Переопределение Base URL встроенного провайдера У каждого встроенного провайдера есть предустановленный Base URL @@ -80,6 +104,8 @@ API-ключ. Если `providers..api_key` не задан, OCR испо предустановленному адресу `http://localhost:4000/v1` — задайте `providers..url`: +Предварительная версия `chatgpt` является исключением: публичный адрес фиксирован, переопределение URL отклоняется. + ```bash ocr config set provider litellm ocr config set model openai/gpt-5.4 @@ -94,7 +120,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1 ### AWS Bedrock -`bedrock` использует тот же Messages API, что и `anthropic`, но запросы +По умолчанию `bedrock` использует протокол `anthropic-bedrock` и тот же Messages API, что и `anthropic`, но запросы подписываются по SigV4 из стандартной цепочки учётных данных AWS вместо передачи API-ключа, а хост определяется регионом. Задавать `api_key` не нужно, и он не принимается как замена подписи: @@ -106,6 +132,8 @@ ocr config set providers.bedrock.aws_region us-west-2 ocr config set providers.bedrock.aws_profile example-profile ``` +Если для подключения к OpenAI-совместимому шлюзу вы задаете `providers.bedrock.protocol` значение `openai`, укажите адрес шлюза в `providers.bedrock.url`, а его API-ключ — через `providers.bedrock.api_key` или `providers.bedrock.api_key_cmd`. При выборе другой модели в `ocr config provider` сохраненный API-ключ остается на месте. + | Поле | Значение | |---|---| | `providers.bedrock.aws_region` | Регион, чей хост `bedrock-runtime` обслуживает запрос. По умолчанию — `AWS_REGION` или активный профиль. | @@ -123,8 +151,8 @@ ocr config set providers.bedrock.aws_profile example-profile `aws bedrock list-inference-profiles --region `, чтобы увидеть, что доступно в аккаунте; суффикс версии вроде `-v1:0` недопустим для новых семейств. -`ocr llm test` показывает регион и профиль вместо URL, потому что у bedrock нет -настроенного URL — хост определяется регионом: +При использовании протокола по умолчанию `anthropic-bedrock` команда `ocr llm test` +показывает регион и профиль вместо URL. Хост определяется регионом: ``` Source: provider:bedrock @@ -141,6 +169,15 @@ Model: claude-sonnet-5 ### Пользовательские провайдеры +Поддерживаются имена вида `mimo-v2.6-flash` и `foo.api_key`. Прежде чем +задавать поля нового имени с точками, создайте и выберите его командой +`ocr config set provider `; она меняет активного провайдера. +Существующие записи можно редактировать без выбора через +`providers..` или `custom_providers..`. +Имя не должно начинаться или заканчиваться точкой, содержать несколько +точек подряд, пустые части или части только из пробелов. Имена без точек +по-прежнему создаются неявно при настройке полей. + Любое имя провайдера, которого нет в таблице выше, считается пользовательским. Для него необходимо задать как минимум `url` и `protocol` (`protocol` может принимать значения `anthropic`, `openai`, @@ -200,6 +237,18 @@ Ollama игнорирует API-ключ, однако для пользоват ### Тайм-ауты +Тайм-аут задачи и тайм-аут отдельного запроса независимы: + +- `ocr review --timeout` и `ocr scan --timeout` задают бюджет времени параллельной + задачи в **минутах** (по умолчанию **15**; `0` отключает срок выполнения задачи). + В него входят все вызовы LLM, выполнение инструментов и ожидание повторных + попыток в этой задаче. Для review бюджет умножается на число раундов проверки, + заданное уровнем effort. +- Тайм-аут отдельного HTTP-запроса задаётся в **секундах** (по умолчанию **300**). + Увеличение `--timeout` его не меняет, а отключение срока выполнения задачи + не отключает тайм-ауты запросов. Если оставшийся бюджет задачи меньше, + действует именно он. + Для каждого HTTP-запроса к LLM действует тайм-аут, по умолчанию **300 секунд**. Медленным локальным моделям (или большим файлам) может потребоваться больше времени. Доступны три настройки с возрастающей областью действия: @@ -220,6 +269,26 @@ Ollama игнорирует API-ключ, однако для пользоват } ``` +Например, `OCR_LLM_TIMEOUT=900 ocr review --timeout 30` позволяет отдельному +запросу выполняться до 15 минут в пределах бюджета задачи. Если не заданы +эта переменная окружения или `timeout_sec`, запросы по-прежнему завершаются +по тайм-ауту через 5 минут даже с `--timeout 30`. + +Истечение срока отдельного запроса завершает вызов без автоматической повторной +отправки, в том числе при чтении тела ответа или потока. Для стабильно медленных +запросов следует увеличить тайм-аут запроса, а не повторять его с тем же лимитом. +Существующая политика повторов SDK не меняется: ошибки соединения и HTTP, +допускающие повтор, обрабатываются с задержками и с учётом подсказок провайдера +`Retry-After`, максимум 5 повторных попыток. Срок каждой попытки SDK также +ограничивает ожидание перед повтором. Отмена или истечение срока задачи +останавливает как запросы, так и ожидание повторных попыток. + +Диагностика различает `LLM request timeout` (проверьте `OCR_LLM_TIMEOUT` или +`timeout_sec` провайдера) и `caller deadline exceeded`: последнее означает, +что истёк срок контекста вызывающей операции. Если истёк срок задачи review/scan, +проверьте `--timeout`; у других операций, таких как фоновое сжатие памяти +и `ocr llm test`, есть собственные сроки. + ### Получение API-ключа из команды Вместо того чтобы хранить ключ в файле конфигурации, параметр `api_key_cmd` diff --git a/pages/src/content/docs/ru/contributing.md b/pages/src/content/docs/ru/contributing.md index 4edd5bca0..31739bbaa 100644 --- a/pages/src/content/docs/ru/contributing.md +++ b/pages/src/content/docs/ru/contributing.md @@ -29,7 +29,7 @@ OCR распространяется с открытым исходным код ### Предварительные требования -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -116,39 +116,57 @@ refactor(viewer): extract task-card rendering into helper Тот же формат используется для **заголовков PR**, чтобы они аккуратно отображались в автоматически созданном журнале изменений. +`PR Commit Policy` проверяет заголовок PR и полное сообщение каждого коммита, +включая тело, без учёта регистра. Не добавляйте `[skip ci]`, `[ci skip]`, `[no ci]`, +`[skip actions]`, `[actions skip]` или отдельную строку +`skip-checks: true` / `skip-checks:true`. + +Если проверка не пройдена, измените заголовок PR или перепишите сообщения +соответствующих коммитов в своей рабочей ветке. Новый коммит без этих инструкций +не устраняет нарушения в предыдущих коммитах. Описание PR может содержать +обсуждение этих инструкций и не проверяется. + ## Структура проекта ``` open-code-review/ -├── cmd/opencodereview/ # CLI entry point — flag parsing, dispatch +├── acp/ # Независимый адаптер ACP (Agent Client Protocol) для CLI +├── cmd/opencodereview/ # Точка входа CLI — разбор флагов и диспетчеризация +├── docs/ # Локализованные README и руководства для участников +├── imgs/ # Изображения и логотипы для README ├── internal/ -│ ├── agent/ # Review agent logic, sub-agent dispatch -│ ├── config/ # Template, rules, allowlist, embedded JSON -│ ├── diff/ # Git diff parsing, three modes -│ ├── gitcmd/ # Git subprocess runner -│ ├── llm/ # LLM client (Anthropic & OpenAI), endpoint resolver -│ ├── model/ # Data structs (LlmComment, Diff, …) -│ ├── pathutil/ # Path utilities -│ ├── release/ # Release-notes generation -│ ├── session/ # JSONL session writer -│ ├── stdout/ # Quiet-able stdout writer -│ ├── suggestdiff/ # Suggestion diff rendering -│ ├── telemetry/ # OpenTelemetry config + helpers -│ ├── tool/ # Tool registry + provider impls -│ └── viewer/ # Embedded HTTP UI -├── pages/ # WebUI marketing page (separate React app) -├── plugins/ # Claude Code slash command -├── extensions/ # Editor extensions (VS Code) -├── examples/ # CI recipes (GitHub Actions, GitLab CI) -├── skills/ # Agent SDK skill manifest -├── scripts/ # NPM postinstall + cross-build scripts -├── npm/ # Per-platform optional dependency packages -└── bin/ # NPM wrapper (Node) +│ ├── agent/ # Логика агента ревью и запуск подагентов +│ ├── chatgptauth/ # ChatGPT OAuth и управление учётными данными +│ ├── config/ # Шаблоны, правила, список разрешений, встроенный JSON +│ ├── delegate/ # Генерация спецификаций ревью без вызовов LLM +│ ├── diff/ # Разбор Git diff +│ ├── gitcmd/ # Запуск Git в дочерних процессах +│ ├── llm/ # Клиенты LLM и определение конечных точек API +│ ├── llmloop/ # Общий цикл вызова инструментов LLM для review и scan +│ ├── mcp/ # Клиенты MCP и адаптеры инструментов +│ ├── model/ # Структуры данных (LlmComment, Diff, …) +│ ├── pathutil/ # Утилиты для работы с путями +│ ├── release/ # Проверки согласованности имён артефактов релиза +│ ├── scan/ # Ревью кода полных файлов +│ ├── session/ # Запись сеансов в JSONL +│ ├── stdout/ # Вывод в stdout с возможностью отключения +│ ├── suggestdiff/ # Отображение diff предлагаемых изменений +│ ├── telemetry/ # Настройка OpenTelemetry и вспомогательные функции +│ ├── tool/ # Реестр инструментов и реализации провайдеров +│ └── viewer/ # Встроенный HTTP UI для просмотра сеансов ревью +├── pages/ # Главная страница сайта и документация (отдельное приложение React) +├── plugins/ # Интеграции с агентами для программирования (Claude Code, Codex, OpenCode, …) +├── extensions/ # Расширения VS Code и IntelliJ IDEA, общий фронтенд +├── examples/ # Примеры CI (GitHub Actions, GitLab CI) +├── skills/ # Определения навыков для ревью и делегирования +├── scripts/ # Скрипты установки, публикации и проверки репозитория +├── npm/ # Необязательные пакеты зависимостей для каждой платформы +└── bin/ # Обёртка NPM (Node) ``` Большинство изменений затрагивает `internal/agent/`, `internal/tool/` или `internal/llm/`. Интерфейс CLI в `cmd/opencodereview/` намеренно сделан -тонким: он разбирает флаги, а затем передаёт управление пакету агента. +тонким: он разбирает флаги и выполняет диспетчеризацию команд. ## Разработка с помощью ИИ diff --git a/pages/src/content/docs/ru/installation.md b/pages/src/content/docs/ru/installation.md index 07c989559..9b2eabe94 100644 --- a/pages/src/content/docs/ru/installation.md +++ b/pages/src/content/docs/ru/installation.md @@ -148,7 +148,7 @@ shasum -a 256 -c sha256sum.txt --ignore-missing #### Предварительные требования -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) diff --git a/pages/src/content/docs/ru/integrations/acp.md b/pages/src/content/docs/ru/integrations/acp.md new file mode 100644 index 000000000..46e601d78 --- /dev/null +++ b/pages/src/content/docs/ru/integrations/acp.md @@ -0,0 +1,228 @@ +--- +title: ACP +sidebar: + order: 6 +--- + +## Требования {#prerequisites} + +- Установите [CLI OCR](../../installation/) и настройте + [модель для ревью](../../configuration/). Проверьте её командой `ocr llm test`. +- Для сборки адаптера нужны исходный код с каталогом `acp/`, Git и Go 1.23+. + Для сборки самого CLI OCR требуется Go 1.25.5+. +- Используйте клиент с поддержкой ACP v1 через stdio и настраиваемой команды запуска агента. + +Адаптер собирается отдельно от CLI OCR. Инструкции ниже предполагают локальную +сборку. + +## Сборка и подключение клиента {#build-and-connect-your-client} + +Выполните в корне репозитория: + +```bash +make -C acp build +command -v ocr +``` + +Сборка создаёт `dist/ocr-acp`. Укажите абсолютный путь, выведенный `command -v ocr`, в параметре `--ocr-binary`. + +Добавьте следующую конфигурацию в настройки Agent клиента. Замените все `/absolute/path` и значения модели разбора на свои. + +```json +{ + "agent_servers": { + "OpenCodeReview": { + "type": "custom", + "command": "/absolute/path/open-code-review/dist/ocr-acp", + "args": ["--ocr-binary", "/absolute/path/ocr"], + "env": { + "OCR_ACP_PARSER_PROVIDER": "anthropic", + "OCR_ACP_PARSER_MODEL": "deepseek-flash", + "OCR_ACP_PARSER_BASE_URL": "https://your-gateway.example", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" + } + } + } +} +``` + +Названия полей зависят от клиента; используйте его инструкцию по настройке ACP. + +## Запуск ревью или сканирования {#run-a-review-or-scan} + +Отправляйте эти команды в беседе OpenCodeReview: + +| Запрос | Команда | +| --- | --- | +| Проверить индексированные, неиндексированные и неотслеживаемые изменения | `/review` | +| Выполнить не более одного раунда ревью | `/review --effort low` | +| Проверить последний коммит текущей рабочей копии | `/review --commit HEAD` | +| Сравнить две существующие ссылки Git | `/review --from main --to HEAD` | +| Просканировать каталог | `/scan --path internal/agent` | +| Просканировать несколько путей | `/scan --path internal/agent,internal/config` | + +Замените ссылки Git и пути из примеров значениями вашего проекта. Для ревью +нужен репозиторий Git; расположение замечаний определяется относительно его +корня. Сканирование использует рабочий каталог сеанса. `--effort low` запускает +не более одного раунда ревью, `medium` — двух, `high` — трёх. Если новых замечаний нет, +ревью может закончиться раньше. Если флаг не указан, применяется конфигурация OCR. +Меньшее число раундов может привести к пропуску проблем: выбирайте баланс +скорости и качества в зависимости от задачи. + +Введите `/` в клиенте с поддержкой подсказок команд ACP, чтобы увидеть команды +и их аргументы. Динамического автодополнения веток и путей нет. Если клиент +прикрепляет ссылки на локальные файлы, отправьте их вместе с `/scan`, без +`--path`. Ссылки нельзя сочетать с `/review` или явными путями сканирования. +Неподдерживаемые и неоднозначные ссылки отклоняются, а не расширяют область +сканирования автоматически. + +Адаптер принимает определённый набор параметров CLI, а не передаёт произвольные +параметры напрямую: + +- Ревью: `--commit`, `--from`, `--to`, `--effort`, `--no-filter`, + `--background`, `--background-file`. +- Сканирование: `--path`, `--batch`, `--no-plan`, `--no-dedup`, `--no-summary`, + `--background`. + +Используйте `/review` для всего набора изменений; `--staged` не поддерживается. +В командах беседы нельзя задавать параметры вывода, такие как `--format` и +`--audience`, или переопределения `--repo`, `--model` и `--provider`. +Настраивайте модель для ревью средствами самого OCR. + +## Запросы на естественном языке {#enable-natural-language-requests} + +Модель разбора преобразует запросы на естественном языке в команды ревью или +сканирования либо задаёт уточняющие вопросы. Она настраивается отдельно от +модели OCR для ревью; выбор собственной модели клиента не настраивает ни одну из них. + +Добавьте следующие переменные окружения в объект `env` агента, заменив +значения модели и ключа данными вашего провайдера: + +```json +{ + "OCR_ACP_PARSER_PROVIDER": "openai", + "OCR_ACP_PARSER_MODEL": "YOUR_TOOL_CALLING_MODEL", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" +} +``` + +В примере используется протокол OpenAI-совместимого Chat Completions. Для +протокола Anthropic Messages укажите `anthropic`. При использовании совместимого +шлюза также задайте его базовый URL API в `OCR_ACP_PARSER_BASE_URL`. Храните +учётные данные в локальных настройках или передавайте их через окружение процесса +агента; не добавляйте их в общие настройки проекта в репозитории. Графическое +приложение может не наследовать переменные окружения, заданные в терминале. + +Правила пути в базовом URL зависят от протокола: + +| Протокол | Пример `OCR_ACP_PARSER_BASE_URL` | Адрес запроса | +| --- | --- | --- | +| `openai` | `https://gateway.example/v1` | `https://gateway.example/v1/chat/completions` | +| `anthropic` | `https://gateway.example` | `https://gateway.example/v1/messages` | + +Можно также указать полный адрес, оканчивающийся соответственно на `/chat/completions` или +`/v1/messages`: этот суффикс не добавляется повторно. Базовый URL Anthropic, оканчивающийся +на `/v1`, даст путь `/v1/v1/messages`; указывайте путь согласно API вашего шлюза. + +| Переменная окружения | Флаг запуска | Назначение | +| --- | --- | --- | +| `OCR_ACP_PARSER_PROVIDER` | `--parser-provider` | `openai` или `anthropic` | +| `OCR_ACP_PARSER_MODEL` | `--parser-model` | Модель с поддержкой вызова инструментов | +| `OCR_ACP_PARSER_BASE_URL` | `--parser-base-url` | Необязательное переопределение базового URL API | +| `OCR_ACP_PARSER_API_KEY` | Нет | Ключ API модели разбора; только через окружение | + +Флаги запуска имеют приоритет над соответствующими переменными окружения. +Модель разбора не читает конфигурацию OCR и переменные `OCR_LLM_*`. Здесь +поддерживаются протоколы `openai` и `anthropic`; `openai-responses` и +`anthropic-bedrock` не поддерживаются. + +В запросах разбора Anthropic режим Thinking явно отключается для обязательного +вызова инструмента `submit_intent`. Настройки Thinking модели OCR для ревью +при этом не меняются. + +После настройки модели разбора начните новую беседу и попробуйте: + +```text +Проверь изменения в моём рабочем каталоге. +Проверь последний коммит текущей рабочей копии. +Просканируй internal/agent на наличие потенциальных ошибок. +``` + +Если запрос неоднозначен, ответьте на уточняющий вопрос до начала выполнения. +Модели предписано задавать вопросы и давать пояснения на вашем языке; +фиксированные сообщения проверки и подписи отчёта остаются на английском. +Если модель разбора недоступна, slash-команды продолжают работать. Неполная +конфигурация модели разбора приводит к явной ошибке при запуске. + +«Последний коммит текущей рабочей копии» соответствует `/review --commit HEAD`. +Сеанс хранит один незавершённый запрос на уточнение, который может содержать несколько совместимых +полей. Последовательные уточнения сохраняют известные значения, а явно указанные новые значения +заменяют старые. Поля разных типов ревью не смешиваются. После завершения или отмены состояние +очищается и не восстанавливается в другом сеансе. + +## Результаты и отмена {#read-results-and-cancel-work} + +Прогресс использует существующие журналы CLI OCR и итоговый JSON; менять CLI не требуется. Отображаются последнее сообщённое действие и общее время выполнения. Этапы групп, их длительность и ожидание ответа модели не вычисляются предположительно. + +Одна запись **OCR review** или **OCR scan** показывает текущую активность и время, +измеренное адаптером. Раскройте её, чтобы увидеть **Command:**, рабочий каталог и +ограниченный по размеру текстовый журнал. Диагностические строки остаются в деталях +и не заменяют заголовок прогресса. Команда и журнал не дублируются в тексте беседы; +начальное состояние раскрытия определяет клиент. + +Итоговый отчёт начинается со статуса завершения, количества файлов и замечаний, +а также распределения по серьёзности. Заголовок каждого замечания содержит номер, +серьёзность и расположение файла; далее идут категория, объяснение и блоки кода +с указанием языка. Предложения явно помечены как неприменённые. Замечания появляются +только один раз в итоговом сообщении. Допустимые расположения становятся ссылками; +несуществующие файлы, строки вне диапазона и пути за пределами рабочего корня +остаются обычным текстом. Отображение и переходы зависят от версии клиента. + +Сведения о частичных сбоях и достижении лимита бюджета сохраняются. В конце отчёта +показаны доступные суммарные токены и время, сообщённое OCR. Детали выполнения также +содержат число вызовов инструментов и входные, выходные и кешированные токены, +если OCR их предоставил. Отсутствующие значения не выдумываются. Локальный таймер +может отличаться от времени OCR; сумма токенов охватывает все запросы к модели, +а не только вновь сгенерированный ответ. + +Чтобы остановить выполняющийся запрос, воспользуйтесь отменой в клиенте. +Адаптер отменит работу и дождётся завершения управляемых процессов. Чтобы +ограничить длительность всего запроса, включая разбор, добавьте +`"--turn-timeout", "10m"` в массив `args` агента. Значение по умолчанию — `0`; +оно отключает общий тайм-аут. У разбора есть отдельный лимит, по умолчанию +15 секунд. При превышении времени адаптер предложит повторить запрос. + +## Устранение неполадок {#troubleshooting} + +| Симптом | Что проверить | +| --- | --- | +| Агент не запускается | Проверьте оба абсолютных пути к исполняемым файлам и права на выполнение. Выполните `ocr llm test` для проверки конфигурации OCR; удалите неполные настройки модели разбора или задайте все необходимые значения. | +| Slash-команды работают, запросы на естественном языке — нет | Настройте отдельные переменные окружения `OCR_ACP_PARSER_*`. Проверьте провайдера, модель, ключ и URL шлюза. | +| Истёк тайм-аут разбора естественного языка | Разбор по умолчанию ограничен 15 секундами; OCR на этом этапе ещё не запущен. Изучите этап запроса, HTTP-статус и ответ провайдера в диагностике. Увеличение `--turn-timeout` не продлевает собственный тайм-аут разбора. Повторите запрос или используйте `/review`, `/scan` без модели разбора. | +| Команда или флаг отклонены | Используйте поддерживаемые параметры из списка выше. Вместо `/review --path` используйте `/scan --path`; для ревью коммита или диапазона указывайте существующие ссылки Git. | +| Ревью завершается ошибкой или возвращает частичный результат | Раскройте OCR review/scan для диагностики и прочитайте итоговые сведения об ошибке. Проверьте конфигурацию модели OCR и доступность провайдера. | +| Расположение в результате недоступно для перехода | Убедитесь, что файл существует внутри корня операции и диапазон строк корректен. Отображение только текста в остальных случаях предусмотрено намеренно. | +| Задача выполняется слишком долго | Отмените её, изучите сведения о ходе выполнения или задайте общий тайм-аут. Для ревью `--effort low` уменьшает число раундов. | +| После пересборки сохраняется старое поведение | Проверьте путь к исполняемому файлу в настройках и начните новую беседу с агентом. Перезапустите агент или клиент, если он продолжает использовать старый процесс. | + +При проблемах с подключением изучите журналы ACP клиента и stderr адаптера. +Перед передачей журналов удалите учётные данные и конфиденциальные сведения. + +## Обновление и границы поддержки {#upgrade-and-support-boundaries} + +Обновите исходный код, снова выполните `make -C acp build` и начните новую +беседу OpenCodeReview. Активные процессы и старые сообщения не перезагружаются. +Восстановление сеанса не поддерживается; новая беседа начинается без сохранённых +незавершённых уточнений. + +Адаптер использует локальный транспорт stdio. Транспорт HTTP, дополнительные +корни рабочей области, автоматическое редактирование файлов и запросы +с изображениями или аудио не поддерживаются. Другие клиенты ACP требуют +собственных проверок совместимости; успешные тесты протокола не подтверждают +поведение интерфейса каждого клиента. + +## См. также {#see-also} + +- [Конфигурация](../../configuration/) — модель для ревью и настройки провайдера OCR. +- [Справочник CLI](../../cli-reference/) — подробности параметров ревью и сканирования. +- [Введение в ACP](https://agentclientprotocol.com/get-started/introduction) — официальное введение в Agent Client Protocol. diff --git a/pages/src/content/docs/ru/integrations/delegate.md b/pages/src/content/docs/ru/integrations/delegate.md index 2be4b8284..88d687367 100644 --- a/pages/src/content/docs/ru/integrations/delegate.md +++ b/pages/src/content/docs/ru/integrations/delegate.md @@ -93,24 +93,42 @@ ocr delegate rule ... ### Шаг 3. Получить diff -Используйте Git напрямую на основе режима и сведений о ссылках из шага 1: +Выберите команду diff по режиму и ссылкам из шага 1. При каждом вызове Git во время ревью используйте `git --no-pager`. + +Следующие команды diff отключают внешние diff-программы, преобразование текста и цветной вывод, чтобы получить патчи в виде обычного текста. **Режим диапазона** (предоставлен merge\_base): ```bash -git diff .. -- +git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- "" ``` **Режим коммита**: ```bash -git show -- +git --no-pager show --no-ext-diff --no-textconv --no-color -- "" ``` **Режим рабочей области**: ```bash -git diff HEAD -- # tracked files -cat # new untracked files +git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- "" # tracked files +cat "" # new untracked files +``` + +Для получения контекста: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" ``` +Для больших diff добавьте `--output=""` в команду выбранного режима. Выберите уникальный абсолютный путь вне репозитория и создайте родительский каталог. Используйте тот же путь в вызовах инструмента чтения файлов: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +После завершения Git с кодом 0 прочитайте и проверьте весь файл по частям, затем удалите его. При ошибке или тайм-ауте повторите попытку либо отметьте файл как `skipped`, указав ошибку. Если вывод неожиданно пуст, повторите preview и сопоставьте результаты. + ### Шаг 4. Проверить каждый файл Для каждого проверяемого файла: @@ -119,6 +137,8 @@ cat # new untracked files 2. Использовать подходящую группу правил (шаг 2) как контрольный список ревью. 3. Провести тщательное ревью, при необходимости исследуя контекст. +Запишите каждый элемент `(path, status)` из preview как `reviewed` или `skipped`. Укажите причину для каждого пропущенного элемента и включите в отчёт `total_files`, `reviewed_files`, `skipped_files`, `coverage_rate`. + ### Шаг 5. Подготовить отчёт Классифицируйте каждое замечание по серьёзности: diff --git a/pages/src/content/docs/ru/review-rules.md b/pages/src/content/docs/ru/review-rules.md index 0f357525c..ff3315d2b 100644 --- a/pages/src/content/docs/ru/review-rules.md +++ b/pages/src/content/docs/ru/review-rules.md @@ -217,6 +217,11 @@ OCR использует [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com - `**/test/**/*.lhs` - `**/*Spec.lhs` - `**/tests/**/*.nim` +- `**/tests/**/*.nu` +- `**/test/**/*.nu` +- `**/*_test.nu` +- `**/test_*.nu` +- `**/test-*.nu` - `**/tests/**/*.R` - `**/__snapshots__/**` - `**/*.snap` @@ -325,12 +330,14 @@ OCR использует [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com | `**/*.{graphql,gql}` | `graphql.md` — схема и операции GraphQL. | | `**/*.prisma` | `prisma.md` — схема Prisma. | | `**/*.jl` | `julia.md` — исходный код Julia. | -| `**/*.{tf,hcl,tfvars}` | `terraform.md` — Terraform / HCL. | +| `**/*.{tf,tofu,hcl,tfvars}` | `terraform.md` — Terraform / HCL. | | `**/*.bicep` | `bicep.md` — шаблоны Bicep (Azure). | +| `**/*.nu` | `nushell.md` — скрипты Nushell. | | `**/*.elm` | `elm.md` - исходный код Elm. | | `**/*.{jsonnet,libsonnet}` | `jsonnet.md` — шаблоны конфигурации и библиотеки Jsonnet. | | `**/*.thrift` | `thrift.md` — совместимость Apache Thrift IDL на уровне wire. | | `**/*.capnp` | `capnp.md` — совместимость схем Cap'n Proto на уровне wire. | +| `**/*.{glsl,hlsl,wgsl,metal}` | `shader.md` - шейдеры GLSL, HLSL, WGSL и Metal. | | `**/*.{v,sv,vh}` | `verilog.md` — RTL на Verilog и SystemVerilog. | | `**/*.{vhd,vhdl}` | `vhdl.md` — RTL на VHDL. | | `**/*.m` | `matlab.md` (или `objc.md` через [определение содержимого](#content-sniffing-for-m-files)) | @@ -338,6 +345,7 @@ OCR использует [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com | `**/*.sol` | `solidity.md` — смарт-контракты Solidity. | | `**/*.vy` | `vyper.md` — смарт-контракты Vyper. | | `**/*.rego` | `rego.md` — политики Rego (OPA). | +| `**/*.{ps1,psm1,psd1}` | `powershell.md` — скрипты, модули и файлы данных/манифестов PowerShell. | | _(fallback)_ | `default.md` | Разрешённое тело правила становится значением плейсхолдера `{{system_rule}}` diff --git a/pages/src/content/docs/zh/chatgpt-preview.md b/pages/src/content/docs/zh/chatgpt-preview.md new file mode 100644 index 000000000..56692b3df --- /dev/null +++ b/pages/src/content/docs/zh/chatgpt-preview.md @@ -0,0 +1,83 @@ +--- +title: 使用 ChatGPT 登录预览版 +--- + +OpenCodeReview 可通过 OpenAI 公开 Responses API 使用符合条件的 ChatGPT 套餐额度。此功能是开源 CLI 中的可选预览版,OpenCodeReview 不另收订阅费。账户、工作区、地区和模型资格由 OpenAI 决定。 + +`chatgpt` provider 使用公开的 Sign in with ChatGPT 流程。 `ocr auth login`、`status` 和 `logout` 默认使用 Sign in with ChatGPT。仍支持显式指定 `--provider chatgpt`。 + +## 登录并选择模型 + +```bash +ocr auth login --provider chatgpt +ocr auth status --provider chatgpt +ocr llm models +ocr config set provider chatgpt +ocr config set providers.chatgpt.model gpt-6-luna +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +OAuth 注册请求完整的 OpenID Connect 与 OpenAI 范围:`openid`(身份验证)、`profile` 和 `email`(账户资料)、`offline_access`(刷新令牌以便离线续期)、`resource.invoke`(调用资源)及 `chatgpt.tokens.use.direct`(直接使用 ChatGPT 套餐额度)。首次注册会发送 `agent_name_hint=OpenCodeReview`。请求不包含连接器授权,也不会复用 Codex CLI 客户端注册。仅请求这些文档流程所需的范围,不请求任意额外权限。 + +首次登录时由 OpenAI 签发客户端注册,并为该账户保留。应用在首次注册时发送真实名称作为提示,并在不同安装间使用相同名称;返回登录时省略此提示。每次登录都会发送此本地安装对应的稳定、不透明 `ext_agent_host_id`;同一安装始终使用相同主机 ID。 + +每次授权都会生成新的 state、nonce 和 PKCE 值。交换授权码前会验证回调 state。使用 issuer 提供的 RSA JWKS 验证 ID token 签名,并在信任账户身份和授权范围前检查 issuer、audience、subject、有效期和 nonce。 + +模型目录按服务端顺序显示当前账户可见的建议和名称。未设置模型时,provider 选择目录中第一个可见模型。显式设置的模型或 `--model` 参数会原样发送,即使目录中没有该模型;OpenAI 决定当前账户是否可用。OpenCodeReview 不为此 provider 编译固定模型列表或别名。 + +`extra_body` 中的推理强度是 API 参数。单独的 review `--effort` 参数控制 OpenCodeReview 的审查轮次和工具预算。 + +登录会打开系统浏览器,并且只监听 `127.0.0.1`。使用 `--no-browser` 可打印一次性本地 URL。CLI 不会打印包含已保存 ID token 提示的授权 URL。此预览版不支持设备码登录。 + +如果登录成功但未获得 ChatGPT 套餐权限,OpenCodeReview 会保留账户身份并报告套餐用量已禁用。可针对当前保存的注册请求同意页: + +```bash +ocr auth login --provider chatgpt --enable-plan +# 对另一项已保存注册: +ocr auth login --provider chatgpt --enable-plan --account +``` + +此显式操作会发送 `prompt=consent`、完整请求范围和资源,以及已保存的客户端和主机 ID。它不能与 `--new-account` 同时使用。普通的返回用户登录不会强制重新同意。只有返回授权允许套餐使用后才会启用推理。也可以配置 API key provider;OpenCodeReview 不会自动切换计费方式。 + +## 账户与退出登录 + +```bash +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth login --provider chatgpt --account +ocr auth logout --provider chatgpt --account +``` + +每个签发的客户端 ID 都与已验证的 issuer 和 subject 关联,即使多个注册使用相同邮箱也彼此区分。返回登录会复用该客户端 ID。授权码交换出现 `invalid_grant` 后,登录会用回调签发的客户端 ID、全新的 state、nonce 和 PKCE 重新开始一次授权。在签名身份验证通过之前,不会启用此待处理注册。第二次失败会停止登录并保留先前凭证。 + +凭证数据库位于 `~/.opencodereview/auth/chatgpt/accounts.json`。独立的 `host.json` 保存稳定的安装主机 ID。凭证文件采用原子替换。 + +在 Unix 上,凭证文件使用仅所有者可读写的 `0600` 权限,存储目录使用 `0700`。在 Windows 上,访问权限取决于从本地用户配置文件目录继承的 ACL;OpenCodeReview 不设置或验证 DACL。Go 的 [`os.Chmod`](https://pkg.go.dev/os#Chmod) 在 Windows 上只改变只读属性。请使用 ACL 将访问限制为本人账户的用户配置文件目录,在登录前检查权限,并避免共享或允许广泛访问的存储位置。目录位于用户配置文件下并不保证私密访问。 + +存储变更和刷新令牌轮换使用操作系统文件锁;浏览器授权在锁外运行,以便其他账户继续使用。完成时会重新读取存储,并检查原注册版本。该注册上的退出、刷新或其他已完成登录会使待处理结果失效;其他账户的更新会保留。访问令牌会在过期前使用轮换刷新令牌续期。 + +如果刷新已返回新令牌,但验证因请求取消或暂时无法获取验证签名所需的公钥而中断,OCR 会保留收到的令牌。重新运行原命令即可。下次请求会继续验证,重启 CLI 后也一样。 + +如果刷新返回的 ID token 未通过签名或身份验证,OCR 会停止重试并提示重新登录。请按错误提示,使用已保存的客户端 ID 运行: + +```bash +ocr auth login --provider chatgpt --account +``` + +登录成功后,新凭证会替换验证失败的凭证。 + +退出登录会使用最新的刷新令牌尝试撤销可续期会话,包括 ID token 尚待验证时收到的新刷新令牌。随后清除访问令牌、刷新令牌和 ID token,并保留账户注册及主机 ID。退出后的后续登录不会发送 `id_token_hint`。如果无法确认远端撤销,CLI 会说明本地令牌已清除,并提示在 ChatGPT 设置中断开应用。 + +## 传输与用量 + +此 provider 只向 `https://api.openai.com/v1/responses` 发送请求。端点、协议和凭证覆盖均不接受。请求使用流式传输及 `store: false`,发送完整历史记录,将本地函数归入 `ocr` 命名空间,并为后续轮次保留调用 ID 和加密推理内容。只有 `response.completed` 算作成功。失败、不完整或中断的流都会停止请求,包括输出开始后收到的用量限制错误。 + +预览版请求字段限制为 OpenAI 支持的子集。`extra_body` 仅接受 `reasoning`、`text` 和 `prompt_cache_key`;不能替换模型、历史记录、工具、流模式或存储策略。响应仍提供 provider 原始用量数据;已有的选择性原始审查记录会保存数据流,但不包含凭证请求头。 + +**使用 ChatGPT 套餐:**符合条件的请求使用所选账户的 ChatGPT 套餐。[在 ChatGPT 设置中管理用量](https://chatgpt.com/settings/usage)。用量限制错误可能来自套餐整体或应用级限制;CLI 不推断重置时间。 + +请参阅 OpenAI 的[开源 SIWC 概览](https://developers.openai.com/siwc/token-sharing-open-source)、[登录规范](https://developers.openai.com/siwc/token-sharing-open-source/sign-in)和[预览版限制](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations),了解当前要求。 + +命令详见 [CLI 参考](../cli-reference/)中的 `ocr auth` 和 `ocr llm models`,provider 设置请参阅[配置](../configuration/)。 diff --git a/pages/src/content/docs/zh/cli-reference.md b/pages/src/content/docs/zh/cli-reference.md index f577bf5c7..72363182a 100644 --- a/pages/src/content/docs/zh/cli-reference.md +++ b/pages/src/content/docs/zh/cli-reference.md @@ -118,7 +118,7 @@ unstaged + untracked 变更。 | `--background-file ` | `-B` | — | 用作评审背景的 Markdown 文件路径。与 `--background` 同时设置时会合并两者。 | | `--exclude ` | — | — | 逗号分隔的 gitignore 风格排除模式;与 `rule.json` 的 excludes 合并。 | | `--concurrency ` | — | `8` | 并行评审的最大子任务数。 | -| `--timeout ` | — | `15` | 每个子任务的截止时间。`0` 关闭超时。按 effort 轮数线性缩放(如 low/medium/high 分别为 15/30/45 分钟)。 | +| `--timeout ` | — | `15` | 每个子任务的时间预算,包括 LLM 调用、工具执行和重试等待。`0` 关闭任务期限,但不关闭请求超时。单次请求超时独立设置:`OCR_LLM_TIMEOUT` 或 provider 的 `timeout_sec`,单位秒(默认 `300`)。按 effort 评审轮数线性缩放(如 low/medium/high 分别为 15/30/45 分钟)。 | | `--rule ` | — | — | 自定义 JSON 评审规则文件路径。覆盖项目级与全局 `rule.json`。 | | `--max-tools ` | — | 模板默认 | 每个子任务的最大工具调用轮数。`0` 用模板默认(`100`);1–49 会被上调到 `50`;解析后的值只在**大于**模板默认值时才生效(即只能上调,不能下调)。 | | `--max-tokens ` | — | 配置或模板默认 | 每个子任务的**提示词** token 上限(review 默认 `200000`)。覆盖本次运行已保存的 `max_tokens` 设置。不影响输出上限——那由 `MAX_COMPLETION_TOKENS`(`16384`)单独控制。 | @@ -149,6 +149,10 @@ ocr scan --provider openai --model gpt-5.4 --format json 最终选中来源中的 model,但不会改变来源顺序。不完整的策略会继续回退,而不会与其他策略 混合。选中的内置 provider 仍可从其支持的环境变量读取凭据。 +对于内置 provider,`--model` 不受 `ocr config model` 的建议模型列表限制。 +如果模型既不在内置列表中,也不在 `providers..models` 中,OCR 会向 stderr +输出警告,并交由 provider 验证。自定义 provider 仍遵循原有的 `--model` 校验规则。 + ### 模式 #### 工作区模式(默认) @@ -349,7 +353,7 @@ ocr s [flags] (alias) | 参数 | 简写 | 默认 | 说明 | |---|---|---|---| -| `--path ` | - | 整个仓库 | 逗号分隔的仓库相对目录或文件(如 `internal/agent`、`internal/llm/client.go`)。 | +| `--path ` | - | 整个仓库 | 逗号分隔的仓库相对目录或文件(如 `internal/agent`、`internal/llm/client.go`)。选择器按工作树中的实际路径解析:原拼写优先,因此 `src` 不会同时选中同级的 `SRC/`;在不区分大小写的文件系统(Windows、默认 macOS)上,用其他大小写拼写、但文件系统能解析时,仍会选中对应文件;若选择器指向的路径不存在,则报错而不是得到空扫描。 | | `--exclude ` | - | - | 逗号分隔的 gitignore 风格排除模式(如 `**/generated/*,*.pb.go`);与 `rule.json` 的 excludes 合并。 | | `--output ` | `-o` | 标准输出 | 将扫描结果写入 UTF-8 文件(`-` 表示标准输出)。首次写入时惰性创建文件,运行失败不会截断已有文件;文本格式自动剥离 ANSI 颜色码。 | | `--preview` | `-p` | `false` | 枚举并过滤文件但跳过 LLM。打印文件列表、可评审/排除数量、总行数及每个文件的排除原因。支持 `--format json`;不支持 `--format sarif`。 | @@ -554,9 +558,30 @@ ocr config model Interactive model selection 完整的 key 参考、schema 与示例见[配置](../configuration/)。 + + +## `ocr auth` + +`ocr auth login`、`status` 和 `logout` 默认使用 Sign in with ChatGPT。仍支持显式指定 `--provider chatgpt`。 + +`chatgpt` provider 使用公开的 Sign in with ChatGPT 流程。请参阅 [ChatGPT 预览版指南](../chatgpt-preview/)。 + +```bash +ocr auth login --provider chatgpt +ocr auth login --provider chatgpt --no-browser +ocr auth login --provider chatgpt --enable-plan +ocr auth login --provider chatgpt --new-account +ocr auth status --provider chatgpt +ocr auth select --provider chatgpt +ocr auth logout --provider chatgpt --account +ocr llm models +``` + +`ocr auth` 支持 `--provider chatgpt` 和 `--account `;`login` 还支持 `--no-browser`、`--new-account` 和 `--enable-plan`。ChatGPT 不支持 `--device`。`--enable-plan` 会针对已保存的注册请求同意,不能与 `--new-account` 同时使用。模型目录按账户显示;即使目录中没有,显式模型也会被接受。此 provider 的 `extra_body` 仅支持 `reasoning`、`text` 和 `prompt_cache_key`。账户管理、权限和限制请参阅 [ChatGPT 预览版指南](../chatgpt-preview/)。 + ## `ocr llm` -LLM 工具命令。两个子命令: +LLM 工具命令。三个子命令: ```text ocr llm @@ -564,6 +589,7 @@ ocr llm Sub-commands: test Send a test conversation to the configured LLM model providers List all built-in LLM providers + models List current account-specific ChatGPT models ``` ### `ocr llm test` diff --git a/pages/src/content/docs/zh/configuration.md b/pages/src/content/docs/zh/configuration.md index c27cfb93e..230f0883b 100644 --- a/pages/src/content/docs/zh/configuration.md +++ b/pages/src/content/docs/zh/configuration.md @@ -36,13 +36,15 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ### 内置 provider -下列 provider 随 OCR 发布,已预置 Base URL 与协议,选中后只需填 API key。 -若 `providers..api_key` 未设置,会自动回退到对应的环境变量。 +下列 provider 随 OCR 发布,并已预置 Base URL 与协议。多数 provider 需要 API key; +若 `providers..api_key` 未设置,会自动回退到对应的环境变量。`chatgpt` +改用 `ocr auth login` 创建的 ChatGPT 凭证。 | 名称 | 协议 | Base URL | API key 环境变量 | |---|---|---|---| | `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | `bedrock` | anthropic-bedrock | 由 `aws_region` 决定 | —(AWS 凭证链) | +| `chatgpt` | openai-responses | `https://api.openai.com/v1` | 不适用(Sign in with ChatGPT) | | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | | `openai-responses` | openai-responses | `https://api.openai.com/v1` | `OPENAI_RESPONSES_API_KEY` | | `openrouter` | openai | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | @@ -66,12 +68,35 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx | `novita` | openai | `https://api.novita.ai/openai` | `NOVITA_API_KEY` | | `xai` | openai | `https://api.x.ai/v1` | `XAI_API_KEY` | +内置 provider 的模型列表只为 `ocr config model` 提供选择建议,不限制 `--model`。 +如果指定的模型既不在内置列表中,也不在 `providers..models` 中,OCR 会向 +stderr 输出警告;发送请求时由 provider 验证模型。自定义 provider 仍遵循原有的 +`--model` 校验规则。 + +## ChatGPT 登录预览版 + +此预览版新增通过公开 Responses API 使用的内置 `chatgpt` provider,没有 API key 环境变量。 请参阅 [ChatGPT 预览版指南](../chatgpt-preview/)了解登录、同意和使用限制。 + +使用 `ocr config set` 配置 provider 和可选请求字段。可从 `ocr llm models` 选择模型,也可显式设置模型 slug: + +```bash +ocr auth login --provider chatgpt +ocr config set provider chatgpt +ocr config set providers.chatgpt.model +ocr config set providers.chatgpt.extra_body '{"reasoning":{"effort":"low"}}' +ocr llm test +``` + +即使账户模型目录没有列出,显式模型仍会传递。`extra_body` 仅接受 `reasoning`、`text` 和 `prompt_cache_key`;端点、协议和凭证覆盖会被拒绝。 + ### 覆盖内置 provider 的 Base URL 每个内置 provider 都有一个预设 Base URL(见上表)。要将内置 provider 指向不同的端点——例如自建的 LiteLLM 网关,其地址很少是预设默认值 `http://localhost:4000/v1`——设置 `providers..url`: +`chatgpt` 预览版例外:其公开端点固定,不接受 URL 覆盖。 + ```bash ocr config set provider litellm ocr config set model openai/gpt-5.4 @@ -84,7 +109,7 @@ ocr config set providers.litellm.url https://gateway.internal:8000/v1 ### AWS Bedrock -`bedrock` 使用与 `anthropic` 相同的 Messages API,但请求不携带 API key,而是 +`bedrock` 默认使用 `anthropic-bedrock` 协议,调用与 `anthropic` 相同的 Messages API,但请求不携带 API key,而是 用标准 AWS 凭证链做 SigV4 签名,主机由区域决定。没有 `api_key` 需要设置,也不 接受用它替代签名: @@ -95,6 +120,8 @@ ocr config set providers.bedrock.aws_region us-west-2 ocr config set providers.bedrock.aws_profile example-profile ``` +如果将 `providers.bedrock.protocol` 设为 `openai` 来连接 OpenAI 兼容网关,请在 `providers.bedrock.url` 中配置网关地址,并通过 `providers.bedrock.api_key` 或 `providers.bedrock.api_key_cmd` 提供网关的 API key。在 `ocr config provider` 中切换模型会保留已保存的 API key。 + | 字段 | 含义 | |---|---| | `providers.bedrock.aws_region` | 处理请求的 `bedrock-runtime` 主机所在区域。未设置时回退到 `AWS_REGION` 或当前 profile。 | @@ -109,7 +136,7 @@ ocr config set providers.bedrock.aws_profile example-profile 会被接受。运行 `aws bedrock list-inference-profiles --region ` 可以查看 账号提供了哪些;注意新系列不接受 `-v1:0` 这类版本后缀。 -bedrock 没有配置的 URL——主机由区域决定——所以 `ocr llm test` 显示区域和 profile +使用默认的 `anthropic-bedrock` 协议时,主机由区域决定,`ocr llm test` 显示区域和 profile 而不是 URL: ``` @@ -126,6 +153,12 @@ URL 加一个 token,没有地方放区域或 profile,而 bedrock 这两个 ### 自定义 provider +支持 `mimo-v2.6-flash` 和 `foo.api_key` 这样的名称。为新的含点名称设置字段前, +请先运行 `ocr config set provider ` 创建并选中它;这会更改当前 provider。 +已有的含点条目无需选中即可通过 `providers..` 或 +`custom_providers..` 编辑。名称不能以点开头或结尾、包含连续的点, +也不能包含空白或仅由空白字符组成的分段。不含点的名称仍支持隐式创建。 + 任何不在上表中的 provider 名都视为自定义,至少要提供 `url` 和 `protocol` (`protocol` 取 `anthropic`、`openai`、`openai-responses` 或 `anthropic-bedrock`): @@ -180,6 +213,15 @@ provider 没有环境变量回退),所以设任意占位值即可。模型 ### 超时 +任务超时和单次请求超时相互独立: + +- `ocr review --timeout` 和 `ocr scan --timeout` 以**分钟**为单位设置并发任务的 + 时间预算(默认 **15**;`0` 关闭任务期限)。预算包括任务中的所有 LLM 调用、 + 工具执行和重试等待。review 会按 effort 的评审轮数放大预算。 +- 单次 HTTP 请求超时以**秒**为单位(默认 **300**)。调大 `--timeout` 不会改变 + 请求超时,关闭任务期限也不会关闭请求超时。如果任务的剩余预算更短,则以 + 剩余任务预算为限。 + 每个 LLM 请求都有 HTTP 超时,默认 **300 秒**。慢的本地模型(或大文件)可能 需要更长的时间。三个配置项,作用域递增: @@ -199,6 +241,21 @@ provider 没有环境变量回退),所以设任意占位值即可。模型 } ``` +例如,`OCR_LLM_TIMEOUT=900 ocr review --timeout 30` 允许单次请求在任务预算内 +最多运行 15 分钟。如果没有设置该环境变量或 `timeout_sec`,即使使用 +`--timeout 30`,请求仍会在 5 分钟后超时。 + +单次请求期限耗尽后会结束调用,不会自动重放请求,包括在读取响应体或流式响应时 +超时的情况。对于持续较慢的请求,应调大请求超时,而不是用相同的时限重试。 +现有 SDK 重试策略保持不变:可重试的连接和 HTTP 错误最多重试 5 次,采用退避 +等待并遵守服务端的 `Retry-After` 提示。SDK 的单次尝试期限也会约束重试等待。 +取消任务或任务期限耗尽时,请求和重试等待都会停止。 + +诊断信息会区分 `LLM request timeout`(检查 `OCR_LLM_TIMEOUT` 或 provider 的 +`timeout_sec`)和 `caller deadline exceeded`,后者表示调用方操作的上下文期限已耗尽。 +如果耗尽的是 review/scan 任务期限,应检查 `--timeout`;后台内存压缩、`ocr llm test` +等其他操作有各自独立的期限。 + ### 通过命令获取 API key 除了把 key 直接写进配置文件,还可以用 `api_key_cmd` 在运行时从密钥管理器 diff --git a/pages/src/content/docs/zh/contributing.md b/pages/src/content/docs/zh/contributing.md index 1fd0f3913..520768652 100644 --- a/pages/src/content/docs/zh/contributing.md +++ b/pages/src/content/docs/zh/contributing.md @@ -25,7 +25,7 @@ OCR 是 Apache-2.0 许可下的开源项目。欢迎 bug 报告、文档修复 ### 前置条件 -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) @@ -110,38 +110,54 @@ refactor(viewer): extract task-card rendering into helper **PR 标题**也用相同格式,以便在生成的 changelog 中整洁显示。 +`PR Commit Policy` 会检查 PR 标题以及每条提交的完整信息(包括正文),不区分大小写。 +请勿包含 `[skip ci]`、`[ci skip]`、`[no ci]`、`[skip actions]`、`[actions skip]`, +或单独一行的 `skip-checks: true` / `skip-checks:true`。 + +检查失败时,请修改 PR 标题,或在你的贡献分支上重写相关提交的信息。 +新增一条不含这些指令的提交,无法消除历史提交中的违规内容。 +PR 描述可以讨论这些指令,不在检查范围内。 + ## 项目结构 ``` open-code-review/ +├── acp/ # 独立的 CLI ACP(Agent Client Protocol)适配器 ├── cmd/opencodereview/ # CLI 入口——参数解析、分发 +├── docs/ # 本地化 README 与贡献指南 +├── imgs/ # README 图片与标志 ├── internal/ │ ├── agent/ # 评审 agent 逻辑、子 agent 分发 +│ ├── chatgptauth/ # ChatGPT OAuth 与凭据管理 │ ├── config/ # 模板、规则、白名单、内嵌 JSON -│ ├── diff/ # Git diff 解析、三种模式 +│ ├── delegate/ # 无需调用 LLM 的审查规范生成 +│ ├── diff/ # Git diff 解析 │ ├── gitcmd/ # Git 子进程运行器 -│ ├── llm/ # LLM client(Anthropic 与 OpenAI)、端点解析器 +│ ├── llm/ # LLM 客户端与端点解析 +│ ├── llmloop/ # Review 与 Scan 共用的 LLM 工具调用循环 +│ ├── mcp/ # MCP 客户端与工具适配器 │ ├── model/ # 数据结构(LlmComment、Diff……) │ ├── pathutil/ # 路径工具 -│ ├── release/ # Release notes 生成 +│ ├── release/ # 发布产物命名一致性检查 +│ ├── scan/ # 全文件代码审查 │ ├── session/ # JSONL 会话写入器 │ ├── stdout/ # 可静音的 stdout writer │ ├── suggestdiff/ # 建议 diff 渲染 │ ├── telemetry/ # OpenTelemetry 配置 + 辅助 │ ├── tool/ # 工具注册表 + provider 实现 -│ └── viewer/ # 内嵌 HTTP UI -├── pages/ # WebUI 营销页(独立 React app) -├── plugins/ # Claude Code slash 命令 -├── extensions/ # 编辑器扩展(VS Code) +│ └── viewer/ # 浏览审查会话的内嵌 HTTP UI +├── pages/ # 官网 Landing Page 与文档(独立 React app) +├── plugins/ # 编码代理集成(Claude Code、Codex、OpenCode 等) +├── extensions/ # VS Code 与 IntelliJ IDEA 扩展、共享前端 ├── examples/ # CI 配方(GitHub Actions、GitLab CI) -├── skills/ # Agent SDK skill manifest -├── scripts/ # NPM postinstall + 跨平台构建脚本 +├── skills/ # 审查与委托技能定义 +├── scripts/ # 安装、发布与仓库检查脚本 ├── npm/ # 各平台 optional dependency 包 └── bin/ # NPM wrapper(Node) ``` 多数贡献触及 `internal/agent/`、`internal/tool/` 或 `internal/llm/`。 -`cmd/opencodereview/` 中的 CLI 层有意保持精简——参数解析后分发到 agent 包。 +`cmd/opencodereview/` 中的 CLI 层有意保持精简——负责参数解析与命令分发。 ## AI 参与辅助开发 diff --git a/pages/src/content/docs/zh/installation.md b/pages/src/content/docs/zh/installation.md index d0ade3f43..f4f872ee1 100644 --- a/pages/src/content/docs/zh/installation.md +++ b/pages/src/content/docs/zh/installation.md @@ -144,7 +144,7 @@ shasum -a 256 -c sha256sum.txt --ignore-missing #### 前置条件 -- [Go ≥ 1.25](https://go.dev/dl/) +- [Go ≥ 1.26.9](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) diff --git a/pages/src/content/docs/zh/integrations/acp.md b/pages/src/content/docs/zh/integrations/acp.md new file mode 100644 index 000000000..142be368f --- /dev/null +++ b/pages/src/content/docs/zh/integrations/acp.md @@ -0,0 +1,188 @@ +--- +title: ACP +sidebar: + order: 6 +--- + +**实验性功能:** ACP 适配器目前尚不稳定,仅支持用户从源码在本地编译使用。 +升级时请更新源码并重新编译。 + +## 准备工作 {#prerequisites} +- 安装 [OCR CLI](../../installation/),配置好[审查模型](../../configuration/), +并用 `ocr llm test` 检查连接。 +- 准备包含 `acp/` 目录的源码、Git 和 Go 1.23+,用于构建适配器。 +如果还要从源码构建主项目的 OCR CLI,则需要 Go 1.25.5+。 +- 使用支持 ACP v1 stdio 和自定义 Agent 启动命令的客户端。 + +适配器与 OCR CLI 分别构建。下面使用本地构建方式。 + +## 构建并连接客户端 {#build-and-connect-your-client} + +在仓库根目录运行: +```bash +make -C acp build +command -v ocr +``` + +构建产物是 `dist/ocr-acp`。请将 `command -v ocr` 输出的 OCR CLI +绝对路径填入下面的 `--ocr-binary` 参数。 + +把下面的配置加入客户端的 Agent 设置,把所有 `/absolute/path` 替换为实际绝对路径, +并把 `env` 中的占位值替换为你的解析模型配置: +```json +{ + "agent_servers": { + "OpenCodeReview": { + "type": "custom", + "command": "/absolute/path/open-code-review/dist/ocr-acp", + "args": [ + "--ocr-binary", + "/absolute/path/ocr" + ], + "env": { + "OCR_ACP_PARSER_PROVIDER": "anthropic", + "OCR_ACP_PARSER_MODEL": "deepseek-flash", + "OCR_ACP_PARSER_BASE_URL": "https://your-gateway.example", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" + } + } + } +} +``` + +字段名称因客户端而异,请参考其 ACP 配置说明。 + +## 运行审查或扫描 {#run-a-review-or-scan} + +在 OpenCodeReview 会话中发送: + +| 需求 | 命令 | +| --- | --- | +| 审查暂存、未暂存及未跟踪的变更 | `/review` | +| 最多进行一轮审查 | `/review --effort low` | +| 审查当前检出的最新提交 | `/review --commit HEAD` | +| 比较两个已有的引用 | `/review --from main --to HEAD` | +| 扫描目录 | `/scan --path internal/agent` | +| 扫描多个路径 | `/scan --path internal/agent,internal/config` | + +review 要求工作目录位于 Git仓库中,结果路径以仓库根目录为基准。 + +scan 以会话工作目录为基准。 + +`--effort low` 最多执行一轮审查,`medium` 最多两轮,`high` 最多三轮。没有新增问题时,审查可能提前停止。省略该参数时沿用 OCR 的配置。减少轮数可能漏掉问题,请按需权衡速度与质量。 + +在支持 ACP 命令提示的客户端中输入 `/`,可以查看可用命令和参数提示, + +若客户端附加了本地文件链接,请连同 `/scan`一起发送,并省略 `--path`。文件链接不能与 `/review` 或显式扫描路径混用; +不支持或有歧义的链接会被拒绝,不会自动扩大扫描范围。 + +适配器仅接受约定的 CLI 参数,不支持任意透传: +- review:`--commit`、`--from`、`--to`、`--effort`、`--no-filter`、 `--background`、`--background-file`。 +- scan:`--path`、`--batch`、`--no-plan`、`--no-dedup`、`--no-summary`、 `--background`。 + +`/review`暂不支持 `--staged`。会话命令也不接受 `--format`、`--audience` 等输出参数,以及 `--repo`、`--model`、 `--provider` 等覆盖参数。审查模型请通过 OCR 本身配置。 + +## 启用自然语言请求 {#enable-natural-language-requests} + +解析模型将自然语言请求转换为 review/scan 命令,或在信息不足时发起澄清。 +它与 OCR 的审查模型分别配置;客户端自身的模型选择不会配置这两个模型。 + +将以下环境设置加入 Agent 的 `env` 对象,把模型和密钥占位值替换为提供方的实际值: +```json +{ + "OCR_ACP_PARSER_PROVIDER": "openai", + "OCR_ACP_PARSER_MODEL": "YOUR_TOOL_CALLING_MODEL", + "OCR_ACP_PARSER_BASE_URL": "https://your-gateway.example", + "OCR_ACP_PARSER_API_KEY": "YOUR_PARSER_API_KEY" +} +``` + +此示例使用兼容 OpenAI 的 Chat Completions 协议。使用 Anthropic Messages +协议时,将提供方设为 `anthropic`。使用兼容网关时,额外设置 `OCR_ACP_PARSER_BASE_URL` 为其 API 基础地址。 + +基础地址的路径规则因协议而异: + +| 协议 | `OCR_ACP_PARSER_BASE_URL` 示例 | 实际请求地址 | +| --- | --- | --- | +| `openai` | `https://gateway.example/v1` | `https://gateway.example/v1/chat/completions` | +| `anthropic` | `https://gateway.example` | `https://gateway.example/v1/messages` | + +也可以填写以 `/chat/completions` 或 `/v1/messages` 结尾的对应完整地址,适配器不会再次追加该后缀。 +Anthropic 的基础地址若以 `/v1` 结尾,会拼出 `/v1/v1/messages`;请按网关的实际接口路径填写。 + +| 环境变量 | 启动参数 | 用途 | +| --- | --- | --- | +| `OCR_ACP_PARSER_PROVIDER` | `--parser-provider` | `openai` 或 `anthropic` | +| `OCR_ACP_PARSER_MODEL` | `--parser-model` | 支持工具调用的模型 | +| `OCR_ACP_PARSER_BASE_URL` | `--parser-base-url` | 可选的 API 基础地址覆盖 | +| `OCR_ACP_PARSER_API_KEY` | 无 | 解析模型密钥,仅通过环境变量传入 | + +当前支持的解析协议为 `openai` 和 `anthropic`,不支持`openai-responses` 与 `anthropic-bedrock`。 + +Anthropic 解析请求会显式关闭 Thinking,以支持必需的 `submit_intent`工具调用。这不会改变 OCR 审查模型的 Thinking 设置。 + +配置后新建会话,可以尝试: +```text +审查我当前工作区的变更。 +审查当前检出的最新提交。 +扫描 internal/agent,找出潜在问题。 +``` + +请求有歧义时,先回答澄清问题再执行。模型会被要求使用你的语言提问和说明; +固定校验消息和报告标签仍为英文。解析模型不可用时,slash 命令仍然可用。 +如果解析配置不完整,适配器会在启动时报出明确错误。 + +“当前检出的最新提交”对应 `/review --commit HEAD`。会话只保留一份待澄清请求; +连续澄清可保留多个兼容的已知字段,明确的新值覆盖旧值,不会混入其他审查类型的字段。 +请求完成或取消后清空该状态,不支持跨会话恢复。 + +## 查看结果与取消任务 {#read-results-and-cancel-work} + +进度使用 OCR CLI 原有日志和最终 JSON,无需修改 CLI。展示最新上报的活动和总运行时间,不推测文件组阶段、阶段耗时或等待模型响应的时间。 + +单个 **OCR review** 或 **OCR scan** 条目展示进度和本地测量的运行时间。展开后可查看 **Command:**、工作目录及有长度上限的纯文本日志。诊断信息保留在详情中,不会替换 +进度标题;命令与日志不会重复出现在会话正文。初始展开状态由客户端决定。 + +最终报告先展示完成状态、文件与问题数量、严重级别分布。每个问题使用带序号、 +严重级别和文件位置的标题,下方展示分类、说明和带语言标记的代码块。建议代码明确 +标注为尚未应用。问题只在最终正文展示一次。文件存在且位置有效时,可通过链接跳转; +文件不存在、行号越界或路径超出工作根目录时保留文本,不提供跳转。 +具体渲染和点击行为取决于客户端版本。 + +部分失败详情和预算上限提示会保留。报告末尾显示可用的累计 token 和 OCR 报告的耗时。 +执行详情还会展示 OCR 提供的工具调用总数及输入、输出、缓存 token;缺少的数据不补造。 +实时计时测量的是本地执行时间,可能与 OCR 报告的耗时不同;token 总数累计多次模型 +请求,不是新生成回答的 token 数。 + +使用客户端的取消控件停止当前请求,适配器会取消任务并等待受管进程清理。 +若要限制包含解析在内的整轮耗时,可在 Agent 的 `args` 数组中加入 `"--turn-timeout", "10m"`。默认值为 `0`,不限制整轮耗时; +解析请求有独立的默认 15 秒限制。超时后适配器会提示重试。 + +## 排错 {#troubleshooting} + +| 现象 | 检查方法 | +| --- | --- | +| Agent 无法启动 | 检查两个可执行文件的绝对路径和执行权限。用 `ocr llm test` 检查 OCR 配置;删除不完整的解析配置或补齐必填值。 | +| slash 命令可用,自然语言不可用 | 单独配置 `OCR_ACP_PARSER_*` 环境变量,检查提供方、模型、密钥和网关地址。 | +| 自然语言解析超时 | 解析默认限时 15 秒,此时 OCR 尚未启动。根据诊断中的请求阶段、HTTP 状态及提供方响应定位原因;增大 `--turn-timeout` 不会延长解析器自身期限。可重试,或使用 `/review`、`/scan` 绕过解析模型。 | +| 审查失败或只返回部分结果 | 展开 OCR review/scan 查看诊断,并阅读最终失败信息。检查 OCR 模型配置及提供方可用性。 | +| 任务耗时太长 | 取消任务、查看进度详情,或设置整轮超时。review 可用 `--effort low` 减少审查轮数。 | +| 重新构建后仍是旧行为 | 检查配置中的二进制路径,并新建 Agent 会话;若仍复用旧进程,重启 Agent 或客户端。 | + +连接异常时,查看客户端的 ACP 日志和适配器的 stderr 诊断信息。 +分享日志前,请移除密钥和私有源码内容。 + +## 升级与支持范围 {#upgrade-and-support-boundaries} + +更新源码后重新运行 `make -C acp build`,再新建 OpenCodeReview 会话。 +正在运行的进程和旧消息不会自动加载更新。目前不支持会话恢复;新会话不保留之前 +待回答的澄清状态。 + +适配器当前使用本地 stdio 通信,不支持 HTTP、多工作区根目录、自动修改文件以及 +图片或音频请求。其他 ACP 客户端需要分别验证兼容性;协议测试通过不代表每个 +客户端的界面行为都已验证。 + +## 另见 {#see-also} +- [配置](../../configuration/)——OCR 审查模型和提供方设置。 +- [CLI 参考](../../cli-reference/)——review 和 scan 参数详情。 +- [ACP 简介](https://agentclientprotocol.com/get-started/introduction)——Agent Client Protocol 官方介绍。 diff --git a/pages/src/content/docs/zh/integrations/delegate.md b/pages/src/content/docs/zh/integrations/delegate.md index 0cd479797..dd140faad 100644 --- a/pages/src/content/docs/zh/integrations/delegate.md +++ b/pages/src/content/docs/zh/integrations/delegate.md @@ -81,24 +81,42 @@ ocr delegate rule ... ### 第 3 步:获取 diff -根据第 1 步的 mode/ref 信息,使用 git 直接获取: +根据第 1 步的模式和引用信息选择 diff 命令。审查中的所有 Git 调用均使用 `git --no-pager`。 + +以下 diff 命令关闭外部 diff 程序、文本转换和颜色输出,以获取纯文本补丁。 **Range 模式**(有 merge\_base): ```bash -git diff .. -- +git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- "" ``` **Commit 模式**: ```bash -git show -- +git --no-pager show --no-ext-diff --no-textconv --no-color -- "" ``` **Workspace 模式**: ```bash -git diff HEAD -- # 已跟踪文件 -cat # 新的未跟踪文件 +git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- "" # 已跟踪文件 +cat "" # 新的未跟踪文件 +``` + +查询上下文: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" ``` +较大的 diff 可在对应模式的命令中添加 `--output=""`。选择仓库外的唯一绝对路径,并先创建父目录。后续文件读取工具使用同一路径: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +Git 以退出码 0 结束后,分块读取并审查整个文件,再删除临时文件。命令失败或超时时,重试或标为 `skipped` 并记录错误。输出意外为空时,重新运行 preview 并核对。 + ### 第 4 步:审查每个文件 对每个可审查文件: @@ -107,6 +125,8 @@ cat # 新的未跟踪文件 2. 参照匹配的规则组(第 2 步)作为审查清单 3. 进行深入审查,按需探索上下文 +将每个 preview 中的 `(path, status)` 标为 `reviewed` 或 `skipped`,为跳过的条目说明原因。报告中包含 `total_files`、`reviewed_files`、`skipped_files` 和 `coverage_rate`。 + ### 第 5 步:报告 按严重程度分类: diff --git a/pages/src/content/docs/zh/review-rules.md b/pages/src/content/docs/zh/review-rules.md index 2a1182909..564905ce9 100644 --- a/pages/src/content/docs/zh/review-rules.md +++ b/pages/src/content/docs/zh/review-rules.md @@ -192,6 +192,11 @@ OCR 用 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest - `**/test/**/*.lhs` - `**/*Spec.lhs` - `**/tests/**/*.nim` +- `**/tests/**/*.nu` +- `**/test/**/*.nu` +- `**/*_test.nu` +- `**/test_*.nu` +- `**/test-*.nu` - `**/tests/**/*.R` - `**/__snapshots__/**` - `**/*.snap` @@ -282,7 +287,7 @@ OCR 用 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest | `**/*.astro` | `astro.md`——Astro 组件与 islands。 | | `**/*.{ts,js,tsx,jsx,mjs,cjs}` | `ts_js_tsx_jsx.md` | | `**/*.{kt,kts}` | `kotlin.md` | -| `**/*.{fs,fsi,fsx}` | `fsharp.md`——F# 实现、签名和脚本文件。 | +| `**/*.{fs,fsi,fsx}` | `fsharp.md`——F# 实现、签名和脚本文件。 | | `**/*.rs` | `rust.md` | | `**/*.R` | `r.md` | | `**/*.{cpp,cc,cxx,hpp,hxx}` | `cpp.md` | @@ -295,12 +300,14 @@ OCR 用 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest | `**/*.{graphql,gql}` | `graphql.md`——GraphQL schema 与操作。 | | `**/*.prisma` | `prisma.md`——Prisma schema。 | | `**/*.jl` | `julia.md`——Julia 源代码。 | -| `**/*.{tf,hcl,tfvars}` | `terraform.md`——Terraform / HCL。 | +| `**/*.{tf,tofu,hcl,tfvars}` | `terraform.md`——Terraform / HCL。 | | `**/*.bicep` | `bicep.md`——Bicep(Azure)模板。 | +| `**/*.nu` | `nushell.md`——Nushell 脚本。 | | `**/*.elm` | `elm.md` - Elm 源代码。 | | `**/*.{jsonnet,libsonnet}` | `jsonnet.md`——Jsonnet 配置模板与库。 | | `**/*.thrift` | `thrift.md`——Apache Thrift IDL 线协议兼容性。 | | `**/*.capnp` | `capnp.md`——Cap'n Proto schema 线协议兼容性。 | +| `**/*.{glsl,hlsl,wgsl,metal}` | `shader.md` - GLSL、HLSL、WGSL、Metal 着色器。 | | `**/*.{v,sv,vh}` | `verilog.md`——Verilog 与 SystemVerilog RTL。 | | `**/*.{vhd,vhdl}` | `vhdl.md`——VHDL RTL。 | | `**/*.m` | `matlab.md`(或通过[内容嗅探](#针对-m-文件的内容嗅探)使用 `objc.md`) | @@ -308,6 +315,7 @@ OCR 用 [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublest | `**/*.sol` | `solidity.md`——Solidity 智能合约。 | | `**/*.vy` | `vyper.md`——Vyper 智能合约。 | | `**/*.rego` | `rego.md`——Rego 策略(OPA)。 | +| `**/*.{ps1,psm1,psd1}` | `powershell.md`——PowerShell 脚本、模块和数据/清单文件。 | | _(fallback)_ | `default.md` | 解析出的规则正文成为 plan 和 main task prompt 中 `{{system_rule}}` 占位符的内容。 diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 69a397036..85b5c7d79 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -293,8 +293,10 @@ export const en = { 'docs.sidebar.claudeCode': 'Command (Claude Code)', 'docs.sidebar.delegate': 'Delegation Mode', 'docs.sidebar.cicd': 'CI/CD', + 'docs.sidebar.acp': 'ACP', 'docs.sidebar.contributing': 'Contributing', 'docs.sidebar.faq': 'FAQ', + 'docs.sidebar.chatgptPreview': 'Sign in with ChatGPT preview', 'docs.search.placeholder': 'Search...', 'docs.search.noResults': 'No results found', 'docs.search.hint.select': 'Select', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 511812378..c64615418 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -297,8 +297,10 @@ export const ja: TranslationKeys = { 'docs.sidebar.claudeCode': 'Command(Claude Code)', 'docs.sidebar.delegate': 'デリゲーションモード', 'docs.sidebar.cicd': 'CI/CD', + 'docs.sidebar.acp': 'ACP', 'docs.sidebar.contributing': 'コントリビュート', 'docs.sidebar.faq': 'FAQ', + 'docs.sidebar.chatgptPreview': 'ChatGPT サインインのプレビュー', 'docs.search.placeholder': '検索...', 'docs.search.noResults': '結果が見つかりません', 'docs.search.hint.select': '選択', diff --git a/pages/src/i18n/ko.ts b/pages/src/i18n/ko.ts index 2478182ce..d195ba606 100644 --- a/pages/src/i18n/ko.ts +++ b/pages/src/i18n/ko.ts @@ -297,8 +297,10 @@ export const ko: TranslationKeys = { 'docs.sidebar.claudeCode': 'Command (Claude Code)', 'docs.sidebar.delegate': '위임 모드', 'docs.sidebar.cicd': 'CI/CD', + 'docs.sidebar.acp': 'ACP', 'docs.sidebar.contributing': '기여하기', 'docs.sidebar.faq': 'FAQ', + 'docs.sidebar.chatgptPreview': 'ChatGPT 로그인 미리보기', 'docs.search.placeholder': '검색...', 'docs.search.noResults': '검색 결과가 없습니다', 'docs.search.hint.select': '선택', diff --git a/pages/src/i18n/ru.ts b/pages/src/i18n/ru.ts index 12c1b4394..ce9c37f1f 100644 --- a/pages/src/i18n/ru.ts +++ b/pages/src/i18n/ru.ts @@ -295,8 +295,10 @@ export const ru: TranslationKeys = { 'docs.sidebar.claudeCode': 'Command (Claude Code)', 'docs.sidebar.delegate': 'Режим делегирования', 'docs.sidebar.cicd': 'CI/CD', + 'docs.sidebar.acp': 'ACP', 'docs.sidebar.contributing': 'Участие в разработке', 'docs.sidebar.faq': 'FAQ', + 'docs.sidebar.chatgptPreview': 'Вход через ChatGPT (предварительная версия)', 'docs.search.placeholder': 'Поиск...', 'docs.search.noResults': 'Ничего не найдено', 'docs.search.hint.select': 'Выбрать', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index e3a2a3930..7563955a9 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -297,8 +297,10 @@ export const zh: TranslationKeys = { 'docs.sidebar.claudeCode': 'Command(Claude Code)', 'docs.sidebar.delegate': '委托模式', 'docs.sidebar.cicd': 'CI/CD', + 'docs.sidebar.acp': 'ACP', 'docs.sidebar.contributing': '贡献', 'docs.sidebar.faq': 'FAQ', + 'docs.sidebar.chatgptPreview': 'ChatGPT 登录预览版', 'docs.search.placeholder': '搜索...', 'docs.search.noResults': '未找到结果', 'docs.search.hint.select': '选择', diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 0a3afa048..520e553fb 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -74,6 +74,7 @@ const sidebarTree: SidebarGroup[] = [ groupLabelKey: 'docs.sidebar.userGuide', items: [ { id: 'sb-cli', labelKey: 'docs.sidebar.cliReference', slug: 'cli-reference' }, + { id: 'sb-chatgpt-preview', labelKey: 'docs.sidebar.chatgptPreview', slug: 'chatgpt-preview' }, { id: 'sb-rules', labelKey: 'docs.sidebar.reviewRules', slug: 'review-rules' }, { id: 'sb-arch', labelKey: 'docs.sidebar.architecture', slug: 'architecture' }, { id: 'sb-tools', labelKey: 'docs.sidebar.tools', slug: 'tools' }, @@ -88,6 +89,7 @@ const sidebarTree: SidebarGroup[] = [ { id: 'sb-claude-code', labelKey: 'docs.sidebar.claudeCode', slug: 'claude-code' }, { id: 'sb-delegate', labelKey: 'docs.sidebar.delegate', slug: 'delegate' }, { id: 'sb-cicd', labelKey: 'docs.sidebar.cicd', slug: 'cicd' }, + { id: 'sb-acp', labelKey: 'docs.sidebar.acp', slug: 'acp' }, ], }, { id: 'sb-contributing', labelKey: 'docs.sidebar.contributing', slug: 'contributing' }, diff --git a/plugins/open-code-review/claude-code/commands/delegate-review.md b/plugins/open-code-review/claude-code/commands/delegate-review.md index 743fda6e9..2c1f444d2 100644 --- a/plugins/open-code-review/claude-code/commands/delegate-review.md +++ b/plugins/open-code-review/claude-code/commands/delegate-review.md @@ -30,15 +30,36 @@ ocr delegate rule ... ### Step 3: Get Diffs and Review -For each reviewable file, get its diff using git (based on mode/ref from Step 1): -- Range: `git diff .. -- ` -- Commit: `git show -- ` -- Workspace: `git diff HEAD -- ` (or read directly for untracked files) +Choose the diff command using the mode and refs from Step 1. Use `git --no-pager` for every Git call during the review. + +The diff commands disable external diff programs, text conversion, and color to produce plain patches. + +- Range: `git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- ""` +- Commit: `git --no-pager show --no-ext-diff --no-textconv --no-color -- ""` +- Workspace: `git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- ""` (or `cat ""` for untracked files) + +To read context: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" +``` + +For large diffs, add `--output=""` to the command for the selected mode. Choose a unique absolute path outside the repository and create its parent directory. Use the same path in file-reading tool calls: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +After Git exits with code 0, read and review the entire file in chunks, then remove it. If Git fails or times out, retry or record `skipped` with the error. Check unexpectedly empty output against a fresh preview. Then review focusing on: correctness, security, performance, error handling, concurrency, maintainability. Only comment on changed code (+ lines). ### Step 4: Report and Fix +Record each previewed `(path, status)` as `reviewed` or `skipped`. Give a reason for every skipped entry, and report `total_files`, `reviewed_files`, `skipped_files`, and `coverage_rate`. + Classify each issue by severity: - **High**: Obvious bugs, security issues, data loss, or clear mistakes with precise fix proposals diff --git a/plugins/open-code-review/kimi-code/commands/delegate-review.md b/plugins/open-code-review/kimi-code/commands/delegate-review.md index 743fda6e9..2c1f444d2 100644 --- a/plugins/open-code-review/kimi-code/commands/delegate-review.md +++ b/plugins/open-code-review/kimi-code/commands/delegate-review.md @@ -30,15 +30,36 @@ ocr delegate rule ... ### Step 3: Get Diffs and Review -For each reviewable file, get its diff using git (based on mode/ref from Step 1): -- Range: `git diff .. -- ` -- Commit: `git show -- ` -- Workspace: `git diff HEAD -- ` (or read directly for untracked files) +Choose the diff command using the mode and refs from Step 1. Use `git --no-pager` for every Git call during the review. + +The diff commands disable external diff programs, text conversion, and color to produce plain patches. + +- Range: `git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- ""` +- Commit: `git --no-pager show --no-ext-diff --no-textconv --no-color -- ""` +- Workspace: `git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- ""` (or `cat ""` for untracked files) + +To read context: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" +``` + +For large diffs, add `--output=""` to the command for the selected mode. Choose a unique absolute path outside the repository and create its parent directory. Use the same path in file-reading tool calls: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +After Git exits with code 0, read and review the entire file in chunks, then remove it. If Git fails or times out, retry or record `skipped` with the error. Check unexpectedly empty output against a fresh preview. Then review focusing on: correctness, security, performance, error handling, concurrency, maintainability. Only comment on changed code (+ lines). ### Step 4: Report and Fix +Record each previewed `(path, status)` as `reviewed` or `skipped`. Give a reason for every skipped entry, and report `total_files`, `reviewed_files`, `skipped_files`, and `coverage_rate`. + Classify each issue by severity: - **High**: Obvious bugs, security issues, data loss, or clear mistakes with precise fix proposals diff --git a/plugins/open-code-review/opencode/open-code-review.ts b/plugins/open-code-review/opencode/open-code-review.ts index bc5fd2b79..63d0f267d 100644 --- a/plugins/open-code-review/opencode/open-code-review.ts +++ b/plugins/open-code-review/opencode/open-code-review.ts @@ -178,7 +178,7 @@ async function runOcr(args: string[], options: RunOptions): Promise { cwd: options.cwd, env: process.env, shell: false, - detached: true, + detached: process.platform !== "win32", }, ) child.stdin.end() diff --git a/plugins/open-code-review/opencode/test/open-code-review.test.mjs b/plugins/open-code-review/opencode/test/open-code-review.test.mjs index 780514b88..629e98395 100644 --- a/plugins/open-code-review/opencode/test/open-code-review.test.mjs +++ b/plugins/open-code-review/opencode/test/open-code-review.test.mjs @@ -358,6 +358,24 @@ test("ocr_review reports non-zero exits with OCR output", async () => { ) }) +test("ocr_review preserves output from the Windows command shim", { skip: process.platform !== "win32" }, async () => { + await withFakeOcr( + "console.error('windows shim output'); process.exit(7)", + async (worktree) => { + const { hooks } = await loadPlugin(worktree) + await assert.rejects( + hooks.tool.ocr_review.execute({}, toolContext(worktree)), + (error) => { + assert.equal(error.name, "OcrExecutionError") + assert.equal(error.exitCode, 7) + assert.match(error.message, /windows shim output/) + return true + }, + ) + }, + ) +}) + test("ocr_review removes its temporary background after a non-zero exit", async () => { await withFakeOcr( [ diff --git a/plugins/open-code-review/skills/open-code-review-delegate/SKILL.md b/plugins/open-code-review/skills/open-code-review-delegate/SKILL.md index 4ac9437ea..7648afd65 100644 --- a/plugins/open-code-review/skills/open-code-review-delegate/SKILL.md +++ b/plugins/open-code-review/skills/open-code-review-delegate/SKILL.md @@ -59,26 +59,44 @@ Pass the reviewable file paths from Step 1. Output is grouped by rule content ### Step 3: Get Diffs -Use git directly based on the mode/ref info from Step 1: +Choose the diff command using the mode and refs from Step 1. Use `git --no-pager` for every Git call during the review. + +The diff commands disable external diff programs, text conversion, and color to produce plain patches. **Range mode** (merge_base provided in preview output): ```bash -git diff .. -- +git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- "" ``` **Commit mode**: ```bash -git show -- +git --no-pager show --no-ext-diff --no-textconv --no-color -- "" ``` **Workspace mode**: ```bash # Tracked files -git diff HEAD -- +git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- "" # New untracked files — read directly (entire file is new code) -cat +cat "" +``` + +To read context: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" ``` +For large diffs, add `--output=""` to the command for the selected mode. Choose a unique absolute path outside the repository and create its parent directory. Use the same path in file-reading tool calls: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +After Git exits with code 0, read and review the entire file in chunks, then remove it. If Git fails or times out, retry or record `skipped` with the error. Check unexpectedly empty output against a fresh preview. + ### Step 4: Review Each File Create a checklist containing every `reviewable_files` entry. For each reviewable file: @@ -191,4 +209,4 @@ other JSON fields must require a JSON-capable CLI instead: verify with ```bash npm install -g @alibaba-group/open-code-review -``` \ No newline at end of file +``` diff --git a/scripts/github-actions/check-pr-commit-policy.js b/scripts/github-actions/check-pr-commit-policy.js new file mode 100644 index 000000000..41f1957b3 --- /dev/null +++ b/scripts/github-actions/check-pr-commit-policy.js @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +"use strict"; + +const QUERY = ` + query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + title + baseRefOid + headRefOid + commits(first: 100, after: $cursor) { + totalCount + nodes { commit { oid message } } + pageInfo { hasNextPage endCursor } + } + } + } + } +`; + +function findSkipDirectives(message) { + const directives = new Set( + (message.match(/\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/gi) || []) + .map((directive) => directive.toLowerCase()) + ); + if (/^[\t ]*skip-checks:[\t ]*true[\t ]*\r?$/im.test(message)) { + directives.add("skip-checks: true"); + } + return [...directives]; +} + +function collectCommits(commits, nodes) { + for (const node of nodes) { + const commit = node && node.commit; + if (!commit || !/^[0-9a-f]{40}$/.test(commit.oid) || + typeof commit.message !== "string" || commits.has(commit.oid)) { + throw new Error("Invalid or duplicate PR commit data. Re-run PR Commit Policy."); + } + commits.set(commit.oid, commit); + } +} + +async function readComparison(github, variables, snapshot) { + const commits = new Map(); + for (let page = 1; commits.size < snapshot.count; page += 1) { + const { data } = await github.rest.repos.compareCommitsWithBasehead({ + owner: variables.owner, + repo: variables.repo, + basehead: `${snapshot.base}...${snapshot.head}`, + per_page: 100, + page, + }); + if (!data || data.total_commits !== snapshot.count || + !Array.isArray(data.commits) || data.commits.length === 0) { + throw new Error("Unable to read the complete PR commit comparison. Re-run PR Commit Policy."); + } + collectCommits(commits, data.commits.map((entry) => ({ + commit: entry && { oid: entry.sha, message: entry.commit?.message }, + }))); + } + if (commits.size !== snapshot.count || !commits.has(snapshot.head)) { + throw new Error("PR commit comparison does not match its head and count. Re-run PR Commit Policy."); + } + const response = await github.graphql(QUERY, { ...variables, cursor: null }); + const current = response.repository?.pullRequest; + if (!current || current.title !== snapshot.title || current.baseRefOid !== snapshot.base || + current.headRefOid !== snapshot.head || current.commits?.totalCount !== snapshot.count) { + throw new Error("PR changed during validation. Check the run for the latest PR update."); + } + return { title: snapshot.title, head: snapshot.head, commits: [...commits.values()] }; +} + +async function readPullRequest(github, variables) { + let snapshot; + let cursor = null; + const cursors = new Set(); + const commits = new Map(); + + do { + const response = await github.graphql(QUERY, { ...variables, cursor }); + const pullRequest = response.repository && response.repository.pullRequest; + if (!pullRequest || typeof pullRequest.title !== "string" || + !/^[0-9a-f]{40}$/.test(pullRequest.baseRefOid) || + !/^[0-9a-f]{40}$/.test(pullRequest.headRefOid)) { + throw new Error("Unable to read PR metadata. Re-run PR Commit Policy."); + } + const connection = pullRequest.commits; + if (!connection || !Number.isInteger(connection.totalCount) || + connection.totalCount < 1 || !Array.isArray(connection.nodes) || + !connection.pageInfo || typeof connection.pageInfo.hasNextPage !== "boolean") { + throw new Error("Unable to read the complete PR commit list. Re-run PR Commit Policy."); + } + + if (!snapshot) { + snapshot = { + title: pullRequest.title, + base: pullRequest.baseRefOid, + head: pullRequest.headRefOid, + count: connection.totalCount, + }; + } else if (snapshot.title !== pullRequest.title || + snapshot.base !== pullRequest.baseRefOid || + snapshot.head !== pullRequest.headRefOid || + snapshot.count !== connection.totalCount) { + throw new Error("PR changed during validation. Check the run for the latest PR update."); + } + + collectCommits(commits, connection.nodes); + + if (!connection.pageInfo.hasNextPage) break; + cursor = connection.pageInfo.endCursor; + if (typeof cursor !== "string" || !cursor || cursors.has(cursor) || + connection.nodes.length === 0 || commits.size >= snapshot.count) { + throw new Error("Incomplete PR commit pagination. Re-run PR Commit Policy."); + } + cursors.add(cursor); + } while (true); + + if (snapshot.count > 250 && commits.size < snapshot.count) { + return readComparison(github, variables, snapshot); + } + if (commits.size !== snapshot.count || !commits.has(snapshot.head)) { + throw new Error("PR commit list does not match its head and count. Re-run PR Commit Policy."); + } + return { title: snapshot.title, head: snapshot.head, commits: [...commits.values()] }; +} + +async function run({ github, context, core }) { + if (context.eventName !== "pull_request_target" || + !Number.isInteger(context.issue.number) || context.issue.number < 1) { + throw new Error("PR Commit Policy requires a pull_request_target event."); + } + const pullRequest = await readPullRequest(github, { + owner: context.repo.owner, + repo: context.repo.repo, + number: context.issue.number, + }); + if (pullRequest.head !== context.payload.pull_request?.head?.sha) { + throw new Error("PR head does not match the triggering event. Re-run PR Commit Policy for the latest PR update."); + } + if (pullRequest.title !== context.payload.pull_request.title) { + throw new Error("PR title does not match the triggering event. Re-run PR Commit Policy for the latest PR update."); + } + const violations = []; + const titleDirectives = findSkipDirectives(pullRequest.title); + if (titleDirectives.length) { + violations.push(`PR title contains ${titleDirectives.join(", ")}.`); + } + for (const commit of pullRequest.commits) { + const directives = findSkipDirectives(commit.message); + if (directives.length) { + violations.push(`Commit ${commit.oid} contains ${directives.join(", ")}.`); + } + } + + if (violations.length) { + for (const violation of violations.slice(0, 20)) core.error(violation); + core.setFailed( + `Found CI skip directives in ${violations.length} PR title/commit message(s). ` + + "Remove them from the title and rewrite affected commit messages; " + + "adding a clean commit does not remove earlier violations. " + + "See CONTRIBUTING.md#commit-messages. At most 20 violations are shown." + ); + } else { + core.info(`PR title and all ${pullRequest.commits.length} commit messages contain no CI skip directives.`); + } +} + +module.exports = { findSkipDirectives, readPullRequest, run }; diff --git a/scripts/github-actions/check-pr-commit-policy.test.js b/scripts/github-actions/check-pr-commit-policy.test.js new file mode 100644 index 000000000..e76317157 --- /dev/null +++ b/scripts/github-actions/check-pr-commit-policy.test.js @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const { findSkipDirectives, readPullRequest, run } = require("./check-pr-commit-policy.js"); + +const ROOT = path.resolve(__dirname, "../.."); +const tests = []; +const VARIABLES = { owner: "example", repo: "project", number: 42 }; +const CONTEXT = { + eventName: "pull_request_target", + repo: { owner: VARIABLES.owner, repo: VARIABLES.repo }, + issue: { number: VARIABLES.number }, + payload: { pull_request: { title: "fix: keep CI running", body: "Discuss [skip ci]", head: { sha: commit(1).oid } } }, +}; + +function contextWithHead(head, title = CONTEXT.payload.pull_request.title) { + return { + ...CONTEXT, + payload: { pull_request: { ...CONTEXT.payload.pull_request, head: { sha: head }, title } }, + }; +} + +function test(name, check) { + tests.push({ name, check }); +} + +function commit(index, message = "fix: keep CI running") { + return { oid: index.toString(16).padStart(40, "0"), message }; +} + +function page(commits, options = {}) { + return { + repository: { + pullRequest: { + title: "fix: keep CI running", + baseRefOid: commit(900).oid, + headRefOid: commits.length ? commits[commits.length - 1].oid : commit(1).oid, + ...options, + commits: { + totalCount: commits.length, + nodes: commits.map((entry) => ({ commit: entry })), + pageInfo: { hasNextPage: false, endCursor: null }, + ...options.commits, + }, + }, + }, + }; +} + +function client(pages, comparisons = []) { + const calls = []; + const comparisonCalls = []; + return { + calls, + comparisonCalls, + rest: { + repos: { + async compareCommitsWithBasehead(options) { + comparisonCalls.push(options); + assert.strictEqual(options.owner, VARIABLES.owner); + assert.strictEqual(options.repo, VARIABLES.repo); + assert.strictEqual(options.per_page, 100); + assert.strictEqual(options.page, comparisonCalls.length); + assert.ok(comparisonCalls.length <= comparisons.length, "unexpected comparison call"); + const data = comparisons[comparisonCalls.length - 1]; + if (data instanceof Error) throw data; + return { data }; + }, + }, + }, + async graphql(query, variables) { + assert.ok(query.includes("commits(first: 100, after: $cursor)")); + assert.ok(!query.includes("body")); + assert.deepStrictEqual( + { owner: variables.owner, repo: variables.repo, number: variables.number }, + VARIABLES + ); + calls.push(variables); + assert.ok(calls.length <= pages.length, "unexpected API call"); + const response = pages[calls.length - 1]; + if (response instanceof Error) throw response; + return response; + }, + }; +} + +function reporter() { + const messages = { errors: [], failures: [], info: [] }; + return { + messages, + error: (message) => messages.errors.push(message), + setFailed: (message) => messages.failures.push(message), + info: (message) => messages.info.push(message), + }; +} + +for (const marker of ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"]) { + test(`detects ${marker} anywhere in a full message`, () => { + assert.deepStrictEqual(findSkipDirectives(`fix: title\n\n* format ${marker}`), [marker]); + assert.deepStrictEqual(findSkipDirectives(`fix: ${marker.toUpperCase()}`), [marker]); + assert.deepStrictEqual(findSkipDirectives(`${marker} ${marker}`), [marker]); + }); +} + +test("detects skip-checks trailer variants, including CRLF", () => { + for (const trailer of ["skip-checks:true", "skip-checks: true", "SKIP-CHECKS:\tTRUE", " skip-checks: true\t"]) { + assert.deepStrictEqual(findSkipDirectives(`fix: title\r\n\r\n\r\n${trailer}\r\n`), ["skip-checks: true"]); + } + assert.deepStrictEqual(findSkipDirectives("skip-checks: true"), ["skip-checks: true"]); +}); + +test("does not reject ordinary prose or false trailers", () => { + for (const message of ["", "fix: do not skip ci", "[skip-ci]", "[skip ci-ish]", "skip-checks: false", "skip-checks: trueish", "Document skip-checks: true", "skip-checks:\ntrue"]) { + assert.deepStrictEqual(findSkipDirectives(message), [], message); + } +}); + +test("collects distinct directives", () => { + assert.deepStrictEqual(findSkipDirectives("[skip ci] [no ci]\n\nskip-checks:true"), [ + "[skip ci]", "[no ci]", "skip-checks: true", + ]); +}); + +test("follows GraphQL cursors until all reported commits are read", async () => { + const commits = Array.from({ length: 301 }, (_, index) => commit(index + 1)); + commits[300].message = "chore: format [skip ci]"; + const pages = []; + for (let offset = 0; offset < commits.length; offset += 100) { + pages.push(page(commits.slice(offset, offset + 100), { + headRefOid: commits[300].oid, + commits: { + totalCount: commits.length, + pageInfo: { hasNextPage: offset < 300, endCursor: String(offset + 100) }, + }, + })); + } + const github = client(pages); + const core = reporter(); + await run({ github, context: contextWithHead(commits[300].oid), core }); + assert.deepStrictEqual(github.calls.map((call) => call.cursor), [null, "100", "200", "300"]); + assert.strictEqual(core.messages.failures.length, 1); + assert.ok(core.messages.errors[0].includes(commits[300].oid)); +}); + +function comparisonFixture() { + const commits = Array.from({ length: 301 }, (_, index) => commit(index + 1)); + commits[300].message = "chore: format [skip ci]"; + const pages = [0, 100, 200].map((offset) => page(commits.slice(offset, Math.min(offset + 100, 250)), { + headRefOid: commits[300].oid, + commits: { + totalCount: commits.length, + pageInfo: { hasNextPage: offset < 200, endCursor: String(offset + 100) }, + }, + })); + pages.push(pages[0]); + const comparisons = [0, 100, 200, 300].map((offset) => ({ + total_commits: commits.length, + commits: commits.slice(offset, offset + 100).map((entry) => ({ + sha: entry.oid, commit: { message: entry.message }, + })), + })); + return { commits, pages, comparisons }; +} + +test("falls back to paginated comparison when GitHub truncates GraphQL at 250 commits", async () => { + const fixture = comparisonFixture(); + const github = client(fixture.pages, fixture.comparisons); + const core = reporter(); + await run({ github, context: contextWithHead(commit(301).oid), core }); + assert.deepStrictEqual(github.calls.map((call) => call.cursor), [null, "100", "200", null]); + assert.strictEqual(github.comparisonCalls.length, 4); + for (const call of github.comparisonCalls) { + assert.strictEqual(call.basehead, `${commit(900).oid}...${commit(301).oid}`); + } + assert.deepStrictEqual(core.messages.errors, [`Commit ${commit(301).oid} contains [skip ci].`]); + assert.strictEqual(core.messages.failures.length, 1); +}); + +for (const [name, replacement] of [ + ["missing comparison", null], + ["wrong total", { total_commits: 300, commits: [] }], + ["missing commits", { total_commits: 301, commits: null }], + ["truncated comparison", { total_commits: 301, commits: [] }], + ["missing commit", { total_commits: 301, commits: [null] }], + ["invalid SHA", { total_commits: 301, commits: [{ sha: "invalid", commit: { message: "fix" } }] }], + ["missing message", { total_commits: 301, commits: [{ sha: commit(1).oid }] }], + ["API failure", new Error("Unable to read comparison API")], +]) { + test(`fails closed on ${name} in comparison fallback`, async () => { + const fixture = comparisonFixture(); + fixture.comparisons[0] = replacement; + await assert.rejects(readPullRequest(client(fixture.pages, fixture.comparisons), VARIABLES), /PR commit|comparison API/); + }); +} + +test("rejects duplicate commits across comparison pages", async () => { + const fixture = comparisonFixture(); + fixture.comparisons[1] = fixture.comparisons[0]; + await assert.rejects(readPullRequest(client(fixture.pages, fixture.comparisons), VARIABLES), /duplicate PR commit data/); +}); + +for (const extra of [false, true]) { + test(`rejects a comparison with ${extra ? "too many commits" : "a missing head"}`, async () => { + const fixture = comparisonFixture(); + const replacement = { sha: commit(302).oid, commit: { message: "fix" } }; + if (extra) fixture.comparisons[3].commits.push(replacement); + else fixture.comparisons[3].commits[0] = replacement; + await assert.rejects(readPullRequest(client(fixture.pages, fixture.comparisons), VARIABLES), /comparison does not match/); + }); +} + +for (const changes of [ + { title: "changed title" }, { baseRefOid: commit(901).oid }, + { headRefOid: commit(302).oid }, { commits: { totalCount: 302 } }, +]) { + test(`rejects PR changes during comparison: ${JSON.stringify(changes)}`, async () => { + const fixture = comparisonFixture(); + fixture.pages[3] = { repository: { pullRequest: { ...fixture.pages[0].repository.pullRequest, ...changes } } }; + await assert.rejects(readPullRequest(client(fixture.pages, fixture.comparisons), VARIABLES), /PR changed during validation/); + }); +} + +test("rejects missing metadata after comparison", async () => { + const fixture = comparisonFixture(); + fixture.pages[3] = { repository: null }; + await assert.rejects(readPullRequest(client(fixture.pages, fixture.comparisons), VARIABLES), /PR changed during validation/); +}); + +test("checks earlier commits even when the head is clean", async () => { + const core = reporter(); + await run({ github: client([page([commit(1, "chore: format [skip ci]"), commit(2)])]), context: contextWithHead(commit(2).oid), core }); + assert.deepStrictEqual(core.messages.errors, [`Commit ${commit(1).oid} contains [skip ci].`]); + assert.strictEqual(core.messages.failures.length, 1); + assert.ok(core.messages.failures[0].includes("CONTRIBUTING.md#commit-messages")); +}); + +test("ignores description, author, and draft state", async () => { + const core = reporter(); + await run({ + github: client([page([commit(1)], { body: "Report [skip ci]", isDraft: true, author: { login: "dependabot[bot]" } })]), + context: CONTEXT, + core, + }); + assert.strictEqual(core.messages.failures.length, 0); + assert.strictEqual(core.messages.info.length, 1); +}); + +test("rejects title-only edits without echoing untrusted text", async () => { + const core = reporter(); + const title = "$(touch /tmp/not-executed) [SKIP CI]\n::error::untrusted-title"; + const message = "[no ci]\n::add-mask::untrusted-message"; + await run({ github: client([page([commit(1, message)], { title })]), context: contextWithHead(commit(1).oid, title), core }); + assert.deepStrictEqual(core.messages.errors, [ + "PR title contains [skip ci].", + `Commit ${commit(1).oid} contains [no ci].`, + ]); + assert.ok(!JSON.stringify(core.messages).includes("untrusted")); +}); + +test("caps annotations without hiding the total number of failures", async () => { + const core = reporter(); + const commits = Array.from({ length: 30 }, (_, index) => commit(index + 1, "fix: [skip ci]")); + await run({ github: client([page(commits)]), context: contextWithHead(commits[29].oid), core }); + assert.strictEqual(core.messages.errors.length, 20); + assert.ok(core.messages.failures[0].includes("30 PR title/commit message(s)")); +}); + +test("propagates API errors instead of reporting success", async () => { + const core = reporter(); + await assert.rejects(run({ github: client([new Error("API unavailable")]), context: CONTEXT, core }), /API unavailable/); + assert.strictEqual(core.messages.info.length, 0); +}); + +for (const [name, commits, eventHead] of [ + ["stale API snapshot after a push", [commit(1)], commit(2).oid], + ["outdated event after another push", [commit(1), commit(2)], commit(1).oid], + ["missing event head", [commit(1)], undefined], +]) { + test(`rejects ${name}`, async () => { + const core = reporter(); + await assert.rejects(run({ + github: client([page(commits)]), context: contextWithHead(eventHead), core, + }), /PR head does not match the triggering event/); + assert.strictEqual(core.messages.info.length, 0); + }); +} + +for (const [apiTitle, eventTitle] of [ + ["fix: keep CI running", "fix: title [skip ci]"], + ["fix: title [skip ci]", "fix: keep CI running"], +]) { + test(`rejects an outdated title snapshot or event: ${apiTitle}`, async () => { + const core = reporter(); + await assert.rejects(run({ + github: client([page([commit(1)], { title: apiTitle })]), + context: contextWithHead(commit(1).oid, eventTitle), core, + }), /PR title does not match the triggering event/); + assert.strictEqual(core.messages.info.length, 0); + }); +} + +for (const context of [ + { ...CONTEXT, eventName: "pull_request" }, + { ...CONTEXT, issue: { number: 0 } }, + { ...CONTEXT, issue: { number: "42; exit 0" } }, +]) { + test(`rejects invalid context ${JSON.stringify(context.issue)} / ${context.eventName}`, async () => { + await assert.rejects(run({ github: client([]), context, core: reporter() }), /requires a pull_request_target/); + }); +} + +const invalidPages = [ + ["missing repository", { repository: null }], + ["missing PR", { repository: { pullRequest: null } }], + ["missing title", page([commit(1)], { title: null })], + ["invalid base", page([commit(1)], { baseRefOid: null })], + ["invalid head", page([commit(1)], { headRefOid: "untrusted\n::error::" })], + ["invalid count", page([commit(1)], { commits: { totalCount: "1" } })], + ["empty PR", page([], { commits: { totalCount: 0 } })], + ["missing nodes", page([commit(1)], { commits: { nodes: null } })], + ["missing page info", page([commit(1)], { commits: { pageInfo: null } })], + ["invalid page info", page([commit(1)], { commits: { pageInfo: {} } })], + ["missing commit", page([commit(1)], { commits: { nodes: [null] } })], + ["invalid commit SHA", page([commit(1)], { commits: { nodes: [{ commit: { oid: "bad", message: "fix" } }] } })], + ["missing message", page([commit(1)], { commits: { nodes: [{ commit: { oid: commit(1).oid } }] } })], + ["duplicate commits", page([commit(1), commit(1)])], + ["truncated list", page([commit(1)], { commits: { totalCount: 2 } })], + ["unexpected head", page([commit(1)], { headRefOid: commit(2).oid })], + ["missing cursor", page([commit(1)], { commits: { totalCount: 2, pageInfo: { hasNextPage: true, endCursor: null } } })], + ["empty intermediate page", page([], { commits: { totalCount: 2, pageInfo: { hasNextPage: true, endCursor: "next" } } })], + ["extra page after total", page([commit(1)], { commits: { pageInfo: { hasNextPage: true, endCursor: "next" } } })], +]; + +for (const [name, response] of invalidPages) { + test(`fails closed on ${name}`, async () => { + await assert.rejects(readPullRequest(client([response]), VARIABLES), /Re-run PR Commit Policy/); + }); +} + +const firstPage = page([commit(1)], { + headRefOid: commit(3).oid, + commits: { totalCount: 3, pageInfo: { hasNextPage: true, endCursor: "next" } }, +}); + +for (const changes of [{ title: "edited title" }, { baseRefOid: commit(901).oid }, { headRefOid: commit(4).oid }, { commits: { totalCount: 4 } }]) { + test(`rejects metadata changes between pages: ${JSON.stringify(changes)}`, async () => { + const lastPage = page([commit(2), commit(3)], { commits: { totalCount: 3 }, ...changes }); + await assert.rejects(readPullRequest(client([firstPage, lastPage]), VARIABLES), /PR changed during validation/); + }); +} + +test("rejects cursor loops", async () => { + const secondPage = page([commit(2)], { + headRefOid: commit(3).oid, + commits: { totalCount: 3, pageInfo: { hasNextPage: true, endCursor: "next" } }, + }); + await assert.rejects(readPullRequest(client([firstPage, secondPage]), VARIABLES), /Incomplete PR commit pagination/); +}); + +for (const initialState of ["fresh", "cleaned sparse"]) { + test(`policy checkout preserves source for later jobs in a ${initialState} workspace`, () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ocr-pr-policy-checkout-")); + const git = (args, allowedStatuses = [0]) => { + const result = spawnSync("git", args, { + cwd: directory, + encoding: "utf8", + timeout: 10000, + env: { + ...process.env, + HOME: directory, + LC_ALL: "C", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: os.devNull, + }, + }); + assert.ifError(result.error); + assert.ok(allowedStatuses.includes(result.status), `git ${args.join(" ")}: ${result.stderr}`); + return result.stdout.trim(); + }; + const policyPath = "scripts/github-actions/check-pr-commit-policy.js"; + const files = { + "go.mod": "module example.test/checkout\n", + "cmd/opencodereview/main.go": "package main\n\nfunc main() {}\n", + [policyPath]: "module.exports = {};\n", + }; + try { + git(["init", "-q"]); + for (const [relativePath, content] of Object.entries(files)) { + const filename = path.join(directory, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); + } + git(["add", "."]); + git(["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"]); + const head = git(["rev-parse", "HEAD"]); + const checkoutWithPinnedActionCommands = (sparsePattern) => { + if (sparsePattern) { + git(["config", "core.sparseCheckout", "true"]); + fs.writeFileSync(path.join(directory, ".git/info/sparse-checkout"), `${sparsePattern}\n`); + } else { + git(["sparse-checkout", "disable"]); + git(["config", "--local", "--unset-all", "extensions.worktreeConfig"], [0, 5]); + } + git(["checkout", "--force", "--detach", head]); + }; + if (initialState === "cleaned sparse") { + checkoutWithPinnedActionCommands(policyPath); + assert.ok(!fs.existsSync(path.join(directory, "go.mod")), "fixture must reproduce sparse source omission"); + git(["config", "--local", "core.sparseCheckout", "false"]); + git(["sparse-checkout", "disable"]); + } + + const workflow = fs.readFileSync(path.join(ROOT, ".github/workflows/pr-commit-policy.yml"), "utf8"); + const sparsePattern = workflow.match(/^\s+sparse-checkout:\s*(.+)$/m)?.[1]; + checkoutWithPinnedActionCommands(sparsePattern); + checkoutWithPinnedActionCommands(); + checkoutWithPinnedActionCommands(); + + for (const [relativePath, content] of Object.entries(files)) { + assert.ok(fs.existsSync(path.join(directory, relativePath)), `${relativePath} must survive later checkouts`); + assert.strictEqual(fs.readFileSync(path.join(directory, relativePath), "utf8"), content); + } + assert.notStrictEqual(git(["config", "--local", "--bool", "core.sparseCheckout"], [0, 1]), "true"); + assert.strictEqual(git(["rev-parse", "HEAD"]), head); + assert.strictEqual(git(["status", "--porcelain"]), ""); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +} + +test("workflow preserves trust boundaries and runs the exported policy", async () => { + const workflow = fs.readFileSync(path.join(ROOT, ".github/workflows/pr-commit-policy.yml"), "utf8"); + assert.match(workflow, /pull_request_target:\n branches: \[main\]/); + assert.match(workflow, /types: \[opened, synchronize, reopened, edited, ready_for_review\]/); + assert.match(workflow, /permissions:\n contents: read\n pull-requests: read\n/); + assert.match(workflow, /name: PR Commit Policy\n runs-on: \[self-hosted, Linux, X64\]\n timeout-minutes: 5/); + assert.match(workflow, /container:\n image: node:24\.18\.0\n options: --cpus=1 --memory=1g\n/); + assert.match(workflow, /ref: \$\{\{ github.sha \}\}/); + assert.match(workflow, /persist-credentials: false/); + assert.doesNotMatch(workflow, /^\s+sparse-checkout(?:-cone-mode)?:/m); + assert.match(workflow, /cancel-in-progress: true/); + assert.ok(!/\bwrite\b|\bsecrets\.|\bpaths(?:-ignore)?:|\bif:|\bcontinue-on-error:|\brun:|\buses:.*(?:cache|artifact)|pull_request\.(?:head|title|body)|--privileged|docker\.sock|\bvolumes:/.test(workflow)); + const script = workflow.split(" script: |\n")[1].trim(); + assert.ok(!script.includes("${{")); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const core = reporter(); + await new AsyncFunction("require", "github", "context", "core", script)( + (modulePath) => { + assert.strictEqual(modulePath, "./scripts/github-actions/check-pr-commit-policy.js"); + return { run }; + }, + client([page([commit(1)])]), CONTEXT, core + ); + assert.strictEqual(core.messages.info.length, 1); + const contract = fs.readFileSync(path.join(ROOT, ".github/workflows/action-contract.yml"), "utf8"); + assert.strictEqual((contract.match(/'\.github\/workflows\/pr-commit-policy.yml'/g) || []).length, 2); + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); + assert.ok(manifest.scripts["test:github-actions"].includes("node scripts/github-actions/check-pr-commit-policy.test.js")); +}); + +async function main() { + for (const { name, check } of tests) { + await check(); + console.log(`PASS ${name}`); + } + console.log(`\nAll ${tests.length} PR commit policy tests passed.`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/github-actions/release-contract.test.js b/scripts/github-actions/release-contract.test.js new file mode 100644 index 000000000..113157347 --- /dev/null +++ b/scripts/github-actions/release-contract.test.js @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const ROOT = path.resolve(__dirname, "../.."); +const WORKFLOW = fs.readFileSync(path.join(ROOT, ".github/workflows/release.yml"), "utf8"); +const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); +const PACKAGES = [MANIFEST.name, ...Object.keys(MANIFEST.optionalDependencies)].sort(); + +function jobText(name) { + const lines = WORKFLOW.split("\n"); + const start = lines.indexOf(` ${name}:`); + assert.ok(start >= 0, `missing job ${name}`); + const end = lines.findIndex((line, index) => index > start && /^ [\w-]+:/.test(line)); + return lines.slice(start, end < 0 ? undefined : end).join("\n"); +} + +function stepText(job, name) { + const lines = jobText(job).split("\n"); + const start = lines.indexOf(` - name: ${name}`); + assert.ok(start >= 0, `missing step ${name}`); + const end = lines.findIndex((line, index) => index > start && /^ - /.test(line)); + return lines.slice(start, end < 0 ? undefined : end).join("\n"); +} + +function runBlock(step) { + const lines = step.split("\n"); + const start = lines.indexOf(" run: |"); + assert.ok(start >= 0, "expected a multiline run block"); + const script = []; + for (const line of lines.slice(start + 1)) { + if (line.trim() && !line.startsWith(" ")) break; + script.push(line.slice(10)); + } + return script.join("\n"); +} + +const PREFLIGHT = stepText("verify-trusted-publishing", "Exchange OIDC token for every package"); +const VERSION_GATE = stepText("npm-publish", "Ensure npm supports trusted publishing"); + +function execute(step, overrides = {}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ocr-release-contract-")); + const bin = path.join(directory, "bin"); + fs.mkdirSync(bin); + fs.symlinkSync(process.execPath, path.join(bin, "node")); + fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify(MANIFEST)); + const mock = `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const command = path.basename(process.argv[1]); +const args = process.argv.slice(2); +fs.appendFileSync("calls.jsonl", JSON.stringify({ command, args }) + "\\n"); +if (command === "npm") { + if (args[0] === "--version") { + console.log(process.env.TEST_NPM_VERSION); + } else if (args[0] === "install") { + process.exit(Number(process.env.TEST_INSTALL_STATUS || 0)); + } else { + throw new Error("Unexpected npm operation: " + args.join(" ")); + } +} else if (args[args.length - 1].startsWith("https://oidc.invalid/")) { + console.log(JSON.stringify({ value: process.env.TEST_ID_TOKEN })); +} else { + if (process.env.TEST_CURL_FAILURE === "1") process.exit(7); + const failed = process.env.TEST_FAILED_PACKAGE && + args[args.length - 1].endsWith(process.env.TEST_FAILED_PACKAGE.split("/").join("%2f")); + const body = failed ? JSON.stringify({ message: "Not authorized" }) : process.env.TEST_RESPONSE; + const outputIndex = args.indexOf("-o"); + if (outputIndex < 0) throw new Error("Expected a curl output file"); + fs.writeFileSync(args[outputIndex + 1], body); + process.stdout.write(failed ? "401" : process.env.TEST_HTTP_STATUS); +} +`; + for (const command of ["curl", "npm"]) { + fs.writeFileSync(path.join(bin, command), mock, { mode: 0o755 }); + } + const claims = Buffer.from(JSON.stringify({ + repository: "alibaba/open-code-review", + workflow_ref: "alibaba/open-code-review/.github/workflows/release.yml@refs/heads/main", + repository_visibility: "public", + })).toString("base64url"); + try { + const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-e", "-c", runBlock(step)], { + cwd: directory, + encoding: "utf8", + timeout: 30000, + env: { + PATH: `${bin}:${process.env.PATH}`, + HOME: directory, + LC_ALL: "C", + ACTIONS_ID_TOKEN_REQUEST_URL: "https://oidc.invalid/?request=fixture", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "fixture-request-token", + TEST_ID_TOKEN: `header.${claims}.signature`, + TEST_HTTP_STATUS: "201", + TEST_RESPONSE: JSON.stringify({ token: "fixture-npm-token" }), + TEST_NPM_VERSION: "11.15.0", + ...overrides, + }, + }); + assert.ifError(result.error); + const callsPath = path.join(directory, "calls.jsonl"); + const calls = fs.existsSync(callsPath) + ? fs.readFileSync(callsPath, "utf8").trim().split("\n").map(JSON.parse) + : []; + return { ...result, calls }; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +let failures = 0; +function test(name, check) { + try { + check(); + console.log(`PASS ${name}`); + } catch (error) { + failures += 1; + console.error(`FAIL ${name}: ${error.message}`); + } +} + +test("container scripts explicitly select Bash", () => { + for (const step of [PREFLIGHT, VERSION_GATE]) { + assert.match(step, /^ shell: bash$/m); + } +}); + +test("manual preflight is separated from tag publishing", () => { + assert.match(WORKFLOW, /^ workflow_dispatch:/m); + assert.match(jobText("verify-trusted-publishing"), /^ if: github.event_name == 'workflow_dispatch'$/m); + assert.match(jobText("build"), /^ if: github.event_name == 'push'$/m); + assert.match(jobText("release"), /^ needs: build$/m); + assert.match(jobText("npm-publish"), /^ needs: release$/m); + for (const job of ["build", "release"]) { + assert.match(jobText(job), /^ runs-on: self-hosted$/m); + } + for (const job of ["verify-trusted-publishing", "npm-publish"]) { + assert.match(jobText(job), /^ runs-on: ubuntu-latest$/m); + assert.match(jobText(job), /^ id-token: write$/m); + } + assert.doesNotMatch(WORKFLOW, /NPM_TOKEN|NODE_AUTH_TOKEN|_authToken/); +}); + +test("HTTP 201 validates all seven packages without publishing", () => { + const result = execute(PREFLIGHT); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + const verified = Array.from(result.stdout.matchAll(/^OK\s+(\S+)$/gm), (match) => match[1]).sort(); + assert.strictEqual(PACKAGES.length, 7); + assert.deepStrictEqual(verified, PACKAGES); + assert.ok(result.calls.every((call) => call.command === "curl")); + const requests = result.calls.map((call) => call.args[call.args.length - 1]); + assert.strictEqual(requests[0], "https://oidc.invalid/?request=fixture&audience=npm:registry.npmjs.org"); + assert.deepStrictEqual(requests.slice(1).sort(), PACKAGES.map((name) => + `https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${name.split("/").join("%2f")}` + ).sort()); + assert.ok(!result.stdout.includes("fixture-npm-token")); +}); + +test("one unauthorized platform fails preflight", () => { + const result = execute(PREFLIGHT, { TEST_FAILED_PACKAGE: PACKAGES[0] }); + assert.notStrictEqual(result.status, 0); + assert.match(result.stdout, /FAILED.*HTTP 401/); + assert.match(result.stdout, /At least one package is not configured/); +}); + +for (const response of [{}, { token: null }, { token: "" }, { token: 123 }, { token: [] }]) { + test(`HTTP 201 rejects an invalid token: ${JSON.stringify(response)}`, () => { + const result = execute(PREFLIGHT, { TEST_RESPONSE: JSON.stringify(response) }); + assert.notStrictEqual(result.status, 0); + assert.match(result.stdout, /At least one package is not configured/); + }); +} + +test("missing OIDC permissions fail before network access", () => { + const result = execute(PREFLIGHT, { ACTIONS_ID_TOKEN_REQUEST_TOKEN: "" }); + assert.notStrictEqual(result.status, 0); + assert.deepStrictEqual(result.calls, []); +}); + +test("network failures cannot pass preflight", () => { + const result = execute(PREFLIGHT, { TEST_CURL_FAILURE: "1" }); + assert.notStrictEqual(result.status, 0); + assert.doesNotMatch(result.stdout, /All packages accept/); +}); + +for (const version of ["10.9.0", "11.5.1", "11.14.0", "11.15.0", "11.19.0"]) { + test(`npm version gate handles ${version}`, () => { + const result = execute(VERSION_GATE, { TEST_NPM_VERSION: version }); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + const installs = result.calls.filter((call) => call.args[0] === "install"); + const needsUpgrade = ["10.9.0", "11.5.1", "11.14.0"].includes(version); + assert.deepStrictEqual(installs.map((call) => call.args), needsUpgrade + ? [["install", "-g", "npm@11.15.0"]] + : []); + }); +} + +test("failed npm upgrade stops the job", () => { + const result = execute(VERSION_GATE, { TEST_NPM_VERSION: "10.9.0", TEST_INSTALL_STATUS: "1" }); + assert.notStrictEqual(result.status, 0); +}); + +test("both publish paths require provenance", () => { + for (const name of ["Publish platform packages", "Publish to npm"]) { + const commands = runBlock(stepText("npm-publish", name)).split("\n") + .filter((line) => /^\s*npm publish\b/.test(line)); + assert.strictEqual(commands.length, 1); + assert.match(commands[0], /--provenance(?:\s|$)/); + assert.match(commands[0], /--access public(?:\s|$)/); + } +}); + +if (failures) process.exitCode = 1; diff --git a/skills/open-code-review-delegate/SKILL.md b/skills/open-code-review-delegate/SKILL.md index 77aa5ed22..493d2112c 100644 --- a/skills/open-code-review-delegate/SKILL.md +++ b/skills/open-code-review-delegate/SKILL.md @@ -54,26 +54,44 @@ Pass the reviewable file paths from Step 1. Output is grouped by rule content ### Step 3: Get Diffs -Use git directly based on the mode/ref info from Step 1: +Choose the diff command using the mode and refs from Step 1. Use `git --no-pager` for every Git call during the review. + +The diff commands disable external diff programs, text conversion, and color to produce plain patches. **Range mode** (merge_base provided in preview output): ```bash -git diff .. -- +git --no-pager diff --no-ext-diff --no-textconv --no-color .. -- "" ``` **Commit mode**: ```bash -git show -- +git --no-pager show --no-ext-diff --no-textconv --no-color -- "" ``` **Workspace mode**: ```bash # Tracked files -git diff HEAD -- +git --no-pager diff --no-ext-diff --no-textconv --no-color HEAD -- "" # New untracked files — read directly (entire file is new code) -cat +cat "" +``` + +To read context: + +```bash +git --no-pager log --no-color --oneline -- "" +git --no-pager blame --no-textconv -- "" +git --no-pager show --no-ext-diff --no-textconv --no-color ":" ``` +For large diffs, add `--output=""` to the command for the selected mode. Choose a unique absolute path outside the repository and create its parent directory. Use the same path in file-reading tool calls: + +```bash +git --no-pager diff --no-ext-diff --no-textconv --no-color --output="" .. -- "" +``` + +After Git exits with code 0, read and review the entire file in chunks, then remove it. If Git fails or times out, retry or record `skipped` with the error. Check unexpectedly empty output against a fresh preview. + ### Step 4: Review Each File Create a checklist containing every `reviewable_files` entry. For each reviewable file: @@ -186,4 +204,4 @@ other JSON fields must require a JSON-capable CLI instead: verify with ```bash npm install -g @alibaba-group/open-code-review -``` \ No newline at end of file +```