From f35437671cda9e2c7773dd6d8b56de7426634663 Mon Sep 17 00:00:00 2001 From: MuoDoo <36472129+MuoDoo@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:43:11 +0800 Subject: [PATCH] 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 --- README.ja-JP.md | 48 ++++ README.ko-KR.md | 48 ++++ README.md | 54 +++- README.ru-RU.md | 50 ++++ README.zh-CN.md | 47 ++++ cmd/opencodereview/emit_run_result_test.go | 60 +++++ cmd/opencodereview/flags.go | 9 + cmd/opencodereview/flags_test.go | 17 ++ cmd/opencodereview/main.go | 5 + cmd/opencodereview/output.go | 6 +- cmd/opencodereview/output_helpers_test.go | 6 +- cmd/opencodereview/review_cmd.go | 47 ++++ cmd/opencodereview/scan_cmd.go | 3 + cmd/opencodereview/session_cmd.go | 299 +++++++++++++++++++++ cmd/opencodereview/session_cmd_test.go | 170 ++++++++++++ cmd/opencodereview/shared.go | 14 +- internal/agent/agent.go | 157 +++++++++-- internal/agent/agent_test.go | 191 +++++++++++++ internal/agent/coverage_test.go | 32 ++- internal/llmloop/loop.go | 13 +- internal/llmloop/loop_test.go | 41 ++- internal/scan/agent.go | 11 +- internal/session/history.go | 60 ++++- internal/session/list.go | 293 ++++++++++++++++++++ internal/session/list_test.go | 189 +++++++++++++ internal/session/persist.go | 96 +++++-- internal/session/persist_test.go | 88 ++++++ internal/session/resume.go | 209 ++++++++++++++ pages/src/content/docs/en/cli-reference.md | 75 ++++++ pages/src/content/docs/ja/cli-reference.md | 73 +++++ pages/src/content/docs/zh/cli-reference.md | 71 +++++ 31 files changed, 2418 insertions(+), 64 deletions(-) create mode 100644 cmd/opencodereview/session_cmd.go create mode 100644 cmd/opencodereview/session_cmd_test.go create mode 100644 internal/session/list.go create mode 100644 internal/session/list_test.go create mode 100644 internal/session/resume.go diff --git a/README.ja-JP.md b/README.ja-JP.md index 3a0989c..698fcf7 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -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 + # フルファイルスキャン — diffではなくファイル全体をレビュー(git履歴不要) ocr scan # リポジトリ全体をスキャン ocr scan --path internal/agent # ディレクトリまたは特定のファイルをスキャン @@ -443,6 +447,8 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `ocr config unset custom_providers.` | — | カスタムプロバイダーを削除 | | `ocr llm test` | — | LLMの疎通テスト | | `ocr llm providers` | — | ビルトインLLMプロバイダーを一覧表示 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存済みレビューセッションを一覧表示 | +| `ocr session show ` | `ocr sessions show ` | 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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +再開は意図的に厳密です。範囲レビューと単一 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 ` | `--repo` | カレントディレクトリ | 確認するセッションのリポジトリ | +| `ocr session show ` | `--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 +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # このレビューでモデルを選択またはオーバーライド ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.ko-KR.md b/README.ko-KR.md index f3f33d1..0733c58 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -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 + # 전체 파일 스캔 — 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.` | - | 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 ` | `ocr sessions show ` | 단일 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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +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 ` | `--repo` | current dir | 확인할 session의 repository | +| `ocr session show ` | `--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 +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # 이번 리뷰에서 model 선택 또는 override ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.md b/README.md index 57730fb..85b0a80 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,10 @@ ocr review --from main --to feature-branch # Single commit ocr review --commit abc123 +# Resume an interrupted range or commit review +ocr session list +ocr review --from main --to feature-branch --resume + # 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.` | — | 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 ` | `ocr sessions show ` | 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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +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 ` | `--repo` | current dir | Repository whose session should be inspected | +| `ocr session show ` | `--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 +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # Select or override model for this review ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.ru-RU.md b/README.ru-RU.md index 6cbcc46..936b2a5 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -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 + # Полнофайловое сканирование — ревью целых файлов вместо диффа (история git не нужна) ocr scan # сканировать весь репозиторий ocr scan --path internal/agent # сканировать каталог или конкретные файлы @@ -445,6 +449,8 @@ ocr review \ | `ocr config unset custom_providers.` | — | Удалить пользовательского провайдера | | `ocr llm test` | — | Проверить подключение к LLM | | `ocr llm providers` | — | Показать список встроенных LLM-провайдеров | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | Показать сохранённые сессии ревью | +| `ocr session show ` | `ocr sessions show ` | Показать одну сессию и её 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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +Возобновление намеренно строгое: поддерживаются только ревью диапазона веток и одного +коммита, но не ревью рабочей копии. Текущие `--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 ` | `--repo` | текущий каталог | Репозиторий, сессию которого нужно посмотреть | +| `ocr session show ` | `--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 +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # Выбрать или переопределить модель для этого ревью ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f288db..e103b1e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 + # 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史) ocr scan # 扫描整个仓库 ocr scan --path internal/agent # 扫描指定目录或文件 @@ -443,6 +447,8 @@ ocr review \ | `ocr config unset custom_providers.` | — | 删除自定义供应商 | | `ocr llm test` | — | 测试 LLM 连通性 | | `ocr llm providers` | — | 列出内置 LLM 供应商 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话 | +| `ocr session show ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点 | | `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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +恢复逻辑是严格的:仅支持分支区间和单 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 ` | `--repo` | 当前目录 | 要查看会话的仓库 | +| `ocr session show ` | `--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 +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # 为本次审查选择或覆盖模型 ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go index 6ad71bb..f153fe2 100644 --- a/cmd/opencodereview/emit_run_result_test.go +++ b/cmd/opencodereview/emit_run_result_test.go @@ -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) + } +} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index 56a35e9..1f72bb1 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -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 + # 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') diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 0514e98..8e25996 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -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 { diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index fac044e..378e22e 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -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`) } diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go index cabeb78..0006164 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -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 { diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index 31001e7..80520b2 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -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 diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index adf553c..8379156 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -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 diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go index c383579..46353ab 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -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) } diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go new file mode 100644 index 0000000..805b8a5 --- /dev/null +++ b/cmd/opencodereview/session_cmd.go @@ -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-commands: + list, ls List recent review sessions for the current repo + show 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 '. + +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] + +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`) +} diff --git a/cmd/opencodereview/session_cmd_test.go b/cmd/opencodereview/session_cmd_test.go new file mode 100644 index 0000000..1c32a71 --- /dev/null +++ b/cmd/opencodereview/session_cmd_test.go @@ -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") + } +} diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go index 4d70c84..c99c1b2 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -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 != "" { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e7e02c9..a50dead 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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 diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 99f8280..24095dd 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -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{ diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go index f580942..018c86c 100644 --- a/internal/agent/coverage_test.go +++ b/internal/agent/coverage_test.go @@ -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) { diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go index 38d7b3f..ee0fe6a 100644 --- a/internal/llmloop/loop.go +++ b/internal/llmloop/loop.go @@ -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 diff --git a/internal/llmloop/loop_test.go b/internal/llmloop/loop_test.go index 5b6d449..352fc20 100644 --- a/internal/llmloop/loop_test.go +++ b/internal/llmloop/loop_test.go @@ -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) diff --git a/internal/scan/agent.go b/internal/scan/agent.go index d9095e9..aa0b834 100644 --- a/internal/scan/agent.go +++ b/internal/scan/agent.go @@ -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 diff --git a/internal/session/history.go b/internal/session/history.go index 6ece4da..c64ac88 100644 --- a/internal/session/history.go +++ b/internal/session/history.go @@ -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() { diff --git a/internal/session/list.go b/internal/session/list.go new file mode 100644 index 0000000..3e91494 --- /dev/null +++ b/internal/session/list.go @@ -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{} +} diff --git a/internal/session/list_test.go b/internal/session/list_test.go new file mode 100644 index 0000000..2c5c394 --- /dev/null +++ b/internal/session/list_test.go @@ -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 +} diff --git a/internal/session/persist.go b/internal/session/persist.go index 143d299..464da1b 100644 --- a/internal/session/persist.go +++ b/internal/session/persist.go @@ -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//.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() diff --git a/internal/session/persist_test.go b/internal/session/persist_test.go index 6231341..bb9119d 100644 --- a/internal/session/persist_test.go +++ b/internal/session/persist_test.go @@ -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) + } +} diff --git a/internal/session/resume.go b/internal/session/resume.go new file mode 100644 index 0000000..756037c --- /dev/null +++ b/internal/session/resume.go @@ -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 +} diff --git a/pages/src/content/docs/en/cli-reference.md b/pages/src/content/docs/en/cli-reference.md index c80f8cb..905b0c4 100644 --- a/pages/src/content/docs/en/cli-reference.md +++ b/pages/src/content/docs/en/cli-reference.md @@ -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 ` | `ocr sessions show ` | 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 ` | — | — | Target ref to end the diff at (e.g., `feature-branch`). When set, OCR computes `merge-base(from, to)..to`. | | `--commit ` | `-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 ` | — | — | Resume from a previous compatible range or commit review session. | | `--format ` | `-f` | `text` | `text` (human-readable) or `json` (machine-readable comment array). | | `--audience ` | — | `human` | `human` streams progress lines; `agent` quiets stdout and prints only the final summary / JSON. | | `--background ` | `-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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +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 ` 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 +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show 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 ` | current dir | Repository whose sessions should be listed. | +| `--json` | `false` | Emit session summaries as JSON. | +| `--limit ` | `20` | Cap the number of listed sessions. Use `0` for unlimited. | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| Flag | Default | Description | +|---|---|---| +| `--repo ` | 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: diff --git a/pages/src/content/docs/ja/cli-reference.md b/pages/src/content/docs/ja/cli-reference.md index 22691c7..c62da6e 100644 --- a/pages/src/content/docs/ja/cli-reference.md +++ b/pages/src/content/docs/ja/cli-reference.md @@ -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 ` | `ocr sessions show ` | 1つのセッションとファイル単位のチェックポイントを表示します。 | | `ocr viewer` | — | 過去のレビューセッション用のローカル Web UI を起動します(`localhost:5483`)。 | | `ocr version` | — | バージョン、commit、プラットフォーム、ビルド日、GitHub URL を出力します。 | @@ -79,6 +84,7 @@ ocr r [flags] (alias) | `--to ` | — | — | diff の終了 ref(例: `feature-branch`)。設定すると OCR は `merge-base(from, to)..to` を計算します。 | | `--commit ` | `-c` | — | 単一の commit をレビューします(その親との差分)。 | | `--preview` | `-p` | `false` | フィルタリングのパイプラインを実行しますが LLM はスキップします。ファイル一覧と除外理由を出力します。 | +| `--resume ` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開します。 | | `--format ` | `-f` | `text` | `text`(人間が読みやすい形式)または `json`(機械可読なコメント配列)。 | | `--audience ` | — | `human` | `human` は進捗行をストリーム出力します。`agent` は stdout を静音化し、最終サマリー / JSON のみを出力します。 | | `--background ` | `-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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +再開は意図的に厳密です: + +- ワークスペースレビューは再開できません +- 範囲レビューは同じ `--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 ` へ渡せます。 | +| `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 +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show 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 ` | カレントディレクトリ | セッションを一覧表示するリポジトリ。 | +| `--json` | `false` | セッションサマリーを JSON として出力します。 | +| `--limit ` | `20` | 一覧表示するセッション数を制限します。`0` は無制限です。 | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| 引数 | デフォルト | 説明 | +|---|---|---| +| `--repo ` | カレントディレクトリ | セッションを確認するリポジトリ。 | +| `--json` | `false` | セッションのメタデータとファイル単位の項目を JSON として出力します。 | + ## `ocr rules` ルールの自己確認です。サブコマンドは 1 つだけです: diff --git a/pages/src/content/docs/zh/cli-reference.md b/pages/src/content/docs/zh/cli-reference.md index 34387db..39ca0fa 100644 --- a/pages/src/content/docs/zh/cli-reference.md +++ b/pages/src/content/docs/zh/cli-reference.md @@ -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 ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点。 | | `ocr viewer` | — | 启动用于历史评审会话的本地 Web UI(`localhost:5483`)。 | | `ocr version` | — | 打印版本、commit、平台、构建日期与 GitHub URL。 | @@ -80,6 +85,7 @@ unstaged + untracked 变更。 | `--to ` | — | — | diff 结束 ref(如 `feature-branch`)。设置后 OCR 计算 `merge-base(from, to)..to`。 | | `--commit ` | `-c` | — | 评审单个 commit(相对其父)。 | | `--preview` | `-p` | `false` | 运行过滤流水线但跳过 LLM。打印文件列表与排除原因。 | +| `--resume ` | — | — | 从之前兼容的区间或单 commit 评审会话恢复。 | | `--format ` | `-f` | `text` | `text`(人类可读)或 `json`(机器可读的评论数组)。 | | `--audience ` | — | `human` | `human` 流式输出进度行;`agent` 静默 stdout,只打印最终摘要 / JSON。 | | `--background ` | `-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 +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +恢复逻辑是严格的: + +- 工作区评审不能恢复 +- 区间评审必须使用相同的 `--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 `。 | +| `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 +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show 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 ` | 当前目录 | 要列出会话的仓库。 | +| `--json` | `false` | 以 JSON 输出会话摘要。 | +| `--limit ` | `20` | 限制列出的会话数量。使用 `0` 表示不限制。 | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--repo ` | 当前目录 | 要查看会话的仓库。 | +| `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目。 | + ## `ocr rules` 规则自查。只有一个子命令: