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:
MuoDoo 2026-07-09 11:43:11 +08:00 committed by GitHub
parent bd05c2b071
commit f35437671c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2418 additions and 64 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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')

View file

@ -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 {

View file

@ -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`)
}

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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)
}

View 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`)
}

View 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")
}
}

View file

@ -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 != "" {

View file

@ -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

View file

@ -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{

View file

@ -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) {

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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
View 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{}
}

View 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
}

View file

@ -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()

View file

@ -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
View 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
}

View file

@ -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:

View file

@ -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 つだけです:

View file

@ -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`
规则自查。只有一个子命令: