From 182898cf522da3d04157b422752d028417974e19 Mon Sep 17 00:00:00 2001
From: Dante
Date: Mon, 5 Oct 2026 14:34:14 +0800
Subject: [PATCH 01/32] fix(llm): allow unlisted OpenRouter model overrides
(#1610)
* fix(llm): allow unlisted OpenRouter model overrides
* docs(llm): clarify OpenRouter model overrides
* fix(llm): allow unlisted model overrides for built-in providers
* fix(llm): share stderr test helper across platforms
---------
Co-authored-by: kite
---
internal/llm/bedrock_test.go | 46 ++++--
internal/llm/resolver.go | 19 +--
internal/llm/resolver_keycmd_test.go | 26 ---
internal/llm/resolver_test.go | 180 ++++++++++++++++-----
internal/llm/test_stderr_test.go | 35 ++++
pages/src/content/docs/en/cli-reference.md | 5 +
pages/src/content/docs/en/configuration.md | 6 +
pages/src/content/docs/ja/cli-reference.md | 5 +
pages/src/content/docs/ja/configuration.md | 5 +
pages/src/content/docs/ko/cli-reference.md | 5 +
pages/src/content/docs/ko/configuration.md | 6 +
pages/src/content/docs/ru/cli-reference.md | 13 ++
pages/src/content/docs/ru/configuration.md | 6 +
pages/src/content/docs/zh/cli-reference.md | 4 +
pages/src/content/docs/zh/configuration.md | 5 +
15 files changed, 274 insertions(+), 92 deletions(-)
create mode 100644 internal/llm/test_stderr_test.go
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/resolver.go b/internal/llm/resolver.go
index 5edefd974..12f37ee84 100644
--- a/internal/llm/resolver.go
+++ b/internal/llm/resolver.go
@@ -525,18 +525,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 +538,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..6ecb7e4e8 100644
--- a/internal/llm/resolver_test.go
+++ b/internal/llm/resolver_test.go
@@ -6,6 +6,7 @@ package llm
import (
"bytes"
"encoding/json"
+ "fmt"
"os"
"path/filepath"
"strings"
@@ -532,12 +533,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 +1150,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 +1165,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 +1193,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 +1365,86 @@ 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) {
+ path, _ := writeResolverConfig(t, configFile{
+ Provider: provider.Name,
+ Providers: map[string]providerEntryConfig{
+ provider.Name: {APIKey: "test-key", AWSRegion: "us-west-2"},
+ },
+ })
+ 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 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 +1463,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)
}
}
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/pages/src/content/docs/en/cli-reference.md b/pages/src/content/docs/en/cli-reference.md
index 6a489b493..1f53dfd34 100644
--- a/pages/src/content/docs/en/cli-reference.md
+++ b/pages/src/content/docs/en/cli-reference.md
@@ -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)
diff --git a/pages/src/content/docs/en/configuration.md b/pages/src/content/docs/en/configuration.md
index 7f4a37b9f..d63d6340e 100644
--- a/pages/src/content/docs/en/configuration.md
+++ b/pages/src/content/docs/en/configuration.md
@@ -69,6 +69,12 @@ 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.
+
### Overriding a built-in provider's Base URL
Every built-in provider has a preset Base URL (shown in the table above).
diff --git a/pages/src/content/docs/ja/cli-reference.md b/pages/src/content/docs/ja/cli-reference.md
index 50c3c2d2f..af13eb06e 100644
--- a/pages/src/content/docs/ja/cli-reference.md
+++ b/pages/src/content/docs/ja/cli-reference.md
@@ -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` 検証ルールが適用されます。
+
### モード
#### ワークスペースモード(デフォルト)
diff --git a/pages/src/content/docs/ja/configuration.md b/pages/src/content/docs/ja/configuration.md
index 1533b435b..235b4d9ad 100644
--- a/pages/src/content/docs/ja/configuration.md
+++ b/pages/src/content/docs/ja/configuration.md
@@ -67,6 +67,11 @@ 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` 検証ルールが適用されます。
+
### 組み込み provider の Base URL を上書きする
各組み込み provider にはプリセット Base URL があります(上表を参照)。
diff --git a/pages/src/content/docs/ko/cli-reference.md b/pages/src/content/docs/ko/cli-reference.md
index b46adcf3e..8769dddb4 100644
--- a/pages/src/content/docs/ko/cli-reference.md
+++ b/pages/src/content/docs/ko/cli-reference.md
@@ -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}
diff --git a/pages/src/content/docs/ko/configuration.md b/pages/src/content/docs/ko/configuration.md
index 87f341edd..ed3472f3c 100644
--- a/pages/src/content/docs/ko/configuration.md
+++ b/pages/src/content/docs/ko/configuration.md
@@ -65,6 +65,12 @@ 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` 검증 규칙이 적용됩니다.
+
### 내장 프로바이더의 Base URL 재정의 {#overriding-a-built-in-provider-s-base-url}
모든 내장 프로바이더에는 미리 설정된 Base URL이 있습니다(위 표 참고). 내장 프로바이더를 다른 엔드포인트로 보내려면 `providers..url`을 설정합니다(예: 자체 호스팅 LiteLLM 게이트웨이는 미리 설정된 기본값 `http://localhost:4000/v1`에 있는 경우가 드뭅니다):
diff --git a/pages/src/content/docs/ru/cli-reference.md b/pages/src/content/docs/ru/cli-reference.md
index e28d3eee0..3479f514f 100644
--- a/pages/src/content/docs/ru/cli-reference.md
+++ b/pages/src/content/docs/ru/cli-reference.md
@@ -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`.
+
### Режимы
#### Режим рабочей области (по умолчанию)
diff --git a/pages/src/content/docs/ru/configuration.md b/pages/src/content/docs/ru/configuration.md
index bc0c90a6b..57ce92c2c 100644
--- a/pages/src/content/docs/ru/configuration.md
+++ b/pages/src/content/docs/ru/configuration.md
@@ -72,6 +72,12 @@ 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`.
+
### Переопределение Base URL встроенного провайдера
У каждого встроенного провайдера есть предустановленный Base URL
diff --git a/pages/src/content/docs/zh/cli-reference.md b/pages/src/content/docs/zh/cli-reference.md
index f577bf5c7..f1ce6b41e 100644
--- a/pages/src/content/docs/zh/cli-reference.md
+++ b/pages/src/content/docs/zh/cli-reference.md
@@ -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` 校验规则。
+
### 模式
#### 工作区模式(默认)
diff --git a/pages/src/content/docs/zh/configuration.md b/pages/src/content/docs/zh/configuration.md
index c27cfb93e..bd85c349c 100644
--- a/pages/src/content/docs/zh/configuration.md
+++ b/pages/src/content/docs/zh/configuration.md
@@ -66,6 +66,11 @@ 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` 校验规则。
+
### 覆盖内置 provider 的 Base URL
每个内置 provider 都有一个预设 Base URL(见上表)。要将内置 provider
From 2d67596c961f80436deb2afa643efa2b1725d34c Mon Sep 17 00:00:00 2001
From: nolanchic
Date: Thu, 8 Oct 2026 15:06:38 +0800
Subject: [PATCH 02/32] fix(llm): clarify independent timeouts and deadline
diagnostics (#1159)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(llm): let --timeout raise the per-request HTTP deadline
The --timeout flag documents a concurrent-task timeout in minutes, but
the per-request HTTP deadline stayed at the client's independent
5-minute default. A review of one slow file therefore failed with
classification=timeout at duration_to_headers_ms≈300000 even when the
caller granted --timeout 30, and no CLI setting could let the file
review finish (issue #1155).
ResolveOptions gains a TaskTimeout field: when no env
(OCR_LLM_TIMEOUT) or config-file (timeout_sec) request timeout is set,
the task budget raises the request deadline so a single request can use
the whole task budget. Existing precedence is unchanged — env and
config-file timeouts still win, and leaving --timeout at its default
keeps the 5-minute client default.
ocr review and ocr scan both pass their --timeout value through.
Fixes #1155
* fix(cmd): feed the concurrent task timeout into the request deadline
main replaced the per-file timeout flag with --timeout binding
concurrentTaskTimeout, and the agent applies it as a context deadline
across review rounds — but the per-request HTTP deadline stayed at the
client's independent 5-minute default, so a single slow file request
could still die inside a task that had plenty of budget left (#1155's
original symptom).
Wire ResolveOptions.TaskTimeout from the live
opts.concurrentTaskTimeout (minutes) in both review and scan commands.
Precedence is unchanged: env OCR_LLM_TIMEOUT and config-file
timeout_sec still win, and an unset task timeout keeps the 5-minute
client default.
* fix(llm): keep request and task timeouts independent
* docs: localize timeout configuration guidance
* fix(llm): avoid attributing caller deadlines to task timeout
---------
Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
Co-authored-by: kite
---
cmd/opencodereview/shared_flags.go | 6 +-
cmd/opencodereview/timeout_help_test.go | 33 +++
internal/agent/agent.go | 6 +-
internal/agent/coverage_test.go | 7 +
internal/llm/client.go | 2 +
internal/llm/request_timeout.go | 22 ++
internal/llm/request_timeout_test.go | 267 +++++++++++++++++++++
internal/llm/resolver_test.go | 46 ++++
internal/llm/responses_client.go | 1 +
pages/src/content/docs/en/cli-reference.md | 2 +-
pages/src/content/docs/en/configuration.md | 29 +++
pages/src/content/docs/ja/cli-reference.md | 2 +-
pages/src/content/docs/ja/configuration.md | 29 +++
pages/src/content/docs/ko/cli-reference.md | 2 +-
pages/src/content/docs/ko/configuration.md | 27 +++
pages/src/content/docs/ru/cli-reference.md | 2 +-
pages/src/content/docs/ru/configuration.md | 32 +++
pages/src/content/docs/zh/cli-reference.md | 2 +-
pages/src/content/docs/zh/configuration.md | 24 ++
19 files changed, 532 insertions(+), 9 deletions(-)
create mode 100644 cmd/opencodereview/timeout_help_test.go
create mode 100644 internal/llm/request_timeout.go
create mode 100644 internal/llm/request_timeout_test.go
diff --git a/cmd/opencodereview/shared_flags.go b/cmd/opencodereview/shared_flags.go
index 8040943dc..73f2a3855 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)")
@@ -229,7 +231,7 @@ func registerScanFlags(cmd *cobra.Command, opts *scanOptions) {
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/internal/agent/agent.go b/internal/agent/agent.go
index 2de06d9f3..bbf74b043 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -1193,12 +1193,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):
diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go
index f035db8da..18e9d0a7a 100644
--- a/internal/agent/coverage_test.go
+++ b/internal/agent/coverage_test.go
@@ -949,6 +949,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 +964,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/llm/client.go b/internal/llm/client.go
index a315daff9..2213b15a1 100644
--- a/internal/llm/client.go
+++ b/internal/llm/client.go
@@ -649,6 +649,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 +1456,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/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_test.go b/internal/llm/resolver_test.go
index 6ecb7e4e8..04f5bb299 100644
--- a/internal/llm/resolver_test.go
+++ b/internal/llm/resolver_test.go
@@ -2869,3 +2869,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..4a9a3ea90 100644
--- a/internal/llm/responses_client.go
+++ b/internal/llm/responses_client.go
@@ -98,6 +98,7 @@ 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)
}()
diff --git a/pages/src/content/docs/en/cli-reference.md b/pages/src/content/docs/en/cli-reference.md
index 1f53dfd34..e8ad4b4d8 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. |
diff --git a/pages/src/content/docs/en/configuration.md b/pages/src/content/docs/en/configuration.md
index d63d6340e..c58d636a8 100644
--- a/pages/src/content/docs/en/configuration.md
+++ b/pages/src/content/docs/en/configuration.md
@@ -199,6 +199,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:
@@ -219,6 +229,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/ja/cli-reference.md b/pages/src/content/docs/ja/cli-reference.md
index af13eb06e..ce1778cee 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`)が個別に制御します。 |
diff --git a/pages/src/content/docs/ja/configuration.md b/pages/src/content/docs/ja/configuration.md
index 235b4d9ad..094366d84 100644
--- a/pages/src/content/docs/ja/configuration.md
+++ b/pages/src/content/docs/ja/configuration.md
@@ -195,6 +195,16 @@ Ollama は API key を無視しますが、カスタム provider は空でない
### タイムアウト(Timeouts)
+タスクの制限時間とリクエスト単位のタイムアウトは独立しています。
+
+- `ocr review --timeout` と `ocr scan --timeout` は並行タスクの制限時間を
+ **分**単位で設定します(デフォルトは **15**、`0` でタスクの期限を無効化)。
+ この時間には、タスク内のすべての LLM 呼び出し、ツール実行、再試行の待機が含まれます。
+ review では effort に応じたレビューラウンド数を掛けた時間になります。
+- HTTP リクエスト単位のタイムアウトは**秒**単位です(デフォルトは **300**)。
+ `--timeout` を増やしても変更されず、タスクの期限を無効にしてもリクエストの
+ タイムアウトは無効になりません。タスクの残り時間の方が短い場合は、その時間が優先されます。
+
各 LLM リクエストには HTTP タイムアウトがあり、デフォルトは **300 秒**です。
遅いローカルモデル(あるいは大きなファイル)では、それ以上の時間が必要になることがあります。
スコープの狭い順に、3 つの設定があります。
@@ -215,6 +225,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/ko/cli-reference.md b/pages/src/content/docs/ko/cli-reference.md
index 8769dddb4..9745afeec 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`으로 올려 맞춥니다. 이 플래그는 상한을 *올리기만* 합니다. 템플릿 기본값보다 낮은 값은 무시됩니다. |
diff --git a/pages/src/content/docs/ko/configuration.md b/pages/src/content/docs/ko/configuration.md
index ed3472f3c..b8e697f96 100644
--- a/pages/src/content/docs/ko/configuration.md
+++ b/pages/src/content/docs/ko/configuration.md
@@ -164,6 +164,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` — 프로바이더별, 초 단위.
@@ -180,6 +190,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/ru/cli-reference.md b/pages/src/content/docs/ru/cli-reference.md
index 3479f514f..248db7fcf 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`). |
diff --git a/pages/src/content/docs/ru/configuration.md b/pages/src/content/docs/ru/configuration.md
index 57ce92c2c..9bac40197 100644
--- a/pages/src/content/docs/ru/configuration.md
+++ b/pages/src/content/docs/ru/configuration.md
@@ -206,6 +206,18 @@ Ollama игнорирует API-ключ, однако для пользоват
### Тайм-ауты
+Тайм-аут задачи и тайм-аут отдельного запроса независимы:
+
+- `ocr review --timeout` и `ocr scan --timeout` задают бюджет времени параллельной
+ задачи в **минутах** (по умолчанию **15**; `0` отключает срок выполнения задачи).
+ В него входят все вызовы LLM, выполнение инструментов и ожидание повторных
+ попыток в этой задаче. Для review бюджет умножается на число раундов проверки,
+ заданное уровнем effort.
+- Тайм-аут отдельного HTTP-запроса задаётся в **секундах** (по умолчанию **300**).
+ Увеличение `--timeout` его не меняет, а отключение срока выполнения задачи
+ не отключает тайм-ауты запросов. Если оставшийся бюджет задачи меньше,
+ действует именно он.
+
Для каждого HTTP-запроса к LLM действует тайм-аут, по умолчанию **300 секунд**.
Медленным локальным моделям (или большим файлам) может потребоваться больше
времени. Доступны три настройки с возрастающей областью действия:
@@ -226,6 +238,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/zh/cli-reference.md b/pages/src/content/docs/zh/cli-reference.md
index f1ce6b41e..42babeec5 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`)单独控制。 |
diff --git a/pages/src/content/docs/zh/configuration.md b/pages/src/content/docs/zh/configuration.md
index bd85c349c..f3123c3cb 100644
--- a/pages/src/content/docs/zh/configuration.md
+++ b/pages/src/content/docs/zh/configuration.md
@@ -185,6 +185,15 @@ provider 没有环境变量回退),所以设任意占位值即可。模型
### 超时
+任务超时和单次请求超时相互独立:
+
+- `ocr review --timeout` 和 `ocr scan --timeout` 以**分钟**为单位设置并发任务的
+ 时间预算(默认 **15**;`0` 关闭任务期限)。预算包括任务中的所有 LLM 调用、
+ 工具执行和重试等待。review 会按 effort 的评审轮数放大预算。
+- 单次 HTTP 请求超时以**秒**为单位(默认 **300**)。调大 `--timeout` 不会改变
+ 请求超时,关闭任务期限也不会关闭请求超时。如果任务的剩余预算更短,则以
+ 剩余任务预算为限。
+
每个 LLM 请求都有 HTTP 超时,默认 **300 秒**。慢的本地模型(或大文件)可能
需要更长的时间。三个配置项,作用域递增:
@@ -204,6 +213,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` 在运行时从密钥管理器
From f971ccd36209d58f7f9e500925d64d9bec022e9c Mon Sep 17 00:00:00 2001
From: Kite <254839944+lizhengfeng101@users.noreply.github.com>
Date: Thu, 8 Oct 2026 19:38:26 +0800
Subject: [PATCH 03/32] fix(readme): use a proxy-friendly DeepWiki badge
(#1673)
---
README.md | 2 +-
docs/i18n/README.ja-JP.md | 2 +-
docs/i18n/README.ko-KR.md | 2 +-
docs/i18n/README.ru-RU.md | 2 +-
docs/i18n/README.zh-CN.md | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 45618b0b4..96b4e2312 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
-
+
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 @@
-
+
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 @@
-
+
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 @@
-
+
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 @@
-
+
From fabbdb296b0d97e2140ada8d54ca7d6d6d1d7ad4 Mon Sep 17 00:00:00 2001
From: BASIL K AJI
Date: Thu, 8 Oct 2026 17:12:53 +0530
Subject: [PATCH 04/32] fix(scan): treat "." as the repository root and convert
separators first (#1598)
`ocr scan --path .` selected no files and exited 0. `git ls-files` prints
repository-relative names such as `main.go`, and filterByPaths matches an
exact path or a `/` prefix, so a selector left as "." matched
nothing. `--path ./` normalized to the same dead selector. Both spellings now
mean the whole repository, which is what an omitted --path already means.
filepath.ToSlash also ran after the "./" trim. On Windows a selector like
`.\internal\scan` only becomes `./internal/scan` during that conversion, so
the prefix survived the trim and matched nothing there either. ToSlash now
runs first, and the "./" trim repeats so `././` collapses.
A root selector alongside a narrower one widens the scan, since the root
already covers it.
closes #1596
closes #1597
---
internal/scan/provider.go | 23 +++++-
internal/scan/provider_paths_test.go | 100 +++++++++++++++++++++++++++
2 files changed, 121 insertions(+), 2 deletions(-)
diff --git a/internal/scan/provider.go b/internal/scan/provider.go
index a0471ed3b..45622407e 100644
--- a/internal/scan/provider.go
+++ b/internal/scan/provider.go
@@ -53,6 +53,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 +61,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
diff --git a/internal/scan/provider_paths_test.go b/internal/scan/provider_paths_test.go
index 681f4dc1d..5bb54cf00 100644
--- a/internal/scan/provider_paths_test.go
+++ b/internal/scan/provider_paths_test.go
@@ -120,3 +120,103 @@ 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 := NewProvider(repo, selectors, gitcmd.New(2), 0).Enumerate(context.Background())
+ if err != nil {
+ t.Fatalf("Enumerate(%q): %v", selectors, err)
+ }
+ paths := make([]string, 0, len(got))
+ for _, it := range got {
+ paths = append(paths, it.Path)
+ }
+ sort.Strings(paths)
+ return paths
+}
+
+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)
+ }
+}
From 25af21ce37af8efceeaff4185fa3c71d02a32627 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=A5=88=E6=84=BFQiii?= <2297740147@qq.com>
Date: Sat, 10 Oct 2026 08:40:12 +0800
Subject: [PATCH 05/32] docs(delegate): prevent pager hangs; ci: upgrade Go and
x/net (#1062)
* docs(delegate): disable git pagers in agent environments
* docs(delegate): streamline pager guidance
* docs(delegate): clarify pager terminology
* docs(delegate): harden diff retrieval across integrations
* fix(ci): refresh Go toolchains and simplify delegate docs
* fix(delegate): complete pager and path coverage
* fix(ci): restore patched Go dependencies
* docs(delegate): simplify review instructions
* test(delegate): reject empty command examples
---------
Co-authored-by: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com>
---
.github/workflows/ci.yml | 10 +-
.github/workflows/release.yml | 2 +-
.github/workflows/translation-sync.yml | 3 +-
CONTRIBUTING.md | 2 +-
docs/i18n/CONTRIBUTING.ja-JP.md | 2 +-
docs/i18n/CONTRIBUTING.ko-KR.md | 2 +-
docs/i18n/CONTRIBUTING.ru-RU.md | 2 +-
docs/i18n/CONTRIBUTING.zh-CN.md | 2 +-
go.mod | 10 +-
go.sum | 20 +-
internal/delegate/instructions_test.go | 445 ++++++++++++++++++
pages/go.mod | 2 +-
pages/src/content/docs/en/contributing.md | 2 +-
pages/src/content/docs/en/installation.md | 2 +-
.../content/docs/en/integrations/delegate.md | 30 +-
pages/src/content/docs/ja/contributing.md | 2 +-
pages/src/content/docs/ja/installation.md | 2 +-
.../content/docs/ja/integrations/delegate.md | 30 +-
pages/src/content/docs/ko/contributing.md | 2 +-
pages/src/content/docs/ko/installation.md | 2 +-
.../content/docs/ko/integrations/delegate.md | 30 +-
pages/src/content/docs/ru/contributing.md | 2 +-
pages/src/content/docs/ru/installation.md | 2 +-
.../content/docs/ru/integrations/delegate.md | 30 +-
pages/src/content/docs/zh/contributing.md | 2 +-
pages/src/content/docs/zh/installation.md | 2 +-
.../content/docs/zh/integrations/delegate.md | 30 +-
.../claude-code/commands/delegate-review.md | 29 +-
.../kimi-code/commands/delegate-review.md | 29 +-
.../skills/open-code-review-delegate/SKILL.md | 30 +-
skills/open-code-review-delegate/SKILL.md | 30 +-
31 files changed, 707 insertions(+), 83 deletions(-)
create mode 100644 internal/delegate/instructions_test.go
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 924ee1255..75378d62b 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}'
@@ -119,7 +121,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 +130,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
@@ -171,7 +173,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/release.yml b/.github/workflows/release.yml
index adba83c53..9c1fe40e1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: self-hosted
timeout-minutes: 15
container:
- image: golang:1.26.6
+ image: golang:1.26.9
options: --cpus=2
strategy:
matrix:
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..ade6a6b17 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/)
diff --git a/docs/i18n/CONTRIBUTING.ja-JP.md b/docs/i18n/CONTRIBUTING.ja-JP.md
index b34c752ad..61d0ff8f8 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/)
diff --git a/docs/i18n/CONTRIBUTING.ko-KR.md b/docs/i18n/CONTRIBUTING.ko-KR.md
index 079b05465..1e801eff3 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/)
diff --git a/docs/i18n/CONTRIBUTING.ru-RU.md b/docs/i18n/CONTRIBUTING.ru-RU.md
index 6467fbdad..ed949be6c 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/)
diff --git a/docs/i18n/CONTRIBUTING.zh-CN.md b/docs/i18n/CONTRIBUTING.zh-CN.md
index e065e36aa..f040cd4c0 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/)
diff --git a/go.mod b/go.mod
index cf1d1266b..b2bd99e32 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
@@ -81,11 +81,11 @@ require (
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/sys v0.48.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
diff --git a/go.sum b/go.sum
index 16e779f83..3e73f1897 100644
--- a/go.sum
+++ b/go.sum
@@ -179,20 +179,20 @@ 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=
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/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/contributing.md b/pages/src/content/docs/en/contributing.md
index 3360518e5..df4044a19 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/)
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/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/ja/contributing.md b/pages/src/content/docs/ja/contributing.md
index 17d8c1395..af6c6fe0c 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/)
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/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/ko/contributing.md b/pages/src/content/docs/ko/contributing.md
index dbdb43e97..15227b3bd 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/)
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/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/ru/contributing.md b/pages/src/content/docs/ru/contributing.md
index 4edd5bca0..72a82999f 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/)
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/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/zh/contributing.md b/pages/src/content/docs/zh/contributing.md
index 1fd0f3913..5e3f1c80a 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/)
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/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/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/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/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
+```
From 35fc3e259da583b96cf0fb470d535da9dd5ad7b3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=B9=9D=E6=97=A5?= <2730232528@qq.com>
Date: Sat, 10 Oct 2026 08:55:20 +0800
Subject: [PATCH 06/32] test(allowlist): catch an extension disappearing from
the allowlist (#1626)
Co-authored-by: gu-feng418 <287555569+gu-feng418@users.noreply.github.com>
Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
---
internal/config/allowlist/allowed_ext_test.go | 64 +++++++++++++++++++
1 file changed, 64 insertions(+)
diff --git a/internal/config/allowlist/allowed_ext_test.go b/internal/config/allowlist/allowed_ext_test.go
index c3d0e0660..2b172e49f 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"
)
@@ -145,6 +148,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
From 357c3f09a264563ab64d8f4fc3b25b186cd3c4a4 Mon Sep 17 00:00:00 2001
From: OUAO-FRANK <152768228+OUAO-FRANK@users.noreply.github.com>
Date: Sat, 10 Oct 2026 16:37:26 +0800
Subject: [PATCH 07/32] fix(opencode): preserve OCR output on Windows (#1606)
Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
---
.../opencode/open-code-review.ts | 2 +-
.../opencode/test/open-code-review.test.mjs | 18 ++++++++++++++++++
2 files changed, 19 insertions(+), 1 deletion(-)
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(
[
From f32dace4dc5674bcb3f6ef0aefd5a9522e07e873 Mon Sep 17 00:00:00 2001
From: Trainingcqy
Date: Mon, 12 Oct 2026 11:59:11 +0900
Subject: [PATCH 08/32] fix(agent): list deleted files under their own path in
the change list (#1680)
Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
---
internal/agent/agent.go | 2 +-
internal/agent/agent_test.go | 7 ++++++-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index bbf74b043..084fcb4ce 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -1643,7 +1643,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..bc521ad9e 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -905,7 +905,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 +955,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)
From 64654f834ed3d5411d92402968524b5569e33e1a Mon Sep 17 00:00:00 2001
From: Trainingcqy
Date: Mon, 12 Oct 2026 12:53:06 +0900
Subject: [PATCH 09/32] fix(agent): make deleted files readable through
file_read_diff (#1698)
Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
---
internal/agent/agent.go | 4 +---
internal/agent/coverage_test.go | 14 +++++++++++---
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 084fcb4ce..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 {
diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go
index 18e9d0a7a..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)
}
}
From e96c79d4d64195230d8af1d0aabe7031d0cf901f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=B6=85=E7=B4=9A=E3=81=AE=E6=96=B0=E4=BA=BA?=
Date: Mon, 12 Oct 2026 12:52:29 +0800
Subject: [PATCH 10/32] feat(acp): support ACP service for OpenCodeReview
(#1218)
Add an independent stdio ACP adapter with slash commands, optional
natural-language parsing, managed review/scan processes, CI, and
multilingual setup docs. Keep the OCR CLI source and prompts unchanged.
* refactor(acp): add ACP adapter and supporting services
* ci(acp): use patched Go toolchain
* docs(acp): trim support caveats and sync CI notes
Drop dated verification claims and the prebuilt-binary disclaimer so the
adapter docs match the current local-build story and acp-ci.yml.
Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
---
.github/workflows/acp-ci.yml | 147 +++++
ROADMAP.md | 9 +
acp/.gitignore | 14 +
acp/Makefile | 93 +++
acp/README.md | 491 ++++++++++++++++
acp/cmd/ocr-acp/adapter_signal_unix_test.go | 17 +
.../ocr-acp/adapter_signal_windows_test.go | 26 +
acp/cmd/ocr-acp/main.go | 93 +++
acp/cmd/ocr-acp/main_test.go | 129 +++++
acp/cmd/ocr-acp/stdio.go | 41 ++
acp/cmd/ocr-acp/stdio_test.go | 94 +++
acp/cmd/ocr-acp/stdio_unix.go | 39 ++
acp/cmd/ocr-acp/stdio_windows.go | 281 +++++++++
acp/cmd/ocr-acp/stdio_windows_test.go | 257 +++++++++
acp/cmd/ocr-acp/stub_main_test.go | 21 +
acp/go.mod | 5 +
acp/go.sum | 2 +
acp/internal/adapter/agent.go | 89 +++
acp/internal/adapter/connection.go | 35 ++
acp/internal/adapter/connection_test.go | 283 +++++++++
acp/internal/adapter/discovery_test.go | 137 +++++
acp/internal/adapter/lifecycle_test.go | 231 ++++++++
acp/internal/adapter/locations_test.go | 159 +++++
acp/internal/adapter/output_test.go | 90 +++
.../adapter/presentation/helpers_test.go | 78 +++
acp/internal/adapter/presentation/markdown.go | 45 ++
.../adapter/presentation/markdown_test.go | 33 ++
acp/internal/adapter/presentation/progress.go | 145 +++++
.../adapter/presentation/progress_format.go | 185 ++++++
.../presentation/progress_format_test.go | 102 ++++
.../adapter/presentation/progress_test.go | 346 +++++++++++
acp/internal/adapter/presentation/report.go | 212 +++++++
.../adapter/presentation/report_test.go | 226 ++++++++
acp/internal/adapter/presentation/tool_ids.go | 29 +
.../adapter/presentation/tool_ids_test.go | 94 +++
acp/internal/adapter/progress_test.go | 238 ++++++++
acp/internal/adapter/prompt.go | 149 +++++
acp/internal/adapter/prompt_test.go | 84 +++
acp/internal/adapter/resources.go | 74 +++
acp/internal/adapter/resources_test.go | 212 +++++++
acp/internal/adapter/server_test.go | 147 +++++
acp/internal/adapter/session.go | 153 +++++
acp/internal/adapter/transport/connection.go | 84 +++
acp/internal/adapter/transport/discovery.go | 73 +++
.../adapter/transport/discovery_test.go | 92 +++
acp/internal/adapter/transport/dispatch.go | 68 +++
acp/internal/adapter/workspace/files.go | 113 ++++
acp/internal/adapter/workspace/files_test.go | 97 ++++
acp/internal/adapter/workspace/fileurl.go | 35 ++
.../adapter/workspace/fileurl_test.go | 53 ++
acp/internal/adapter/workspace/root.go | 48 ++
acp/internal/adapter/workspace/root_test.go | 53 ++
acp/internal/contract/intent.go | 277 +++++++++
acp/internal/contract/intent_test.go | 403 +++++++++++++
acp/internal/contract/interrupt_unix_test.go | 17 +
.../contract/interrupt_windows_test.go | 28 +
acp/internal/contract/mock_contract_test.go | 311 ++++++++++
acp/internal/contract/real_cli_test.go | 180 ++++++
acp/internal/contract/result.go | 128 ++++
acp/internal/contract/result_test.go | 37 ++
acp/internal/intent/fake_test.go | 137 +++++
acp/internal/intent/llm/client.go | 290 ++++++++++
acp/internal/intent/llm/client_test.go | 379 ++++++++++++
acp/internal/intent/llm/diagnostics.go | 197 +++++++
acp/internal/intent/llm/diagnostics_test.go | 308 ++++++++++
acp/internal/intent/llm/real_llm_test.go | 71 +++
acp/internal/intent/llm/resolve.go | 180 ++++++
acp/internal/intent/llm/resolve_test.go | 171 ++++++
acp/internal/intent/natural.go | 188 ++++++
acp/internal/intent/natural_test.go | 545 ++++++++++++++++++
acp/internal/intent/nl_catalog_test.go | 86 +++
acp/internal/intent/parser.go | 236 ++++++++
acp/internal/intent/parser_extra_test.go | 20 +
acp/internal/intent/result.go | 62 ++
acp/internal/intent/slash.go | 180 ++++++
acp/internal/intent/slash_test.go | 216 +++++++
acp/internal/intent/state.go | 71 +++
acp/internal/intent/state_test.go | 89 +++
acp/internal/intent/testdata/nl_cases.txt | 84 +++
acp/internal/intent/tool.go | 143 +++++
acp/internal/intent/tool_test.go | 51 ++
acp/internal/intent/validate.go | 40 ++
acp/internal/intent/validate_extra_test.go | 25 +
acp/internal/orchestrator/decode.go | 74 +++
acp/internal/orchestrator/internal_test.go | 198 +++++++
acp/internal/orchestrator/process/process.go | 33 ++
.../orchestrator/process/process_test.go | 37 ++
.../orchestrator/process/process_unix.go | 58 ++
.../orchestrator/process/process_unix_test.go | 55 ++
.../orchestrator/process/process_windows.go | 107 ++++
.../orchestrator/process/stub_main_test.go | 21 +
.../orchestrator/process/winapi_windows.go | 268 +++++++++
.../process/winapi_windows_test.go | 33 ++
.../orchestrator/process_alive_unix_test.go | 31 +
.../process_alive_windows_test.go | 56 ++
.../orchestrator/process_group_test.go | 166 ++++++
.../orchestrator/process_unix_test.go | 67 +++
.../orchestrator/process_windows_test.go | 98 ++++
acp/internal/orchestrator/resolve.go | 210 +++++++
.../orchestrator/resolve_probe_test.go | 83 +++
acp/internal/orchestrator/resolve_test.go | 154 +++++
acp/internal/orchestrator/runner.go | 332 +++++++++++
acp/internal/orchestrator/runner_test.go | 267 +++++++++
acp/internal/orchestrator/streams.go | 169 ++++++
acp/internal/orchestrator/stub_main_test.go | 21 +
acp/internal/orchestrator/termination_test.go | 232 ++++++++
acp/internal/orchestrator/types.go | 173 ++++++
acp/internal/testutil/stub.go | 145 +++++
acp/internal/testutil/stub_main_test.go | 19 +
acp/internal/testutil/stub_test.go | 136 +++++
acp/testdata/mock-ocr/interrupt_unix.go | 15 +
acp/testdata/mock-ocr/interrupt_windows.go | 14 +
acp/testdata/mock-ocr/main.go | 329 +++++++++++
acp/testdata/ocr-stub/interrupt_unix.go | 18 +
acp/testdata/ocr-stub/interrupt_windows.go | 19 +
acp/testdata/ocr-stub/main.go | 288 +++++++++
acp/testdata/python-client-smoke.py | 250 ++++++++
acp/testdata/python-smoke-requirements.in | 2 +
acp/testdata/python-smoke-requirements.txt | 126 ++++
pages/src/content/docs/en/integrations/acp.md | 165 ++++++
pages/src/content/docs/index.ts | 11 +
pages/src/content/docs/ja/integrations/acp.md | 216 +++++++
pages/src/content/docs/ko/integrations/acp.md | 213 +++++++
pages/src/content/docs/ru/integrations/acp.md | 228 ++++++++
pages/src/content/docs/zh/integrations/acp.md | 188 ++++++
pages/src/i18n/en.ts | 1 +
pages/src/i18n/ja.ts | 1 +
pages/src/i18n/ko.ts | 1 +
pages/src/i18n/ru.ts | 1 +
pages/src/i18n/zh.ts | 1 +
pages/src/pages/DocsPage.tsx | 1 +
131 files changed, 16532 insertions(+)
create mode 100644 .github/workflows/acp-ci.yml
create mode 100644 acp/.gitignore
create mode 100644 acp/Makefile
create mode 100644 acp/README.md
create mode 100644 acp/cmd/ocr-acp/adapter_signal_unix_test.go
create mode 100644 acp/cmd/ocr-acp/adapter_signal_windows_test.go
create mode 100644 acp/cmd/ocr-acp/main.go
create mode 100644 acp/cmd/ocr-acp/main_test.go
create mode 100644 acp/cmd/ocr-acp/stdio.go
create mode 100644 acp/cmd/ocr-acp/stdio_test.go
create mode 100644 acp/cmd/ocr-acp/stdio_unix.go
create mode 100644 acp/cmd/ocr-acp/stdio_windows.go
create mode 100644 acp/cmd/ocr-acp/stdio_windows_test.go
create mode 100644 acp/cmd/ocr-acp/stub_main_test.go
create mode 100644 acp/go.mod
create mode 100644 acp/go.sum
create mode 100644 acp/internal/adapter/agent.go
create mode 100644 acp/internal/adapter/connection.go
create mode 100644 acp/internal/adapter/connection_test.go
create mode 100644 acp/internal/adapter/discovery_test.go
create mode 100644 acp/internal/adapter/lifecycle_test.go
create mode 100644 acp/internal/adapter/locations_test.go
create mode 100644 acp/internal/adapter/output_test.go
create mode 100644 acp/internal/adapter/presentation/helpers_test.go
create mode 100644 acp/internal/adapter/presentation/markdown.go
create mode 100644 acp/internal/adapter/presentation/markdown_test.go
create mode 100644 acp/internal/adapter/presentation/progress.go
create mode 100644 acp/internal/adapter/presentation/progress_format.go
create mode 100644 acp/internal/adapter/presentation/progress_format_test.go
create mode 100644 acp/internal/adapter/presentation/progress_test.go
create mode 100644 acp/internal/adapter/presentation/report.go
create mode 100644 acp/internal/adapter/presentation/report_test.go
create mode 100644 acp/internal/adapter/presentation/tool_ids.go
create mode 100644 acp/internal/adapter/presentation/tool_ids_test.go
create mode 100644 acp/internal/adapter/progress_test.go
create mode 100644 acp/internal/adapter/prompt.go
create mode 100644 acp/internal/adapter/prompt_test.go
create mode 100644 acp/internal/adapter/resources.go
create mode 100644 acp/internal/adapter/resources_test.go
create mode 100644 acp/internal/adapter/server_test.go
create mode 100644 acp/internal/adapter/session.go
create mode 100644 acp/internal/adapter/transport/connection.go
create mode 100644 acp/internal/adapter/transport/discovery.go
create mode 100644 acp/internal/adapter/transport/discovery_test.go
create mode 100644 acp/internal/adapter/transport/dispatch.go
create mode 100644 acp/internal/adapter/workspace/files.go
create mode 100644 acp/internal/adapter/workspace/files_test.go
create mode 100644 acp/internal/adapter/workspace/fileurl.go
create mode 100644 acp/internal/adapter/workspace/fileurl_test.go
create mode 100644 acp/internal/adapter/workspace/root.go
create mode 100644 acp/internal/adapter/workspace/root_test.go
create mode 100644 acp/internal/contract/intent.go
create mode 100644 acp/internal/contract/intent_test.go
create mode 100644 acp/internal/contract/interrupt_unix_test.go
create mode 100644 acp/internal/contract/interrupt_windows_test.go
create mode 100644 acp/internal/contract/mock_contract_test.go
create mode 100644 acp/internal/contract/real_cli_test.go
create mode 100644 acp/internal/contract/result.go
create mode 100644 acp/internal/contract/result_test.go
create mode 100644 acp/internal/intent/fake_test.go
create mode 100644 acp/internal/intent/llm/client.go
create mode 100644 acp/internal/intent/llm/client_test.go
create mode 100644 acp/internal/intent/llm/diagnostics.go
create mode 100644 acp/internal/intent/llm/diagnostics_test.go
create mode 100644 acp/internal/intent/llm/real_llm_test.go
create mode 100644 acp/internal/intent/llm/resolve.go
create mode 100644 acp/internal/intent/llm/resolve_test.go
create mode 100644 acp/internal/intent/natural.go
create mode 100644 acp/internal/intent/natural_test.go
create mode 100644 acp/internal/intent/nl_catalog_test.go
create mode 100644 acp/internal/intent/parser.go
create mode 100644 acp/internal/intent/parser_extra_test.go
create mode 100644 acp/internal/intent/result.go
create mode 100644 acp/internal/intent/slash.go
create mode 100644 acp/internal/intent/slash_test.go
create mode 100644 acp/internal/intent/state.go
create mode 100644 acp/internal/intent/state_test.go
create mode 100644 acp/internal/intent/testdata/nl_cases.txt
create mode 100644 acp/internal/intent/tool.go
create mode 100644 acp/internal/intent/tool_test.go
create mode 100644 acp/internal/intent/validate.go
create mode 100644 acp/internal/intent/validate_extra_test.go
create mode 100644 acp/internal/orchestrator/decode.go
create mode 100644 acp/internal/orchestrator/internal_test.go
create mode 100644 acp/internal/orchestrator/process/process.go
create mode 100644 acp/internal/orchestrator/process/process_test.go
create mode 100644 acp/internal/orchestrator/process/process_unix.go
create mode 100644 acp/internal/orchestrator/process/process_unix_test.go
create mode 100644 acp/internal/orchestrator/process/process_windows.go
create mode 100644 acp/internal/orchestrator/process/stub_main_test.go
create mode 100644 acp/internal/orchestrator/process/winapi_windows.go
create mode 100644 acp/internal/orchestrator/process/winapi_windows_test.go
create mode 100644 acp/internal/orchestrator/process_alive_unix_test.go
create mode 100644 acp/internal/orchestrator/process_alive_windows_test.go
create mode 100644 acp/internal/orchestrator/process_group_test.go
create mode 100644 acp/internal/orchestrator/process_unix_test.go
create mode 100644 acp/internal/orchestrator/process_windows_test.go
create mode 100644 acp/internal/orchestrator/resolve.go
create mode 100644 acp/internal/orchestrator/resolve_probe_test.go
create mode 100644 acp/internal/orchestrator/resolve_test.go
create mode 100644 acp/internal/orchestrator/runner.go
create mode 100644 acp/internal/orchestrator/runner_test.go
create mode 100644 acp/internal/orchestrator/streams.go
create mode 100644 acp/internal/orchestrator/stub_main_test.go
create mode 100644 acp/internal/orchestrator/termination_test.go
create mode 100644 acp/internal/orchestrator/types.go
create mode 100644 acp/internal/testutil/stub.go
create mode 100644 acp/internal/testutil/stub_main_test.go
create mode 100644 acp/internal/testutil/stub_test.go
create mode 100644 acp/testdata/mock-ocr/interrupt_unix.go
create mode 100644 acp/testdata/mock-ocr/interrupt_windows.go
create mode 100644 acp/testdata/mock-ocr/main.go
create mode 100644 acp/testdata/ocr-stub/interrupt_unix.go
create mode 100644 acp/testdata/ocr-stub/interrupt_windows.go
create mode 100644 acp/testdata/ocr-stub/main.go
create mode 100644 acp/testdata/python-client-smoke.py
create mode 100644 acp/testdata/python-smoke-requirements.in
create mode 100644 acp/testdata/python-smoke-requirements.txt
create mode 100644 pages/src/content/docs/en/integrations/acp.md
create mode 100644 pages/src/content/docs/ja/integrations/acp.md
create mode 100644 pages/src/content/docs/ko/integrations/acp.md
create mode 100644 pages/src/content/docs/ru/integrations/acp.md
create mode 100644 pages/src/content/docs/zh/integrations/acp.md
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/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 ]