mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat(review): add resumable sessions and session inspection (#306)
* feat(review): add session resume support * Add session inspection command * fix(review): correct session resume checkpoint handling * fix(review): require completed main loop for resume checkpoints * docs: document review resume sessions * docs: refine resume session guidance
This commit is contained in:
parent
bd05c2b071
commit
f35437671c
31 changed files with 2418 additions and 64 deletions
|
|
@ -280,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# 単一コミット
|
||||
ocr review --commit abc123
|
||||
|
||||
# 中断した範囲または単一 commit レビューを再開
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# フルファイルスキャン — diffではなくファイル全体をレビュー(git履歴不要)
|
||||
ocr scan # リポジトリ全体をスキャン
|
||||
ocr scan --path internal/agent # ディレクトリまたは特定のファイルをスキャン
|
||||
|
|
@ -443,6 +447,8 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと
|
|||
| `ocr config unset custom_providers.<name>` | — | カスタムプロバイダーを削除 |
|
||||
| `ocr llm test` | — | LLMの疎通テスト |
|
||||
| `ocr llm providers` | — | ビルトインLLMプロバイダーを一覧表示 |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存済みレビューセッションを一覧表示 |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | 1つのセッションとファイル単位のチェックポイントを表示 |
|
||||
| `ocr viewer` | `ocr v` | `localhost:5483`でWebUIセッションビューアーを起動 |
|
||||
| `ocr version` | — | バージョン情報を表示 |
|
||||
|
||||
|
|
@ -456,6 +462,7 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと
|
|||
| `--commit` | `-c` | — | レビュー対象の単一コミット |
|
||||
| `--exclude` | — | — | カンマ区切りのgitignoreスタイルパターンでスキップ対象を指定;rule.jsonのexcludesとマージ |
|
||||
| `--preview` | `-p` | `false` | LLMを実行せずにレビュー対象ファイルをプレビュー |
|
||||
| `--resume` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開 |
|
||||
| `--format` | `-f` | `text` | 出力形式:`text`または`json` |
|
||||
| `--concurrency` | — | `8` | ファイルレビューの最大同時実行数 |
|
||||
| `--timeout` | — | `10` | 同時実行タスクのタイムアウト(分) |
|
||||
|
|
@ -468,6 +475,41 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと
|
|||
| `--max-git-procs` | — | 組み込み値 | gitサブプロセスの最大同時実行数 |
|
||||
| `--tools` | — | — | カスタムJSONツール設定へのパス |
|
||||
|
||||
#### 再開可能なレビューとセッション
|
||||
|
||||
すべての `ocr review` 実行は、`~/.opencodereview/sessions/` 配下にローカル
|
||||
セッションログを保存します。正常終了したテキスト出力はレビュー結果に集中し、session ID
|
||||
は表示しません。保存済みセッションは `ocr session list/show` で確認でき、
|
||||
`--format json` では機械可読出力に `session_id` が含まれます。範囲または単一 commit
|
||||
レビューが中断された場合は、保存済みセッションを一覧表示し、同じレビュー対象に一致するセッションから再開します:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
再開は意図的に厳密です。範囲レビューと単一 commit レビューのみ対応し、ワークスペースレビューは再開できません。
|
||||
現在の `--from/--to` または `--commit` は保存済みセッションと一致する必要があります。`--preview` と `--resume` は併用できません。
|
||||
|
||||
`--format json` を使用すると、再開した実行には次が含まれます:
|
||||
|
||||
- `session_id` — 現在の実行の session ID
|
||||
- `resume.resumed_from` — 再開元の session ID
|
||||
- `resume.reused_files` — 保存済みチェックポイントから再利用したファイル数
|
||||
- `resume.rerun_files` — 現在の実行で再レビューしたファイル数
|
||||
|
||||
### `ocr session`のフラグ
|
||||
|
||||
| コマンド | フラグ | デフォルト | 説明 |
|
||||
|---------|------|---------|------|
|
||||
| `ocr session list` | `--repo` | カレントディレクトリ | 一覧表示するセッションのリポジトリ |
|
||||
| `ocr session list` | `--json` | `false` | セッション概要をJSONで出力 |
|
||||
| `ocr session list` | `--limit` | `20` | 一覧表示するセッション数の上限。`0` は無制限 |
|
||||
| `ocr session show <id>` | `--repo` | カレントディレクトリ | 確認するセッションのリポジトリ |
|
||||
| `ocr session show <id>` | `--json` | `false` | セッションメタデータとファイル単位の項目をJSONで出力 |
|
||||
|
||||
### `ocr scan`のフラグ
|
||||
|
||||
`ocr scan` はdiffではなくファイル全体をレビューします — 不慣れなコードベースの監査、マイグレーション前のスキャン、意味のあるdiffがないディレクトリなどに有用です。非gitディレクトリでも動作します(`.gitignore` を尊重するファイルシステムウォークにフォールバック)。
|
||||
|
|
@ -513,6 +555,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# 特定のコミットを詳細なJSON出力でレビュー
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# 中断した範囲または単一 commit レビューを再開
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# このレビューでモデルを選択またはオーバーライド
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
|
|||
|
|
@ -280,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# 단일 commit
|
||||
ocr review --commit abc123
|
||||
|
||||
# 중단된 range 또는 단일 commit review 재개
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# 전체 파일 스캔 — diff 대신 파일 전체를 리뷰 (git 이력 불필요)
|
||||
ocr scan # 전체 repository 스캔
|
||||
ocr scan --path internal/agent # 디렉터리 또는 특정 파일 스캔
|
||||
|
|
@ -443,6 +447,8 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s
|
|||
| `ocr config unset custom_providers.<name>` | - | custom provider 삭제 |
|
||||
| `ocr llm test` | - | LLM 연결 테스트 |
|
||||
| `ocr llm providers` | - | built-in LLM provider 목록 표시 |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | 저장된 review session 목록 표시 |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | 단일 session과 파일별 checkpoint 확인 |
|
||||
| `ocr viewer` | `ocr v` | `localhost:5483`에서 WebUI session viewer 실행 |
|
||||
| `ocr version` | - | version 정보 표시 |
|
||||
|
||||
|
|
@ -456,6 +462,7 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s
|
|||
| `--commit` | `-c` | - | 리뷰할 단일 commit |
|
||||
| `--exclude` | - | - | 건너뛸 파일의 쉼표 구분 gitignore 스타일 패턴; rule.json의 excludes와 병합 |
|
||||
| `--preview` | `-p` | `false` | LLM 실행 없이 리뷰 대상 파일 미리보기 |
|
||||
| `--resume` | - | - | 이전의 호환되는 range 또는 단일 commit review session에서 재개 |
|
||||
| `--format` | `-f` | `text` | Output format: `text` 또는 `json` |
|
||||
| `--concurrency` | - | `8` | 최대 동시 파일 리뷰 수 |
|
||||
| `--timeout` | - | `10` | 동시 task timeout(분) |
|
||||
|
|
@ -468,6 +475,41 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s
|
|||
| `--max-git-procs` | - | built-in | 최대 동시 git subprocess 수 |
|
||||
| `--tools` | - | - | custom JSON tools config 경로 |
|
||||
|
||||
#### Resumable Reviews and Sessions
|
||||
|
||||
모든 `ocr review` 실행은 `~/.opencodereview/sessions/` 아래에 local session log를 저장합니다.
|
||||
정상 완료된 text output은 review 결과에 집중하며 session ID를 출력하지 않습니다.
|
||||
저장된 session은 `ocr session list/show`로 찾을 수 있고, `--format json`을 사용하면
|
||||
machine-readable output에 `session_id`가 포함됩니다. range 또는 단일 commit review가 중단된 경우,
|
||||
저장된 session을 나열한 뒤 동일한 review target과 일치하는 session에서 재개합니다.
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
Resume은 의도적으로 엄격합니다. branch range와 단일 commit review만 지원하고 workspace review는 지원하지 않습니다.
|
||||
현재 `--from/--to` 또는 `--commit`은 저장된 session과 일치해야 합니다. `--preview`와 `--resume`은 함께 사용할 수 없습니다.
|
||||
|
||||
`--format json`을 사용하면 재개된 run에는 다음 field가 포함됩니다.
|
||||
|
||||
- `session_id`: 현재 run의 session ID
|
||||
- `resume.resumed_from`: source session ID
|
||||
- `resume.reused_files`: 저장된 checkpoint에서 재사용한 파일 수
|
||||
- `resume.rerun_files`: 현재 run에서 다시 review한 파일 수
|
||||
|
||||
### `ocr session` Flags
|
||||
|
||||
| Command | Flag | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `ocr session list` | `--repo` | current dir | session을 나열할 repository |
|
||||
| `ocr session list` | `--json` | `false` | session summary를 JSON으로 출력 |
|
||||
| `ocr session list` | `--limit` | `20` | 나열할 session 수 제한. `0`은 unlimited |
|
||||
| `ocr session show <id>` | `--repo` | current dir | 확인할 session의 repository |
|
||||
| `ocr session show <id>` | `--json` | `false` | session metadata와 파일별 item을 JSON으로 출력 |
|
||||
|
||||
### `ocr scan` Flags
|
||||
|
||||
`ocr scan`은 diff가 아닌 전체 파일을 리뷰합니다 — 익숙하지 않은 코드베이스 감사, 마이그레이션 전 스캔, 의미 있는 diff가 없는 디렉터리 등에 유용합니다. 비-git 디렉터리에서도 작동합니다 (`.gitignore`를 따르는 파일 시스템 탐색으로 폴백).
|
||||
|
|
@ -513,6 +555,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# 특정 commit을 verbose JSON output으로 리뷰
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# 중단된 range 또는 단일 commit review 재개
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# 이번 리뷰에서 model 선택 또는 override
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -280,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# Single commit
|
||||
ocr review --commit abc123
|
||||
|
||||
# Resume an interrupted range or commit review
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# Full-file scan — review whole files instead of a diff (no git history needed)
|
||||
ocr scan # scan the entire repository
|
||||
ocr scan --path internal/agent # scan a directory or specific files
|
||||
|
|
@ -445,6 +449,8 @@ See the [`examples/`](./examples/) directory for integration examples:
|
|||
| `ocr config unset custom_providers.<name>` | — | Delete a custom provider |
|
||||
| `ocr llm test` | — | Test LLM connectivity |
|
||||
| `ocr llm providers` | — | List built-in LLM providers |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | List saved review sessions |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | Inspect one session and its per-file checkpoints |
|
||||
| `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` |
|
||||
| `ocr version` | — | Show version info |
|
||||
|
||||
|
|
@ -458,6 +464,7 @@ See the [`examples/`](./examples/) directory for integration examples:
|
|||
| `--commit` | `-c` | — | Single commit to review |
|
||||
| `--exclude` | — | — | Comma-separated gitignore-style patterns to skip; merged with rule.json excludes |
|
||||
| `--preview` | `-p` | `false` | Preview which files will be reviewed without running the LLM |
|
||||
| `--resume` | — | — | Resume from a previous compatible range or commit review session |
|
||||
| `--format` | `-f` | `text` | Output format: `text` or `json` |
|
||||
| `--concurrency` | — | `8` | Max concurrent file reviews |
|
||||
| `--timeout` | — | `10` | Concurrent task timeout in minutes |
|
||||
|
|
@ -467,8 +474,45 @@ See the [`examples/`](./examples/) directory for integration examples:
|
|||
| `--model` | — | — | Select or override the LLM model for this review |
|
||||
| `--rule` | — | — | Path to custom JSON review rules |
|
||||
| `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default |
|
||||
| `--max-git-procs` | — | built-in | Max concurrent git subprocesses |
|
||||
| `--tools` | — | — | Path to custom JSON tools config |
|
||||
| `--max-git-procs` | — | `16` | Max concurrent git subprocesses |
|
||||
| `--tools` | — | built-in | Path to custom JSON tools config |
|
||||
|
||||
#### Resumable Reviews and Sessions
|
||||
|
||||
Every `ocr review` run persists a local session log under
|
||||
`~/.opencodereview/sessions/`. Successful text output stays focused on review
|
||||
results and does not print the session ID; use `ocr session list/show` to find
|
||||
saved sessions, or `--format json` to include `session_id` in machine-readable
|
||||
output. If a range or commit review is interrupted, list the saved sessions and
|
||||
resume from the one that matches the same review target:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
Resume is intentionally strict: it only supports branch-range and single-commit
|
||||
reviews, not workspace reviews, and the current `--from/--to` or `--commit`
|
||||
must match the saved session. `--preview` cannot be combined with `--resume`.
|
||||
|
||||
When `--format json` is used, resumed runs include:
|
||||
|
||||
- `session_id` — the current run's session ID
|
||||
- `resume.resumed_from` — the source session ID
|
||||
- `resume.reused_files` — files reused from saved checkpoints
|
||||
- `resume.rerun_files` — files reviewed again in the current run
|
||||
|
||||
### `ocr session` Flags
|
||||
|
||||
| Command | Flag | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `ocr session list` | `--repo` | current dir | Repository whose sessions should be listed |
|
||||
| `ocr session list` | `--json` | `false` | Emit session summaries as JSON |
|
||||
| `ocr session list` | `--limit` | `20` | Cap listed sessions; use `0` for unlimited |
|
||||
| `ocr session show <id>` | `--repo` | current dir | Repository whose session should be inspected |
|
||||
| `ocr session show <id>` | `--json` | `false` | Emit session metadata and per-file items as JSON |
|
||||
|
||||
### `ocr scan` Flags
|
||||
|
||||
|
|
@ -518,6 +562,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# Review a specific commit with verbose JSON output
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# Resume an interrupted range or commit review
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# Select or override model for this review
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
|
|||
|
|
@ -280,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# Один коммит
|
||||
ocr review --commit abc123
|
||||
|
||||
# Возобновить прерванное ревью диапазона или одного коммита
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# Полнофайловое сканирование — ревью целых файлов вместо диффа (история git не нужна)
|
||||
ocr scan # сканировать весь репозиторий
|
||||
ocr scan --path internal/agent # сканировать каталог или конкретные файлы
|
||||
|
|
@ -445,6 +449,8 @@ ocr review \
|
|||
| `ocr config unset custom_providers.<name>` | — | Удалить пользовательского провайдера |
|
||||
| `ocr llm test` | — | Проверить подключение к LLM |
|
||||
| `ocr llm providers` | — | Показать список встроенных LLM-провайдеров |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | Показать сохранённые сессии ревью |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | Показать одну сессию и её checkpoint'ы по файлам |
|
||||
| `ocr viewer` | `ocr v` | Запустить WebUI-просмотрщик сессий на `localhost:5483` |
|
||||
| `ocr version` | — | Показать информацию о версии |
|
||||
|
||||
|
|
@ -458,6 +464,7 @@ ocr review \
|
|||
| `--commit` | `-c` | — | Один коммит для ревью |
|
||||
| `--exclude` | — | — | Паттерны в стиле gitignore через запятую для пропуска файлов; объединяются с excludes из rule.json |
|
||||
| `--preview` | `-p` | `false` | Показать, какие файлы попадут в ревью, без запуска LLM |
|
||||
| `--resume` | — | — | Возобновить предыдущую совместимую сессию ревью диапазона или одного коммита |
|
||||
| `--format` | `-f` | `text` | Формат вывода: `text` или `json` |
|
||||
| `--concurrency` | — | `8` | Максимум одновременных ревью файлов |
|
||||
| `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах |
|
||||
|
|
@ -470,6 +477,43 @@ ocr review \
|
|||
| `--max-git-procs` | — | встроенное | Максимум одновременных git-подпроцессов |
|
||||
| `--tools` | — | — | Путь к пользовательскому JSON-конфигу инструментов |
|
||||
|
||||
#### Возобновляемые ревью и сессии
|
||||
|
||||
Каждый запуск `ocr review` сохраняет локальный журнал сессии в
|
||||
`~/.opencodereview/sessions/`. Успешный текстовый вывод остаётся сфокусированным
|
||||
на результате ревью и не печатает session ID. Сохранённые сессии можно найти через
|
||||
`ocr session list/show`, а `--format json` добавляет `session_id` в машиночитаемый
|
||||
вывод. Если ревью диапазона или одного коммита было прервано, выберите сохранённую
|
||||
сессию с тем же целевым ревью и возобновите её:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
Возобновление намеренно строгое: поддерживаются только ревью диапазона веток и одного
|
||||
коммита, но не ревью рабочей копии. Текущие `--from/--to` или `--commit` должны
|
||||
совпадать с сохранённой сессией. `--preview` нельзя использовать вместе с `--resume`.
|
||||
|
||||
При `--format json` возобновлённый запуск включает:
|
||||
|
||||
- `session_id` — session ID текущего запуска
|
||||
- `resume.resumed_from` — исходный session ID
|
||||
- `resume.reused_files` — файлы, повторно использованные из сохранённых checkpoint'ов
|
||||
- `resume.rerun_files` — файлы, заново проверенные в текущем запуске
|
||||
|
||||
### Флаги `ocr session`
|
||||
|
||||
| Команда | Флаг | По умолчанию | Описание |
|
||||
|---------|------|--------------|----------|
|
||||
| `ocr session list` | `--repo` | текущий каталог | Репозиторий, для которого нужно показать сессии |
|
||||
| `ocr session list` | `--json` | `false` | Вывести сводки сессий в JSON |
|
||||
| `ocr session list` | `--limit` | `20` | Ограничить количество сессий; `0` означает без ограничения |
|
||||
| `ocr session show <id>` | `--repo` | текущий каталог | Репозиторий, сессию которого нужно посмотреть |
|
||||
| `ocr session show <id>` | `--json` | `false` | Вывести метаданные сессии и элементы по файлам в JSON |
|
||||
|
||||
### Флаги `ocr scan`
|
||||
|
||||
`ocr scan` проверяет целые файлы, а не дифф — удобно для аудита незнакомой кодовой базы, предмиграционного сканирования или любого каталога без значимого диффа. Работает и в каталогах без git (используется обход файловой системы с учётом `.gitignore`).
|
||||
|
|
@ -515,6 +559,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# Ревью конкретного коммита с подробным JSON-выводом
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# Возобновить прерванное ревью диапазона или одного коммита
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# Выбрать или переопределить модель для этого ревью
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
|
|||
|
|
@ -280,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# 单个提交
|
||||
ocr review --commit abc123
|
||||
|
||||
# 恢复中断的区间或单 commit 评审
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史)
|
||||
ocr scan # 扫描整个仓库
|
||||
ocr scan --path internal/agent # 扫描指定目录或文件
|
||||
|
|
@ -443,6 +447,8 @@ ocr review \
|
|||
| `ocr config unset custom_providers.<name>` | — | 删除自定义供应商 |
|
||||
| `ocr llm test` | — | 测试 LLM 连通性 |
|
||||
| `ocr llm providers` | — | 列出内置 LLM 供应商 |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话 |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | 查看单个会话及其逐文件检查点 |
|
||||
| `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` |
|
||||
| `ocr version` | — | 显示版本信息 |
|
||||
|
||||
|
|
@ -456,6 +462,7 @@ ocr review \
|
|||
| `--commit` | `-c` | — | 审查单个提交 |
|
||||
| `--exclude` | — | — | 以逗号分隔的 gitignore 风格模式,用于跳过匹配文件;与 rule.json 中的 excludes 合并 |
|
||||
| `--preview` | `-p` | `false` | 预览将被审查的文件列表,不调用 LLM |
|
||||
| `--resume` | — | — | 从之前兼容的区间或单 commit 评审会话恢复 |
|
||||
| `--format` | `-f` | `text` | 输出格式:`text` 或 `json` |
|
||||
| `--concurrency` | — | `8` | 最大并发文件审查数 |
|
||||
| `--timeout` | — | `10` | 并发任务超时时间(分钟) |
|
||||
|
|
@ -468,6 +475,40 @@ ocr review \
|
|||
| `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 |
|
||||
| `--tools` | — | — | 自定义 JSON 工具配置路径 |
|
||||
|
||||
#### 可恢复评审与会话
|
||||
|
||||
每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。
|
||||
正常完成的文本输出只展示评审结果,不打印 session ID;可使用
|
||||
`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取
|
||||
`session_id`。如果区间或单 commit 评审被中断,可列出保存的会话,并从匹配相同评审目标的会话恢复:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
恢复逻辑是严格的:仅支持分支区间和单 commit 评审,不支持工作区评审;当前
|
||||
`--from/--to` 或 `--commit` 必须与保存的会话一致。`--preview` 不能与 `--resume` 同时使用。
|
||||
|
||||
使用 `--format json` 时,恢复运行会包含:
|
||||
|
||||
- `session_id` — 当前运行的 session ID
|
||||
- `resume.resumed_from` — 来源 session ID
|
||||
- `resume.reused_files` — 从已保存检查点复用的文件数
|
||||
- `resume.rerun_files` — 本次重新评审的文件数
|
||||
|
||||
### `ocr session` 参数
|
||||
|
||||
| 命令 | 参数 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `ocr session list` | `--repo` | 当前目录 | 要列出会话的仓库 |
|
||||
| `ocr session list` | `--json` | `false` | 以 JSON 输出会话摘要 |
|
||||
| `ocr session list` | `--limit` | `20` | 限制列出的会话数量;`0` 表示不限 |
|
||||
| `ocr session show <id>` | `--repo` | 当前目录 | 要查看会话的仓库 |
|
||||
| `ocr session show <id>` | `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目 |
|
||||
|
||||
### `ocr scan` 参数
|
||||
|
||||
`ocr scan` 审查整个文件而非 diff —— 适用于审计不熟悉的代码库、迁移前扫描,或任何没有有意义 diff 的目录。它也可以在非 git 目录中工作(会回退到遵循 `.gitignore` 的文件系统遍历)。
|
||||
|
|
@ -513,6 +554,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# 审查特定提交并以 JSON 格式输出详细信息
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# 恢复中断的区间或单 commit 评审
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# 为本次审查选择或覆盖模型
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ type mockResultProvider struct {
|
|||
warnings []agent.AgentWarning
|
||||
projectSummary string
|
||||
toolCalls map[string]int64
|
||||
resumeInfo *agent.ResumeInfo
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs }
|
||||
|
|
@ -37,6 +39,8 @@ func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWri
|
|||
func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings }
|
||||
func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary }
|
||||
func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls }
|
||||
func (m *mockResultProvider) ResumeInfo() *agent.ResumeInfo { return m.resumeInfo }
|
||||
func (m *mockResultProvider) SessionID() string { return m.sessionID }
|
||||
|
||||
func TestEmitRunResult_JSONNoFiles(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 0}
|
||||
|
|
@ -83,6 +87,32 @@ func TestEmitRunResult_JSONWithComments(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_JSONWithResumeInfo(t *testing.T) {
|
||||
ag := &mockResultProvider{
|
||||
filesReviewed: 2,
|
||||
resumeInfo: &agent.ResumeInfo{
|
||||
ResumedFrom: "old-session",
|
||||
ReusedFiles: 1,
|
||||
RerunFiles: 1,
|
||||
PreviousModel: "anthropic-model",
|
||||
CurrentModel: "openai-model",
|
||||
},
|
||||
}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Resume == nil || out.Resume.ResumedFrom != "old-session" || out.Resume.ReusedFiles != 1 || out.Resume.RerunFiles != 1 {
|
||||
t.Fatalf("resume = %+v", out.Resume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_TextNoComments(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 2}
|
||||
got := captureStdout(t, func() {
|
||||
|
|
@ -96,6 +126,19 @@ func TestEmitRunResult_TextNoComments(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_TextDoesNotPrintSuccessfulSessionHint(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 2, sessionID: "session-123"}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if strings.Contains(got, "session-123") || strings.Contains(got, "--resume") {
|
||||
t.Fatalf("successful text output should not print session ID or resume hint, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_TextWithComments(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 1}
|
||||
comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}}
|
||||
|
|
@ -236,3 +279,20 @@ func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) {
|
|||
t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_JSONIncludesSessionID(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 1, sessionID: "session-99"}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.SessionID != "session-99" {
|
||||
t.Errorf("session_id = %q, want session-99", out.SessionID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ type reviewOptions struct {
|
|||
from string
|
||||
to string
|
||||
commit string
|
||||
resume string
|
||||
excludes string // --exclude: comma-separated gitignore-style patterns
|
||||
outputFormat string
|
||||
audience string // --audience: "human" (default) or "agent"
|
||||
|
|
@ -126,6 +127,7 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
|
|||
a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')")
|
||||
a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')")
|
||||
a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)")
|
||||
a.StringVar(&opts.resume, "resume", "", "resume from a previous review session id")
|
||||
a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes")
|
||||
a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
|
||||
a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews")
|
||||
|
|
@ -164,6 +166,9 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
|
|||
if opts.to != "" && opts.from == "" {
|
||||
return opts, fmt.Errorf("--from is required when --to is specified")
|
||||
}
|
||||
if opts.preview && opts.resume != "" {
|
||||
return opts, fmt.Errorf("--preview and --resume cannot be used together")
|
||||
}
|
||||
|
||||
switch opts.audience {
|
||||
case "human", "agent":
|
||||
|
|
@ -205,6 +210,9 @@ Examples:
|
|||
ocr review --commit abc123
|
||||
ocr review -c abc123
|
||||
|
||||
# Resume a previous range review
|
||||
ocr review --from master --to dev-ref --resume <session-id>
|
||||
|
||||
# Output JSON format
|
||||
ocr review --format json
|
||||
ocr review -f json
|
||||
|
|
@ -234,6 +242,7 @@ Flags:
|
|||
--model string override LLM model for this review (e.g., claude-opus-4-6)
|
||||
-p, --preview preview which files will be reviewed without running the LLM
|
||||
--repo string root directory of the git repository (default: current dir)
|
||||
--resume string resume from a previous review session id
|
||||
--rule string path to JSON file with system review rules
|
||||
--timeout int concurrent task timeout in minutes (default 10)
|
||||
--to string target ref to end diff at (e.g., 'feature-branch')
|
||||
|
|
|
|||
|
|
@ -36,6 +36,23 @@ func TestParseReviewFlagsModelOverride(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlagsResume(t *testing.T) {
|
||||
opts, err := parseReviewFlags([]string{"--from", "main", "--to", "feature", "--resume", "session-123"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseReviewFlags: %v", err)
|
||||
}
|
||||
if opts.resume != "session-123" {
|
||||
t.Errorf("resume = %q, want session-123", opts.resume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_PreviewWithResume(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--commit", "abc123", "--preview", "--resume", "session-123"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --preview with --resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_InvalidAudience(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--audience", "robot"})
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ func dispatch() error {
|
|||
return runRules(args[1:])
|
||||
case "viewer":
|
||||
return runViewer(args[1:])
|
||||
case "session", "sessions":
|
||||
return runSession(args[1:])
|
||||
case "-h", "--help":
|
||||
printTopLevelUsage()
|
||||
return nil
|
||||
|
|
@ -77,6 +79,7 @@ Commands:
|
|||
config Manage configuration settings
|
||||
llm LLM utility commands
|
||||
viewer Start the WebUI session viewer
|
||||
session, sessions List and inspect saved review sessions
|
||||
version Show version information
|
||||
|
||||
Examples:
|
||||
|
|
@ -89,6 +92,7 @@ Examples:
|
|||
ocr config set llm.model opus-4-6 Set a config value
|
||||
ocr llm test Test LLM connectivity
|
||||
ocr llm providers List built-in providers
|
||||
ocr session list List saved review sessions
|
||||
ocr version Show version info
|
||||
|
||||
Use "ocr review -h" for more information about review.
|
||||
|
|
@ -96,6 +100,7 @@ Use "ocr scan -h" for more information about scan.
|
|||
Use "ocr rules -h" for more information about rules.
|
||||
Use "ocr config" for more information about config.
|
||||
Use "ocr llm" for more information about LLM utilities.
|
||||
Use "ocr session -h" for more information about session inspection.
|
||||
|
||||
GitHub: https://github.com/alibaba/open-code-review`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,6 +247,8 @@ type jsonOutput struct {
|
|||
Comments []model.LlmComment `json:"comments"`
|
||||
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
|
||||
ProjectSummary string `json:"project_summary,omitempty"`
|
||||
Resume *agent.ResumeInfo `json:"resume,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
func outputJSON(comments []model.LlmComment) error {
|
||||
|
|
@ -264,7 +266,7 @@ func outputJSON(comments []model.LlmComment) error {
|
|||
|
||||
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
|
||||
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
|
||||
duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string) error {
|
||||
duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error {
|
||||
out := jsonOutput{
|
||||
Status: "success",
|
||||
TraceID: traceID,
|
||||
|
|
@ -280,6 +282,8 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
|
|||
Elapsed: duration.Round(time.Second).String(),
|
||||
},
|
||||
ProjectSummary: projectSummary,
|
||||
Resume: resumeInfo,
|
||||
SessionID: sessionID,
|
||||
}
|
||||
var total int64
|
||||
for _, v := range toolCalls {
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
|
|||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace")
|
||||
err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
|
|
@ -281,7 +281,7 @@ func TestOutputJSONWithWarnings(t *testing.T) {
|
|||
|
||||
comments := []model.LlmComment{{Path: "b.go", Content: "test"}}
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}}
|
||||
err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789")
|
||||
err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
|
|
@ -319,7 +319,7 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
|
|||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "")
|
||||
err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"github.com/open-code-review/open-code-review/internal/agent"
|
||||
"github.com/open-code-review/open-code-review/internal/mcp"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
"github.com/open-code-review/open-code-review/internal/telemetry"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
|
||||
|
|
@ -61,6 +62,11 @@ func runReview(args []string) error {
|
|||
return runPreview(cc, opts)
|
||||
}
|
||||
|
||||
resumeState, err := loadReviewResumeState(cc.RepoDir, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -94,6 +100,7 @@ func runReview(args []string) error {
|
|||
From: opts.from,
|
||||
To: opts.to,
|
||||
Commit: opts.commit,
|
||||
ReviewMode: reviewModeFromOptions(opts),
|
||||
Template: *cc.Template,
|
||||
SystemRule: cc.Resolver,
|
||||
FileFilter: cc.FileFilter,
|
||||
|
|
@ -108,6 +115,7 @@ func runReview(args []string) error {
|
|||
Model: rt.Model,
|
||||
Background: opts.background,
|
||||
GitRunner: cc.GitRunner,
|
||||
Resume: resumeState,
|
||||
})
|
||||
|
||||
// Silence progress output during execution; restored before the trace
|
||||
|
|
@ -134,12 +142,51 @@ func runReview(args []string) error {
|
|||
if err != nil {
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
if id := ag.SessionID(); id != "" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Session: %s (retry with: --resume %s)\n", id, id)
|
||||
}
|
||||
return fmt.Errorf("review failed: %w", err)
|
||||
}
|
||||
|
||||
return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q)
|
||||
}
|
||||
|
||||
func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) {
|
||||
if opts.resume == "" {
|
||||
return nil, nil
|
||||
}
|
||||
current := session.SessionOptions{
|
||||
ReviewMode: reviewModeFromOptions(opts),
|
||||
DiffFrom: opts.from,
|
||||
DiffTo: opts.to,
|
||||
DiffCommit: opts.commit,
|
||||
}
|
||||
if current.ReviewMode == session.ReviewModeWorkspace {
|
||||
return nil, fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported")
|
||||
}
|
||||
state, err := session.LoadResumeState(repoDir, opts.resume)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err)
|
||||
}
|
||||
if err := state.ValidateOptions(current); err != nil {
|
||||
return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err)
|
||||
}
|
||||
if state.CompletedCount() == 0 {
|
||||
return nil, fmt.Errorf("resume session %q has no completed review items (run 'ocr session list' to see available sessions)", opts.resume)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func reviewModeFromOptions(opts reviewOptions) string {
|
||||
if opts.commit != "" {
|
||||
return session.ReviewModeCommit
|
||||
}
|
||||
if opts.from != "" && opts.to != "" {
|
||||
return session.ReviewModeRange
|
||||
}
|
||||
return session.ReviewModeWorkspace
|
||||
}
|
||||
|
||||
// resolveRepoDir resolves the repo dir for `ocr rules check`. It delegates to
|
||||
// resolveWorkingDir(requireGit=true) so it anchors at the git top-level just
|
||||
// like the review path — keeping rule resolution consistent when run from a
|
||||
|
|
|
|||
|
|
@ -223,6 +223,9 @@ func runScan(args []string) error {
|
|||
if err != nil {
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
if id := ag.SessionID(); id != "" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id)
|
||||
}
|
||||
return fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
299
cmd/opencodereview/session_cmd.go
Normal file
299
cmd/opencodereview/session_cmd.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
)
|
||||
|
||||
func runSession(args []string) error {
|
||||
if len(args) == 0 {
|
||||
printSessionUsage()
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list", "ls":
|
||||
return runSessionList(args[1:])
|
||||
case "show":
|
||||
return runSessionShow(args[1:])
|
||||
case "-h", "--help":
|
||||
printSessionUsage()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown session sub-command: %s\nRun 'ocr session -h' for usage", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runSessionList(args []string) error {
|
||||
a := newOcrFlagSet("ocr session list")
|
||||
var repoDir string
|
||||
var asJSON bool
|
||||
var limit int
|
||||
a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
||||
a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table")
|
||||
a.IntVar(&limit, "limit", 20, "cap the number of listed sessions (0 = unlimited)")
|
||||
if err := a.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.showHelp {
|
||||
printSessionListUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
resolvedRepo, err := resolveWorkingDirForSession(repoDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summaries, err := session.ListSessions(resolvedRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
if limit > 0 && len(summaries) > limit {
|
||||
summaries = summaries[:limit]
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(summaries)
|
||||
}
|
||||
|
||||
if len(summaries) == 0 {
|
||||
fmt.Printf("No sessions found for %s\n", resolvedRepo)
|
||||
return nil
|
||||
}
|
||||
printSessionTable(os.Stdout, summaries)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSessionShow(args []string) error {
|
||||
a := newOcrFlagSet("ocr session show")
|
||||
var repoDir string
|
||||
var asJSON bool
|
||||
a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
||||
a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table")
|
||||
if err := a.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.showHelp {
|
||||
printSessionShowUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
rest := a.fs.Args()
|
||||
if len(rest) == 0 {
|
||||
printSessionShowUsage()
|
||||
return fmt.Errorf("session show requires a session ID")
|
||||
}
|
||||
sessionID := rest[0]
|
||||
|
||||
resolvedRepo, err := resolveWorkingDirForSession(repoDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summary, items, err := session.LoadDetail(resolvedRepo, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load session %q: %w", sessionID, err)
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
payload := struct {
|
||||
Summary *session.Summary `json:"summary"`
|
||||
Items []session.ItemDetail `json:"items"`
|
||||
}{Summary: summary, Items: items}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(payload)
|
||||
}
|
||||
|
||||
printSessionDetail(os.Stdout, summary, items)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveWorkingDirForSession accepts an explicit --repo flag value and falls
|
||||
// back to the current working directory. Unlike resolveRepoDir it does not
|
||||
// require the target to be a git repository, so users can inspect sessions
|
||||
// even after archiving a checkout.
|
||||
func resolveWorkingDirForSession(input string) (string, error) {
|
||||
dir, _, err := resolveWorkingDir(input, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func printSessionTable(w io.Writer, summaries []session.Summary) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SESSION ID\tMODE\tRANGE\tFILES\tCOMMENTS\tSTATUS\tSTARTED")
|
||||
for _, s := range summaries {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
|
||||
s.SessionID,
|
||||
displayMode(s.ReviewMode),
|
||||
describeRange(s),
|
||||
describeFiles(s),
|
||||
s.TotalComments,
|
||||
describeStatus(s),
|
||||
describeStart(s),
|
||||
)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func printSessionDetail(w io.Writer, s *session.Summary, items []session.ItemDetail) {
|
||||
fmt.Fprintf(w, "Session: %s\n", s.SessionID)
|
||||
fmt.Fprintf(w, " File: %s\n", s.FilePath)
|
||||
fmt.Fprintf(w, " Repo: %s\n", s.RepoDir)
|
||||
if s.GitBranch != "" {
|
||||
fmt.Fprintf(w, " Branch: %s\n", s.GitBranch)
|
||||
}
|
||||
if s.Model != "" {
|
||||
fmt.Fprintf(w, " Model: %s\n", s.Model)
|
||||
}
|
||||
fmt.Fprintf(w, " Mode: %s\n", displayMode(s.ReviewMode))
|
||||
if r := describeRange(*s); r != "" && r != "-" {
|
||||
fmt.Fprintf(w, " Range: %s\n", r)
|
||||
}
|
||||
if s.ResumedFrom != "" {
|
||||
fmt.Fprintf(w, " Resumed: from session %s\n", s.ResumedFrom)
|
||||
}
|
||||
fmt.Fprintf(w, " Started: %s\n", describeStart(*s))
|
||||
if !s.EndTime.IsZero() {
|
||||
fmt.Fprintf(w, " Ended: %s\n", s.EndTime.Local().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if s.Duration > 0 {
|
||||
fmt.Fprintf(w, " Duration: %s\n", s.Duration.Round(time.Second))
|
||||
}
|
||||
fmt.Fprintf(w, " Status: %s\n", describeStatus(*s))
|
||||
fmt.Fprintf(w, " Files: %d completed, %d reused, %d failed\n",
|
||||
s.CompletedFiles, s.ReusedFiles, s.FailedFiles)
|
||||
fmt.Fprintf(w, " Comments: %d\n", s.TotalComments)
|
||||
if s.LLMFailures > 0 {
|
||||
fmt.Fprintf(w, " LLM err: %d\n", s.LLMFailures)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Files:")
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, " TYPE\tFILE\tCOMMENTS\tNOTE")
|
||||
for _, it := range items {
|
||||
note := ""
|
||||
switch it.Type {
|
||||
case "reused":
|
||||
note = "from " + shortSessionID(it.SourceSessionID)
|
||||
case "failed":
|
||||
note = truncate(it.Error, 60)
|
||||
}
|
||||
fmt.Fprintf(tw, " %s\t%s\t%d\t%s\n", it.Type, it.FilePath, it.Comments, note)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func displayMode(m string) string {
|
||||
if m == "" {
|
||||
return "-"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func describeRange(s session.Summary) string {
|
||||
switch s.ReviewMode {
|
||||
case session.ReviewModeRange:
|
||||
if s.DiffFrom != "" || s.DiffTo != "" {
|
||||
return fmt.Sprintf("%s..%s", s.DiffFrom, s.DiffTo)
|
||||
}
|
||||
case session.ReviewModeCommit:
|
||||
if s.DiffCommit != "" {
|
||||
return s.DiffCommit
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func describeFiles(s session.Summary) string {
|
||||
total := s.CompletedFiles + s.ReusedFiles
|
||||
if s.ReusedFiles > 0 {
|
||||
return fmt.Sprintf("%d (reused %d)", total, s.ReusedFiles)
|
||||
}
|
||||
return fmt.Sprintf("%d", total)
|
||||
}
|
||||
|
||||
func describeStatus(s session.Summary) string {
|
||||
if s.Aborted {
|
||||
return "aborted"
|
||||
}
|
||||
if s.FailedFiles > 0 {
|
||||
return fmt.Sprintf("completed (%d fail)", s.FailedFiles)
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
func describeStart(s session.Summary) string {
|
||||
if s.StartTime.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return s.StartTime.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func shortSessionID(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\t", " ")
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 1 {
|
||||
return "…"
|
||||
}
|
||||
return string(runes[:n-1]) + "…"
|
||||
}
|
||||
|
||||
func printSessionUsage() {
|
||||
fmt.Println(`Usage:
|
||||
ocr session <sub-command>
|
||||
|
||||
Sub-commands:
|
||||
list, ls List recent review sessions for the current repo
|
||||
show <id> Show one session's metadata and per-file items
|
||||
|
||||
Use "ocr session list -h" or "ocr session show -h" for details.`)
|
||||
}
|
||||
|
||||
func printSessionListUsage() {
|
||||
fmt.Println(`Usage:
|
||||
ocr session list [flags]
|
||||
ocr session ls [flags]
|
||||
|
||||
List review sessions previously persisted to ~/.opencodereview/sessions/. The
|
||||
session id printed here can be passed to 'ocr review --resume <id>'.
|
||||
|
||||
Flags:
|
||||
--repo string Root directory of the git repository (default: current dir)
|
||||
--json Emit JSON instead of a table
|
||||
--limit int Cap the number of listed sessions (default 20; 0 = unlimited)`)
|
||||
}
|
||||
|
||||
func printSessionShowUsage() {
|
||||
fmt.Println(`Usage:
|
||||
ocr session show [flags] <session-id>
|
||||
|
||||
Show metadata and per-file items for a single session.
|
||||
|
||||
Flags:
|
||||
--repo string Root directory of the git repository (default: current dir)
|
||||
--json Emit JSON instead of a table`)
|
||||
}
|
||||
170
cmd/opencodereview/session_cmd_test.go
Normal file
170
cmd/opencodereview/session_cmd_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
)
|
||||
|
||||
func TestRunSessionList_TextIncludesSessionID(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionList([]string{"--repo", repoDir}); err != nil {
|
||||
t.Fatalf("runSessionList: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if !strings.Contains(got, sh.SessionID) {
|
||||
t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got)
|
||||
}
|
||||
if !strings.Contains(got, "abc123") {
|
||||
t.Errorf("expected list output to contain commit range, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "SESSION ID") {
|
||||
t.Errorf("expected header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionList_JSON(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionList([]string{"--repo", repoDir, "--json"}); err != nil {
|
||||
t.Fatalf("runSessionList: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var decoded []session.Summary
|
||||
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v (out=%q)", err, got)
|
||||
}
|
||||
if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID {
|
||||
t.Fatalf("decoded = %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionList_EmptyRepo(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionList([]string{"--repo", repoDir}); err != nil {
|
||||
t.Fatalf("runSessionList: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "No sessions found") {
|
||||
t.Errorf("expected empty message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionShow_Text(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
|
||||
sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom")
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionShow([]string{"--repo", repoDir, sh.SessionID}); err != nil {
|
||||
t.Fatalf("runSessionShow: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("expected output to contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionShow_JSON(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionShow([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
|
||||
t.Fatalf("runSessionShow: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var payload struct {
|
||||
Summary *session.Summary `json:"summary"`
|
||||
Items []session.ItemDetail `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v (out=%q)", err, got)
|
||||
}
|
||||
if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID {
|
||||
t.Fatalf("summary mismatch: %+v", payload.Summary)
|
||||
}
|
||||
if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" {
|
||||
t.Fatalf("items = %+v", payload.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionShow_MissingID(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionShow([]string{}); err == nil {
|
||||
t.Fatal("expected error for missing session id")
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "session show") {
|
||||
t.Errorf("expected usage output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUnicode(t *testing.T) {
|
||||
got := truncate("错误原因:超过限制", 6)
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatalf("expected ellipsis suffix, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "错误") {
|
||||
t.Fatalf("expected valid truncated unicode text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSession_UnknownSubcommand(t *testing.T) {
|
||||
err := runSession([]string{"bogus"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown sub-command")
|
||||
}
|
||||
}
|
||||
|
|
@ -251,6 +251,14 @@ type ResultProvider interface {
|
|||
// that skipped / failed the summary phase.
|
||||
ProjectSummary() string
|
||||
ToolCalls() map[string]int64
|
||||
// SessionID returns the persisted session identifier so callers can show it
|
||||
// in JSON output or failure diagnostics. Returns "" when no session was
|
||||
// created.
|
||||
SessionID() string
|
||||
}
|
||||
|
||||
type resumeInfoProvider interface {
|
||||
ResumeInfo() *agent.ResumeInfo
|
||||
}
|
||||
|
||||
// emitRunResult is the post-LLM-run finalization shared by `ocr review` and
|
||||
|
|
@ -295,10 +303,14 @@ func emitRunResult(
|
|||
}
|
||||
|
||||
if outputFormat == "json" {
|
||||
var resumeInfo *agent.ResumeInfo
|
||||
if p, ok := ag.(resumeInfoProvider); ok {
|
||||
resumeInfo = p.ResumeInfo()
|
||||
}
|
||||
return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(),
|
||||
ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
|
||||
ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration,
|
||||
ag.ProjectSummary(), ag.ToolCalls(), traceID)
|
||||
ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID())
|
||||
}
|
||||
outputTextWithWarnings(comments, ag.Warnings())
|
||||
if summary := ag.ProjectSummary(); summary != "" {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
|
|
@ -113,6 +114,9 @@ type Args struct {
|
|||
// Session is an optional session history instance for collecting conversation records.
|
||||
// When nil, a default one is created automatically with git branch auto-detected from repoDir.
|
||||
Session *session.SessionHistory
|
||||
|
||||
// Resume is an optional read-only checkpoint index from a previous review session.
|
||||
Resume *session.ResumeState
|
||||
}
|
||||
|
||||
// Agent orchestrates the AI-powered code review. LLM tool-use loop / memory
|
||||
|
|
@ -127,6 +131,16 @@ type Agent struct {
|
|||
session *session.SessionHistory
|
||||
subtaskFailed int64 // count of failed subtasks, accessed atomically
|
||||
runner *llmloop.Runner
|
||||
resumeInfo *ResumeInfo
|
||||
}
|
||||
|
||||
// ResumeInfo summarizes file-level reuse for a resumed review.
|
||||
type ResumeInfo struct {
|
||||
ResumedFrom string `json:"resumed_from"`
|
||||
ReusedFiles int64 `json:"reused_files"`
|
||||
RerunFiles int64 `json:"rerun_files"`
|
||||
PreviousModel string `json:"previous_model,omitempty"`
|
||||
CurrentModel string `json:"current_model,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new Agent from the given arguments.
|
||||
|
|
@ -144,10 +158,11 @@ func New(args Args) *Agent {
|
|||
mode = reviewModeString(args.From, args.To, args.Commit)
|
||||
}
|
||||
args.Session = session.New(args.RepoDir, gitBranch, args.Model, session.SessionOptions{
|
||||
ReviewMode: mode,
|
||||
DiffFrom: args.From,
|
||||
DiffTo: args.To,
|
||||
DiffCommit: args.Commit,
|
||||
ReviewMode: mode,
|
||||
DiffFrom: args.From,
|
||||
DiffTo: args.To,
|
||||
DiffCommit: args.Commit,
|
||||
ResumedFrom: resumedFromSession(args.Resume),
|
||||
})
|
||||
}
|
||||
a := &Agent{
|
||||
|
|
@ -225,6 +240,23 @@ func (a *Agent) Session() *session.SessionHistory {
|
|||
return a.session
|
||||
}
|
||||
|
||||
// SessionID returns the current review's session id, or "" when no session has been created.
|
||||
func (a *Agent) SessionID() string {
|
||||
if a == nil || a.session == nil {
|
||||
return ""
|
||||
}
|
||||
return a.session.SessionID
|
||||
}
|
||||
|
||||
// ResumeInfo returns resume metadata for output. Nil means this was not a resume run.
|
||||
func (a *Agent) ResumeInfo() *ResumeInfo {
|
||||
if a.resumeInfo == nil {
|
||||
return nil
|
||||
}
|
||||
info := *a.resumeInfo
|
||||
return &info
|
||||
}
|
||||
|
||||
// FilesReviewed returns the number of changed files included in this review.
|
||||
func (a *Agent) FilesReviewed() int64 {
|
||||
return int64(len(a.diffs))
|
||||
|
|
@ -327,6 +359,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
if len(a.diffs) == 0 {
|
||||
return nil, fmt.Errorf("all diffs filtered out by token size")
|
||||
}
|
||||
toDispatch := a.applyResume(a.diffs)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
|
@ -339,8 +372,8 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute
|
||||
|
||||
var dispatched int64
|
||||
for i := range a.diffs {
|
||||
if a.diffs[i].IsDeleted {
|
||||
for i := range toDispatch {
|
||||
if toDispatch[i].IsDeleted {
|
||||
continue
|
||||
}
|
||||
dispatched++
|
||||
|
|
@ -348,6 +381,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
sem <- struct{}{} // acquire semaphore
|
||||
|
||||
go func(d model.Diff) {
|
||||
fingerprint := reviewItemFingerprint(a.reviewMode(), d)
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // release
|
||||
// A panic while reviewing one file must be isolated exactly like an
|
||||
|
|
@ -359,6 +393,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
atomic.AddInt64(&a.subtaskFailed, 1)
|
||||
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, fmt.Sprintf("panic: %v", r))
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Subtask panic for %s: %v\n%s\n", d.NewPath, r, debug.Stack())
|
||||
telemetry.ErrorEvent(ctx, "subtask.panic", fmt.Errorf("panic: %v", r),
|
||||
telemetry.AnyToAttr("file.path", d.NewPath))
|
||||
|
|
@ -375,20 +410,31 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
fileCtx = ctx
|
||||
}
|
||||
|
||||
if err := a.executeSubtask(fileCtx, d); err != nil {
|
||||
completed, skipReason, err := a.executeSubtask(fileCtx, d)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&a.subtaskFailed, 1)
|
||||
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, err.Error())
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Subtask error for %s: %v\n", d.NewPath, err)
|
||||
telemetry.ErrorEvent(fileCtx, "subtask.error", err,
|
||||
telemetry.AnyToAttr("file.path", d.NewPath))
|
||||
a.recordWarning("subtask_error", d.NewPath, err.Error())
|
||||
return
|
||||
}
|
||||
}(a.diffs[i])
|
||||
if !completed {
|
||||
if skipReason != "" {
|
||||
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, skipReason)
|
||||
}
|
||||
return
|
||||
}
|
||||
comments := a.args.CommentCollector.CommentsForPath(d.NewPath)
|
||||
a.session.RecordReviewItemDone(d.NewPath, d.OldPath, d.NewPath, fingerprint, comments)
|
||||
}(toDispatch[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if dispatched == 0 {
|
||||
return []model.LlmComment{}, nil
|
||||
return a.args.CommentCollector.Comments(), nil
|
||||
}
|
||||
|
||||
// All subtasks finished — collect comments from the global collector once.
|
||||
|
|
@ -404,8 +450,76 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
return a.args.CommentCollector.Comments(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) applyResume(diffs []model.Diff) []model.Diff {
|
||||
resume := a.args.Resume
|
||||
if resume == nil {
|
||||
return diffs
|
||||
}
|
||||
|
||||
mode := a.reviewMode()
|
||||
toDispatch := make([]model.Diff, 0, len(diffs))
|
||||
var reused int64
|
||||
for _, d := range diffs {
|
||||
if d.IsDeleted {
|
||||
toDispatch = append(toDispatch, d)
|
||||
continue
|
||||
}
|
||||
fingerprint := reviewItemFingerprint(mode, d)
|
||||
item, ok := resume.Item(fingerprint)
|
||||
if !ok {
|
||||
toDispatch = append(toDispatch, d)
|
||||
continue
|
||||
}
|
||||
for _, cm := range item.Comments {
|
||||
a.args.CommentCollector.Add(cm)
|
||||
}
|
||||
a.session.RecordReviewItemReused(effectivePath(d), d.OldPath, d.NewPath, fingerprint, resume.SessionID, item.Comments)
|
||||
reused++
|
||||
}
|
||||
|
||||
rerun := countDispatchable(toDispatch)
|
||||
a.resumeInfo = &ResumeInfo{
|
||||
ResumedFrom: resume.SessionID,
|
||||
ReusedFiles: reused,
|
||||
RerunFiles: rerun,
|
||||
PreviousModel: resume.Model,
|
||||
CurrentModel: a.args.Model,
|
||||
}
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Resume %s: reusing %d file(s), reviewing %d file(s)\n", resume.SessionID, reused, rerun)
|
||||
return toDispatch
|
||||
}
|
||||
|
||||
func countDispatchable(diffs []model.Diff) int64 {
|
||||
var n int64
|
||||
for _, d := range diffs {
|
||||
if !d.IsDeleted {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (a *Agent) reviewMode() string {
|
||||
if a.args.ReviewMode != "" {
|
||||
return a.args.ReviewMode
|
||||
}
|
||||
return reviewModeString(a.args.From, a.args.To, a.args.Commit)
|
||||
}
|
||||
|
||||
func reviewItemFingerprint(mode string, d model.Diff) string {
|
||||
sum := sha256.Sum256([]byte(mode + "\x00" + d.OldPath + "\x00" + d.NewPath + "\x00" + d.Diff))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
func resumedFromSession(resume *session.ResumeState) string {
|
||||
if resume == nil {
|
||||
return ""
|
||||
}
|
||||
return resume.SessionID
|
||||
}
|
||||
|
||||
// executeSubtask performs the Plan Phase + Main Loop for a single file.
|
||||
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
||||
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, string, error) {
|
||||
ctx, span := telemetry.StartSpan(ctx, "subtask.execute."+d.NewPath)
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "file.path", d.NewPath)
|
||||
|
|
@ -414,7 +528,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
telemetry.SetAttr(span, "lines.deleted", d.Deletions)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
return false, "", ctx.Err()
|
||||
}
|
||||
|
||||
newPath := d.NewPath
|
||||
|
|
@ -448,7 +562,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
|
||||
// Phase 2: Main task loop
|
||||
if len(a.args.Template.MainTask.Messages) == 0 {
|
||||
return fmt.Errorf("main_task.messages is empty in template")
|
||||
return false, "", fmt.Errorf("main_task.messages is empty in template")
|
||||
}
|
||||
|
||||
rawMsgs := a.args.Template.MainTask.Messages
|
||||
|
|
@ -486,19 +600,20 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
telemetry.AnyToAttr("file.path", newPath),
|
||||
telemetry.AnyToAttr("tokens", tokenCount),
|
||||
telemetry.AnyToAttr("max_tokens", maxAllowed))
|
||||
return nil
|
||||
return false, msg, nil
|
||||
}
|
||||
|
||||
err := func() error {
|
||||
mainCompleted, err := func() (bool, error) {
|
||||
ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop")
|
||||
defer mainSpan.End()
|
||||
telemetry.SetAttr(mainSpan, "file.path", newPath)
|
||||
if err := a.runner.RunPerFile(ctx, messages, newPath); err != nil {
|
||||
completed, err := a.runner.RunPerFile(ctx, messages, newPath)
|
||||
if err != nil {
|
||||
mainSpan.SetStatus(codes.Error, err.Error())
|
||||
mainSpan.RecordError(err)
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
return nil
|
||||
return completed, nil
|
||||
}()
|
||||
if err == nil {
|
||||
// REVIEW_FILTER_TASK runs after the main loop and decides which of the
|
||||
|
|
@ -509,7 +624,13 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
}
|
||||
a.executeReviewFilter(ctx, d, newPath)
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if !mainCompleted {
|
||||
return false, "main_task did not complete before stopping", nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/open-code-review/open-code-review/internal/config/toolsconfig"
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
)
|
||||
|
||||
|
|
@ -407,6 +408,62 @@ func TestFilterLargeDiffs_ZeroMaxTokens(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyResumeReusesCompletedItemsAcrossModels(t *testing.T) {
|
||||
diffs := []model.Diff{
|
||||
{OldPath: "a.go", NewPath: "a.go", Diff: "+a", Insertions: 1},
|
||||
{OldPath: "b.go", NewPath: "b.go", Diff: "+b", Insertions: 1},
|
||||
}
|
||||
fp := reviewItemFingerprint(session.ReviewModeRange, diffs[0])
|
||||
resume := &session.ResumeState{
|
||||
SessionID: "old-session",
|
||||
Model: "anthropic-model",
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
Items: map[string]session.ResumeItem{
|
||||
fp: {
|
||||
FilePath: "a.go",
|
||||
OldPath: "a.go",
|
||||
NewPath: "a.go",
|
||||
Fingerprint: fp,
|
||||
Comments: []model.LlmComment{{
|
||||
Path: "a.go",
|
||||
Content: "cached comment",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
collector := tool.NewCommentCollector()
|
||||
sess := session.New(t.TempDir(), "feature", "openai-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
ResumedFrom: "old-session",
|
||||
})
|
||||
defer sess.Finalize()
|
||||
a := New(Args{
|
||||
From: "main",
|
||||
To: "feature",
|
||||
Model: "openai-model",
|
||||
CommentCollector: collector,
|
||||
Resume: resume,
|
||||
Session: sess,
|
||||
})
|
||||
|
||||
toDispatch := a.applyResume(diffs)
|
||||
if len(toDispatch) != 1 || toDispatch[0].NewPath != "b.go" {
|
||||
t.Fatalf("toDispatch = %+v, want only b.go", toDispatch)
|
||||
}
|
||||
comments := collector.Comments()
|
||||
if len(comments) != 1 || comments[0].Content != "cached comment" {
|
||||
t.Fatalf("comments = %+v", comments)
|
||||
}
|
||||
info := a.ResumeInfo()
|
||||
if info == nil || info.ReusedFiles != 1 || info.RerunFiles != 1 || info.PreviousModel != "anthropic-model" || info.CurrentModel != "openai-model" {
|
||||
t.Fatalf("ResumeInfo = %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountReviewable(t *testing.T) {
|
||||
a := New(Args{})
|
||||
diffs := []model.Diff{
|
||||
|
|
@ -500,6 +557,140 @@ func TestDispatchSubtasks_WithFakeLLM(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_TokenThresholdSkipIsNotReusableCheckpoint(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
sess := session.New(repoDir, "feature", "fake", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
})
|
||||
|
||||
client := &fakeAgentClient{responses: []*llm.ChatResponse{
|
||||
agentTaskDoneResponse(),
|
||||
}}
|
||||
a := New(Args{
|
||||
From: "main",
|
||||
To: "feature",
|
||||
LLMClient: client,
|
||||
Model: "fake",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: strings.Repeat("context ", 200) + "{{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
diff := model.Diff{NewPath: "large-prompt.go", OldPath: "large-prompt.go", Diff: "+x", Insertions: 1}
|
||||
a.diffs = []model.Diff{diff}
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
comments, err := a.dispatchSubtasks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchSubtasks: %v", err)
|
||||
}
|
||||
if len(comments) != 0 {
|
||||
t.Fatalf("expected no comments, got %d", len(comments))
|
||||
}
|
||||
if client.calls != 0 {
|
||||
t.Fatalf("threshold skip should not call LLM, got %d calls", client.calls)
|
||||
}
|
||||
sess.Finalize()
|
||||
|
||||
state, err := session.LoadResumeState(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeState: %v", err)
|
||||
}
|
||||
if state.CompletedCount() != 0 {
|
||||
t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount())
|
||||
}
|
||||
fp := reviewItemFingerprint(session.ReviewModeRange, diff)
|
||||
if _, ok := state.Item(fp); ok {
|
||||
t.Fatal("token-threshold skip was recorded as a reusable checkpoint")
|
||||
}
|
||||
summary, items, err := session.LoadDetail(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail: %v", err)
|
||||
}
|
||||
if summary.CompletedFiles != 0 || summary.FailedFiles != 1 {
|
||||
t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles)
|
||||
}
|
||||
if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "prompt tokens") {
|
||||
t.Fatalf("items = %+v, want one token-threshold failed item", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_MainTaskWithoutTaskDoneIsNotReusableCheckpoint(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
sess := session.New(repoDir, "feature", "fake", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
})
|
||||
|
||||
emptyContent := ""
|
||||
client := &fakeAgentClient{responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &emptyContent}}},
|
||||
Model: "fake",
|
||||
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 1},
|
||||
}}}
|
||||
a := New(Args{
|
||||
From: "main",
|
||||
To: "feature",
|
||||
LLMClient: client,
|
||||
Model: "fake",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 1,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "Review {{diff}}"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
diff := model.Diff{NewPath: "needs-review.go", OldPath: "needs-review.go", Diff: "+x", Insertions: 1}
|
||||
a.diffs = []model.Diff{diff}
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchSubtasks: %v", err)
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", client.calls)
|
||||
}
|
||||
sess.Finalize()
|
||||
|
||||
state, err := session.LoadResumeState(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeState: %v", err)
|
||||
}
|
||||
if state.CompletedCount() != 0 {
|
||||
t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount())
|
||||
}
|
||||
fp := reviewItemFingerprint(session.ReviewModeRange, diff)
|
||||
if _, ok := state.Item(fp); ok {
|
||||
t.Fatal("incomplete main task was recorded as a reusable checkpoint")
|
||||
}
|
||||
summary, items, err := session.LoadDetail(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail: %v", err)
|
||||
}
|
||||
if summary.CompletedFiles != 0 || summary.FailedFiles != 1 {
|
||||
t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles)
|
||||
}
|
||||
if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "main_task did not complete") {
|
||||
t.Fatalf("items = %+v, want one incomplete-main failed item", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_AllDeleted(t *testing.T) {
|
||||
client := &fakeAgentClient{}
|
||||
a := New(Args{
|
||||
|
|
|
|||
|
|
@ -412,10 +412,16 @@ func TestExecuteSubtask_EmptyMainTask(t *testing.T) {
|
|||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
completed, skipReason, err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty main_task messages")
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("empty main_task should not complete review")
|
||||
}
|
||||
if skipReason != "" {
|
||||
t.Fatalf("skipReason = %q, want empty on error", skipReason)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "main_task.messages is empty") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -442,10 +448,16 @@ func TestExecuteSubtask_TokenThresholdExceeded(t *testing.T) {
|
|||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "a.go", Diff: strings.Repeat("code ", 200), Insertions: 100}}
|
||||
|
||||
err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("token-threshold skip should not complete review")
|
||||
}
|
||||
if skipReason == "" {
|
||||
t.Fatal("expected skip reason for token-threshold skip")
|
||||
}
|
||||
|
||||
warnings := a.Warnings()
|
||||
found := false
|
||||
|
|
@ -514,10 +526,16 @@ func TestExecuteSubtask_WithPlanPhase(t *testing.T) {
|
|||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "main.go", OldPath: "main.go", Diff: "+new code", Insertions: 5}}
|
||||
|
||||
err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("executeSubtask: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected completed review")
|
||||
}
|
||||
if skipReason != "" {
|
||||
t.Fatalf("skipReason = %q, want empty on completed review", skipReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_ContextCancelled(t *testing.T) {
|
||||
|
|
@ -538,10 +556,16 @@ func TestExecuteSubtask_ContextCancelled(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
completed, skipReason, err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("cancelled context should not complete review")
|
||||
}
|
||||
if skipReason != "" {
|
||||
t.Fatalf("skipReason = %q, want empty on error", skipReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_WithTimeout(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -144,8 +144,9 @@ func (r *Runner) CollectPendingComments() []model.LlmComment {
|
|||
// It sends messages with the configured tool definitions, executes any
|
||||
// tool calls returned by the model, and collects review comments until
|
||||
// task_done is called or limits are reached. Token usage and warnings
|
||||
// are aggregated on the Runner across all files.
|
||||
func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) error {
|
||||
// are aggregated on the Runner across all files. The returned bool is true
|
||||
// only when the model explicitly calls task_done.
|
||||
func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) (bool, error) {
|
||||
toolReqCount := r.deps.Template.MaxToolRequestTimes
|
||||
const maxConsecutiveEmptyRounds = 3
|
||||
consecutiveEmptyRounds := 0
|
||||
|
|
@ -153,7 +154,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
for toolReqCount > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
return false, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +177,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error")
|
||||
return fmt.Errorf("LLM completion error: %w", err)
|
||||
return false, fmt.Errorf("LLM completion error: %w", err)
|
||||
}
|
||||
rec.SetResponse(resp, duration)
|
||||
totalTokens := int64(0)
|
||||
|
|
@ -233,7 +234,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
}
|
||||
|
||||
if taskCompleted {
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
if !hasValidResult {
|
||||
consecutiveEmptyRounds++
|
||||
|
|
@ -256,7 +257,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
if toolReqCount <= 0 {
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", newPath)
|
||||
}
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// executeToolCall dispatches a single tool call from the LLM response and
|
||||
|
|
|
|||
|
|
@ -99,10 +99,13 @@ func TestRunPerFile_TaskDoneImmediately(t *testing.T) {
|
|||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review this file")}
|
||||
err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected task_done to complete RunPerFile")
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Errorf("expected 1 LLM call, got %d", client.calls)
|
||||
}
|
||||
|
|
@ -123,10 +126,13 @@ func TestRunPerFile_ToolCallThenDone(t *testing.T) {
|
|||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected task_done to complete RunPerFile")
|
||||
}
|
||||
if client.calls != 2 {
|
||||
t.Errorf("expected 2 LLM calls, got %d", client.calls)
|
||||
}
|
||||
|
|
@ -149,10 +155,13 @@ func TestRunPerFile_ContextCancelled(t *testing.T) {
|
|||
cancel()
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
err := runner.RunPerFile(ctx, msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(ctx, msgs, "main.go")
|
||||
if err == nil {
|
||||
t.Error("expected error for cancelled context")
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("cancelled context should not complete RunPerFile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPerFile_UnknownTool(t *testing.T) {
|
||||
|
|
@ -179,15 +188,39 @@ func TestRunPerFile_UnknownTool(t *testing.T) {
|
|||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected task_done to complete RunPerFile")
|
||||
}
|
||||
if client.calls != 2 {
|
||||
t.Errorf("expected 2 calls, got %d", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPerFile_MaxToolRequestsWithoutTaskDoneDoesNotComplete(t *testing.T) {
|
||||
content := ""
|
||||
client := &fakeClient{responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
|
||||
Model: "fake",
|
||||
Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 5},
|
||||
}}}
|
||||
deps := newTestDeps(client)
|
||||
deps.Template.MaxToolRequestTimes = 1
|
||||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("RunPerFile completed without task_done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RecordWarning(t *testing.T) {
|
||||
deps := newTestDeps(&fakeClient{})
|
||||
runner := NewRunner(deps)
|
||||
|
|
|
|||
|
|
@ -154,6 +154,14 @@ func toLoopTemplate(s template.ScanTemplate) template.Template {
|
|||
// Session returns the session history associated with this Agent.
|
||||
func (a *Agent) Session() *session.SessionHistory { return a.session }
|
||||
|
||||
// SessionID returns the current scan's session id, or "" when no session has been created.
|
||||
func (a *Agent) SessionID() string {
|
||||
if a == nil || a.session == nil {
|
||||
return ""
|
||||
}
|
||||
return a.session.SessionID
|
||||
}
|
||||
|
||||
// FilesReviewed returns the number of items included in this scan.
|
||||
func (a *Agent) FilesReviewed() int64 { return int64(len(a.items)) }
|
||||
|
||||
|
|
@ -553,7 +561,8 @@ func (a *Agent) executeSubtask(ctx context.Context, it model.ScanItem) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
return a.runner.RunPerFile(ctx, messages, it.Path)
|
||||
_, err := a.runner.RunPerFile(ctx, messages, it.Path)
|
||||
return err
|
||||
}
|
||||
|
||||
// maybeRunPlan invokes PLAN_TASK on the file and returns a human-readable
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
// TaskType identifies the kind of LLM request within a file subtask.
|
||||
|
|
@ -42,6 +43,7 @@ type SessionHistory struct {
|
|||
DiffFrom string
|
||||
DiffTo string
|
||||
DiffCommit string
|
||||
ResumedFrom string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
persist *jsonlWriter
|
||||
|
|
@ -96,10 +98,11 @@ type ToolResultRecord struct {
|
|||
|
||||
// SessionOptions holds optional metadata for a new session.
|
||||
type SessionOptions struct {
|
||||
ReviewMode string
|
||||
DiffFrom string
|
||||
DiffTo string
|
||||
DiffCommit string
|
||||
ReviewMode string
|
||||
DiffFrom string
|
||||
DiffTo string
|
||||
DiffCommit string
|
||||
ResumedFrom string
|
||||
}
|
||||
|
||||
// New creates a new SessionHistory with the given repo directory.
|
||||
|
|
@ -114,6 +117,7 @@ func New(repoDir, gitBranch, model string, opts SessionOptions) *SessionHistory
|
|||
DiffFrom: opts.DiffFrom,
|
||||
DiffTo: opts.DiffTo,
|
||||
DiffCommit: opts.DiffCommit,
|
||||
ResumedFrom: opts.ResumedFrom,
|
||||
StartTime: time.Now(),
|
||||
FileSessions: make(map[string]*FileSession),
|
||||
}
|
||||
|
|
@ -147,6 +151,54 @@ func (sh *SessionHistory) GetOrCreateFileSession(filePath string) *FileSession {
|
|||
return fs
|
||||
}
|
||||
|
||||
// RecordReviewItemDone persists the file-level checkpoint used by resume.
|
||||
func (sh *SessionHistory) RecordReviewItemDone(filePath, oldPath, newPath, fingerprint string, comments []model.LlmComment) {
|
||||
if sh == nil {
|
||||
return
|
||||
}
|
||||
if filePath == "" {
|
||||
filePath = newPath
|
||||
}
|
||||
if filePath != "" {
|
||||
sh.GetOrCreateFileSession(filePath)
|
||||
}
|
||||
if p := sh.persist; p != nil {
|
||||
p.WriteReviewItemDone(filePath, oldPath, newPath, fingerprint, comments)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordReviewItemReused records that this run reused a checkpoint from another session.
|
||||
func (sh *SessionHistory) RecordReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID string, comments []model.LlmComment) {
|
||||
if sh == nil {
|
||||
return
|
||||
}
|
||||
if filePath == "" {
|
||||
filePath = newPath
|
||||
}
|
||||
if filePath != "" {
|
||||
sh.GetOrCreateFileSession(filePath)
|
||||
}
|
||||
if p := sh.persist; p != nil {
|
||||
p.WriteReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID, comments)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordReviewItemFailed persists an incomplete file-level checkpoint.
|
||||
func (sh *SessionHistory) RecordReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg string) {
|
||||
if sh == nil {
|
||||
return
|
||||
}
|
||||
if filePath == "" {
|
||||
filePath = newPath
|
||||
}
|
||||
if filePath != "" {
|
||||
sh.GetOrCreateFileSession(filePath)
|
||||
}
|
||||
if p := sh.persist; p != nil {
|
||||
p.WriteReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize marks the session as complete, sets the end time, and persists
|
||||
// the final summary record.
|
||||
func (sh *SessionHistory) Finalize() {
|
||||
|
|
|
|||
293
internal/session/list.go
Normal file
293
internal/session/list.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Summary is a compact digest of one persisted session, suitable for
|
||||
// listing recent runs.
|
||||
type Summary struct {
|
||||
SessionID string `json:"session_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
RepoDir string `json:"repo_dir"`
|
||||
GitBranch string `json:"git_branch,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ReviewMode string `json:"review_mode,omitempty"`
|
||||
DiffFrom string `json:"diff_from,omitempty"`
|
||||
DiffTo string `json:"diff_to,omitempty"`
|
||||
DiffCommit string `json:"diff_commit,omitempty"`
|
||||
ResumedFrom string `json:"resumed_from,omitempty"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time,omitempty"`
|
||||
Duration time.Duration `json:"duration_ns,omitempty"`
|
||||
CompletedFiles int `json:"completed_files"`
|
||||
FailedFiles int `json:"failed_files"`
|
||||
ReusedFiles int `json:"reused_files"`
|
||||
TotalComments int `json:"total_comments"`
|
||||
LLMFailures int64 `json:"llm_failures"`
|
||||
Aborted bool `json:"aborted"`
|
||||
}
|
||||
|
||||
// ItemDetail describes one file-level record within a session, used by `ocr session show`.
|
||||
type ItemDetail struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
FilePath string `json:"file_path"`
|
||||
OldPath string `json:"old_path,omitempty"`
|
||||
NewPath string `json:"new_path,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Comments int `json:"comments"`
|
||||
SourceSessionID string `json:"source_session_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// summaryRecord is a superset of resumeRecord that also carries session_end fields.
|
||||
type summaryRecord struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Cwd string `json:"cwd"`
|
||||
GitBranch string `json:"gitBranch"`
|
||||
Model string `json:"model"`
|
||||
ReviewMode string `json:"reviewMode"`
|
||||
DiffFrom string `json:"diffFrom"`
|
||||
DiffTo string `json:"diffTo"`
|
||||
DiffCommit string `json:"diffCommit"`
|
||||
ResumedFrom string `json:"resumedFrom"`
|
||||
FilePath string `json:"filePath"`
|
||||
OldPath string `json:"oldPath"`
|
||||
NewPath string `json:"newPath"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
SourceSessionID string `json:"sourceSessionId"`
|
||||
Error string `json:"error"`
|
||||
Comments json.RawMessage `json:"comments"`
|
||||
FilesReviewed []string `json:"files_reviewed"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
LLMFailures int64 `json:"llm_failures"`
|
||||
}
|
||||
|
||||
// SessionsDir returns the on-disk directory that holds JSONL session files
|
||||
// for a given repository. It does not create the directory.
|
||||
func SessionsDir(repoDir string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".opencodereview", sessionSubDir, encodeRepoPath(repoDir)), nil
|
||||
}
|
||||
|
||||
// ListSessions enumerates all persisted sessions for the given repository
|
||||
// directory, sorted by StartTime descending (most recent first). Missing
|
||||
// directories return an empty slice with no error.
|
||||
func ListSessions(repoDir string) ([]Summary, error) {
|
||||
dir, err := SessionsDir(repoDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read sessions dir %q: %w", dir, err)
|
||||
}
|
||||
summaries := make([]Summary, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
sessionID := strings.TrimSuffix(name, ".jsonl")
|
||||
summary, err := loadSummaryFromFile(filepath.Join(dir, name), sessionID, repoDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, *summary)
|
||||
}
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
return summaries[i].StartTime.After(summaries[j].StartTime)
|
||||
})
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// LoadSummary loads a single session's Summary. Errors when the session
|
||||
// file is missing or unreadable.
|
||||
func LoadSummary(repoDir, sessionID string) (*Summary, error) {
|
||||
path, err := SessionFilePath(repoDir, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadSummaryFromFile(path, sessionID, repoDir)
|
||||
}
|
||||
|
||||
// LoadDetail returns the summary plus per-file item records for one session.
|
||||
func LoadDetail(repoDir, sessionID string) (*Summary, []ItemDetail, error) {
|
||||
path, err := SessionFilePath(repoDir, sessionID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
summary := &Summary{
|
||||
SessionID: sessionID,
|
||||
FilePath: path,
|
||||
RepoDir: repoDir,
|
||||
Aborted: true,
|
||||
}
|
||||
var items []ItemDetail
|
||||
err = walkSessionFile(path, func(rec summaryRecord) {
|
||||
applyRecordToSummary(summary, rec)
|
||||
if item, ok := recordToItem(rec); ok {
|
||||
items = append(items, item)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if summary.SessionID == "" {
|
||||
summary.SessionID = sessionID
|
||||
}
|
||||
return summary, items, nil
|
||||
}
|
||||
|
||||
func loadSummaryFromFile(path, sessionID, repoDir string) (*Summary, error) {
|
||||
summary := &Summary{
|
||||
SessionID: sessionID,
|
||||
FilePath: path,
|
||||
RepoDir: repoDir,
|
||||
Aborted: true,
|
||||
}
|
||||
if err := walkSessionFile(path, func(rec summaryRecord) {
|
||||
applyRecordToSummary(summary, rec)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if summary.SessionID == "" {
|
||||
summary.SessionID = sessionID
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func walkSessionFile(path string, apply func(summaryRecord)) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open session %q: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := bufio.NewReader(f)
|
||||
for {
|
||||
line, readErr := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
var rec summaryRecord
|
||||
if err := json.Unmarshal(line, &rec); err == nil {
|
||||
apply(rec)
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read session %q: %w", path, readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyRecordToSummary(s *Summary, rec summaryRecord) {
|
||||
ts := parseRecordTime(rec.Timestamp)
|
||||
switch rec.Type {
|
||||
case "session_start":
|
||||
if rec.SessionID != "" {
|
||||
s.SessionID = rec.SessionID
|
||||
}
|
||||
if rec.Cwd != "" {
|
||||
s.RepoDir = rec.Cwd
|
||||
}
|
||||
s.GitBranch = rec.GitBranch
|
||||
s.Model = rec.Model
|
||||
s.ReviewMode = rec.ReviewMode
|
||||
s.DiffFrom = rec.DiffFrom
|
||||
s.DiffTo = rec.DiffTo
|
||||
s.DiffCommit = rec.DiffCommit
|
||||
s.ResumedFrom = rec.ResumedFrom
|
||||
if !ts.IsZero() {
|
||||
s.StartTime = ts
|
||||
}
|
||||
case "review_item_done":
|
||||
s.CompletedFiles++
|
||||
s.TotalComments += countCommentsRaw(rec.Comments)
|
||||
case "review_item_reused":
|
||||
s.ReusedFiles++
|
||||
s.TotalComments += countCommentsRaw(rec.Comments)
|
||||
case "review_item_failed":
|
||||
s.FailedFiles++
|
||||
case "session_end":
|
||||
s.Aborted = false
|
||||
if s.CompletedFiles == 0 && s.ReusedFiles == 0 && s.FailedFiles == 0 && len(rec.FilesReviewed) > 0 {
|
||||
s.CompletedFiles = len(rec.FilesReviewed)
|
||||
}
|
||||
if !ts.IsZero() {
|
||||
s.EndTime = ts
|
||||
}
|
||||
if rec.DurationSeconds > 0 {
|
||||
s.Duration = time.Duration(rec.DurationSeconds * float64(time.Second))
|
||||
} else if !s.EndTime.IsZero() && !s.StartTime.IsZero() {
|
||||
s.Duration = s.EndTime.Sub(s.StartTime)
|
||||
}
|
||||
s.LLMFailures = rec.LLMFailures
|
||||
}
|
||||
}
|
||||
|
||||
func recordToItem(rec summaryRecord) (ItemDetail, bool) {
|
||||
switch rec.Type {
|
||||
case "review_item_done", "review_item_reused", "review_item_failed":
|
||||
default:
|
||||
return ItemDetail{}, false
|
||||
}
|
||||
kind := strings.TrimPrefix(rec.Type, "review_item_")
|
||||
filePath := rec.FilePath
|
||||
if filePath == "" {
|
||||
filePath = rec.NewPath
|
||||
}
|
||||
return ItemDetail{
|
||||
Type: kind,
|
||||
Timestamp: parseRecordTime(rec.Timestamp),
|
||||
FilePath: filePath,
|
||||
OldPath: rec.OldPath,
|
||||
NewPath: rec.NewPath,
|
||||
Fingerprint: rec.Fingerprint,
|
||||
Comments: countCommentsRaw(rec.Comments),
|
||||
SourceSessionID: rec.SourceSessionID,
|
||||
Error: rec.Error,
|
||||
}, true
|
||||
}
|
||||
|
||||
func countCommentsRaw(raw json.RawMessage) int {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(arr)
|
||||
}
|
||||
|
||||
func parseRecordTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
189
internal/session/list_test.go
Normal file
189
internal/session/list_test.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
func TestListSessions_EmptyRepoReturnsNil(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
|
||||
got, err := ListSessions(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("ListSessions: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty result, got %d entries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSessions_SortsAndAggregates(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
older := writeTestSession(t, repoDir, "feature-a", "commit-x", []model.LlmComment{
|
||||
{Path: "a.go", Content: "one"},
|
||||
}, 1, 0, true)
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
newer := writeTestSession(t, repoDir, "feature-a", "commit-y", []model.LlmComment{
|
||||
{Path: "b.go", Content: "one"},
|
||||
{Path: "b.go", Content: "two"},
|
||||
}, 2, 1, false)
|
||||
|
||||
got, err := ListSessions(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSessions: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 sessions, got %d", len(got))
|
||||
}
|
||||
if got[0].SessionID != newer {
|
||||
t.Errorf("expected newest first, got %q vs %q", got[0].SessionID, newer)
|
||||
}
|
||||
if got[1].SessionID != older {
|
||||
t.Errorf("expected older second, got %q", got[1].SessionID)
|
||||
}
|
||||
|
||||
if !got[0].Aborted {
|
||||
t.Errorf("newest session was interrupted; expected Aborted=true")
|
||||
}
|
||||
if got[1].Aborted {
|
||||
t.Errorf("older session was finalized; expected Aborted=false")
|
||||
}
|
||||
if got[0].TotalComments != 2 {
|
||||
t.Errorf("newest TotalComments = %d, want 2", got[0].TotalComments)
|
||||
}
|
||||
if got[0].FailedFiles != 1 {
|
||||
t.Errorf("newest FailedFiles = %d, want 1", got[0].FailedFiles)
|
||||
}
|
||||
if got[0].CompletedFiles != 2 {
|
||||
t.Errorf("newest CompletedFiles = %d, want 2", got[0].CompletedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDetail_ReturnsItems(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := New(repoDir, "main", "test-model", SessionOptions{
|
||||
ReviewMode: ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
|
||||
sh.RecordReviewItemReused("b.go", "b.go", "b.go", "fp-b", "prior-session", []model.LlmComment{{Path: "b.go", Content: "cached"}})
|
||||
sh.RecordReviewItemFailed("c.go", "c.go", "c.go", "fp-c", "boom")
|
||||
sh.Finalize()
|
||||
|
||||
summary, items, err := LoadDetail(repoDir, sh.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail: %v", err)
|
||||
}
|
||||
if summary.CompletedFiles != 1 || summary.ReusedFiles != 1 || summary.FailedFiles != 1 {
|
||||
t.Fatalf("summary = %+v", summary)
|
||||
}
|
||||
if summary.TotalComments != 2 {
|
||||
t.Errorf("TotalComments = %d, want 2", summary.TotalComments)
|
||||
}
|
||||
if summary.Aborted {
|
||||
t.Errorf("summary should not be aborted after Finalize")
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", len(items))
|
||||
}
|
||||
byType := map[string]ItemDetail{}
|
||||
for _, it := range items {
|
||||
byType[it.Type] = it
|
||||
}
|
||||
if reused := byType["reused"]; reused.SourceSessionID != "prior-session" {
|
||||
t.Errorf("reused source = %q, want prior-session", reused.SourceSessionID)
|
||||
}
|
||||
if failed := byType["failed"]; failed.Error != "boom" {
|
||||
t.Errorf("failed error = %q, want boom", failed.Error)
|
||||
}
|
||||
if done := byType["done"]; done.Comments != 1 {
|
||||
t.Errorf("done comments = %d, want 1", done.Comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSummary_FallsBackToSessionEndFilesReviewed(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := New(repoDir, "main", "test-model", SessionOptions{
|
||||
ReviewMode: ReviewModeWorkspace,
|
||||
})
|
||||
sh.GetOrCreateFileSession("legacy-a.go")
|
||||
sh.GetOrCreateFileSession("legacy-b.go")
|
||||
sh.Finalize()
|
||||
|
||||
summary, items, err := LoadDetail(repoDir, sh.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail: %v", err)
|
||||
}
|
||||
if summary.CompletedFiles != 2 {
|
||||
t.Fatalf("CompletedFiles = %d, want 2", summary.CompletedFiles)
|
||||
}
|
||||
if summary.ReusedFiles != 0 || summary.FailedFiles != 0 {
|
||||
t.Fatalf("unexpected checkpoint counts: %+v", summary)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("legacy session should not synthesize item details, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSummary_MissingFile(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
if _, err := LoadSummary(t.TempDir(), "nonexistent"); err == nil {
|
||||
t.Fatal("expected error for missing session")
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestSession creates a real JSONL session using the persistence layer
|
||||
// so tests exercise the same on-disk format that ListSessions consumes.
|
||||
// It returns the session id.
|
||||
func writeTestSession(t *testing.T, repoDir, from, to string, comments []model.LlmComment, doneCount, failedCount int, finalize bool) string {
|
||||
t.Helper()
|
||||
sh := New(repoDir, "main", "test-model", SessionOptions{
|
||||
ReviewMode: ReviewModeRange,
|
||||
DiffFrom: from,
|
||||
DiffTo: to,
|
||||
})
|
||||
for i := 0; i < doneCount; i++ {
|
||||
filePath := filepath.Base(t.TempDir()) + ".go"
|
||||
var perFile []model.LlmComment
|
||||
if i < len(comments) {
|
||||
perFile = []model.LlmComment{comments[i]}
|
||||
}
|
||||
sh.RecordReviewItemDone(filePath, filePath, filePath, "fp-"+filePath, perFile)
|
||||
}
|
||||
for i := 0; i < failedCount; i++ {
|
||||
filePath := "failed-" + filepath.Base(t.TempDir()) + ".go"
|
||||
sh.RecordReviewItemFailed(filePath, filePath, filePath, "fp-fail-"+filePath, "test error")
|
||||
}
|
||||
if finalize {
|
||||
sh.Finalize()
|
||||
} else {
|
||||
// Simulate an aborted run: flush the writer without emitting session_end.
|
||||
if sh.persist != nil {
|
||||
sh.persist.mu.Lock()
|
||||
if sh.persist.writer != nil {
|
||||
sh.persist.writer.Flush()
|
||||
}
|
||||
if sh.persist.file != nil {
|
||||
_ = sh.persist.file.Close()
|
||||
}
|
||||
sh.persist.writer = nil
|
||||
sh.persist.file = nil
|
||||
sh.persist.mu.Unlock()
|
||||
}
|
||||
}
|
||||
return sh.SessionID
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
var sessionSubDir = "sessions"
|
||||
|
|
@ -19,31 +21,33 @@ var sessionSubDir = "sessions"
|
|||
// $HOME/.opencodereview/sessions/<encoded-repo-path>/<session-id>.jsonl.
|
||||
// It is safe for concurrent use by multiple goroutines.
|
||||
type jsonlWriter struct {
|
||||
mu sync.Mutex
|
||||
sessionID string
|
||||
repoDir string
|
||||
gitBranch string
|
||||
model string
|
||||
reviewMode string
|
||||
diffFrom string
|
||||
diffTo string
|
||||
diffCommit string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
lastUUID string // tracks chain of records via parentUuid
|
||||
mu sync.Mutex
|
||||
sessionID string
|
||||
repoDir string
|
||||
gitBranch string
|
||||
model string
|
||||
reviewMode string
|
||||
diffFrom string
|
||||
diffTo string
|
||||
diffCommit string
|
||||
resumedFrom string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
lastUUID string // tracks chain of records via parentUuid
|
||||
}
|
||||
|
||||
// newJSONLWriter creates and opens a new JSONL writer for the given session.
|
||||
func newJSONLWriter(sessionID, repoDir, gitBranch, model string, opts SessionOptions) (*jsonlWriter, error) {
|
||||
jw := &jsonlWriter{
|
||||
sessionID: sessionID,
|
||||
repoDir: repoDir,
|
||||
gitBranch: gitBranch,
|
||||
model: model,
|
||||
reviewMode: opts.ReviewMode,
|
||||
diffFrom: opts.DiffFrom,
|
||||
diffTo: opts.DiffTo,
|
||||
diffCommit: opts.DiffCommit,
|
||||
sessionID: sessionID,
|
||||
repoDir: repoDir,
|
||||
gitBranch: gitBranch,
|
||||
model: model,
|
||||
reviewMode: opts.ReviewMode,
|
||||
diffFrom: opts.DiffFrom,
|
||||
diffTo: opts.DiffTo,
|
||||
diffCommit: opts.DiffCommit,
|
||||
resumedFrom: opts.ResumedFrom,
|
||||
}
|
||||
if err := jw.open(); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -148,6 +152,9 @@ func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string {
|
|||
if jw.diffCommit != "" {
|
||||
rec["diffCommit"] = jw.diffCommit
|
||||
}
|
||||
if jw.resumedFrom != "" {
|
||||
rec["resumedFrom"] = jw.resumedFrom
|
||||
}
|
||||
|
||||
jw.mu.Lock()
|
||||
defer jw.mu.Unlock()
|
||||
|
|
@ -156,6 +163,55 @@ func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string {
|
|||
return uuid
|
||||
}
|
||||
|
||||
// WriteReviewItemDone writes a file-level resume checkpoint for a completed diff.
|
||||
func (jw *jsonlWriter) WriteReviewItemDone(filePath, oldPath, newPath, fingerprint string, comments []model.LlmComment) string {
|
||||
return jw.writeReviewItemRecord("review_item_done", filePath, oldPath, newPath, fingerprint, "", "", comments)
|
||||
}
|
||||
|
||||
// WriteReviewItemReused writes a checkpoint reused from a previous session.
|
||||
func (jw *jsonlWriter) WriteReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID string, comments []model.LlmComment) string {
|
||||
return jw.writeReviewItemRecord("review_item_reused", filePath, oldPath, newPath, fingerprint, sourceSessionID, "", comments)
|
||||
}
|
||||
|
||||
// WriteReviewItemFailed writes a file-level checkpoint for a failed diff.
|
||||
func (jw *jsonlWriter) WriteReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg string) string {
|
||||
return jw.writeReviewItemRecord("review_item_failed", filePath, oldPath, newPath, fingerprint, "", errorMsg, nil)
|
||||
}
|
||||
|
||||
func (jw *jsonlWriter) writeReviewItemRecord(recordType, filePath, oldPath, newPath, fingerprint, sourceSessionID, errorMsg string, comments []model.LlmComment) string {
|
||||
uuid := generateUUID()
|
||||
|
||||
jw.mu.Lock()
|
||||
defer jw.mu.Unlock()
|
||||
rec := map[string]any{
|
||||
"uuid": uuid,
|
||||
"parentUuid": jw.lastUUID,
|
||||
"type": recordType,
|
||||
"sessionId": jw.sessionID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"filePath": filePath,
|
||||
"oldPath": oldPath,
|
||||
"newPath": newPath,
|
||||
"fingerprint": fingerprint,
|
||||
"model": jw.model,
|
||||
}
|
||||
if len(comments) > 0 {
|
||||
rec["comments"] = comments
|
||||
}
|
||||
if sourceSessionID != "" {
|
||||
rec["sourceSessionId"] = sourceSessionID
|
||||
}
|
||||
if errorMsg != "" {
|
||||
rec["error"] = errorMsg
|
||||
}
|
||||
jw.writeRecordLocked(rec)
|
||||
if jw.writer != nil {
|
||||
jw.writer.Flush()
|
||||
}
|
||||
jw.lastUUID = uuid
|
||||
return uuid
|
||||
}
|
||||
|
||||
// WriteLLMRequest writes a request entry with the resolved messages.
|
||||
func (jw *jsonlWriter) WriteLLMRequest(filePath string, taskType TaskType, requestNo int, messages any) string {
|
||||
uuid := generateUUID()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import (
|
|||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
func init() { UseTestSessions() }
|
||||
|
|
@ -268,3 +270,89 @@ func TestSessionEndIncludesFailures(t *testing.T) {
|
|||
t.Errorf("llm_failures = %v, want 3", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewItemResumeRoundTrip(t *testing.T) {
|
||||
repoDir := t.TempDir()
|
||||
sh := New(repoDir, "feature", "new-model", SessionOptions{
|
||||
ReviewMode: ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
ResumedFrom: "old-session",
|
||||
})
|
||||
comments := []model.LlmComment{{
|
||||
Path: "a.go",
|
||||
Content: "fix this",
|
||||
ExistingCode: "old()",
|
||||
}}
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", comments)
|
||||
sh.RecordReviewItemFailed("b.go", "b.go", "b.go", "fp-b", "rate limit")
|
||||
sh.Finalize()
|
||||
|
||||
state, err := LoadResumeState(repoDir, sh.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeState: %v", err)
|
||||
}
|
||||
if state.SessionID != sh.SessionID {
|
||||
t.Errorf("SessionID = %q, want %q", state.SessionID, sh.SessionID)
|
||||
}
|
||||
if state.ReviewMode != ReviewModeRange || state.DiffFrom != "main" || state.DiffTo != "feature" {
|
||||
t.Errorf("metadata mismatch: %+v", state)
|
||||
}
|
||||
if state.CompletedCount() != 1 {
|
||||
t.Fatalf("CompletedCount = %d, want 1", state.CompletedCount())
|
||||
}
|
||||
item, ok := state.Item("fp-a")
|
||||
if !ok {
|
||||
t.Fatal("missing fp-a")
|
||||
}
|
||||
if item.FilePath != "a.go" || len(item.Comments) != 1 || item.Comments[0].Content != "fix this" {
|
||||
t.Errorf("item mismatch: %+v", item)
|
||||
}
|
||||
if _, ok := state.Item("fp-b"); ok {
|
||||
t.Error("failed item should not be reusable")
|
||||
}
|
||||
if err := state.ValidateOptions(SessionOptions{ReviewMode: ReviewModeRange, DiffFrom: "main", DiffTo: "feature"}); err != nil {
|
||||
t.Fatalf("ValidateOptions: %v", err)
|
||||
}
|
||||
|
||||
records := readJSONLRecords(t, sessionJSONLPath(t, repoDir, sh.SessionID))
|
||||
var foundStart bool
|
||||
for _, r := range records {
|
||||
if r["type"] == "session_start" {
|
||||
foundStart = true
|
||||
if r["resumedFrom"] != "old-session" {
|
||||
t.Errorf("resumedFrom = %v, want old-session", r["resumedFrom"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundStart {
|
||||
t.Fatal("missing session_start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeStateValidateOptionsRejectsMismatchedRange(t *testing.T) {
|
||||
state := &ResumeState{
|
||||
SessionID: "s1",
|
||||
ReviewMode: ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
}
|
||||
err := state.ValidateOptions(SessionOptions{ReviewMode: ReviewModeRange, DiffFrom: "main", DiffTo: "other"})
|
||||
if err == nil {
|
||||
t.Fatal("expected mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeStateSessionStartKeepsRepoDirWhenCwdEmpty(t *testing.T) {
|
||||
state := &ResumeState{RepoDir: "/repo/from/caller"}
|
||||
|
||||
state.applySessionStart(resumeRecord{
|
||||
SessionID: "s1",
|
||||
ReviewMode: ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
|
||||
if state.RepoDir != "/repo/from/caller" {
|
||||
t.Fatalf("RepoDir = %q, want caller-provided repo dir", state.RepoDir)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
209
internal/session/resume.go
Normal file
209
internal/session/resume.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
// ResumeState is the replayed, read-only checkpoint index for one prior session.
|
||||
type ResumeState struct {
|
||||
SessionID string
|
||||
RepoDir string
|
||||
GitBranch string
|
||||
Model string
|
||||
ReviewMode string
|
||||
DiffFrom string
|
||||
DiffTo string
|
||||
DiffCommit string
|
||||
Items map[string]ResumeItem
|
||||
}
|
||||
|
||||
// ResumeItem is a completed file-level checkpoint, keyed by diff fingerprint.
|
||||
type ResumeItem struct {
|
||||
FilePath string
|
||||
OldPath string
|
||||
NewPath string
|
||||
Fingerprint string
|
||||
Comments []model.LlmComment
|
||||
}
|
||||
|
||||
type resumeRecord struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
GitBranch string `json:"gitBranch"`
|
||||
Model string `json:"model"`
|
||||
ReviewMode string `json:"reviewMode"`
|
||||
DiffFrom string `json:"diffFrom"`
|
||||
DiffTo string `json:"diffTo"`
|
||||
DiffCommit string `json:"diffCommit"`
|
||||
FilePath string `json:"filePath"`
|
||||
OldPath string `json:"oldPath"`
|
||||
NewPath string `json:"newPath"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
SourceSessionID string `json:"sourceSessionId"`
|
||||
Error string `json:"error"`
|
||||
Comments []model.LlmComment `json:"comments"`
|
||||
}
|
||||
|
||||
// SessionFilePath returns the JSONL path for a persisted session.
|
||||
func SessionFilePath(repoDir, sessionID string) (string, error) {
|
||||
if sessionID == "" {
|
||||
return "", fmt.Errorf("session id is required")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".opencodereview", sessionSubDir, encodeRepoPath(repoDir), sessionID+".jsonl"), nil
|
||||
}
|
||||
|
||||
// LoadResumeState replays a previous session JSONL into a fingerprint index.
|
||||
func LoadResumeState(repoDir, sessionID string) (*ResumeState, error) {
|
||||
path, err := SessionFilePath(repoDir, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open resume session %q: %w", sessionID, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
state := &ResumeState{
|
||||
SessionID: sessionID,
|
||||
RepoDir: repoDir,
|
||||
Items: make(map[string]ResumeItem),
|
||||
}
|
||||
reader := bufio.NewReader(f)
|
||||
for {
|
||||
line, readErr := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
if err := state.applyResumeLine(line); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read resume session %q: %w", sessionID, readErr)
|
||||
}
|
||||
}
|
||||
if state.SessionID == "" {
|
||||
state.SessionID = sessionID
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *ResumeState) applyResumeLine(line []byte) error {
|
||||
var rec resumeRecord
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
return fmt.Errorf("parse resume session %q: %w", s.SessionID, err)
|
||||
}
|
||||
|
||||
switch rec.Type {
|
||||
case "session_start":
|
||||
s.applySessionStart(rec)
|
||||
case "review_item_done", "review_item_reused":
|
||||
if rec.Fingerprint == "" {
|
||||
return nil
|
||||
}
|
||||
filePath := rec.FilePath
|
||||
if filePath == "" {
|
||||
filePath = rec.NewPath
|
||||
}
|
||||
s.Items[rec.Fingerprint] = ResumeItem{
|
||||
FilePath: filePath,
|
||||
OldPath: rec.OldPath,
|
||||
NewPath: rec.NewPath,
|
||||
Fingerprint: rec.Fingerprint,
|
||||
Comments: copyLlmComments(rec.Comments),
|
||||
}
|
||||
case "review_item_failed":
|
||||
if rec.Fingerprint != "" {
|
||||
delete(s.Items, rec.Fingerprint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ResumeState) applySessionStart(rec resumeRecord) {
|
||||
if rec.SessionID != "" {
|
||||
s.SessionID = rec.SessionID
|
||||
}
|
||||
if rec.Cwd != "" {
|
||||
s.RepoDir = rec.Cwd
|
||||
}
|
||||
s.GitBranch = rec.GitBranch
|
||||
s.Model = rec.Model
|
||||
s.ReviewMode = rec.ReviewMode
|
||||
s.DiffFrom = rec.DiffFrom
|
||||
s.DiffTo = rec.DiffTo
|
||||
s.DiffCommit = rec.DiffCommit
|
||||
}
|
||||
|
||||
// CompletedCount returns the number of reusable file-level checkpoints.
|
||||
func (s *ResumeState) CompletedCount() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return len(s.Items)
|
||||
}
|
||||
|
||||
// Item returns a copy of the checkpoint for fingerprint.
|
||||
func (s *ResumeState) Item(fingerprint string) (ResumeItem, bool) {
|
||||
if s == nil {
|
||||
return ResumeItem{}, false
|
||||
}
|
||||
item, ok := s.Items[fingerprint]
|
||||
if !ok {
|
||||
return ResumeItem{}, false
|
||||
}
|
||||
item.Comments = copyLlmComments(item.Comments)
|
||||
return item, true
|
||||
}
|
||||
|
||||
// ValidateOptions verifies that the requested review range matches the prior session.
|
||||
func (s *ResumeState) ValidateOptions(opts SessionOptions) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if opts.ReviewMode == "" || opts.ReviewMode == ReviewModeWorkspace {
|
||||
return fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported")
|
||||
}
|
||||
if s.ReviewMode == "" {
|
||||
return fmt.Errorf("resume session %q is missing review mode metadata", s.SessionID)
|
||||
}
|
||||
if s.ReviewMode != opts.ReviewMode {
|
||||
return fmt.Errorf("resume session review mode %q does not match current mode %q", s.ReviewMode, opts.ReviewMode)
|
||||
}
|
||||
switch opts.ReviewMode {
|
||||
case ReviewModeRange:
|
||||
if s.DiffFrom != opts.DiffFrom || s.DiffTo != opts.DiffTo {
|
||||
return fmt.Errorf("resume session range %q..%q does not match current range %q..%q", s.DiffFrom, s.DiffTo, opts.DiffFrom, opts.DiffTo)
|
||||
}
|
||||
case ReviewModeCommit:
|
||||
if s.DiffCommit != opts.DiffCommit {
|
||||
return fmt.Errorf("resume session commit %q does not match current commit %q", s.DiffCommit, opts.DiffCommit)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("resume mode %q is not supported", opts.ReviewMode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyLlmComments(in []model.LlmComment) []model.LlmComment {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.LlmComment, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ Commands:
|
|||
config Manage configuration settings
|
||||
llm LLM utility commands
|
||||
viewer Start the WebUI session viewer
|
||||
session, sessions List and inspect saved review sessions
|
||||
version Show version information
|
||||
|
||||
Examples:
|
||||
|
|
@ -31,12 +32,14 @@ Examples:
|
|||
ocr config set llm.model opus-4-6 Set a config value
|
||||
ocr llm test Test LLM connectivity
|
||||
ocr llm providers List built-in providers
|
||||
ocr session list List saved review sessions
|
||||
ocr version Show version info
|
||||
|
||||
Use "ocr review -h" for more information about review.
|
||||
Use "ocr rules -h" for more information about rules.
|
||||
Use "ocr config" for more information about config.
|
||||
Use "ocr llm" for more information about LLM utilities.
|
||||
Use "ocr session -h" for more information about session inspection.
|
||||
|
||||
GitHub: https://github.com/alibaba/open-code-review
|
||||
```
|
||||
|
|
@ -53,6 +56,8 @@ GitHub: https://github.com/alibaba/open-code-review
|
|||
| `ocr config model` | — | Interactive model-selection TUI. |
|
||||
| `ocr llm test` | — | Send a small chat request to verify the configured endpoint. |
|
||||
| `ocr llm providers` | — | List all built-in LLM providers. |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | List saved review sessions. |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | Inspect one session and its per-file checkpoints. |
|
||||
| `ocr viewer` | — | Launch the local web UI for past review sessions (`localhost:5483`). |
|
||||
| `ocr version` | — | Print version, commit, platform, build date, and GitHub URL. |
|
||||
|
||||
|
|
@ -83,6 +88,7 @@ staged + unstaged + untracked changes in the current directory's repo.
|
|||
| `--to <ref>` | — | — | Target ref to end the diff at (e.g., `feature-branch`). When set, OCR computes `merge-base(from, to)..to`. |
|
||||
| `--commit <sha>` | `-c` | — | Single commit to review (vs its parent). |
|
||||
| `--preview` | `-p` | `false` | Run the filter pipeline but skip the LLM. Prints the file list and exclusion reasons. |
|
||||
| `--resume <session-id>` | — | — | Resume from a previous compatible range or commit review session. |
|
||||
| `--format <fmt>` | `-f` | `text` | `text` (human-readable) or `json` (machine-readable comment array). |
|
||||
| `--audience <who>` | — | `human` | `human` streams progress lines; `agent` quiets stdout and prints only the final summary / JSON. |
|
||||
| `--background <text>` | `-b` | — | Optional requirement / business context injected into the plan + main prompts. |
|
||||
|
|
@ -96,6 +102,8 @@ staged + unstaged + untracked changes in the current directory's repo.
|
|||
|
||||
> Mode flags are mutually exclusive: pass either `--from`/`--to`, or
|
||||
> `--commit`, or neither (workspace mode). Mixing them is a hard error.
|
||||
> `--resume` supports only range or commit reviews and cannot be combined
|
||||
> with `--preview`.
|
||||
|
||||
### Modes
|
||||
|
||||
|
|
@ -135,6 +143,29 @@ ocr review -c abc123
|
|||
Reviews the diff produced by `git show abc123` (i.e., the changes that
|
||||
single commit introduced).
|
||||
|
||||
### Resuming interrupted reviews
|
||||
|
||||
Every `ocr review` run persists a local session log under
|
||||
`~/.opencodereview/sessions/`. Successful text output stays focused on review
|
||||
results and does not print the session ID; use `ocr session list/show` to find
|
||||
saved sessions, or `--format json` to include `session_id` in machine-readable
|
||||
output. If a range or commit review is interrupted, list saved sessions and
|
||||
resume from one that matches the same review target:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
Resume is strict by design:
|
||||
|
||||
- workspace reviews cannot be resumed
|
||||
- range reviews must use the same `--from` and `--to`
|
||||
- commit reviews must use the same `--commit`
|
||||
- `--preview` and `--resume` cannot be used together
|
||||
|
||||
### Output
|
||||
|
||||
#### Text (default, `--audience human`)
|
||||
|
|
@ -208,6 +239,8 @@ Top-level fields:
|
|||
| `summary` | Optional. Run aggregates: `files_reviewed`, `comments`, `total_tokens`, `input_tokens`, `output_tokens`, `cache_read_tokens` (omitempty), `cache_write_tokens` (omitempty), `elapsed`. Omitted for `skipped` runs. |
|
||||
| `comments` | Always present, possibly empty. Per-comment fields are the ones in the example above. |
|
||||
| `warnings` | Optional. Present when one or more sub-agents failed; each entry describes the affected file and the error. |
|
||||
| `session_id` | Optional. Present on persisted review runs; pass this to `ocr review --resume <session-id>` when retrying compatible range or commit reviews. |
|
||||
| `resume` | Optional. Present on resumed runs with `resumed_from`, `reused_files`, `rerun_files`, `previous_model`, and `current_model`. |
|
||||
|
||||
When no files were eligible for review, JSON mode emits a `skipped`
|
||||
envelope instead so callers can distinguish "no changes" from "no findings":
|
||||
|
|
@ -231,6 +264,48 @@ Non-fatal warnings (a single sub-agent failed, a file exceeded the token
|
|||
threshold, etc.) are printed inline; in JSON mode they're added to the
|
||||
`warnings` array.
|
||||
|
||||
## `ocr session`
|
||||
|
||||
Lists and inspects local review session logs saved under
|
||||
`~/.opencodereview/sessions/`. Use it to find a session ID, inspect
|
||||
per-file checkpoint status, and resume interrupted range or commit reviews.
|
||||
|
||||
```text
|
||||
ocr session <sub-command>
|
||||
ocr sessions <sub-command> (alias)
|
||||
|
||||
Sub-commands:
|
||||
list, ls List recent review sessions for the current repo
|
||||
show <id> Show one session's metadata and per-file items
|
||||
```
|
||||
|
||||
### `ocr session list`
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session list --limit 50
|
||||
ocr session list --json
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--repo <path>` | current dir | Repository whose sessions should be listed. |
|
||||
| `--json` | `false` | Emit session summaries as JSON. |
|
||||
| `--limit <n>` | `20` | Cap the number of listed sessions. Use `0` for unlimited. |
|
||||
|
||||
### `ocr session show`
|
||||
|
||||
```bash
|
||||
ocr session show <session-id>
|
||||
ocr session show --json <session-id>
|
||||
ocr session show --repo /path/to/repo <session-id>
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--repo <path>` | current dir | Repository whose session should be inspected. |
|
||||
| `--json` | `false` | Emit session metadata and per-file items as JSON. |
|
||||
|
||||
## `ocr rules`
|
||||
|
||||
Rule introspection. There is exactly one subcommand:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Commands:
|
|||
config Manage configuration settings
|
||||
llm LLM utility commands
|
||||
viewer Start the WebUI session viewer
|
||||
session, sessions List and inspect saved review sessions
|
||||
version Show version information
|
||||
|
||||
Examples:
|
||||
|
|
@ -30,12 +31,14 @@ Examples:
|
|||
ocr config set llm.model opus-4-6 Set a config value
|
||||
ocr llm test Test LLM connectivity
|
||||
ocr llm providers List built-in providers
|
||||
ocr session list List saved review sessions
|
||||
ocr version Show version info
|
||||
|
||||
Use "ocr review -h" for more information about review.
|
||||
Use "ocr rules -h" for more information about rules.
|
||||
Use "ocr config" for more information about config.
|
||||
Use "ocr llm" for more information about LLM utilities.
|
||||
Use "ocr session -h" for more information about session inspection.
|
||||
|
||||
GitHub: https://github.com/alibaba/open-code-review
|
||||
```
|
||||
|
|
@ -52,6 +55,8 @@ GitHub: https://github.com/alibaba/open-code-review
|
|||
| `ocr config model` | — | 対話的な model 選択 TUI。 |
|
||||
| `ocr llm test` | — | 短い chat リクエストを送信し、設定されたエンドポイントを検証します。 |
|
||||
| `ocr llm providers` | — | 組み込みの LLM プロバイダーをすべて一覧表示します。 |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存されたレビューセッションを一覧表示します。 |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | 1つのセッションとファイル単位のチェックポイントを表示します。 |
|
||||
| `ocr viewer` | — | 過去のレビューセッション用のローカル Web UI を起動します(`localhost:5483`)。 |
|
||||
| `ocr version` | — | バージョン、commit、プラットフォーム、ビルド日、GitHub URL を出力します。 |
|
||||
|
||||
|
|
@ -79,6 +84,7 @@ ocr r [flags] (alias)
|
|||
| `--to <ref>` | — | — | diff の終了 ref(例: `feature-branch`)。設定すると OCR は `merge-base(from, to)..to` を計算します。 |
|
||||
| `--commit <sha>` | `-c` | — | 単一の commit をレビューします(その親との差分)。 |
|
||||
| `--preview` | `-p` | `false` | フィルタリングのパイプラインを実行しますが LLM はスキップします。ファイル一覧と除外理由を出力します。 |
|
||||
| `--resume <session-id>` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開します。 |
|
||||
| `--format <fmt>` | `-f` | `text` | `text`(人間が読みやすい形式)または `json`(機械可読なコメント配列)。 |
|
||||
| `--audience <who>` | — | `human` | `human` は進捗行をストリーム出力します。`agent` は stdout を静音化し、最終サマリー / JSON のみを出力します。 |
|
||||
| `--background <text>` | `-b` | — | plan + main prompt に注入する、任意の要件 / 業務コンテキスト。 |
|
||||
|
|
@ -92,6 +98,7 @@ ocr r [flags] (alias)
|
|||
|
||||
> モード引数は排他です: `--from`/`--to` を渡すか、`--commit` を渡すか、いずれも渡さない(ワークスペースモード)かのいずれかです。
|
||||
> 混在させるとそのままエラーになります。
|
||||
> `--resume` は範囲または単一 commit レビューのみ対応し、`--preview` とは併用できません。
|
||||
|
||||
### モード
|
||||
|
||||
|
|
@ -125,6 +132,28 @@ ocr review -c abc123
|
|||
|
||||
`git show abc123` が生成する diff(すなわちその commit が導入した変更)をレビューします。
|
||||
|
||||
### 中断したレビューの再開
|
||||
|
||||
すべての `ocr review` 実行は、`~/.opencodereview/sessions/` 配下にローカル
|
||||
セッションログを保存します。正常終了したテキスト出力はレビュー結果に集中し、session ID
|
||||
は表示しません。保存済みセッションは `ocr session list/show` で確認でき、
|
||||
`--format json` では機械可読出力に `session_id` が含まれます。範囲または単一 commit
|
||||
レビューが中断された場合は、保存済みセッションを一覧表示し、同じレビュー対象に一致するセッションから再開します:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
再開は意図的に厳密です:
|
||||
|
||||
- ワークスペースレビューは再開できません
|
||||
- 範囲レビューは同じ `--from` と `--to` が必要です
|
||||
- 単一 commit レビューは同じ `--commit` が必要です
|
||||
- `--preview` と `--resume` は併用できません
|
||||
|
||||
### 出力
|
||||
|
||||
#### Text(デフォルト、`--audience human`)
|
||||
|
|
@ -193,6 +222,8 @@ ocr review --format json --audience agent
|
|||
| `summary` | 任意。実行の集計: `files_reviewed`、`comments`、`total_tokens`、`input_tokens`、`output_tokens`、`cache_read_tokens`(omitempty)、`cache_write_tokens`(omitempty)、`elapsed`。`skipped` の実行時は省略されます。 |
|
||||
| `comments` | 常に存在しますが、空の場合があります。各コメントのフィールドは上記の例のとおりです。 |
|
||||
| `warnings` | 任意。1 つ以上のサブエージェントが失敗した場合に存在します。各項目は影響を受けたファイルとエラーを記述します。 |
|
||||
| `session_id` | 任意。永続化されたレビュー実行に含まれます。互換性のある範囲または単一 commit レビューを再試行する際に `ocr review --resume <session-id>` へ渡せます。 |
|
||||
| `resume` | 任意。再開した実行で存在し、`resumed_from`、`reused_files`、`rerun_files`、`previous_model`、`current_model` を含みます。 |
|
||||
|
||||
レビュー対象のファイルがない場合、JSON モードは `skipped` の外殻を発行し、呼び出し側が「変更なし」と「発見なし」を区別できるようにします:
|
||||
|
||||
|
|
@ -213,6 +244,48 @@ ocr review --format json --audience agent
|
|||
|
||||
致命的でない警告(個々のサブエージェントの失敗、あるファイルが token しきい値を超過、など)はインラインで出力されます。JSON モードでは `warnings` 配列に追加されます。
|
||||
|
||||
## `ocr session`
|
||||
|
||||
`~/.opencodereview/sessions/` 配下に保存されたローカルレビューセッションログを一覧表示・確認します。
|
||||
session ID の確認、ファイル単位のチェックポイント状態の確認、中断した範囲または単一 commit
|
||||
レビューの再開に使用します。
|
||||
|
||||
```text
|
||||
ocr session <sub-command>
|
||||
ocr sessions <sub-command> (alias)
|
||||
|
||||
Sub-commands:
|
||||
list, ls List recent review sessions for the current repo
|
||||
show <id> Show one session's metadata and per-file items
|
||||
```
|
||||
|
||||
### `ocr session list`
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session list --limit 50
|
||||
ocr session list --json
|
||||
```
|
||||
|
||||
| 引数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `--repo <path>` | カレントディレクトリ | セッションを一覧表示するリポジトリ。 |
|
||||
| `--json` | `false` | セッションサマリーを JSON として出力します。 |
|
||||
| `--limit <n>` | `20` | 一覧表示するセッション数を制限します。`0` は無制限です。 |
|
||||
|
||||
### `ocr session show`
|
||||
|
||||
```bash
|
||||
ocr session show <session-id>
|
||||
ocr session show --json <session-id>
|
||||
ocr session show --repo /path/to/repo <session-id>
|
||||
```
|
||||
|
||||
| 引数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `--repo <path>` | カレントディレクトリ | セッションを確認するリポジトリ。 |
|
||||
| `--json` | `false` | セッションのメタデータとファイル単位の項目を JSON として出力します。 |
|
||||
|
||||
## `ocr rules`
|
||||
|
||||
ルールの自己確認です。サブコマンドは 1 つだけです:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Commands:
|
|||
config Manage configuration settings
|
||||
llm LLM utility commands
|
||||
viewer Start the WebUI session viewer
|
||||
session, sessions List and inspect saved review sessions
|
||||
version Show version information
|
||||
|
||||
Examples:
|
||||
|
|
@ -30,12 +31,14 @@ Examples:
|
|||
ocr config set llm.model opus-4-6 Set a config value
|
||||
ocr llm test Test LLM connectivity
|
||||
ocr llm providers List built-in providers
|
||||
ocr session list List saved review sessions
|
||||
ocr version Show version info
|
||||
|
||||
Use "ocr review -h" for more information about review.
|
||||
Use "ocr rules -h" for more information about rules.
|
||||
Use "ocr config" for more information about config.
|
||||
Use "ocr llm" for more information about LLM utilities.
|
||||
Use "ocr session -h" for more information about session inspection.
|
||||
|
||||
GitHub: https://github.com/alibaba/open-code-review
|
||||
```
|
||||
|
|
@ -52,6 +55,8 @@ GitHub: https://github.com/alibaba/open-code-review
|
|||
| `ocr config model` | — | 交互式 model 选择 TUI。 |
|
||||
| `ocr llm test` | — | 发送一条简短 chat 请求以验证配置的端点。 |
|
||||
| `ocr llm providers` | — | 列出所有内置 LLM provider。 |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话。 |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | 查看单个会话及其逐文件检查点。 |
|
||||
| `ocr viewer` | — | 启动用于历史评审会话的本地 Web UI(`localhost:5483`)。 |
|
||||
| `ocr version` | — | 打印版本、commit、平台、构建日期与 GitHub URL。 |
|
||||
|
||||
|
|
@ -80,6 +85,7 @@ unstaged + untracked 变更。
|
|||
| `--to <ref>` | — | — | diff 结束 ref(如 `feature-branch`)。设置后 OCR 计算 `merge-base(from, to)..to`。 |
|
||||
| `--commit <sha>` | `-c` | — | 评审单个 commit(相对其父)。 |
|
||||
| `--preview` | `-p` | `false` | 运行过滤流水线但跳过 LLM。打印文件列表与排除原因。 |
|
||||
| `--resume <session-id>` | — | — | 从之前兼容的区间或单 commit 评审会话恢复。 |
|
||||
| `--format <fmt>` | `-f` | `text` | `text`(人类可读)或 `json`(机器可读的评论数组)。 |
|
||||
| `--audience <who>` | — | `human` | `human` 流式输出进度行;`agent` 静默 stdout,只打印最终摘要 / JSON。 |
|
||||
| `--background <text>` | `-b` | — | 注入 plan + main prompt 的可选需求 / 业务上下文。 |
|
||||
|
|
@ -93,6 +99,7 @@ unstaged + untracked 变更。
|
|||
|
||||
> 模式参数互斥:传 `--from`/`--to`,或 `--commit`,或都不传(工作区模式)。
|
||||
> 混用会直接报错。
|
||||
> `--resume` 仅支持区间或单 commit 评审,不能与 `--preview` 同时使用。
|
||||
|
||||
### 模式
|
||||
|
||||
|
|
@ -129,6 +136,27 @@ ocr review -c abc123
|
|||
|
||||
评审 `git show abc123` 产生的 diff(即该 commit 引入的变更)。
|
||||
|
||||
### 恢复中断的评审
|
||||
|
||||
每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。
|
||||
正常完成的文本输出只展示评审结果,不打印 session ID;可使用
|
||||
`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取
|
||||
`session_id`。如果区间或单 commit 评审被中断,先列出已保存会话,再从与当前评审目标一致的会话恢复:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
恢复逻辑是严格的:
|
||||
|
||||
- 工作区评审不能恢复
|
||||
- 区间评审必须使用相同的 `--from` 和 `--to`
|
||||
- 单 commit 评审必须使用相同的 `--commit`
|
||||
- `--preview` 和 `--resume` 不能同时使用
|
||||
|
||||
### 输出
|
||||
|
||||
#### Text(默认,`--audience human`)
|
||||
|
|
@ -201,6 +229,8 @@ ocr review --format json --audience agent
|
|||
| `summary` | 可选。运行聚合:`files_reviewed`、`comments`、`total_tokens`、`input_tokens`、`output_tokens`、`cache_read_tokens`(omitempty)、`cache_write_tokens`(omitempty)、`elapsed`。`skipped` 运行时省略。 |
|
||||
| `comments` | 总是存在,可能为空。每条评论的字段如上例。 |
|
||||
| `warnings` | 可选。当一个或多个子 agent 失败时存在;每条描述受影响文件与错误。 |
|
||||
| `session_id` | 可选。持久化的评审运行会包含该字段;重试兼容的区间或单 commit 评审时可传给 `ocr review --resume <session-id>`。 |
|
||||
| `resume` | 可选。恢复运行时存在,包含 `resumed_from`、`reused_files`、`rerun_files`、`previous_model` 和 `current_model`。 |
|
||||
|
||||
当没有文件可评审时,JSON 模式会发一个 `skipped` 外壳,以便调用方区分“无变更”
|
||||
与“无发现”:
|
||||
|
|
@ -223,6 +253,47 @@ ocr review --format json --audience agent
|
|||
非致命警告(单个子 agent 失败、某文件超过 token 阈值等)内联打印;JSON 模式下
|
||||
会加入 `warnings` 数组。
|
||||
|
||||
## `ocr session`
|
||||
|
||||
列出和查看保存在 `~/.opencodereview/sessions/` 下的本地评审会话日志。
|
||||
可用它查找 session ID、查看逐文件检查点状态,并恢复中断的区间或单 commit 评审。
|
||||
|
||||
```text
|
||||
ocr session <sub-command>
|
||||
ocr sessions <sub-command> (alias)
|
||||
|
||||
Sub-commands:
|
||||
list, ls List recent review sessions for the current repo
|
||||
show <id> Show one session's metadata and per-file items
|
||||
```
|
||||
|
||||
### `ocr session list`
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session list --limit 50
|
||||
ocr session list --json
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--repo <path>` | 当前目录 | 要列出会话的仓库。 |
|
||||
| `--json` | `false` | 以 JSON 输出会话摘要。 |
|
||||
| `--limit <n>` | `20` | 限制列出的会话数量。使用 `0` 表示不限制。 |
|
||||
|
||||
### `ocr session show`
|
||||
|
||||
```bash
|
||||
ocr session show <session-id>
|
||||
ocr session show --json <session-id>
|
||||
ocr session show --repo /path/to/repo <session-id>
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--repo <path>` | 当前目录 | 要查看会话的仓库。 |
|
||||
| `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目。 |
|
||||
|
||||
## `ocr rules`
|
||||
|
||||
规则自查。只有一个子命令:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue