diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 9a45414..1b7cfcc 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -9,7 +9,10 @@ ui/ ──▶ core/ ◀── utils/ (依存先なし) ``` -- **`src/core/`**: 純粋なドメインロジック。**Ink / React / execa / node の I/O を import しない**。SDK 型 (`@anthropic-ai/claude-agent-sdk`) の型参照は可。ここが全ロジックの中心で、ユニットテストで完全に駆動できること。 +- **`src/core/`**: 純粋なドメインロジック。**Ink / React / execa / node の I/O を import しない**。ここが全ロジックの中心で、ユニットテストで完全に駆動できること。 + 特定エージェントの SDK (`@anthropic-ai/claude-agent-sdk`) を import してよいのは**アダプタ 3 点** + (`claude-adapter.ts` / `claude-parse.ts` / `claude-errors.ts`)だけで、他の中立モジュールは + 型も定数も引かない([sdk-integration.md](./sdk-integration.md))。 - **`src/utils/`**: I/O の薄いラッパ(`git.ts` = execFile ラッパ)。`core` のみに依存。 - **`src/ui/`**: Ink コンポーネントのみ。`core`(状態・型)を `@/core` から使う。ロジックを持たない — 状態計算は `core` の純関数に委譲する。 - **`src/index.tsx` / `src/main.tsx` / `src/app.tsx` / `src/bootstrap/`**: 合成レイヤ(どのレイヤにも属さず core と utils を束ねる)。 @@ -27,10 +30,22 @@ ui/ ──▶ core/ ◀── utils/ 副作用は境界で注入する。テスト容易性の要。 -- `Session` は `queryFn`(SDK の `query`)を DI で受ける → テストはフェイクを注入。 +- `Session` / `SessionManager` は `agent`(`AgentAdapter`)を DI で受ける。省略時は `queryFn` + (SDK の `query`)から Claude アダプタを組み立てる短縮形 → テストはフェイクを注入。 - `SessionManager` は `worktrees`(`WorktreeService`)と `createSession` factory を DI で受ける。 - `now: () => number` も注入可能にして時間を決定的にする(reducer は純粋、時刻はイベントの `at` で渡す)。 +**DI 用の interface は 2 つの leaf に置く**(どちらも他の core モジュールを import しない末端。 +そこに集約することで core 内の循環 import を防いでいる): + +| ファイル | 何の seam か | 主な型 | +|---|---|---| +| `core/session-ports.ts` | codiva 側(manager が駆動するもの) | `WorktreeService` / `SessionHandle` / `PrAutomation` / `PrLookup` | +| `core/agent-ports.ts` | エージェント側(provider の差し替え) | `AgentAdapter` / `AgentRun` / `AgentRunRequest` / `AgentCapabilities` / `PermissionDecision` | + +`session-ports.ts` が `agent-ports.ts` を型で参照する(`SessionHandle.getAgent()`)ので、 +依存の向きは **session-ports → agent-ports** の一方向。ここにある型を他ファイルで再定義しない。 + ## ファイル/モジュール規約 - **ファイル名は kebab-case**(`session-list.tsx`, `status-reducer.ts`)。コンポーネント/クラスの識別子は PascalCase。 diff --git a/.claude/rules/sdk-integration.md b/.claude/rules/sdk-integration.md index 2178eac..1beb847 100644 --- a/.claude/rules/sdk-integration.md +++ b/.claude/rules/sdk-integration.md @@ -1,8 +1,10 @@ -# Claude Agent SDK 連携規約 +# エージェント連携規約(Claude Agent SDK) -`@anthropic-ai/claude-agent-sdk` を触るときの不変条件。**`core/session.ts` / `core/sdk-parse.ts` / -`utils/model-catalog.ts` / `utils/title.ts` を触る前に読む。** 実測データと詳細は -[docs/TECH_NOTES.md](../../docs/TECH_NOTES.md)。 +コーディングエージェントとの境界と、`@anthropic-ai/claude-agent-sdk` を触るときの不変条件。 +**`core/agent-ports.ts` / `core/agent-events.ts` / `core/claude-adapter.ts` / `core/claude-parse.ts` / +`core/claude-errors.ts` / `core/session.ts` / `utils/model-catalog.ts` / `utils/title.ts` を触る前に読む。** +実測データと詳細は [docs/TECH_NOTES.md](../../docs/TECH_NOTES.md)、設計の理由は +[docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)「エージェント抽象」。 ## 大原則: 想定で書かない @@ -12,34 +14,84 @@ - SDK の型(`SDKMessage` / `SDKResultSuccess` / `SDKPartialAssistantMessage` / `ModelInfo` …)は `import type` で引く。`any` は使わず、変換は `toXxx()` / ガード `isXxx()` に閉じ込める。 -## SDK 形状の知識は sdk-parse.ts にだけ置く - -- 生の `SDKMessage` を解釈するのは `core/sdk-parse.ts`(`applySdkMessage` / `summarizeToolUse` / - `toolResultSummary`)のみ。`status-reducer.ts` は**型付き `CodivaEvent` しか受けない** - (`message.subtype` 等の生参照をここへ持ち込まない)。 +## 境界は `AgentAdapter`(`QueryFn` ではない) + +- エージェントの DI 境界は `core/agent-ports.ts` の `AgentAdapter` / `AgentRun` と、 + `core/session-ports.ts` の `SessionHandle`。**SDK の `query()` の署名を共通 IF にしない** — + `AsyncIterable` + `Options` + `canUseTool` + control request は Claude 固有の + 制御モデルで、それを IF にすると全 provider にその模倣を強いることになる。 +- アダプタの責務は 3 つだけ: (1) ストリームを開く(`open`)、(2) provider のメッセージを + `AgentEvent[]` へ写す、(3) 失敗文言を `AgentStopCause` へ分類する(`classifyError`)。 + それ以外(ログの上限・進捗・完了ゲート・PR 検出・コスト集計)は共通側の仕事。 +- 許可要求の型も自前(`PermissionDecision`)。SDK の `PermissionResult` を core へ持ち込まず、 + provider 形への写像はアダプタが行う(Claude は `claude-adapter.ts` の `canUseTool`)。 +- その provider に無い機能は `AgentCapabilities` で表明する(`permissions` / `interrupt` / + `setModel` / `resume` / `modelCatalog` / `usage` / `cost` / `transcript`)。UI は capability を + 見て縮退する(表は入っているが実際の縮退の配線は Phase D)。`AgentRun.interrupt` / `setModel` は + optional。新しいアダプタは `NO_CAPABILITIES` から始めて、実装できたものだけ true にする。 + +## 形の知識は 2 段に割る(アダプタの parse → 共通の fold) + +``` +provider のメッセージ ──[アダプタの parse]──▶ AgentEvent[] ──[applyAgentEvent]──▶ SessionState +``` + +- 生の `SDKMessage` を解釈するのは `core/claude-parse.ts`(`parseClaudeMessage` / + `summarizeToolUse` / `toolResultSummary`)のみ。ここは**状態を変えない** — + `SDKMessage` を `AgentEvent[]` に写すだけ。 +- 状態の畳み込みは `core/agent-events.ts` の `applyAgentEvent` が**全 provider 共通**で持つ。 + ここに provider 固有の分岐(`message.subtype` / SDK のツール名 / CLI の文言)を足さない。 + ツールは `AgentToolKind`(`edit` / `shell` / `todo` / `question` / `other`)へ、TODO 操作は + `TodoOp` へ、失敗は `AgentStopCause` へ、というように**アダプタ側で正規化してから**渡す。 +- `applyClaudeMessage`(parse → fold の合成)は既存の実データテストの入口を保つための薄い糖衣。 + 新しい呼び出し側はこれを増やさず `AgentEvent` 経由にする。 +- `status-reducer.ts` は**型付き `CodivaEvent` しか受けない**(生参照を持ち込まない)。 - UI・`SessionManager` は SDK メッセージを直接読まない。 -## query() の使い方 +## 中立モジュールは SDK を import しない + +- `@anthropic-ai/claude-agent-sdk` を import してよいのは **`core/claude-adapter.ts` / + `core/claude-parse.ts` / `core/claude-errors.ts`**(と `utils/` の Claude 実装)だけ。 + 他の `core/` モジュールから型・定数を引かない。 +- そのため、SDK の union と値が同じでも自前で持つものがある: `core/config.ts` の + `EffortLevel` / `PermissionMode`(配列が唯一の出所で、型も実行時検証もそこから導出)。 + **型で気付けないので、SDK 更新時に目視で追従させる**。 +- CLI の文言・typed error kind・HTTP ステータスの知識は `core/claude-errors.ts` に集める + (`isAuthError` / `isAuthErrorKind` / `isConnectionError` / `isTransientApiErrorKind` / + `isTransientApiStatus` / `isRateLimitError` / `classifyClaudeError`)。状態機械はこの知識を + 持たず、分類結果の `AgentStopCause` だけを受け取る(`CodivaEvent` の `aborted.cause`)。 + 別のエージェントを足すときは、このファイルの対になるものをそのアダプタ用に書く。 +- `USAGE_LIMIT_ERROR_PREFIXES` のように「CLI 側で変わるので SDK に追従したい」定数は、 + **アダプタの中でだけ** SDK から読む。 + +## query() の使い方(`core/claude-adapter.ts` の中だけ) - **streaming input mode 固定**(`prompt` に `AsyncQueue` の `AsyncIterable` を渡す)。 単発 string prompt は使わない(エラーで throw して終わり、追加指示・interrupt・ - `setPermissionMode` が使えない)。 + `setPermissionMode` が使えない)。`Session` が持つのは `AsyncIterable` で、 + `SDKUserMessage` への包み直しはアダプタの `toSdkPrompt`。 - 既定の options: `cwd`=セッションの worktree、`permissionMode`(設定優先、既定 `acceptEdits`)、 `canUseTool`、`abortController`、`settingSources: ['project']`(対象リポジトリの CLAUDE.md を読ませる)、 - `includePartialMessages: true`(ストリーミングプレビュー用)。 + `includePartialMessages: true`(ストリーミングプレビュー用)。**この既定を組み立てるのはアダプタ**で、 + `Session` は provider 非依存の `AgentRunOptions`(model / effort / permissionMode / maxBudgetUsd / + systemPrompt)しか渡さない。各項目をどう解釈するか(無視も可)はアダプタの裁量。 - `systemPrompt` は**純粋な `core/system-prompt.ts` の `composeSystemPrompt()` で組み立てる** (`session.ts` に文言や結合順を書かない)。要素は「worktree の環境説明(`ignoredFiles: 'symlink'` のときだけ載る共有 symlink の注意書き)」→「`/.codiva/prompt.md` の内容」の順で、 どちらも無ければ付与しない。**SDK は `systemPrompt` 省略時に空文字へ写像する**ので単純代入で 現挙動を壊さないが、将来ベースの systemPrompt を導入するなら array / preset-append 形へ - 変える必要がある(`session.ts` のコメント参照)。 + 変える必要がある(`claude-adapter.ts` のコメント参照)。 - **AI 向けのプロンプト文字列は i18n カタログに置かない**(UI 文字列ではない)。英語で書く (`core/system-prompt.ts` / `utils/title.ts` の `TITLE_INSTRUCTION` が前例)。 - `resume` は**モデル側コンテキストだけ**を復元し、過去メッセージをストリームに再送出しない。 UI のログは transcript から再構築する([session-domain.md](./session-domain.md))。 + 渡してよいのは**その provider が発行した id だけ**(`SessionState.agentSessions[agent]`)。 + 別 provider の id を渡すと存在しない会話を resume しようとして壊れる。 -## モデル一覧は SDK が唯一の出所 +## モデル一覧は SDK が唯一の出所(Claude の capability) +- モデルカタログは Claude 固有の機能なので `AgentCapabilities.modelCatalog` / `setModel` で + optional 化してある。持たない provider では `/model` を出さない。 - `/model` の選択肢は `Query.supportedModels()` から取る(`utils/model-catalog.ts` の `fetchModelCatalog`。I/O・throw しない・10 秒でタイムアウト)。**モデル ID・表示名・説明文を アプリ側に直書きしない**(アカウント種別・サブスク・CLI バージョンで実際に選べるモデルが変わる)。 @@ -49,42 +101,55 @@ - 取得失敗時は `FALLBACK_MODEL_OPTIONS`(**バージョンを含まないファミリーエイリアスのみ**)。 - モデル名・説明文は SDK 由来の英語をそのまま出す(i18n の例外。[i18n.md](./i18n.md))。 -## canUseTool の契約 +## canUseTool の契約(アダプタ ⇄ `requestPermission`) +- SDK の `canUseTool` を実装するのはアダプタで、`Session` へは中立の + `requestPermission(req) => Promise` として上げる。`Session` は + 「何が質問か」を知らず、codiva 自身のポリシー(`core/run-mode.ts`)だけを見る。 - Promise を解決するまでセッションはブロックされる。UI の応答待ちで pending のままにしてよい。 -- **`AskUserQuestion` は allow ルールに関係なく必ず届く**。これが「質問あり」の実装点。 - 回答は `{ behavior: 'allow', updatedInput: { ...input, answers } }` の形で返す +- **`AskUserQuestion` は allow ルールに関係なく必ず届く**。これが「質問あり」の実装点で、 + アダプタが `kind: 'question'` + `QuestionSpec[]` へ写して上げる。SDK へ返すときは + `{ behavior: 'allow', updatedInput: { ...input, answers } }` の形 (`answers` = `{ [questionText]: 選択ラベル }`。multiSelect はカンマ区切り)。 - **`answers` を入れずに allow すると質問が無視される**(`"The user did not answer the questions."`)。 + **`answers` を入れずに allow すると質問が無視される**(`"The user did not answer the questions."`)ので、 + UI の回答は `PermissionDecision.input` に載せて丸ごと差し替える。 - ルーチンツール(Write/Edit/Bash 等)は `auto` モードで自動 allow、`confirm` モードで UI に上げる。 判定は `core/run-mode.ts` の `createModePolicy`。`acceptEdits` でも `Write` が `canUseTool` に落ちてくる(実測)ので「編集系は自動許可」を前提にしない。 ## result の解釈 -- streaming input mode では `result` は**ターンの区切りごと**に届く。セッション終了ではない。 -- **完了とみなすのは `subtype === 'success' && !is_error` のときだけ**。それ以外は文言分類 - (認証 → レート制限 → 通信断 → `failed`)へ流す。`subtype === 'success'` だけを見ると - 認証切れが「緑の Completed」になり auto-PR まで走る(実際に起きた不具合)。 +- streaming input mode では `result` は**ターンの区切りごと**に届く。セッション終了ではない + (`turn_completed` / `turn_stopped` であって「セッション終了」イベントではない)。 +- **完了(`turn_completed`)とみなすのは `subtype === 'success' && !is_error` のときだけ**。 + それ以外は `classifyClaudeError` の文言分類(認証 → レート制限 → 通信断 → `failed`)へ流して + `turn_stopped` にする。`subtype === 'success'` だけを見ると認証切れが「緑の Completed」になり + auto-PR まで走る(実際に起きた不具合)。 - エラー系 subtype は `result` を持たず `errors: string[]` に理由を積むので**両方読む**。 -- 同じ失敗が assistant と result の2回届くため、遷移関数(`toNeedsLogin` 等)は冪等に保つ - (同一 detail なら同一参照を返す)。 +- 同じ失敗が assistant と result の2回届く。2 回目(ターン終了の要約)は + `turn_stopped.rollup: true` を立てて出し、既に resumable な状態なら畳み込み側が + コストだけ拾って分類し直さない(やり直すと認証切れが素の `failed` に格下げされる)。 + 遷移関数(`toNeedsLogin` 等)も冪等に保つ(同一 detail なら同一参照を返す)。 ## サブエージェント(Task ツール)の完了ゲート - Task がバックグラウンド実行されると、サブエージェント稼働中に**トップレベルの `result/success` が先に届く**。素直に completed にすると「作業中なのに完了」になる。 -- 対策: `system/task_started` / `system/task_notification` で `activeTaskIds` を追跡し、 - タスクが残っていれば結果を `deferredResult` に保留して `running` を維持、全タスク settle 後に - completed を確定する。`skip_transcript` の雑務タスクはゲート対象外。 +- 対策: `system/task_started` / `system/task_notification` を `task_started` / `task_settled` へ + 写して `activeTaskIds` を追跡し、タスクが残っていれば結果を `deferredResult` に保留して + `running` を維持、全タスク settle 後に completed を確定する。`skip_transcript` の雑務タスクは + ゲート対象外。**ゲート自体は `applyAgentEvent` 側(全 provider 共通)**にあるので、他の + provider は「タスクが始まった/片付いた」を報告するだけでよい。 - `activeTaskIds` / `deferredResult` / `streamingText` は transient で**永続しない**。 ## レート制限情報 -- `rate_limit_event` の `rejected` はセッションを `rate_limited` にする一方、`allowed` / - `allowed_warning` も含めて**アカウント全体**の使用状況を運ぶ。これはセッション状態ではないので - `Session.onRateLimit`(DI)で `SessionManager` へ渡し、ウィンドウ種別ごとに最新値を保持する - (正規化は純粋な `core/rate-limit.ts`)。 +- `rate_limit_event` の `rejected` はセッションを `rate_limited` にする(`turn_stopped` の + `cause: 'rate_limit'`)一方、`allowed` / `allowed_warning` も含めて**アカウント全体**の + 使用状況を運ぶ(`usage` イベント)。後者はセッション状態ではないので `applyAgentEvent` は + 無視し、`Session.onRateLimit`(DI)で `SessionManager` へ横に流してウィンドウ種別ごとに + 最新値を保持する(正規化は純粋な `core/rate-limit.ts`)。使用状況ゲージは + `AgentCapabilities.usage` を持つ provider だけの機能。 ## サブプロセスのコスト意識 diff --git a/.claude/rules/session-domain.md b/.claude/rules/session-domain.md index f6de845..4eac19b 100644 --- a/.claude/rules/session-domain.md +++ b/.claude/rules/session-domain.md @@ -11,20 +11,32 @@ interrupted / rate_limited / needs_login / failed / conflict / archived ``` - `interrupted` / `rate_limited` / `needs_login` は **resumable な idle**(エラーではない)。 - `failed` と混同しない。分類の根拠は `core/errors.ts`(`isAuthError` → `isRateLimitError` → - `isConnectionError` の順に判定。**認証切れを最優先**)。 + `failed` と混同しない。**どの文言がどれに当たるかを知るのはアダプタ**で、状態機械は分類結果の + `AgentStopCause`(`auth` / `rate_limit` / `connection` / `failed`)だけを見る。Claude の判定は + `core/claude-errors.ts` の `classifyClaudeError`(`isAuthError` → `isRateLimitError` → + `isConnectionError` の順。**認証切れを最優先** — 認証エラーがタイムアウトに言及することがあり、 + 通信断と読み違えると「ログインし直せ」と言うべき場面で素の再開を勧めてしまう)。 - `conflict` はマージ競合の可視化専用。自動解消しないので**終端状態**として扱う。 -## 遷移の唯一の経路は reducer +## 遷移の唯一の経路は 2 本の純関数 -- 状態を作るのは `reduce(state, CodivaEvent)`(`core/status-reducer.ts`、純関数)と - `applySdkMessage(state, SDKMessage, at)`(`core/sdk-parse.ts`)だけ。**`SessionStore` に - `status` を手書きで set しない**(過去に provision 失敗が reducer を迂回して、以後 - `send`/`allow` が黙って no-op になる不具合を作った)。失敗も - `reduce(state, { kind: 'aborted', error, at })` を通す。 +- 状態を作るのは次の 2 つだけ。**`SessionStore` に `status` を手書きで set しない** + (過去に provision 失敗が reducer を迂回して、以後 `send`/`allow` が黙って no-op になる + 不具合を作った)。失敗も `reduce(state, { kind: 'aborted', error, cause, at })` を通す。 + + | 関数 | 入力 | 意味 | + |---|---|---| + | `reduce(state, CodivaEvent)`(`core/status-reducer.ts`) | `CodivaEvent` | **codiva 起点**(UI / manager が起こしたこと) | + | `applyAgentEvent(state, AgentEvent, at, agent?)`(`core/agent-events.ts`) | `AgentEvent` | **エージェント起点**(provider に起きたこと。全 provider 共通の畳み込み) | + + 2 本に分けているのは役割が違うため。provider のストリームはアダプタが `AgentEvent` へ + 正規化してから `applyAgentEvent` に渡す([sdk-integration.md](./sdk-integration.md))。 + `applyClaudeMessage`(`core/claude-parse.ts`)は parse → fold を合成した薄い糖衣で、 + 既存の実データテストの入口を保つためだけに残してある。 - `CodivaEvent` は UI/manager 由来のアクションのみ(`permission_request` / `permission_resolved` / - `user_input` / `model` / `title` / `pr` / `pr_lookup` / `conflict` / `aborted` / `interrupted` / - `archived`)。`pr` は「`gh` が答えた」ときだけ流すので `prLookup` も必ずクリアする。 + `user_input` / `model` / `title` / `pr` / `pr_lookup` / `conflict` / `aborted` / + `agent_switched` / `interrupted` / `archived`)。`pr` は「`gh` が答えた」ときだけ流すので + `prLookup` も必ずクリアする。 失敗(`unavailable`)は `pr_lookup: 'error'` で表現し、**`pr` を undefined で上書きしない** (PR 番号がポーリングごとに消える不具合の再発防止)。 - **PR は「識別」と「状態」に分けて持つ**。`pr: PrRef`(番号・URL。ブランチに対して不変なので @@ -34,15 +46,22 @@ interrupted / rate_limited / needs_login / failed / conflict / archived (= state.json の保存)がチェックの進行ごとに走らない。番号が分かっていてステータス未取得 (復元直後・PR 作成直後)は `prPollIntervalMs` が 0 を返し、すぐ取得して埋める。 全 variant が `at: number` を持ち、reducer は時刻を読まない(純粋・決定的)。 -- **SDK メッセージは `CodivaEvent` ではない**。生の形を知るのは `sdk-parse.ts` だけ - ([sdk-integration.md](./sdk-integration.md))。 +- **`aborted` は `cause` で分岐する**(`AgentStopCause`。省略時は `failed`)。reducer が + エラー文言を正規表現で見て分類し直さない — 「認証切れ」「レート制限」「通信断」の見分け方は + provider ごとに違うので、判定はアダプタの `classifyError` に閉じ込める。 +- **`agent_switched` は worktree を動かさない**。今の provider の resume id を `agentSessions` + へ退避し、切替先の id(過去に使っていれば)を `sdkSessionId` に据える。切替先が初めてなら + `sdkSessionId` は undefined になり、次のターンは新しい会話として始まる。`streamingText` と + `model`(解決済みモデルは provider ごとに別物)は捨てる。 +- **SDK メッセージは `CodivaEvent` でも `AgentEvent` でもない**。生の形を知るのは + `claude-parse.ts` だけ([sdk-integration.md](./sdk-integration.md))。 - 状態の確定は `Session.commit` の単一経路。ここが `accrueActive` を呼ぶので、 個別の遷移に稼働時間の計算を散らさない。 ## ログ(`messages`)は上限付き -- **追記の経路は `core/log-buffer.ts` の `pushLogEntry` だけ**(`appendLog` と `sdk-parse` の - 追記もここを通す。例外は `onApiRetry` の**書き換え** = 末尾 1 件の差し替えで、件数を増やさない)。 +- **追記の経路は `core/log-buffer.ts` の `pushLogEntry` だけ**(`appendLog` と + `applyAgentEvent` の追記もここを通す。例外は `onApiRetry` の**書き換え** = 末尾 1 件の差し替えで、件数を増やさない)。 `[...state.messages, entry]` を新しく書かない — 上限なしの追記 + 全体コピーが **実際にヒープ枯渇で TUI を落とした**(`FATAL ERROR: Ineffective mark-compacts`)。 - 上限は 3 つ: 件数 `MAX_LOG_ENTRIES` / **合計文字数 `MAX_LOG_CHARS`** / 1 件あたり @@ -57,6 +76,8 @@ interrupted / rate_limited / needs_login / failed / conflict / archived 上限が無いと一過性のゴミが永続的な保持に化ける)。エントリが immutable であること (変更時は必ず別オブジェクト)が前提なので `LogEntry` をその場で書き換えない。 返る `DisplayLine` は read-only 扱い。 +- **`LogEntry.agent` は切替が起きたあとだけ入る**。単一エージェントで完結するセッションの + ログ行の形を変えないため(切替を使っていないユーザーには何も増えない)。復元した行も undefined。 ## 状態の「性質」は STATUS_META が唯一の表 @@ -83,6 +104,13 @@ UI・永続・通知は**この表を参照**し、独自の集合(`TERMINAL` init 前に落ちて resume 不能なものは保存しない。 - 読み込み側(`fromPersistedJson`)は `completed` / `interrupted` / `failed` のみ受理し、 壊れた JSON は空状態へフォールバックする(TUI を落とさない)。 +- **エージェントも保存する**: `agent`(最後に駆動していた provider)と `agentSessions` + (provider ごとの resume id)。後者を落とすと、再起動をまたいで「Codex に切り替えて、また + Claude に戻す」をしたときに過去の会話が消えて新規セッションから始まってしまう。保存時は + 現在の `sdkSessionId` も `agentSessions[agent]` へ畳む(`agent_switched` は切替の瞬間にしか + 畳まないので、切替せずに終了したセッションの id がそこから漏れる)。読み込みは未知の + provider 名・非文字列を 1 件ずつ捨て、`agent` が無い(切替対応より前の)スナップショットは + `'claude'` として復元する。 - **会話ログは永続しない**。復元時は CLI のトランスクリプトから再構築する (`core/transcript.ts` + `utils/transcript.ts`)。state.json はメタデータのみに保つ。 - 稼働時間は wall-clock ではなく `activeMs` + `activeSince`(`active` な区間だけ積算)。 @@ -96,20 +124,30 @@ UI・永続・通知は**この表を参照**し、独自の集合(`TERMINAL` - **中断(`interrupt()`、詳細ビューの `Ctrl+C`)は3つ目の別物**: 走っているターンだけをやめ、 サブプロセスは生かしたまま `interrupted`(idle & resumable)にする。状態は **SDK の応答を 待たずに先に確定**させる — CLI が返すターン終了 result は `is_error: true` なので、 - 診断が無いと `failed` に落ちる(sdk-parse は `terminal_reason: 'aborted_streaming'` も + 診断が無いと `failed` に落ちる(`claude-parse` は `terminal_reason: 'aborted_streaming'` も 同じ `USER_INTERRUPT_DETAIL` で `interrupted` にするので、二重ログにも `failed` にもならない)。 対象判定(`isInterruptible`)は `SessionManager.interrupt` に置く(`resume` と同じ理由 = UI の購読はスロットルされていて連打を弾けない)。 - `stop()` / 再開可能状態へ落ちる前に**保留中の許可を deny で解決**する。未応答の `tool_use` で終わるトランスクリプトは後の resume を壊す。 - 復元セッションは `start()` せず、最初の `send()` で遅延 resume(起動時にサブプロセスを乱立させない)。 -- 1 SDK セッション 1 ライター。codiva 以外(外部 `claude --resume` 等)から同じセッションに繋がない。 +- **エージェントの切替(`Session.setAgent()`)も走っているターンを畳んでから**行う。2 本の + ストリームが同じ worktree を触らないように現在の run を捨て、保留中の許可も deny で解決する + (未応答の `tool_use` で終わるトランスクリプトは後の resume を壊す)。新しいエージェントが + 立ち上がるのは次の `send()`。 +- 1 エージェントセッション 1 ライター。codiva 以外(外部 `claude --resume` 等)から同じ + セッションに繋がない。**同時に 2 つの provider を 1 つの worktree で走らせない**。 ## DI seam とファサード -- DI 用の interface は `core/session-ports.ts`(leaf)に集約する。ここに置くことで - core 内の循環 import を防いでいるので、`WorktreeService` / `SessionHandle` / `PrAutomation` / - `PrLookup` を他ファイルで再定義しない。 +- DI 用の interface は 2 つの leaf に集約する。どちらも他モジュールを import しない末端に + 置くことで core 内の循環 import を防いでいるので、ここにあるものを他ファイルで再定義しない。 + - `core/session-ports.ts` … codiva 側の seam(`WorktreeService` / `SessionHandle` / + `PrAutomation` / `PrLookup`)。 + - `core/agent-ports.ts` … エージェント側の seam(`AgentAdapter` / `AgentRun` / + `AgentRunRequest` / `AgentCapabilities` / `PermissionDecision`)。 + `SessionHandle` の `getAgent()` / `setAgent()` は optional — 状態だけを動かすテスト用フェイク + (`tests/helpers.ts` の `noopSession`)にエージェントの概念を強制しないため。 - `SessionManager` は**ファサード**。責務を戻さない: `session-store.ts`(購読と参照同一性)/ `session-actions.ts`(merge・discard・diffStat)/ `pr-coordinator.ts`(autoPr・refreshPrs)/ `run-mode.ts`(`auto`⇄`confirm` ポリシー)/ diff --git a/CLAUDE.md b/CLAUDE.md index 524005b..752f4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 |---------|--------------| | `.claude/rules/workflow.md` | 着手前(手順・ドキュメントの役割分担・non-goals) | | `.claude/rules/session-domain.md` | セッションの状態・遷移・永続化に触るとき | -| `.claude/rules/sdk-integration.md` | Agent SDK(session / sdk-parse / model catalog)に触るとき | +| `.claude/rules/sdk-integration.md` | エージェント抽象・Agent SDK(agent-ports / claude-* / session / model catalog)に触るとき | | `.claude/rules/git-and-io.md` | worktree・マージ・PR・ファイル入出力に触るとき | | `.claude/rules/testing.md` | テストを書くとき | @@ -59,12 +59,13 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | やりたいこと | 主なファイル | |---|---| | セッションの状態・遷移 | `core/types.ts`(union)/ `core/status-meta.ts`(性質の表)/ `core/status-reducer.ts`(純粋 reducer) | -| SDK メッセージの解釈 | `core/sdk-parse.ts` **のみ** + `core/__fixtures__/*.jsonl` | +| 別のエージェント(Codex / Grok)に対応させる | `core/agent-ports.ts`(`AgentAdapter` / `AgentCapabilities` / `PermissionDecision` = DI 境界)/ `core/agent-events.ts`(`AgentEvent` の語彙 + 全 provider 共通の畳み込み `applyAgentEvent`)/ `core/claude-adapter.ts`・`core/claude-parse.ts`・`core/claude-errors.ts`(Claude 実装の 3 点セット) | +| SDK メッセージの解釈 | `core/claude-parse.ts` **のみ**(`parseClaudeMessage`: SDKMessage → `AgentEvent[]`)+ `core/__fixtures__/*.jsonl` | | セッションへ渡す systemPrompt | `core/system-prompt.ts`(worktree の共有 symlink 注意書き + `.codiva/prompt.md` の合成) | -| セッションのライフサイクル | `core/session.ts`(1 query)/ `core/session-manager.ts`(ファサード)/ `session-store.ts` / `session-actions.ts` / `pr-coordinator.ts` / `run-mode.ts` / `session-ports.ts`(DI seam) | +| セッションのライフサイクル | `core/session.ts`(1 エージェントストリーム。`setAgent()` で途中切替)/ `core/session-manager.ts`(ファサード)/ `session-store.ts` / `session-actions.ts` / `pr-coordinator.ts` / `run-mode.ts` / `session-ports.ts`(DI seam) | | worktree・マージ・破棄 | `utils/worktree-manager.ts`(I/O)/ `core/worktree.ts`(型・純関数)/ `core/session-actions.ts` | | PR 自動化 | `core/pr-coordinator.ts` / `utils/pr.ts`(`gh` はここだけ) | -| 1 セッション複数 PR(`#12 +2`) | `core/pr-detect.ts`(検知・表示ヘルパ・純粋)/ `core/sdk-parse.ts`(`gh pr create` の tool_use ↔ tool_result 対応)/ `ui/pr-cell.tsx`(`PrCell` / `PrSummary`) | +| 1 セッション複数 PR(`#12 +2`) | `core/pr-detect.ts`(検知・表示ヘルパ・純粋)/ `core/agent-events.ts`(`gh pr create` の tool_use ↔ tool_result 対応。検知は provider 共通)/ `core/claude-parse.ts`(Claude のツール名判定)/ `ui/pr-cell.tsx`(`PrCell` / `PrSummary`) | | 詰まった PR の立て直し | `core/pr-recovery.ts`(判定・指示文・純粋)/ `SessionManager.recover()` / `utils/worktree-manager.ts` の `syncBase`(ベース取り込み)/ `ui/hooks.ts` の `useRecovery` | | 一覧画面 | `ui/session-list.tsx`(composer / list の2フォーカス) | | 詳細画面 | `ui/session-detail.tsx`(ログ + 追加指示 + 操作パネル) | @@ -94,9 +95,15 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 ## 絶対に崩さない不変条件 1. **依存は一方向**(`ui → core ← utils`)。`core/` は Ink / React / node の I/O を import しない。 -2. **状態遷移は reducer 経由だけ**。`SessionStore` に status を手書きしない。状態の性質は `STATUS_META` が唯一の表。 -3. **SDK の形を知るのは `core/sdk-parse.ts` だけ**。形は想定で書かず、spike の実データでテストする。 -4. **UI 文字列はカタログのみ**(`core/i18n.ts` に ja / en 対で追加。例外は SDK 由来のモデル名)。 +2. **状態遷移は 2 本の純関数だけ**(`reduce` = codiva 起点の `CodivaEvent` / `applyAgentEvent` = + エージェント起点の `AgentEvent`)。`SessionStore` に status を手書きしない。状態の性質は `STATUS_META` が唯一の表。 +3. **エージェント固有の知識はアダプタに閉じる**。`core/` の中立モジュールは + `@anthropic-ai/claude-agent-sdk` を import しない — 触ってよいのは `claude-adapter.ts` / + `claude-parse.ts` / `claude-errors.ts` だけ。provider のストリームは + `AgentEvent`(`core/agent-events.ts`)へ写してから畳み込む(`applyAgentEvent` が唯一の畳み込み)。 + 形は想定で書かず、spike の実データでテストする。 +4. **UI 文字列はカタログのみ**(`core/i18n.ts` に ja / en 対で追加。例外は SDK 由来のモデル名と + エージェント名・CLI コマンド名 = 固有名詞。差し込みは `AgentLabel`)。 5. **1画面 1 `useInput`**(モーダルは委譲)。色・記号は `theme.ts` 経由。 6. **git は `utils/git.ts` の `git(cwd, args)`**(execFile + 引数配列。シェル禁止)。**マージ競合は自動解消しない。** 7. **`any` / default export 禁止**、import は**拡張子なし**(`@/core`)、ファイル名は kebab-case。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a38d229..32a46c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,8 +12,10 @@ UI とコアロジックを完全に分離する。コアは Ink/React に一切 └────────┼────────────────────────────────────────────┘ ┌────────┴─ core/ (純TypeScript, UIなし) ─────────────┐ │ SessionManager … セッションの生成・保持・イベント発火 │ -│ Session … 1セッション = SDK query + 状態 │ +│ Session … 1セッション = 1 エージェント + 状態 │ │ reduce() … CodivaEvent → SessionState 畳み込み│ +│ applyAgentEvent() … AgentEvent → SessionState │ +│ AgentAdapter … provider の DI 境界(claude/…) │ │ Worktree 型 / MergeConflictError / 純関数 │ └────────┬────────────────────────────────────────────┘ ┌────────┴─ utils/ (I/O ラッパ, core にのみ依存) ──────┐ @@ -53,13 +55,17 @@ codiva/ │ │ ├── persist-controller.ts # debounce保存 / SIGTERM同期flush / 最終flush を集約 │ │ ├── crash-handler.ts # uncaughtException/unhandledRejection → 端末復元 + クラッシュログ │ │ └── runtime.ts # PRポーリング・alt-screen/mouse・SIGTERM/SIGHUP フラッシュ -│ ├── core/ # 純粋ドメイン(Ink/React/node/utils 非依存。SDK は型 + 定数のみ) +│ ├── core/ # 純粋ドメイン(Ink/React/node/utils 非依存。SDK に触るのは claude-*.ts だけ) │ │ ├── index.ts # バレル(export *) -│ │ ├── types.ts # SessionState, SessionStatus, CodivaEvent 等の型定義 -│ │ ├── status-reducer.ts # reduce(state, CodivaEvent): SessionState(型付きイベントのみ・純関数) -│ │ ├── sdk-parse.ts # applySdkMessage()(SDK メッセージ形状の解釈を集約・純粋) +│ │ ├── types.ts # SessionState, SessionStatus, CodivaEvent, AgentId, AgentStopCause 等の型定義 +│ │ ├── status-reducer.ts # reduce(state, CodivaEvent): SessionState(codiva 起点のイベント・純関数) +│ │ ├── agent-ports.ts # エージェントの DI 境界(AgentAdapter/AgentRun/AgentCapabilities/PermissionDecision・leaf) +│ │ ├── agent-events.ts # AgentEvent の語彙 + applyAgentEvent()(全 provider 共通の畳み込み・純粋) +│ │ ├── claude-adapter.ts # Claude 用 AgentAdapter(query() の組み立て・canUseTool の写像) +│ │ ├── claude-parse.ts # parseClaudeMessage()(SDK メッセージ形状の解釈を集約・純粋) +│ │ ├── claude-errors.ts # Claude CLI の失敗分類(文言/typed kind/HTTP status → AgentStopCause) │ │ ├── status-meta.ts # STATUS_META(terminal/attention/active/resumable/復元先/通知キーの一元表) -│ │ ├── session.ts # 1 SDK query のライフサイクル +│ │ ├── session.ts # 1 エージェントストリームのライフサイクル(setAgent で途中切替) │ │ ├── session-store.ts # 購読可能スナップショット(順序・状態・参照同一性保持) │ │ ├── session-manager.ts # create/restore/dispose + passthrough のファサード │ │ ├── session-actions.ts # merge/discard/diffStat(git 操作の純粋オーケストレーション) @@ -67,7 +73,7 @@ codiva/ │ │ ├── pr-recovery.ts # 詰まった PR の立て直し判定・指示文(純粋) │ │ ├── pr-detect.ts # セッション自身が作った PR の検知・表示ヘルパ(純粋) │ │ ├── run-mode.ts # RunMode + createModePolicy -│ │ ├── session-ports.ts # DI seam の interface 集約(WorktreeService/SessionHandle/…) +│ │ ├── session-ports.ts # codiva 側の DI seam(WorktreeService/SessionHandle/…・leaf) │ │ ├── worktree.ts # Worktree 型 + MergeConflictError + ignoredCopyEntries(純粋) │ │ ├── list-hit.ts # 一覧のマウス当たり判定(純粋) │ │ ├── format.ts / math.ts / ansi.ts / errors.ts # 小さな純粋ヘルパ(formatDuration/clamp/…) @@ -76,7 +82,7 @@ codiva/ │ │ ├── choice-lines.ts # 選択肢(ラベル + 説明)の折返し(純粋・表示幅ベース) │ │ ├── scroll.ts / text-buffer.ts / composer-layout.ts / layout.ts / mouse.ts / key-sequence.ts / model.ts / models.ts / transcript.ts │ │ ├── *.spec.ts # 単体テストは実装の隣に co-located -│ │ └── __fixtures__/ # サニタイズ済み実 SDK メッセージ(sdk-parse テスト用) +│ │ └── __fixtures__/ # サニタイズ済み実 SDK メッセージ(claude-parse テスト用) │ ├── ui/ # Ink コンポーネント(kebab-case, 識別子は PascalCase) │ │ ├── index.ts # バレル │ │ ├── theme.ts # アクセント色・状態色・logColor・グリフ(色は必ずここ経由) @@ -111,9 +117,131 @@ codiva/ # import は `@/*` → `./src/*` エイリアス(ディレクトリ跨ぎ)。ビルドは tsup、型チェックは tsc --noEmit。 ``` +## エージェント抽象 + +codiva は当初 Claude Code(`@anthropic-ai/claude-agent-sdk`)専用で、`SDKMessage` を直接 +`SessionState` へ畳み込んでいた(旧 `core/sdk-parse.ts` の `applySdkMessage`)。そのため +「SDK メッセージの形の知識」と「状態をどう変えるか」が 1 か所に混ざり、別のエージェント +(Codex / Grok)を足すには畳み込みごと書き直すしかなかった。Phase A ではこれを 2 段に割り、 +provider を差し替えられる境界を入れた(アダプタ実装そのものは Phase B 以降)。 + +``` +provider のメッセージ ──[アダプタの parse]──▶ AgentEvent[] ──[applyAgentEvent]──▶ SessionState + SDKMessage claude-parse.ts agent-events.ts core/types.ts + (Codex/Grok の形) (各アダプタ) (全 provider 共通) +``` + +### 1. 境界は `SessionHandle` / `AgentAdapter`(`QueryFn` ではない) + +抽象化の線は **1 ターンぶんのストリーム**に引く(`core/agent-ports.ts`)。理由は 2 つ: + +- `SessionManager` から上(UI・永続化・PR 自動化・worktree・通知)は既に `SessionHandle` + 越しにしかセッションを触っておらず、**もともとエージェント非依存**だった。境界を新設する + 必要はなく、その下に `AgentAdapter` を足すだけで済む。 +- 逆に SDK の `query()` の署名(`AsyncIterable` + `Options` + `canUseTool` + + control request)を共通 IF にすると、**全 provider に Claude の制御モデルの模倣を強いる**。 + Codex / Grok が control request を持つ保証はない。 + +アダプタの責務は 3 つだけ: (1) ストリームを開く(`open`)、(2) provider のメッセージを +`AgentEvent[]` へ写す、(3) 失敗文言を `AgentStopCause` へ分類する(`classifyError`)。 +許可要求の型も SDK の `PermissionResult` ではなく自前の `PermissionDecision` にして、provider 形への +写像はアダプタに置く(`PermissionRequest` が既に自前型なので対にした)。 + +### 2. 中立モジュールは SDK を import しない + +`@anthropic-ai/claude-agent-sdk` を import してよいのは **`core/claude-adapter.ts` / +`core/claude-parse.ts` / `core/claude-errors.ts`** だけ。他の `core/` は型も定数も引かない。 +この境界のために変えたものが 2 つある: + +- `core/config.ts` の `EffortLevel` / `PermissionMode` を SDK の同名 union の再エクスポートから + **自前の配列 + 導出型**にした(値の集合は同じ)。副作用として SDK 側に値が増えても型では + 気付けないので、SDK 更新時に目視で追従させる。とくに `permissionMode` は Claude Code 固有の + 概念で、他エージェントでは解釈が変わりうる(吸収するのはアダプタの仕事)。 +- `core/status-reducer.ts` から `USAGE_LIMIT_ERROR_PREFIXES` の import と `isRateLimitError` が + 消え、CLI の文言・typed error kind・HTTP ステータスの知識は `core/claude-errors.ts` に集まった。 + 「使用制限の文言は CLI 側で変わるので SDK に追従したい」という要求は正しいが、**追従してよいのは + アダプタの中だけ**。 + +### 3. 畳み込みは共通、写像だけがアダプタ + +`applyAgentEvent(state, event, at, agent?)`(`core/agent-events.ts`)が**全 provider 共通の唯一の +畳み込み**で、ログの上限(`pushLogEntry`)・進捗(TODO)・サブエージェントの完了ゲート +(`activeTaskIds` / `deferredResult`)・PR 検出(`gh pr create` の tool_use ↔ tool_result)・ +コスト集計・ストリーミングプレビューはすべてここにある。新しいエージェントは自分のストリームを +`AgentEvent` の語彙へ写すだけでよく、codiva 固有の振る舞いを再実装しない。 + +`AgentEvent` は provider 非依存の語彙になるよう選んである: +`session_started` / `assistant_message` / `assistant_text` / `tool_use` / `tool_result` / +`stream_reset` / `stream_text` / `notice` / `task_started` / `task_settled` / `turn_completed` / +`turn_stopped` / `usage`。ツール名は `AgentToolKind`(`edit` / `shell` / `todo` / `question` / +`other`)へ、TODO 操作は `TodoOp`(`create` / `update` / `replace`)へ、失敗は `AgentStopCause` +(`auth` / `rate_limit` / `connection` / `failed`)へ**アダプタ側で正規化してから**渡す +(`turn_stopped.rollup` は「これは既に診断済みの停止の要約」の印で、2 回目の報告で分類を +やり直して精度を落とさないためのもの)。 + +`applyClaudeMessage`(`claude-parse.ts`)は parse → fold を合成した薄い糖衣で、1,100 行超の実データ +テスト(`claude-parse.spec.ts` + `__fixtures__/*.jsonl`)が**分割前と同じ入口を叩き続けられる**ように +残してある = 分割のリグレッション網。新しい呼び出し側はこれを増やさず `AgentEvent` 経由にする。 + +### 4. セッション途中でエージェントを切り替えられる + +`Session.setAgent(adapter)` → `CodivaEvent` の `agent_switched`。**worktree(=実際の成果物)は +provider に依存しない**ので、Claude で始めた作業を途中から Codex に引き継げる。一方**モデル側の +文脈は provider をまたげない**(各 CLI が自分のトランスクリプトを持つ)ため、切替は +「今のターンを終える → 別 provider の**新しいセッション**を同じ worktree で開く」という形になる。 + +| 引き継がれるもの | 引き継がれないもの | +|---|---| +| worktree・ブランチ・作業ツリーの内容 | モデル側の会話文脈(provider ごとに別のトランスクリプト) | +| codiva 側のログ(`messages`)・タイトル・PR・稼働時間 | `sdkSessionId`(切替先の `agentSessions` に無ければ undefined =新しい会話) | +| `agentSessions`(provider ごとの resume id) | `streamingText`(前のエージェントの途中表示) | +| セッションの状態(`SessionStatus`)| `model`(解決済みモデルは provider ごとに別物。次のターンが埋める) | + +戻ってきたときに続きから再開できるよう、`agentSessions: Partial>` に +provider ごとの resume id を控え、**これは永続化する**(`state.json`)。落とすと再起動をまたいで +「Codex に切り替えて、また Claude に戻す」をしたときに過去の会話が消えて新規セッションから +始まってしまう。保存時は現在の `sdkSessionId` も `agentSessions[agent]` へ畳む(`agent_switched` は +切替の瞬間にしか畳まないので、切替せずに終了したセッションの id がそこから漏れる)。`agent` の +無い(切替対応より前の)スナップショットは `'claude'` として復元する。 + +切替の実装で守っていること: + +- **走っているターンを畳んでから差し替える**。2 本のストリームが同じ worktree を触らないように + 現在の run を捨て、保留中の許可は deny で解決する(未応答の `tool_use` で終わるトランスクリプトは + 後の resume を壊す ⇒ `stop()` と同じ理由)。新しいエージェントが立ち上がるのは次の `send()`。 +- **resume id は provider をまたいで渡さない**。切替後は `agent_switched` が据えた `sdkSessionId` + だけを使う(復元時の `deps.resume` は初期エージェント用なので、別 provider へ持ち込むと存在しない + 会話を resume しようとして壊れる)。 +- **ログ行の帰属**(`LogEntry.agent`)は**切替が起きたあとだけ**刻む。単一エージェントで完結する + セッションのログ行の形を変えないため(切替を使っていないユーザーには何も増えない)。 + +### 5. Claude 専用機能は capability で optional 化する + +`AgentCapabilities`(`permissions` / `interrupt` / `setModel` / `resume` / `modelCatalog` / +`usage` / `cost` / `transcript`)で「そのエージェントが何をできるか」を表明する。UI はこれを見て +段階的に縮退する(持たない機能のキー操作・表示を出さない)— **表と `getAgent()` は Phase A で入れ、 +実際の縮退の配線は Phase D**。参照するときは**固定値として持たず** `SessionHandle.getAgent()` から +引く(セッション途中で切り替えると変わりうるため)。 + +現状 Claude だけが持つ(=他 provider では縮退させる想定の)機能は、使用状況ゲージ(`usage`)・ +モデルカタログと `/model`(`modelCatalog` / `setModel`)・CLI トランスクリプトからのログ復元 +(`transcript`)・学習データ利用の警告(Claude Code の認証情報を読む `utils/privacy.ts`)。 +`AgentRun.interrupt` / `setModel` はメソッド自体が optional で、新しいアダプタは +`NO_CAPABILITIES`(全部 false)から始めて実装できたものだけ true にする。 +文言側も `i18n.ts` の `AgentLabel`(表示名 + ログインコマンド)を差し込む形にしてあり、 +`auth.hint` / `auth.listHint` / `notify.needsLogin` / `action.resumeAllPrompt` は +`(agent: AgentLabel) => string`(既定は `DEFAULT_AGENT_LABEL` = Claude)。エージェント名は固有名詞 +なので翻訳しない(モデル名と同じ i18n の例外)。 + +> capability による UI の縮退・`/agent` コマンド・引き継ぎプロンプトは **Phase D** で入れる。 +> Phase A で入れたのは境界と語彙だけで、実際のアダプタ(Codex / Grok)は未実装 +> ([TASKS.md](./TASKS.md) の Phase A〜D)。 + ## セッション状態機械 -`SessionStatus` の遷移。導出元はすべて SDK メッセージストリームと canUseTool コールバック。 +`SessionStatus` の遷移。導出元はすべてエージェントのイベントストリーム(`AgentEvent`)と許可要求。 +以下の記述は Claude アダプタでの具体(`SDKMessage` の subtype など)を含むが、状態機械そのものは +provider 非依存。 ``` creating ──(worktree作成完了 & query開始)──▶ running @@ -143,9 +271,9 @@ codiva/ ``` `interrupted` は「クリーンに完了していないが resume で続行できる」セッションを表す。発生元は4つ: -(1) **通信断**(`Session.consume` の for-await が throw、または接続断を示すエラー `result`。`core/errors.ts` -の `isConnectionError` で判定し、resume 元となる `sdkSessionId` がある場合のみ。無い=init 前の早期失敗は -`failed`)。(2) **応答途中の API エラー**(後述)。(3) **アプリ終了時の丸め**(`restorableStatus` が実行中/ +(1) **通信断**(`Session.consume` の for-await が throw、または接続断を示すエラー `result`。判定は +アダプタの `classifyError`(Claude は `core/claude-errors.ts` の `isConnectionError`)で、resume 元となる +`sdkSessionId` がある場合のみ。無い=init 前の早期失敗は `failed`)。(2) **応答途中の API エラー**(後述)。(3) **アプリ終了時の丸め**(`restorableStatus` が実行中/ 入力待ちを保存時に `interrupted` にする。`stop()` はメモリ上の状態を変えない)。(4) **ユーザーによる中断** (詳細ビューの `Ctrl+C`。後述)。いずれも `completed` と同じく idle で resumable。追加指示または **再開アクション(一覧/詳細の `r`)** で resume できる — 送信すると `SessionManager.send` → `Session.send` @@ -188,7 +316,7 @@ codiva/ result で閉じる(実測: `__fixtures__/session-interrupt.jsonl`)ため、診断が無いと `failed` に落ちる。 先に `interrupted` を立てておけば、result 側は**すでに resumable なら診断を維持**するロールアップガード (`isResumable`)でコストだけを拾う。 -- **`sdk-parse` 側も `aborted_streaming` を `interrupted` に分類する**(保険)。中断のあとに assistant +- **`claude-parse` 側も `aborted_streaming` を `interrupted` に分類する**(保険)。中断のあとに assistant メッセージが 1 通挟まって status が `running` へ戻っても、ターンの終わりは `failed` にならない。ログに 書くのは `USER_INTERRUPT_DETAIL`(= `'interrupted by user'`)で、CLI の内部診断 (`errors: ['[ede_diagnostic] …']`)は出さない。2 経路で**同じ文言**を使うので `toInterrupted` の @@ -243,18 +371,22 @@ incomplete.`)→ それを集約する `result`(`subtype: 'success'` + `is_e バッジが「Completed」へ倒れてしまう(本 issue の不具合)。対策として `system/task_started` / `system/task_notification` で稼働中タスク集合(`activeTaskIds`)を追跡し、result 受信時にタスクが残って いれば `completed` にせず結果を `deferredResult` に保留して `running` を維持する。最後のタスクが -`task_notification` で settle し集合が空になった時点で保留結果を使って `completed` を確定する -(`sdk-parse.ts` の `onTaskStarted` / `onTaskSettled` / `completeWith`)。`skip_transcript` の雑務タスクは +`task_notification` で settle し集合が空になった時点で保留結果を使って `completed` を確定する。 +形の解釈(`system/task_started` → `task_started` イベント)は `claude-parse.ts`、**ゲートそのものは +`agent-events.ts` の `applyAgentEvent`**(`task_started` / `task_settled` / `completeWith`)にあり +**全 provider 共通**なので、他のエージェントは「タスクが始まった/片付いた」を報告するだけでよい。 +`skip_transcript` の雑務タスクは ゲート対象外。`activeTaskIds` / `deferredResult` は transient で永続しない。実データは `__fixtures__/session-subagent.jsonl`(スパイクの `subagent` シナリオで採取)。 `rate_limited` は「使用量/レート制限に達して止まった」セッションを表す。`completed`/`failed` と同じく idle だが、エラー扱い(`failed`)にはせず「制限が解けるのを待って再開できる」状態として区別する。 検知元は SDK の `rate_limit_event`(`rate_limit_info.status === 'rejected'`)、assistant メッセージの -`error === 'rate_limit'`、および usage-limit を示す `result`/throw されたエラー文言(`isRateLimitError`。 -SDK の `USAGE_LIMIT_ERROR_PREFIXES` に追従)。制限は一時的なので保存時は `interrupted` に丸める。 +`error === 'rate_limit'`、および usage-limit を示す `result`/throw されたエラー文言 +(`core/claude-errors.ts` の `isRateLimitError`。SDK の `USAGE_LIMIT_ERROR_PREFIXES` に追従)。 +制限は一時的なので保存時は `interrupted` に丸める。 -`needs_login` は「Claude の認証が切れて止まった」セッションを表す。作業自体の失敗ではなく、ユーザーが +`needs_login` は「エージェントの認証が切れて止まった」セッションを表す。作業自体の失敗ではなく、ユーザーが 別ターミナルで `claude` に `/login` し直せば resume できるので、`failed` とは区別する。 **とくに `completed` にしてはいけない**。CLI は認証エラーを次の2メッセージで報告する(実バイナリで確認): @@ -275,7 +407,7 @@ auto-PR まで走ってしまう(本 issue の不具合)。そのため resu 検知の優先順は次の通り: -1. **assistant メッセージの型付き `error`**(`core/errors.ts` の `isAuthErrorKind` = `authentication_failed` +1. **assistant メッセージの型付き `error`**(`core/claude-errors.ts` の `isAuthErrorKind` = `authentication_failed` / `oauth_org_not_allowed`)。`SDKAssistantMessageError` として型定義されており文言・ロケールに依存しない ため、これを一次シグナルにする(既存の `error === 'rate_limit'` フックと同じ位置)。 `billing_error`(残高不足)は再ログインで直らないので対象外= `failed` のまま。 @@ -291,7 +423,8 @@ auto-PR まで走ってしまう(本 issue の不具合)。そのため resu `attention: true`(一覧に ● を出す)なのは、`rate_limited` と違い放置しても解決せずユーザーの操作が 必須だから。UI は「別ターミナルで `claude` にログインして再開」という手順そのものを出す -(i18n `auth.hint` / `auth.listHint`)。保存時は `interrupted` に丸める(次回起動時には再ログイン済みかも +(i18n `auth.hint` / `auth.listHint`。どちらもエージェント名とログインコマンドを差し込む +`(agent: AgentLabel) => string` で、既定は `DEFAULT_AGENT_LABEL` = Claude)。保存時は `interrupted` に丸める(次回起動時には再ログイン済みかも しれない)。なお `auth_status` メッセージは CLI の対話的 `/login` UI 用で、`--enable-auth-status` オプトイン時のみ流れる(この SDK 版の型にも無い)ため API 認証エラーの検知には使えない。 @@ -391,12 +524,14 @@ interface SessionState { 1セッションのライフサイクルを保持する。 -- コンストラクタで `queryFn`(SDK の `query` 関数)を **DI で受け取る**。テストでは合成メッセージストリームを注入する。 -- streaming input mode を常用: `query()` の prompt に自前の `AsyncGenerator` を渡し、内部キュー(push可能な async queue)で管理。`send(text)` でいつでも追加メッセージを投入できる。 -- 受信ループ: `for await (const msg of query)` で各 SDK メッセージを `applySdkMessage()`(`core/sdk-parse.ts`)に畳み込む。SDK メッセージ形状の解釈はここに閉じ、純粋 reducer(`reduce(state, CodivaEvent)`)は型付きイベントだけを扱う。UI アクション(追加指示・許可・モデル切替等)は `reduce` へ dispatch。変更のたびに `onChange` を発火。 +- コンストラクタで `agent`(`AgentAdapter`)を **DI で受け取る**(省略時は `queryFn` から Claude アダプタを組み立てる短縮形)。テストでは合成イベントストリームを返すフェイクアダプタを注入する。 +- 入力は provider 非依存の `AsyncIterable`: 内部キュー(push 可能な async queue)を `AgentRunRequest.prompt` として渡し、`send(text)` でいつでも追加できる。`SDKUserMessage` への包み直しはアダプタの仕事。 +- 受信ループ: `for await (const event of run)` で各 `AgentEvent` を `applyAgentEvent()`(`core/agent-events.ts`)に畳み込む。provider のメッセージ形状の解釈はアダプタ(Claude なら `core/claude-parse.ts`)に閉じ、純粋 reducer(`reduce(state, CodivaEvent)`)は codiva 起点の型付きイベントだけを扱う。UI アクション(追加指示・許可・モデル切替等)は `reduce` へ dispatch。変更のたびに `onChange` を発火。 +- `getAgent()` / `setAgent(adapter)`: 駆動するエージェントの読み取りと差し替え(後述「エージェント抽象」)。UI は `getAgent().capabilities` を見て持たない機能を隠す。 +- 例外経路の分類もアダプタ任せ: `catch` した文字列は `adapter.classifyError?.(error) ?? 'failed'` で `AgentStopCause` にしてから `aborted` / `interrupted` を dispatch する。 - `respondToPermission(result)`: 保留中の canUseTool Promise を resolve。 - `interrupt()` / `abort()`: SDK の interrupt / AbortController。**`interrupt()` は「走っているターンだけをやめる」**(詳細ビューの `Ctrl+C`): サブプロセスは生かしたまま `interrupted`(idle & resumable)にし、追加指示 / `Ctrl+R` で同じ SDK 会話を続けられる状態にする。状態は SDK の応答を待たずに**先に**確定させる(体感 + 分類。下記「ユーザーによる中断」を参照)。許可/質問待ちで呼ばれた場合は `commit()` の既存経路が canUseTool の promise を deny で閉じる(未応答の `tool_use` は後の resume を壊す)。`isInterruptible` でない状態では何もしない。 -- `SessionOptions`(`model`/`effort`/`permissionMode`/`maxBudgetUsd`/`appendSystemPrompt`/`ignoredFiles`)を DI で受け、`query()` の `options` に反映(設定ファイル由来)。`permissionMode` 未指定時は `acceptEdits`。 +- `SessionOptions`(`model`/`effort`/`permissionMode`/`maxBudgetUsd`/`appendSystemPrompt`/`ignoredFiles`)を DI で受け、provider 非依存の `AgentRunOptions` に写してアダプタへ渡す(設定ファイル由来)。SDK の `Options`(`canUseTool` / `settingSources` / `includePartialMessages` / `permissionMode` 未指定時の `acceptEdits`)を組み立てるのはアダプタ側。 - **systemPrompt の組み立ては純関数 `core/system-prompt.ts`(`composeSystemPrompt`)**。要素は「worktree の環境説明」→「リポジトリ追加指示」の順(前提の説明が先、著者の具体的な指示が後)で、どちらも無ければ `undefined`(= `systemPrompt` を渡さない)。`session.ts` は文言も結合順も持たない。 - **worktree の環境説明(共有 symlink の注意書き)**: `ignoredFiles: 'symlink'`(既定)では ignore 済みパスが元リポジトリの実体を指すため、セッションが依存更新やビルドを走らせるとメインチェックアウトと並行セッションに波及する。そこで**このモードのときだけ** `SHARED_IGNORED_FILES_NOTICE` を systemPrompt に載せ、「読むのは安全 / 書く前にそのパスだけリンクを切って独立させる / リンク越しに消さない(`rm -rf /` 禁止)/ 触らない作業では何もしない」を伝える。モードは合成レイヤの `sessionOptionsFrom(config, appendSystemPrompt)`(`bootstrap/build-manager.ts`。config → `SessionOptions` の対応付けだけを持つ純関数で、spec で固定してある)が `resolveIgnoredFilesMode(config)` で解決して `SessionOptions.ignoredFiles` へ渡す。解決箇所は合成レイヤの2つ(`index.tsx` の `WorktreeManager` 生成とここ)だが、どちらも同じ config 由来なので一致する。**既知の制約**: モードは state.json に永続していないので、`symlink` で作った worktree を後から `copy` / `none` 設定で復元すると注意書きが載らない(設定を変えた場合のみ。逆向き=実体があるのに注意書きが載るケースは、手順1の `test -L` 判定で無害化される)。**codiva 側でリンクを張り替えることはしない** — 何が書き込み対象かは指示内容次第で、先回りして全部コピーすると symlink モードの利点(複製コストゼロ)が消えるため、判断はセッションに委ねる。文言は AI 向けなので英語・i18n カタログ対象外(`utils/title.ts` と同じ扱い)。 - **リポジトリ追加指示(`.codiva/prompt.md`)**: 合成ルート(`index.tsx`)が起動時に `loadRepoPrompt(repoRoot)` で読み、`buildManager` → `SessionOptions.appendSystemPrompt` へ流す。`consume()` は上記と合成して `options.systemPrompt` として渡す。SDK は systemPrompt 省略時に空文字へ写像する(claude_code プリセットは使わない)ため、文字列を渡すのは「空への追記」と等価で現挙動を変えない。CLAUDE.md は `settingSources: ['project']` 経由で別途注入されるので、これはそれへの上乗せ。将来ベースの systemPrompt を導入する場合は array / preset-append 形へ切り替える(`session.ts` の注入コメント参照)。 @@ -422,7 +557,7 @@ interface SessionState { - `core/pr-recovery.ts` … 詰まった PR の立て直し判定と指示文(純粋) - `core/run-mode.ts` … `RunMode` + `createModePolicy`(shift+tab のツール許可モード) - `core/persistence.ts` の `assemblePersistedState` … state.json スナップショットの組み立て - - DI seam の interface(`WorktreeService` / `SessionHandle` / `PrAutomation` / `PrLookup` / `ActionResult`)は `core/session-ports.ts`(leaf)に集約し循環を防ぐ。 + - DI seam の interface(`WorktreeService` / `SessionHandle` / `PrAutomation` / `PrLookup` / `ActionResult`)は `core/session-ports.ts`(leaf)に集約し循環を防ぐ。エージェント側の seam(`AgentAdapter` / `AgentRun` / `AgentRunRequest` / `AgentCapabilities` / `PermissionDecision`)は `core/agent-ports.ts`(同じく leaf)。`SessionManager` も `agent` を DI で受け、そこを差し替えるだけで新規セッションの provider が変わる。 ### WorktreeManager (`utils/worktree-manager.ts`) @@ -612,8 +747,10 @@ UI 文字列は日本語/英語を設定で切り替えられる。規約は [.c クォータ消費なので、毎ポーリング 2 回投げていたのを 1 回に)。 - **1 セッション 1 PR とは限らない(`core/pr-detect.ts`)**: セッションが自分で別ブランチを切って `gh pr create` することがある。ブランチ名(`codiva/`)からは辿れないので、**`gh pr create` を - 実行した tool_use の結果**に出る URL から拾って `extraPrs` に積む(`sdk-parse` が tool_use id を - 控えて tool_result と突き合わせる)。ログ全体から URL を拾わないのは誤検出を避けるため — + 実行した tool_use の結果**に出る URL から拾って `extraPrs` に積む(`claude-parse` が + `tool_use.prCreate` を立て、`applyAgentEvent` が tool_use id を控えて tool_result と突き合わせる。 + 突き合わせは provider 共通側にあるので、他のエージェントは「PR 作成コマンドだった」ことを + 報告するだけでよい)。ログ全体から URL を拾わないのは誤検出を避けるため — `gh pr list` の出力や `gh pr view` で覗いた他人の PR まで数えてしまう。 表示は一覧が `#12 +2`(代表 + 件数。列幅は複数 PR の行があるときだけ広げる)、全件は詳細ビューの 1 行に出す。**代表はセッションブランチの PR**(`prStatus` = グリフを持つ唯一の PR で、クリックで @@ -834,7 +971,7 @@ TUI は alt screen + マウスレポート(?1002/?1006)で動くため、異 |---|---|---| | `SessionState.messages` が無制限に伸び、追記ごとに全体コピー(O(n²)) | 件数 `MAX_LOG_ENTRIES` **と合計文字数 `MAX_LOG_CHARS`**(先に縛られた方で古い方から落とす)+ 1 件あたり `MAX_LOG_ENTRY_CHARS`(`…` を付けて切る) | `core/log-buffer.ts` の `pushLogEntry` | | 詳細ビューが**更新ごとにログ全体**を折り返し + Markdown 再パース | エントリ単位のメモ化(幅とプレフィックスが同じなら再利用)+ 保持行数の上限 `MAX_CACHED_ROWS`(LRU) | `core/scroll.ts` の `logLines` | -| ツール結果の巨大ペイロード(10MB の `Read` / `Bash`)を平坦化 → 全行 `split` | 読む 200 文字だけ材質化(`asStringHead`)。`tool_use` の入力(`Bash` の heredoc 等)も先に切る | `core/sdk-parse.ts` | +| ツール結果の巨大ペイロード(10MB の `Read` / `Bash`)を平坦化 → 全行 `split` | 読む 200 文字だけ材質化(`asStringHead`)。`tool_use` の入力(`Bash` の heredoc 等)も先に切る | `core/claude-parse.ts` | | `streamingText` に 1 メッセージ全体を溜め、毎フレーム全体を `split` | 末尾 `MAX_STREAM_PREVIEW_CHARS` だけ保持(描くのは最後の 1 行) | `core/log-buffer.ts` の `clipStreamText` | | 復元時に全セッションのトランスクリプト(各数 MB)を同時読み込み | 1 本ずつ読む(変換後すぐ回収される)+ **読みながら**畳む(`History`)+ `capLogEntries` | `bootstrap/restore-sessions.ts` / `core/transcript.ts` | @@ -851,7 +988,7 @@ TUI は alt screen + マウスレポート(?1002/?1006)で動くため、異 不変条件: - **追記の経路は `pushLogEntry` だけ**。`[...state.messages, entry]` を新しく書かない - (`appendLog` と `sdk-parse` の追記はすべてここを通す。例外は `onApiRetry` の**書き換え** + (`appendLog` と `applyAgentEvent` の追記はすべてここを通す。例外は `notice` の coalesce = **書き換え** = 末尾 1 件の差し替えで、件数を増やさないので上限に関係しない)。 - **`seq` は振り直さない**。描画キーが `:<行>` なので、トリムしても既存行のキーは変わらない (= React の再マウントが起きない)。ただし後述のとおり**行 index は変わる**。 diff --git a/docs/PRD.md b/docs/PRD.md index 2931580..eadfaa6 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -40,10 +40,14 @@ codiva は、対象のGitリポジトリで起動する TUI アプリケーシ > 注: この節は MVP 策定時点の切り分け。以下のうち復元・コスト表示・設定ファイル・デスクトップ通知は > Phase 6 で実装済み(`docs/ARCHITECTURE.md` の Phase 6 節を参照)。マウス操作も Phase 10 で追加され、 -> F-9「マウス不要」は「マウス任意(無くても完結)」に緩和されている。未実装として残るのは他エージェント対応と複数リポジトリ管理。 +> F-9「マウス不要」は「マウス任意(無くても完結)」に緩和されている。他エージェント対応は抽象化まで +> 完了(Phase A)。未実装として残るのは各アダプタの実装と複数リポジトリ管理。 - アプリ再起動後のセッション復元(SDK の `resume` を利用) … **実装済み(Phase 6)** -- Claude Code 以外のエージェント(Codex 等)対応 … 未実装 +- Claude Code 以外のエージェント(Codex 等)対応 … **Phase A(抽象化)完了 / アダプタ実装は未着手**。 + `AgentAdapter` / `AgentEvent` の境界と、provider ごとの resume id の永続(`agentSessions`)・ + セッション途中の切替(`Session.setAgent`)まで入っている。Codex(ACP)/ Grok のアダプタ本体と、 + capability による UI 縮退・`/agent` コマンドは Phase B〜D(`docs/ARCHITECTURE.md`「エージェント抽象」) - 複数リポジトリの同時管理 … 未実装 - コスト(トークン/USD)表示 … **実装済み(Phase 6)** - 設定ファイル(モデル選択、permissionMode カスタマイズ等) … **実装済み(Phase 6)** diff --git a/docs/TASKS.md b/docs/TASKS.md index ddaee94..7db0923 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -1083,6 +1083,98 @@ zsh: abort codiva --- +## Phase A: 他エージェント対応の抽象化(Codex / Grok の差し込み口)✅ + +> codiva は Claude Code 専用で、`SDKMessage` を直接 `SessionState` へ畳み込んでいた +> (旧 `core/sdk-parse.ts` の `applySdkMessage`)。「SDK メッセージの形の知識」と「状態をどう +> 変えるか」が 1 か所に混ざっていたため、別のエージェントを足すには畳み込みごと書き直すしか +> なかった。**挙動は変えず、差し込み口だけを作る**のがこの Phase。 + +- [x] `core/agent-ports.ts`(leaf): `AgentAdapter` / `AgentRun` / `AgentRunRequest` / + `AgentRunOptions` / `AgentCapabilities` / `PermissionDecision` / `NO_CAPABILITIES`。 + 境界は **`SessionHandle` / `AgentAdapter`**(`QueryFn` ではない — Claude の control-request + モデルを共通 IF にすると全 provider にその模倣を強いる) +- [x] `core/agent-events.ts`: provider 非依存の `AgentEvent` 語彙 + **全 provider 共通の畳み込み** + `applyAgentEvent`。ログの上限・進捗・サブエージェントの完了ゲート・PR 検出・コスト集計は + すべてここ(新しいエージェントは再実装しなくてよい)。`AgentToolKind` / `TodoOp` で + ツール名・TODO 操作を正規化 +- [x] `core/claude-parse.ts`(旧 `sdk-parse.ts`): `parseClaudeMessage`(`SDKMessage` → + `AgentEvent[]`。**状態を変えない**)+ `summarizeToolUse` / `toolResultSummary`。 + `applyClaudeMessage` は parse → fold の合成で、1,100 行超の実データテストが**同じ入口**を + 叩き続けられるように残す(分割のリグレッション網。spec も `claude-parse.spec.ts` にリネーム) +- [x] `core/claude-errors.ts`: Claude CLI の文言・typed error kind・HTTP ステータスの知識を集約 + (`isAuthError` / `isAuthErrorKind` / `isConnectionError` / `isTransientApiErrorKind` / + `isTransientApiStatus` / `isRateLimitError` / `classifyClaudeError`)。`core/errors.ts` は + `errorMessage` / `errorStack` だけに戻す +- [x] `core/claude-adapter.ts`: `createClaudeAdapter`(`query()` の組み立て・`canUseTool` ↔ + `requestPermission` の写像・`QueryFn` の DI)+ `CLAUDE_CAPABILITIES` +- [x] **中立モジュールから SDK の import を消す**: `status-reducer.ts` の + `USAGE_LIMIT_ERROR_PREFIXES` / `config.ts` の `EffortLevel` / `PermissionMode` + (自前の配列 + 導出型へ。値の集合は同じだが、SDK に値が増えたら目視で追従する必要がある) +- [x] `aborted` イベントが `cause`(`AgentStopCause`)を運ぶ: 失敗の分類は reducer の正規表現から + `AgentAdapter.classifyError` へ移動(文言の見分け方は provider ごとの知識) +- [x] セッション途中の切替に備えた状態: `SessionState.agent` / `agentSessions`(provider ごとの + resume id。**永続化**)/ `LogEntry.agent`(切替後だけ刻む)/ `CodivaEvent` の `agent_switched` / + `Session.setAgent()` / `getAgent()`。旧スナップショットは `'claude'` にフォールバック +- [x] `SessionHandle.getAgent()` / `setAgent()`(optional。状態だけを動かすテスト用フェイクに + エージェントの概念を強制しない)/ `SessionManager` も `agent` を DI で受ける +- [x] i18n: `AgentLabel` / `DEFAULT_AGENT_LABEL` を追加し、エージェント名・ログインコマンドを + 差し込む文言を関数化(`auth.hint` / `auth.listHint` / `notify.needsLogin` / + `action.resumeAllPrompt`。ja/en 対) +- [x] テスト: `agent-events.spec.ts` / `claude-errors.spec.ts` を新設、`claude-parse.spec.ts` は + 実フィクスチャのまま入口だけ差し替え。`status-reducer.spec.ts` に `agent_switched` + (退避・往復・同一 id で no-op)と `aborted.cause` の分岐を追加 +- [x] ドキュメント: `docs/ARCHITECTURE.md`「エージェント抽象」節(設計判断 5 点)/ `docs/PRD.md` / + `CLAUDE.md`(地図・不変条件 3)/ `.claude/rules/sdk-integration.md`(2 段構成 + SDK import の境界)/ + `.claude/rules/session-domain.md`(`applyAgentEvent` / `agent_switched` / 永続)/ + `.claude/rules/architecture.md`(DI seam を 2 つの leaf に) + +> 実績メモ: lint / typecheck / test(2,277 件)/ build 緑。**ユーザー可視の挙動は変えていない** +> (README は更新不要)。副次的に、`Session.consume` が `rate_limit_event` を直接読んでいた +> 規約違反(「形の知識は 1 か所」)と、SDK の `PermissionResult` が core に漏れていたのが解消された。 + +--- + +## Phase B: ACP アダプタ + Codex 対応(未着手) + +> Codex は Agent Client Protocol(ACP)を話す。`AgentAdapter` を 1 本実装し、 +> **Phase A で共通化した畳み込みに載せるだけ**にする。 + +- [ ] ACP のメッセージを実データで採取する(`npm run spike` 相当のシナリオを ACP 向けに用意し、 + `src/core/__fixtures__/` へサニタイズして昇格。**形を想定で書かない**) +- [ ] `core/acp-parse.ts`: ACP メッセージ → `AgentEvent[]`(`claude-parse.ts` と対になる純関数) +- [ ] `core/codex-adapter.ts`: `AgentAdapter` 実装 + `CODEX_CAPABILITIES`(`NO_CAPABILITIES` から + 始めて実装できたものだけ true)+ `classifyError`(Codex CLI の文言 → `AgentStopCause`) +- [ ] 許可要求の写像(ACP の permission request ↔ `PermissionDecision`)と、質問(`QuestionSpec`)を + 表現できるかの確認。できない場合は capability を false にして UI を縮退させる +- [ ] 合成レイヤ(`bootstrap/build-manager.ts`)で設定からアダプタを選べるようにする +- [ ] テスト: フィクスチャ駆動の `acp-parse.spec.ts` + フェイクアダプタでの `session.spec.ts` + +## Phase C: Grok 対応(未着手) + +- [ ] Grok CLI のストリームを実データで採取 → `AgentEvent` への写像を設計 +- [ ] `core/grok-adapter.ts` + capability の表明(resume を持たない場合の扱いを決める — + `resume: false` なら切替・再起動で文脈が切れることを UI が明示する必要がある) +- [ ] `classifyError`(Grok 側の認証切れ / レート制限の文言) + +## Phase D: capability による UI 縮退 / `/agent` / 引き継ぎ(未着手) + +- [ ] UI が `SessionHandle.getAgent().capabilities` を見て段階的に縮退する: + `/model`(`modelCatalog` / `setModel`)・使用状況ゲージ(`usage`)・コスト表示(`cost`)・ + `Ctrl+C` の中断(`interrupt`)・許可ダイアログ(`permissions`)・トランスクリプト復元 + (`transcript`)。**持たない機能のキー操作・ヒントを出さない** +- [ ] `/agent` コマンド(`add-slash-command` skill の手順で追加): 一覧・詳細から駆動エージェントを + 切り替える。確認を挟む(モデル側の文脈が切れることを伝える) +- [ ] **引き継ぎプロンプトの生成**: 切替先は前の会話を持たないので、worktree の状況(ブランチ・ + 差分・直前の指示)を要約した最初の指示文を組み立てる純関数を core に置く +- [ ] i18n: `AgentLabel` を `DEFAULT_AGENT_LABEL` 固定ではなく**セッションのエージェント**から + 引くよう配線(現状は UI が既定値を渡している) +- [ ] 一覧・詳細にエージェントの表示(どのセッションが何で走っているか)と、`LogEntry.agent` を + 使ったログ上の区切り表示 +- [ ] 設定 `~/.codiva/config.json` に既定エージェント(`add-config-option` skill の手順) + +--- + ## 各 Phase 共通の完了チェック 1. `npm run lint` / `npm test` が通る diff --git a/src/core/agent-events.spec.ts b/src/core/agent-events.spec.ts new file mode 100644 index 0000000..afa03c3 --- /dev/null +++ b/src/core/agent-events.spec.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest'; +import { type AgentEvent, applyAgentEvent } from '@/core/agent-events'; +import { initialState } from '@/core/status-reducer'; +import type { CreateSessionInput, SessionState } from '@/core/types'; + +const BASE: CreateSessionInput = { + id: 's1', + title: 'test', + prompt: 'do it', + branch: 'codiva/test', + worktreePath: '/tmp/wt', + startedAt: 0, +}; + +const running = (over: Partial = {}): SessionState => ({ + ...initialState(BASE), + status: 'running', + ...over, +}); + +/** 1 本の列を順に畳む(アダプタが渡す形と同じ)。 */ +const fold = (state: SessionState, events: AgentEvent[], at = 1, agent?: 'claude' | 'codex') => + events.reduce((s, e) => applyAgentEvent(s, e, at, agent), state); + +describe('applyAgentEvent / session_started', () => { + it('records the resume id under the driving agent so a switch can come back to it', () => { + const s = applyAgentEvent( + running(), + { kind: 'session_started', sessionId: 'cx-1', model: 'gpt-5' }, + 1, + 'codex', + ); + expect(s.sdkSessionId).toBe('cx-1'); + expect(s.agentSessions).toEqual({ codex: 'cx-1' }); + expect(s.model).toBe('gpt-5'); + }); + + it('leaves agentSessions untouched when no agent is attributed (single-agent session)', () => { + const s = applyAgentEvent(running(), { kind: 'session_started', sessionId: 'c-1' }, 1); + expect(s.sdkSessionId).toBe('c-1'); + expect(s.agentSessions).toBeUndefined(); + }); + + it('keeps a blocked session in awaiting_* rather than flipping it back to running', () => { + const blocked = running({ + status: 'awaiting_input', + pendingPermission: { id: 'p1', toolName: 'AskUserQuestion', input: {}, kind: 'question' }, + }); + const s = applyAgentEvent(blocked, { kind: 'session_started', sessionId: 'c-1' }, 1); + expect(s.status).toBe('awaiting_input'); + }); +}); + +describe('applyAgentEvent / log attribution', () => { + it('stamps the agent on log lines only when one is supplied', () => { + const withAgent = fold(running(), [{ kind: 'assistant_text', text: 'hi' }], 1, 'codex'); + expect(withAgent.messages.at(-1)?.agent).toBe('codex'); + + const without = fold(running(), [{ kind: 'assistant_text', text: 'hi' }]); + expect(without.messages.at(-1)?.agent).toBeUndefined(); + }); + + it('drops empty assistant text without consuming a seq', () => { + const s = fold(running(), [{ kind: 'assistant_text', text: ' ' }]); + expect(s.messages).toHaveLength(0); + expect(s.logSeq).toBe(0); + }); +}); + +describe('applyAgentEvent / todo ops', () => { + it('creates, updates and replaces the task list, keeping progress in step', () => { + const created = fold(running(), [ + { kind: 'tool_use', summary: 'a', tool: 'todo', todo: { op: 'create', subject: 'first' } }, + { kind: 'tool_use', summary: 'b', tool: 'todo', todo: { op: 'create', subject: 'second' } }, + ]); + expect(created.todos.map((t) => t.subject)).toEqual(['first', 'second']); + expect(created.progress).toEqual({ done: 0, total: 2 }); + + const updated = fold(created, [ + { + kind: 'tool_use', + summary: 'c', + tool: 'todo', + todo: { op: 'update', id: '1', status: 'completed' }, + }, + ]); + expect(updated.progress).toEqual({ done: 1, total: 2 }); + + const replaced = fold(updated, [ + { + kind: 'tool_use', + summary: 'd', + tool: 'todo', + todo: { op: 'replace', items: [{ subject: 'only', status: 'in_progress' }] }, + }, + ]); + expect(replaced.todos).toHaveLength(1); + expect(replaced.progress).toEqual({ done: 0, total: 1 }); + }); +}); + +describe('applyAgentEvent / turn_stopped', () => { + const cases = [ + ['auth', 'needs_login'], + ['rate_limit', 'rate_limited'], + ['connection', 'interrupted'], + ['failed', 'failed'], + ] as const; + + it.each(cases)('a %s cause lands on %s', (cause, status) => { + const s = applyAgentEvent(running(), { kind: 'turn_stopped', cause, detail: 'why' }, 5); + expect(s.status).toBe(status); + }); + + it('a rollup over an already-resumable state only takes the cost', () => { + const stopped = applyAgentEvent( + running(), + { kind: 'turn_stopped', cause: 'auth', detail: 'expired' }, + 5, + ); + const rolled = applyAgentEvent( + stopped, + { kind: 'turn_stopped', cause: 'failed', detail: 'error', totalCostUsd: 0.5, rollup: true }, + 6, + ); + // 分類はやり直さない(認証切れが「よく分からない失敗」に格下げされない)。 + expect(rolled.status).toBe('needs_login'); + expect(rolled.totalCostUsd).toBe(0.5); + // ログも増えない。 + expect(rolled.messages).toHaveLength(stopped.messages.length); + }); +}); + +describe('applyAgentEvent / sub-agent completion gate', () => { + it('holds a completion while a sub-agent task is still in flight', () => { + const withTask = fold(running(), [{ kind: 'task_started', taskId: 't1' }]); + const early = applyAgentEvent(withTask, { kind: 'turn_completed', text: 'done' }, 5); + // バックグラウンド Task が走っている間は「完了」にしない。 + expect(early.status).toBe('running'); + expect(early.deferredResult?.resultText).toBe('done'); + + const settled = applyAgentEvent(early, { kind: 'task_settled', taskId: 't1' }, 6); + expect(settled.status).toBe('completed'); + expect(settled.finishedAt).toBe(6); + expect(settled.activeTaskIds).toBeUndefined(); + }); + + it('does not let a late notification complete a session that already failed', () => { + const withTask = fold(running(), [{ kind: 'task_started', taskId: 't1' }]); + const early = applyAgentEvent(withTask, { kind: 'turn_completed', text: 'done' }, 5); + const failed = applyAgentEvent( + early, + { kind: 'turn_stopped', cause: 'failed', detail: 'x' }, + 6, + ); + const settled = applyAgentEvent(failed, { kind: 'task_settled', taskId: 't1' }, 7); + expect(settled.status).toBe('failed'); + }); +}); + +describe('applyAgentEvent / notices coalesce', () => { + it('rewrites the previous retry line instead of appending a new one', () => { + const first = fold(running(), [ + { kind: 'notice', text: 'api retry 1/3: overloaded', coalesceKey: 'api retry' }, + ]); + const second = applyAgentEvent( + first, + { kind: 'notice', text: 'api retry 2/3: overloaded', coalesceKey: 'api retry' }, + 2, + ); + expect(second.messages).toHaveLength(1); + expect(second.messages[0]?.text).toBe('api retry 2/3: overloaded'); + // seq は据え置き(描画キーが変わらない)。 + expect(second.messages[0]?.seq).toBe(first.messages[0]?.seq); + }); + + it('appends when there is no coalesce key', () => { + const s = fold(running(), [ + { kind: 'notice', text: 'one' }, + { kind: 'notice', text: 'two' }, + ]); + expect(s.messages).toHaveLength(2); + }); +}); + +describe('applyAgentEvent / PR detection', () => { + it('only scans the result of a tool_use that actually created a PR', () => { + const created = fold(running(), [ + { kind: 'tool_use', id: 'tu1', summary: 'Bash gh pr create', tool: 'shell', prCreate: true }, + ]); + expect(created.prCreateToolIds).toEqual(['tu1']); + + const matched = applyAgentEvent( + created, + { + kind: 'tool_result', + toolUseId: 'tu1', + summary: 'ok', + scanText: 'https://github.com/o/r/pull/42', + }, + 2, + ); + expect(matched.extraPrs?.map((p) => p.number)).toEqual([42]); + // 対応が取れたら控えから外す。 + expect(matched.prCreateToolIds).toBeUndefined(); + }); + + it('ignores PR urls in the output of unrelated tools', () => { + const s = applyAgentEvent( + running(), + { + kind: 'tool_result', + toolUseId: 'other', + summary: 'listing', + scanText: 'https://github.com/o/r/pull/99', + }, + 2, + ); + expect(s.extraPrs).toBeUndefined(); + }); +}); + +describe('applyAgentEvent / streaming preview', () => { + it('accumulates deltas and is cleared by the full message', () => { + const streamed = fold(running(), [ + { kind: 'stream_text', text: 'he' }, + { kind: 'stream_text', text: 'llo' }, + ]); + expect(streamed.streamingText).toBe('hello'); + + const full = applyAgentEvent(streamed, { kind: 'assistant_message' }, 2); + expect(full.streamingText).toBeUndefined(); + }); + + it('usage is out-of-band and never changes session state', () => { + const s0 = running(); + const s1 = applyAgentEvent( + s0, + { kind: 'usage', info: { rateLimitType: 'five_hour', status: 'allowed' } }, + 1, + ); + expect(s1).toBe(s0); + }); +}); diff --git a/src/core/agent-events.ts b/src/core/agent-events.ts new file mode 100644 index 0000000..f862058 --- /dev/null +++ b/src/core/agent-events.ts @@ -0,0 +1,438 @@ +import { clipLogText, clipStreamText, pushLogEntry } from './log-buffer'; +import { addPrRefs, extractPrRefs } from './pr-detect'; +import type { RateLimitInfoJson } from './rate-limit'; +import { isResumable } from './status-meta'; +import { + appendLog, + progressOf, + toInterrupted, + toNeedsLogin, + toRateLimited, +} from './status-reducer'; +import type { AgentId, AgentStopCause, SessionState, TaskStatus, TodoItem } from './types'; + +/** + * エージェント非依存の「起きたこと」の語彙と、その畳み込み。 + * + * codiva はもともと Claude Agent SDK の `SDKMessage` を直接 `SessionState` へ畳んで + * いた(旧 `applySdkMessage`)。そのため「SDK メッセージの形の知識」と「状態をどう + * 変えるか」が 1 か所に混ざっており、別のエージェント(Codex / Grok)を足すには + * 畳み込みごと書き直すしかなかった。 + * + * ここで 2 段に割る: + * + * provider のメッセージ ──[アダプタの parse]──▶ AgentEvent[] ──[applyAgentEvent]──▶ SessionState + * + * - 前半(形の知識)は各アダプタが持つ(Claude なら `core/claude-parse.ts`)。 + * - 後半(ログの積み方・状態遷移・no-op の判定)は**全 provider 共通**でここにある。 + * + * これにより新しいエージェントは「自分のストリームを AgentEvent へ写す」だけで済み、 + * ログの上限・進捗・サブエージェントの完了ゲート・コスト集計といった codiva 固有の + * 振る舞いを再実装しなくてよい。**セッション途中でエージェントを切り替えても** + * (`Session.setAgent`)ログと状態は連続したままになる。 + */ + +/** ツールの「意味」。provider ごとに実際のツール名は違うのでここへ正規化する。 */ +export type AgentToolKind = 'edit' | 'shell' | 'todo' | 'question' | 'other'; + +/** + * TODO リストへの操作。Claude の TaskCreate / TaskUpdate / TodoWrite のような + * provider 固有のツール入力は、アダプタがこの 3 種へ写してから渡す。 + */ +export type TodoOp = + | { op: 'create'; subject: string; activeForm?: string } + | { op: 'update'; id: string; status?: TaskStatus; subject?: string; activeForm?: string } + | { + op: 'replace'; + items: readonly { subject: string; status: TaskStatus; activeForm?: string }[]; + }; + +/** provider 非依存の「エージェントに起きたこと」。 */ +export type AgentEvent = + /** セッションが確立した(resume 用の id と解決済みモデルが分かる)。 */ + | { kind: 'session_started'; sessionId?: string; model?: string } + /** + * アシスタントのメッセージが 1 通届き始めた。ストリーミングのプレビューを捨てて + * `running` へ戻す(保留中の許可があるときは維持)ための区切りで、本文は続く + * `assistant_text` / `tool_use` が運ぶ。 + */ + | { kind: 'assistant_message'; model?: string } + | { kind: 'assistant_text'; text: string; timestamp?: number } + | { + kind: 'tool_use'; + /** provider 側の tool_use id。`tool_result` との突き合わせに使う。 */ + id?: string; + /** ログ 1 行ぶんの要約(アダプタが作る)。 */ + summary: string; + tool: AgentToolKind; + todo?: TodoOp; + /** PR 作成コマンド(`gh pr create`)だったか(`core/pr-detect.ts`)。 */ + prCreate?: boolean; + timestamp?: number; + } + | { + kind: 'tool_result'; + toolUseId?: string; + /** ログ 1 行ぶんの要約(先頭 1 行)。 */ + summary: string; + /** + * PR URL 検出のために走査するテキスト(`PR_DETECT_SCAN_CHARS` で上限済み)。 + * 対応する tool_use が `prCreate` だったときだけ読まれる。 + */ + scanText?: string; + } + /** 新しいアシスタントメッセージが始まる — ストリーミングプレビューを白紙に戻す。 */ + | { kind: 'stream_reset' } + /** ストリーミング中の増分テキスト(ライブプレビュー用)。 */ + | { kind: 'stream_text'; text: string } + /** + * 情報だけのログ行(API リトライ等)。`coalesceKey` を持つと、直前の system 行が + * 同じ接頭辞なら**書き換える**(件数を増やさない)。 + */ + | { kind: 'notice'; text: string; coalesceKey?: string } + /** サブエージェント(Task)が走り始めた — 完了ゲートに積む。 */ + | { kind: 'task_started'; taskId: string } + /** サブエージェントが片付いた — 全部片付いたら保留中の完了を確定する。 */ + | { kind: 'task_settled'; taskId?: string } + /** ターンが正常終了した。 */ + | { kind: 'turn_completed'; text: string; totalCostUsd?: number } + /** + * ターンが完了以外で終わった。 + * + * `rollup` は「これは既に診断済みの停止を要約しているだけ」の印。provider が同じ + * 失敗を 2 回(詳細なメッセージ + ターン終了の要約)報告するとき、2 回目で分類を + * やり直すと精度が落ちる(認証切れが「よく分からない failed」に格下げされる)ので、 + * 既に resumable な状態ならコストだけ取って何もしない。 + */ + | { + kind: 'turn_stopped'; + cause: AgentStopCause; + detail: string; + totalCostUsd?: number; + /** `rate_limit` のとき、制限が解除される時刻(epoch ms)。 */ + resetsAt?: number; + rollup?: boolean; + } + /** + * アカウント全体の使用状況。セッションの状態ではないので畳み込みでは無視し、 + * `Session` が横に流す(`onRateLimit`)。 + */ + | { kind: 'usage'; info: RateLimitInfoJson }; + +/** TODO 操作を 1 つ適用する。 */ +function applyTodoOp(todos: TodoItem[], op: TodoOp): TodoItem[] { + if (op.op === 'create') { + return [ + ...todos, + { + id: String(todos.length + 1), + subject: op.subject, + status: 'pending', + activeForm: op.activeForm, + }, + ]; + } + if (op.op === 'update') { + return todos.map((t) => + t.id !== op.id + ? t + : { + ...t, + status: op.status ?? t.status, + subject: op.subject ?? t.subject, + activeForm: op.activeForm ?? t.activeForm, + }, + ); + } + return op.items.map((t, i) => ({ + id: String(i + 1), + subject: t.subject, + status: t.status, + activeForm: t.activeForm, + })); +} + +/** + * 保留していた完了を確定する。ログには「新しい情報のときだけ」結果テキストを積む + * — 多くの provider は最後のアシスタント発話をターン結果としてもう一度返すので、 + * そのまま積むと画面上で同じ文章が 2 回出る。比較は**クリップ後**の文字列で行う + * (`MAX_LOG_ENTRY_CHARS` より長い答えが自分自身の echo と一致しなくなるため)。 + */ +function completeWith( + state: SessionState, + result: { at: number; totalCostUsd?: number; resultText: string }, +): SessionState { + const resultText = result.resultText.trim(); + const lastAssistantText = state.messages.findLast((m) => m.kind === 'assistant_text')?.text; + const isEcho = resultText.length > 0 && clipLogText(resultText) === lastAssistantText; + const withLog = + resultText.length > 0 && !isEcho + ? appendLog(state, 'result', resultText) + : { messages: state.messages, logSeq: state.logSeq }; + // 完了が確定したので、遅延用の一時情報は落とす。 + const { deferredResult, activeTaskIds, ...rest } = state; + void deferredResult; + void activeTaskIds; + return { + ...rest, + status: 'completed', + finishedAt: result.at, + totalCostUsd: result.totalCostUsd, + streamingText: undefined, + messages: withLog.messages, + logSeq: withLog.logSeq, + }; +} + +/** `turn_completed` の畳み込み(サブエージェントが残っていれば保留する)。 */ +function onTurnCompleted( + state: SessionState, + event: Extract, + at: number, +): SessionState { + const cost = event.totalCostUsd ?? state.totalCostUsd; + // サブエージェントがまだ走っている: この完了はバックグラウンド化された Task が + // 先に tool_result を返したせいで届いたもので、作業はまだ終わっていない。 + // 最後の 1 本が片付くまで `running` のまま保留する。 + if ((state.activeTaskIds?.length ?? 0) > 0) { + return { + ...state, + totalCostUsd: cost, + streamingText: undefined, + deferredResult: { at, totalCostUsd: cost, resultText: event.text }, + }; + } + return completeWith(state, { at, totalCostUsd: cost, resultText: event.text }); +} + +/** `turn_stopped` の畳み込み(分類ごとの遷移 + 要約の二重適用ガード)。 */ +function onTurnStopped( + state: SessionState, + event: Extract, + at: number, +): SessionState { + const cost = event.totalCostUsd ?? state.totalCostUsd; + // 既に診断済みの停止の要約なら、分類をやり直さずコストだけ取る。 + if (event.rollup && isResumable(state.status)) { + return cost === state.totalCostUsd ? state : { ...state, totalCostUsd: cost }; + } + switch (event.cause) { + case 'auth': + return { ...toNeedsLogin(state, at, event.detail), totalCostUsd: cost }; + case 'rate_limit': + return { ...toRateLimited(state, at, event.detail, event.resetsAt), totalCostUsd: cost }; + case 'connection': + return { ...toInterrupted(state, at, event.detail), totalCostUsd: cost }; + default: { + const withLog = appendLog(state, 'error', event.detail); + return { + ...state, + status: 'failed', + finishedAt: at, + totalCostUsd: cost, + error: event.detail, + streamingText: undefined, + messages: withLog.messages, + logSeq: withLog.logSeq, + }; + } + } +} + +/** + * 中立イベントを 1 つ畳み込む。**全 provider 共通の唯一の状態遷移経路**。 + * + * `agent` は「今どのエージェントが喋っているか」で、ログ行の帰属に使う + * (セッション途中で切り替えたとき、どこからが Codex の発言かを残すため)。 + */ +export function applyAgentEvent( + state: SessionState, + event: AgentEvent, + at: number, + agent?: AgentId, +): SessionState { + switch (event.kind) { + case 'session_started': { + const sessionId = event.sessionId ?? state.sdkSessionId; + const model = event.model ?? state.model; + return { + ...state, + // 保留中の許可がある間は awaiting_* を維持する(ダイアログの裏で + // "Running" に戻さない)。 + status: state.pendingPermission ? state.status : 'running', + sdkSessionId: sessionId, + // 切替で戻ってきたときに resume できるよう、provider ごとに id を控える。 + agentSessions: + agent && sessionId && state.agentSessions?.[agent] !== sessionId + ? { ...state.agentSessions, [agent]: sessionId } + : state.agentSessions, + model, + }; + } + + case 'assistant_message': { + const model = event.model ?? state.model; + const status = state.pendingPermission ? state.status : 'running'; + if (state.status === status && state.streamingText === undefined && model === state.model) { + return state; + } + return { ...state, status, streamingText: undefined, model }; + } + + case 'assistant_text': { + const text = event.text.trim(); + if (text.length === 0) { + return state; + } + const seq = state.logSeq + 1; + return { + ...state, + messages: pushLogEntry(state.messages, { + seq, + kind: 'assistant_text', + text, + timestamp: event.timestamp, + agent, + }), + logSeq: seq, + }; + } + + case 'tool_use': { + const todos = event.todo ? applyTodoOp(state.todos, event.todo) : state.todos; + const prCreateToolIds = + event.prCreate && event.id + ? trackPrCreate(state.prCreateToolIds, event.id) + : state.prCreateToolIds; + const seq = state.logSeq + 1; + return { + ...state, + todos, + progress: todos === state.todos ? state.progress : progressOf(todos), + prCreateToolIds, + messages: pushLogEntry(state.messages, { + seq, + kind: 'tool_use', + text: event.summary, + timestamp: event.timestamp, + agent, + }), + logSeq: seq, + }; + } + + case 'tool_result': { + let extraPrs = state.extraPrs; + let prCreateToolIds = state.prCreateToolIds; + // `gh pr create` の結果だけを走査する(ログ全体から URL を拾うと `gh pr list` の + // 出力や他人の PR まで「このセッションの PR」になる)。 + if (event.toolUseId && prCreateToolIds?.includes(event.toolUseId)) { + const rest = prCreateToolIds.filter((id) => id !== event.toolUseId); + prCreateToolIds = rest.length > 0 ? rest : undefined; + // 既に PR がある場合も `gh pr create` はその URL を出すので、ブランチの PR と + // 同じものは弾く(`+1` として二重に数えないため)。 + const found = extractPrRefs(event.scanText ?? '').filter( + (ref) => ref.url !== state.pr?.url, + ); + extraPrs = addPrRefs(extraPrs, found); + } + if (event.summary.length === 0) { + if (extraPrs === state.extraPrs && prCreateToolIds === state.prCreateToolIds) { + return state; + } + return { ...state, extraPrs, prCreateToolIds }; + } + const seq = state.logSeq + 1; + return { + ...state, + extraPrs, + prCreateToolIds, + messages: pushLogEntry(state.messages, { + seq, + kind: 'tool_result', + text: event.summary, + agent, + }), + logSeq: seq, + }; + } + + case 'stream_reset': + return state.streamingText === undefined ? state : { ...state, streamingText: undefined }; + + case 'stream_text': { + if (event.text.length === 0) { + return state; + } + return { + ...state, + // 保留中の許可があるセッションは awaiting_* のまま。 + status: state.pendingPermission ? state.status : 'running', + // 描画されるのは末尾 1 行だけなので、丸ごと持ち歩かない。 + streamingText: clipStreamText((state.streamingText ?? '') + event.text), + }; + } + + case 'notice': { + const last = state.messages.at(-1); + // 直前が同種の通知なら seq を保ったまま書き換える(連発でログを流さない)。 + if (event.coalesceKey && last?.kind === 'system' && last.text.startsWith(event.coalesceKey)) { + return { + ...state, + messages: [...state.messages.slice(0, -1), { ...last, text: event.text }], + }; + } + const withLog = appendLog(state, 'system', event.text); + return { ...state, messages: withLog.messages, logSeq: withLog.logSeq }; + } + + case 'task_started': { + const active = state.activeTaskIds ?? []; + if (active.includes(event.taskId)) { + return state; + } + return { ...state, activeTaskIds: [...active, event.taskId] }; + } + + case 'task_settled': { + const active = state.activeTaskIds ?? []; + const next = event.taskId ? active.filter((id) => id !== event.taskId) : active; + // 最後の 1 本が片付き、保留していた完了があるなら今こそ確定する。走っている + // 状態のときだけ — 途中で失敗/中断したセッションを遅れて来た通知で + // completed にしない。 + if (next.length === 0 && state.deferredResult && state.status === 'running') { + return completeWith(state, { ...state.deferredResult, at }); + } + if (next.length === active.length) { + return state; + } + return { ...state, activeTaskIds: next }; + } + + case 'turn_completed': + return onTurnCompleted(state, event, at); + + case 'turn_stopped': + return onTurnStopped(state, event, at); + + // アカウント全体の使用状況はセッション状態ではない(`Session` が横に流す)。 + case 'usage': + return state; + + default: + return state; + } +} + +/** + * 未応答の PR 作成コマンドを何件まで覚えておくか。tool_use は通常すぐ次の + * メッセージで応答されるので、並行呼び出しをまたげれば十分。上限を置くことで + * 結果が返らないセッションが状態を無制限に伸ばせないようにする。 + */ +const MAX_PENDING_PR_CREATES = 8; + +/** `gh pr create` の tool_use id を結果が来るまで覚える(古いものから落ちる)。 */ +function trackPrCreate(ids: readonly string[] | undefined, id: string): readonly string[] { + const current = ids ?? []; + return current.includes(id) ? current : [...current, id].slice(-MAX_PENDING_PR_CREATES); +} diff --git a/src/core/agent-ports.ts b/src/core/agent-ports.ts new file mode 100644 index 0000000..6336cda --- /dev/null +++ b/src/core/agent-ports.ts @@ -0,0 +1,132 @@ +import type { AgentEvent } from './agent-events'; +import type { EffortLevel, PermissionMode } from './config'; +import type { AgentId, AgentStopCause, PermissionRequest } from './types'; + +/** + * コーディングエージェントの DI 境界。 + * + * 境界をここ(1 ターンぶんのストリーム)に引いているのは、`SessionManager` から上 + * (UI・永続化・PR 自動化・worktree・通知)が既に `SessionHandle` 越しにしか + * セッションを触っておらず、**エージェント非依存だから**。逆に Claude Agent SDK の + * `query()` 署名(`AsyncIterable` + `Options` + `canUseTool` + + * control request)を共通 IF にすると、全 provider が Claude の制御モデルを + * 模倣する羽目になる。 + * + * アダプタの責務は 3 つだけ: + * 1. provider のストリームを開く(`open`) + * 2. provider のメッセージを `AgentEvent[]` へ写す(`AgentRun` が yield する) + * 3. provider 固有の失敗文言を `AgentStopCause` へ分類する(`classifyError`) + * + * 状態の畳み込み・ログの上限・完了ゲートは `core/agent-events.ts` の + * `applyAgentEvent` が全 provider 共通で持つ。 + */ + +/** + * 許可要求への回答。SDK の `PermissionResult` をそのまま core へ持ち込まない + * ための自前型(`PermissionRequest` が既に自前型なので対にする)。provider 形への + * 写像は各アダプタが行う。 + */ +export interface PermissionDecision { + behavior: 'allow' | 'deny'; + /** allow のとき、ツールへ渡す(必要なら書き換えた)入力。 */ + input?: Record; + /** deny のとき、エージェントへ返す理由。 */ + message?: string; +} + +/** + * そのエージェントが何をできるか。UI はこれを見て段階的に縮退する + * (持たない機能のキー操作・表示を出さない)。**セッション途中で切り替えると + * 変わりうる**ので、UI 側は固定値として持たずアダプタから引く。 + */ +export interface AgentCapabilities { + /** ツール実行の許可をユーザーへ上げられる(許可ダイアログ・質問ダイアログ)。 */ + permissions: boolean; + /** 進行中ターンの中断(詳細ビューの `Ctrl+C`)。 */ + interrupt: boolean; + /** セッション中のモデル切替(`/model`)。 */ + setModel: boolean; + /** 過去の会話を継続できる(`resume`)。false なら切替や再起動で文脈が切れる。 */ + resume: boolean; + /** 選択できるモデルの一覧を取れる(`/model` の選択肢)。 */ + modelCatalog: boolean; + /** アカウント全体の使用状況を報告する(ヘッダのゲージ)。 */ + usage: boolean; + /** ターンのコスト(USD)を報告する。 */ + cost: boolean; + /** CLI 側のトランスクリプトから会話ログを復元できる。 */ + transcript: boolean; +} + +/** 1 ターンぶんの起動オプション。provider ごとに解釈は違ってよい(無視も可)。 */ +export interface AgentRunOptions { + model?: string; + effort?: EffortLevel; + permissionMode?: PermissionMode; + maxBudgetUsd?: number; + /** worktree の環境説明 + リポジトリ追加指示(`core/system-prompt.ts`)。 */ + systemPrompt?: string; +} + +/** `AgentAdapter.open` への入力。 */ +export interface AgentRunRequest { + /** セッションの worktree。 */ + cwd: string; + /** ユーザー発話のストリーム(追加指示が随時流れ込む)。 */ + prompt: AsyncIterable; + /** + * 継続する provider 側セッション id。**その provider が過去に発行したもの**を渡す + * (`SessionState.agentSessions`)。別 provider の id を渡してはいけない。 + */ + resume?: string; + options: AgentRunOptions; + /** + * 許可/質問をユーザーへ上げる。`id` は `Session` が採番するのでアダプタは渡さない。 + * 解決するまでエージェントはブロックされてよい。 + */ + requestPermission: (request: Omit) => Promise; + abortController: AbortController; +} + +/** + * 開いている 1 本のエージェントストリーム。`Query` の中立版。 + * `interrupt` / `setModel` は capability が false の provider では省略してよい。 + */ +export interface AgentRun extends AsyncIterable { + interrupt?(): Promise; + setModel?(model: string | undefined): Promise | void; +} + +/** + * 1 つのコーディングエージェント。`SessionManager` はこれを差し替えるだけで + * provider を切り替えられる(**セッション途中の切替**は `Session.setAgent`)。 + */ +export interface AgentAdapter { + readonly id: AgentId; + /** 画面に出す名前('Claude' / 'Codex' / 'Grok')。固有名詞なので翻訳しない。 */ + readonly displayName: string; + /** 再ログインに使う CLI コマンド名(認証切れの案内文に差し込む)。 */ + readonly loginCommand: string; + readonly capabilities: AgentCapabilities; + /** ストリームを開く。復帰(resume)も同じ経路で、`request.resume` で区別する。 */ + open(request: AgentRunRequest): AgentRun; + /** + * ストリームが throw した/文字列でしか届かない失敗を分類する。未実装なら + * `failed` 扱い。ここが provider 固有の文言知識の置き場所。 + */ + classifyError?(text: string): AgentStopCause; + /** 指示文から短いタイトルを作る(省略可・best-effort)。 */ + generateTitle?(prompt: string): Promise; +} + +/** capability を全部 false にした素の値(新しいアダプタの出発点)。 */ +export const NO_CAPABILITIES: AgentCapabilities = { + permissions: false, + interrupt: false, + setModel: false, + resume: false, + modelCatalog: false, + usage: false, + cost: false, + transcript: false, +}; diff --git a/src/core/claude-adapter.ts b/src/core/claude-adapter.ts new file mode 100644 index 0000000..0117fce --- /dev/null +++ b/src/core/claude-adapter.ts @@ -0,0 +1,135 @@ +import type { + Options, + PermissionResult, + Query, + SDKMessage, + SDKUserMessage, +} from '@anthropic-ai/claude-agent-sdk'; +import type { AgentEvent } from './agent-events'; +import type { AgentAdapter, AgentCapabilities, AgentRun, AgentRunRequest } from './agent-ports'; +import { classifyClaudeError } from './claude-errors'; +import { parseClaudeMessage } from './claude-parse'; +import type { QuestionSpec } from './types'; + +/** + * Claude Code(`@anthropic-ai/claude-agent-sdk`)用の {@link AgentAdapter}。 + * + * ここが「Claude の `query()` を codiva の中立語彙へ翻訳する」層で、 + * SDK の型が出てくるのはこのファイルと `claude-parse.ts` / `claude-errors.ts` だけ。 + * `Session` から見ると Claude も Codex も同じ `AgentAdapter` なので、 + * **セッション途中の切替**(`Session.setAgent`)が成立する。 + */ + +/** SDK の `query` の署名(DI 用)。 */ +export type QueryFn = (params: { + prompt: AsyncIterable; + options: Options; +}) => Query; + +/** Claude Code が持っている機能。 */ +export const CLAUDE_CAPABILITIES: AgentCapabilities = { + permissions: true, + interrupt: true, + setModel: true, + resume: true, + modelCatalog: true, + usage: true, + cost: true, + transcript: true, +}; + +/** AskUserQuestion の入力を UI が扱える {@link QuestionSpec} へ写す。 */ +function parseQuestions(input: Record): QuestionSpec[] { + const raw = (input.questions as Record[] | undefined) ?? []; + return raw.map((q) => ({ + question: String(q.question ?? ''), + header: String(q.header ?? ''), + multiSelect: Boolean(q.multiSelect), + options: ((q.options as { label?: string; description?: string }[] | undefined) ?? []).map( + (o) => ({ label: String(o.label ?? ''), description: String(o.description ?? '') }), + ), + })); +} + +/** ユーザー発話(文字列)を SDK のメッセージ形へ包む。 */ +function toUserMessage(text: string): SDKUserMessage { + return { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null }; +} + +async function* toSdkPrompt(prompt: AsyncIterable): AsyncIterable { + for await (const text of prompt) { + yield toUserMessage(text); + } +} + +/** `AgentAdapter` を Claude 用に組み立てる。`queryFn` は DI(テストはフェイクを注入)。 */ +export function createClaudeAdapter(deps: { + queryFn: QueryFn; + generateTitle?: (prompt: string) => Promise; +}): AgentAdapter { + return { + id: 'claude', + displayName: 'Claude', + loginCommand: 'claude', + capabilities: CLAUDE_CAPABILITIES, + classifyError: classifyClaudeError, + generateTitle: deps.generateTitle, + + open(request: AgentRunRequest): AgentRun { + const canUseTool = async ( + toolName: string, + input: Record, + ): Promise => { + const isQuestion = toolName === 'AskUserQuestion'; + const decision = await request.requestPermission({ + toolName, + input, + kind: isQuestion ? 'question' : 'tool', + questions: isQuestion ? parseQuestions(input) : undefined, + }); + // `AskUserQuestion` は `answers` を入れずに allow すると質問が無視される + // ("The user did not answer the questions.")ので、UI の回答は + // `decision.input` に載せて丸ごと差し替える。 + return decision.behavior === 'allow' + ? { behavior: 'allow', updatedInput: decision.input ?? input } + : { behavior: 'deny', message: decision.message ?? 'denied' }; + }; + + const opts = request.options; + const handle = deps.queryFn({ + prompt: toSdkPrompt(request.prompt), + options: { + cwd: request.cwd, + permissionMode: opts.permissionMode ?? 'acceptEdits', + canUseTool, + abortController: request.abortController, + settingSources: ['project'], + // Stream partial assistant text so the detail view shows a live preview + // (reduced into state.streamingText). See claude-parse fromStreamEvent. + includePartialMessages: true, + // worktree の環境説明 + リポジトリ追加指示を systemPrompt として注入する。SDK は + // systemPrompt 省略時に空文字("")へ写像する(claude_code プリセットは使わない)ため、 + // ここに文字列を渡すのは「空への追記」と等価。将来ベースの systemPrompt を + // 足すなら、この行は array / preset-append 形へ切り替える必要がある。 + ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}), + ...(opts.model ? { model: opts.model } : {}), + ...(opts.effort ? { effort: opts.effort } : {}), + ...(opts.maxBudgetUsd != null ? { maxBudgetUsd: opts.maxBudgetUsd } : {}), + ...(request.resume ? { resume: request.resume } : {}), + } as Options, + }); + + return { + async *[Symbol.asyncIterator](): AsyncIterator { + for await (const message of handle) { + yield* parseClaudeMessage(message as SDKMessage); + } + }, + interrupt: async () => { + await handle.interrupt?.(); + }, + setModel: (model) => handle.setModel?.(model), + }; + }, + }; +} diff --git a/src/core/claude-errors.spec.ts b/src/core/claude-errors.spec.ts new file mode 100644 index 0000000..c486ac5 --- /dev/null +++ b/src/core/claude-errors.spec.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyClaudeError, + isAuthError, + isAuthErrorKind, + isConnectionError, + isTransientApiErrorKind, + isTransientApiStatus, +} from './claude-errors'; + +describe('isConnectionError', () => { + it.each([ + 'fetch failed', + 'terminated', + 'socket hang up', + 'read ECONNRESET', + 'connect ECONNREFUSED 127.0.0.1:443', + 'request to https://api.anthropic.com failed, reason: ETIMEDOUT', + 'getaddrinfo ENOTFOUND api.anthropic.com', + 'getaddrinfo EAI_AGAIN api.anthropic.com', + 'Connection error.', + 'network error', + 'Premature close', + 'Error: 503 Service Unavailable', + 'Overloaded', + 'The operation timed out', + // The CLI's own wordings for a stream that died after part of the answer had + // already been delivered (recovered from the binary). It finalizes the partial + // response as an `API Error:` assistant message and ends the turn. + 'API Error: Connection closed mid-response. The response above may be incomplete.', + 'API Error: Server error mid-response. The response above may be incomplete.', + 'API Error: Response stalled mid-stream. The response above may be incomplete.', + 'API Error: Connection closed while thinking, before producing a response. Try again.', + 'API Error: Response stalled while thinking, before producing a response. Try again.', + 'API Error: Connection to the API was lost (ECONNRESET). This is usually temporary — try again.', + ])('classifies %j as a connection interruption', (text) => { + expect(isConnectionError(text)).toBe(true); + }); + + it.each([ + 'stream boom', + 'invalid x-api-key', + 'permission denied', + 'error_during_execution', + "You've hit your usage limit", + 'TypeError: cannot read property of undefined', + '', + ])('does not misclassify a genuine failure %j', (text) => { + expect(isConnectionError(text)).toBe(false); + }); +}); + +describe('isAuthError', () => { + it.each([ + // The CLI's own wordings (recovered from the binary). The first is what a + // codiva session gets, since the CLI treats it as non-interactive. + 'Failed to authenticate: OAuth session expired and could not be refreshed', + 'Login expired · Please run /login', + 'Failed to authenticate. API Error: 401', + 'Your account does not have access to Claude. Please login again or contact your administrator.', + 'OAuth token revoked · Please run /login', + 'Not logged in · Please run /login', + 'Invalid API key · Fix external API key', + 'Authentication error · This may be a temporary network issue, please try again', + 'Your organization has disabled API key authentication · Run /login to sign in with your claude.ai account', + 'AWS credentials expired or invalid', + 'Google Cloud authentication failed', + 'Your apiKeyHelper script is failing · This usually means you need to re-authenticate with your provider', + // Auth errors that reach us as a raw kind / thrown message. + 'authentication_failed', + 'oauth_org_not_allowed', + 'Failed to authenticate through the broker: boom', + 'invalid x-api-key', + '401 Unauthorized', + ])('classifies %j as an auth failure', (text) => { + expect(isAuthError(text)).toBe(true); + }); + + it.each([ + // Ordinary failures and limits must keep their own classification. + 'error_during_execution', + "You've hit your usage limit", + 'rate limit reached', + 'fetch failed', + 'socket hang up', + 'permission denied', + 'TypeError: cannot read property of undefined', + 'merge conflict in src/app.tsx', + // Billing, not auth: no login fixes an empty credit balance. + 'Credit balance is too low', + '', + ])('does not misclassify %j as an auth failure', (text) => { + expect(isAuthError(text)).toBe(false); + }); +}); + +describe('isAuthErrorKind', () => { + it.each(['authentication_failed', 'oauth_org_not_allowed'])( + 'treats the SDK error kind %j as needing a login', + (kind) => { + expect(isAuthErrorKind(kind)).toBe(true); + }, + ); + + it.each([ + // Other SDKAssistantMessageError kinds keep their own handling: rate_limit has + // its own state, server/overloaded errors are transient interruptions + // (isTransientApiErrorKind), and billing errors are genuine failures. + 'rate_limit', + 'billing_error', + 'overloaded', + 'invalid_request', + 'model_not_found', + 'server_error', + 'unknown', + ])('does not treat %j as an auth failure', (kind) => { + expect(isAuthErrorKind(kind)).toBe(false); + }); + + it('is safe for non-string values', () => { + expect(isAuthErrorKind(undefined)).toBe(false); + expect(isAuthErrorKind(null)).toBe(false); + expect(isAuthErrorKind(42)).toBe(false); + }); +}); + +describe('isTransientApiErrorKind', () => { + it.each(['server_error', 'overloaded'])( + 'treats the SDK error kind %j as a resumable interruption', + (kind) => { + expect(isTransientApiErrorKind(kind)).toBe(true); + }, + ); + + it.each([ + // Auth and rate limits have their own dedicated states. + 'authentication_failed', + 'oauth_org_not_allowed', + 'rate_limit', + // Real failures that retrying never fixes. + 'billing_error', + 'invalid_request', + 'model_not_found', + // The CLI continues the turn after this one (max-output-tokens recovery), so + // it must never stop the session. + 'max_output_tokens', + // Too vague to promise a resume — the result's terminal_reason classifies it. + 'unknown', + ])('does not treat %j as a transient API failure', (kind) => { + expect(isTransientApiErrorKind(kind)).toBe(false); + }); + + it('is safe for non-string values', () => { + expect(isTransientApiErrorKind(undefined)).toBe(false); + expect(isTransientApiErrorKind(null)).toBe(false); + expect(isTransientApiErrorKind(42)).toBe(false); + }); +}); + +describe('isTransientApiStatus', () => { + it('treats an explicit null as a connection-level failure (no HTTP response)', () => { + expect(isTransientApiStatus(null)).toBe(true); + }); + + it('does not treat an absent status as transient', () => { + // The field only exists on the SDK's success result variant, so on an error + // result its absence carries no information — assuming "no HTTP response" there + // would make a hard 400 look resumable. + expect(isTransientApiStatus(undefined)).toBe(false); + }); + + it.each([500, 502, 503, 504, 529, 408, 429])('treats %i as transient', (status) => { + expect(isTransientApiStatus(status)).toBe(true); + }); + + it.each([400, 401, 403, 404, 413, 422])('treats %i as a real failure', (status) => { + expect(isTransientApiStatus(status)).toBe(false); + }); + + it('is safe for non-numeric values', () => { + expect(isTransientApiStatus('503')).toBe(false); + expect(isTransientApiStatus({})).toBe(false); + }); +}); + +describe('classifyClaudeError', () => { + // 順序に意味がある分類なので、境界(複数の分類に当たり得る文言)を必ず含める。 + const cases: [string, string][] = [ + // 認証切れは待っても再試行しても直らないので最優先。 + ['Failed to authenticate: OAuth session expired and could not be refreshed', 'auth'], + ['invalid x-api-key', 'auth'], + // タイムアウトに*言及する*認証エラーを通信断と読み違えない(実際にあった罠)。 + ['Failed to authenticate through the broker: request timed out', 'auth'], + // 使用量・レート制限は待てば直る。 + ["Error: You've hit your limit", 'rate_limit'], + ['rate limit exceeded', 'rate_limit'], + // 通信断は再開すれば続きから走る。 + ['connection reset', 'connection'], + [ + 'API Error: Connection closed mid-response. The response above may be incomplete.', + 'connection', + ], + ['socket hang up', 'connection'], + // それ以外は本物の失敗。 + ['Cannot find module ./foo', 'failed'], + ['ENOENT: no such file or directory', 'failed'], + ]; + + it.each(cases)('classifies %j as %s', (text, expected) => { + expect(classifyClaudeError(text)).toBe(expected); + }); +}); diff --git a/src/core/claude-errors.ts b/src/core/claude-errors.ts new file mode 100644 index 0000000..9960e15 --- /dev/null +++ b/src/core/claude-errors.ts @@ -0,0 +1,224 @@ +import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentStopCause } from './types'; + +/** + * Claude Code アダプタの「失敗の見分け方」。 + * + * ここに集めたのは **Claude CLI の文言・typed error kind・HTTP ステータスの知識** で、 + * どれも provider が変われば意味を失う(Codex に "OAuth session expired" は無い)。 + * 状態機械(`core/status-reducer.ts` / `core/agent-events.ts`)はこの知識を持たず、 + * 分類結果の {@link AgentStopCause} だけを受け取る。別のエージェントを足すときは、 + * このファイルの対になるものをそのアダプタ用に書く。 + * + * `USAGE_LIMIT_ERROR_PREFIXES` を SDK から読んでいるのは意図的: 使用制限の文言は + * CLI 側で変わるので、**Claude アダプタの中でだけ** SDK に追従させるのが正しい + * (中立モジュールは SDK を import しない、が守るべき境界)。 + */ + +/** + * Transport / connectivity failure signatures. A mid-stream query throw that + * matches one of these is a *connection interruption* — the network dropped + * while Claude was working (moving between networks, flaky wifi, a server + * hiccup) rather than a genuine, unrecoverable error. Such a session can be + * resumed (the SDK keeps the transcript), so we classify it as `interrupted` + * instead of `failed` (see Session.consume / status-reducer `interrupted`). + * + * Kept deliberately broad on transport-level wording (socket/network/timeout, + * common Node errno codes, and transient upstream 5xx / "overloaded") but never + * matches ordinary application errors, which stay `failed`. + * + * The last two entries cover the CLI's *own* synthesized wordings for a stream that + * died mid-answer, which it reports as an assistant message prefixed `API Error:` + * before ending the turn. The wordings (recovered from the CLI binary) are: + * `API Error: Connection closed mid-response. The response above may be incomplete.` + * `API Error: Server error mid-response. The response above may be incomplete.` + * `API Error: Response stalled mid-stream. The response above may be incomplete.` + * `API Error: Connection closed while thinking, before producing a response. Try again.` + * `API Error: Response stalled while thinking, before producing a response. Try again.` + * `API Error: Connection to the API was lost (ECONNRESET). This is usually temporary — try again.` + * The `connection closed` ones already matched the pattern above; the new entries add + * the `mid-response` / `mid-stream` / `stalled` / `lost` phrasings. + * + * Text matching is only the *fallback* here. The wording-independent signals are the + * typed `error` kind on that assistant message (`isTransientApiErrorKind`) and the + * `terminal_reason` / `api_error_status` pair on the result (`isTransientApiStatus`). + */ +const CONNECTION_ERROR_PATTERNS: readonly RegExp[] = [ + /econnreset|econnrefused|econnaborted|etimedout|enotfound|eai_again|enetunreach|ehostunreach|epipe/i, + /socket hang up|getaddrinfo|network error|network request failed|fetch failed/i, + /connection (?:error|closed|reset|refused|timed out|terminated)/i, + /premature close|stream (?:error|closed)|terminated|read econn/i, + /timeout|timed out/i, + /\b(?:502|503|504)\b|bad gateway|gateway timeout|service unavailable|overloaded/i, + /\bmid-(?:response|stream)\b|connection to the api was lost/i, + /response stalled|stalled while thinking/i, +]; + +/** + * True when an error string looks like a network/connection interruption rather + * than a real failure. Used to route a dropped-connection session to the + * resumable `interrupted` state. See CONNECTION_ERROR_PATTERNS. + */ +export function isConnectionError(text: string): boolean { + return CONNECTION_ERROR_PATTERNS.some((re) => re.test(text)); +} + +/** + * Authentication-failure signatures. The Claude CLI stops a turn with one of + * these when its credentials are gone or stale — for a codiva session (which the + * CLI treats as non-interactive) that is most often + * `Failed to authenticate: OAuth session expired and could not be refreshed`, + * i.e. the OAuth login simply aged out and could not be refreshed. + * + * This is neither a completion nor a real failure of the *task*: nothing is wrong + * with the worktree or the prompt, the user just has to log in again (`claude` → + * `/login`) and resume. So we route it to the dedicated `needs_login` state, + * which tells the user what to do instead of showing a green "Completed" badge + * for work that never ran. + * + * This is the *fallback* classifier, for text that reaches us without structure + * (a thrown error from the query, an `errors[]` entry). The primary signal is the + * SDK's own typed `SDKAssistantMessageError` — see `isAuthErrorKind` — which is + * language-independent and covers every variant the CLI can emit. The patterns + * here mirror the CLI's actual wordings (OAuth expired/revoked, bad or missing + * API key, expired cloud credentials, "run /login", re-authenticate) while + * staying narrow enough that ordinary application errors — and Claude merely + * *writing about* authentication — stay `failed`. + */ +const AUTH_ERROR_PATTERNS: readonly RegExp[] = [ + /failed to authenticate|authentication failed|not authenticated|unauthenticated/i, + /re-?authenticate|please (?:re-?)?log ?in again/i, + /oauth[^\n]{0,40}(?:expired|invalid|revoked|refresh)/i, + /authentication[ _]error|authentication_failed|oauth_org_not_allowed|invalid_api_key/i, + /(?:invalid|missing|expired|revoked)\s+(?:x-)?api[- ]key/i, + /(?:session|token|credential)s?[^\n]{0,20}expired/i, + /please (?:re-?)?(?:run|log ?in)[^\n]{0,20}\/login|run `?\/login`?/i, + /\bunauthorized\b|\b401\b/i, +]; + +/** + * True when an error string signals that Claude could not authenticate — the + * user needs to log in again before this session can continue. Used to route the + * session to `needs_login` (see AUTH_ERROR_PATTERNS). + */ +export function isAuthError(text: string): boolean { + return AUTH_ERROR_PATTERNS.some((re) => re.test(text)); +} + +/** + * The `SDKAssistantMessageError` kinds that mean "this session cannot continue + * until the user authenticates again". This is the *primary* auth signal: the SDK + * sets it as a typed field on the assistant message (alongside the human-readable + * text), so it is independent of the CLI's wording and of the user's locale. + * + * `oauth_org_not_allowed` is included because it is equally fatal and equally + * fixable only by signing in differently (with an API key or after an admin + * enables access) — the user has to go and deal with credentials either way. + * `billing_error` (low credit balance) is deliberately NOT here: no login fixes + * it, so it stays a plain `failed`. + */ +const AUTH_ERROR_KINDS: readonly string[] = ['authentication_failed', 'oauth_org_not_allowed']; + +/** True for an SDK assistant-message `error` kind that means "log in again". */ +export function isAuthErrorKind(kind: unknown): boolean { + return typeof kind === 'string' && AUTH_ERROR_KINDS.includes(kind); +} + +/** + * The `SDKAssistantMessageError` kinds that mean "the API call itself failed for a + * transient reason". When the response stream dies (or the upstream is at capacity / + * returns 5xx) the CLI synthesizes an assistant message flagged with one of these, + * carrying the human-readable reason as its text — `API Error: Connection closed + * mid-response. The response above may be incomplete.` — and ends the turn. + * + * Nothing is wrong with the work: the transcript is intact, so resuming continues + * the same conversation. We route it to `interrupted` (idle & resumable) rather than + * letting the roll-up `result` land on a green "Completed" for a truncated answer. + * + * This is the *primary* signal for such a stop — typed, so it is independent of the + * CLI's wording and the user's locale (the same failure has half a dozen phrasings; + * see CONNECTION_ERROR_PATTERNS for the text fallback). + * + * Deliberately NOT here: + * - `max_output_tokens` — the CLI recovers from it by continuing the turn + * (`resumed_from_incomplete_thinking`), so it must not stop the session. + * - `invalid_request` / `model_not_found` / `billing_error` — real, non-transient + * failures that retrying never fixes; they stay `failed`. + * - `rate_limit` / auth kinds — they have their own dedicated states. + * - `unknown` — too vague to promise a resume; the result's `terminal_reason` / + * `api_error_status` pair classifies it instead (`isTransientApiStatus`). + */ +const TRANSIENT_API_ERROR_KINDS: readonly string[] = ['server_error', 'overloaded']; + +/** + * True for an SDK assistant-message `error` kind that means "the API call failed + * transiently — resume it" (see TRANSIENT_API_ERROR_KINDS). + */ +export function isTransientApiErrorKind(kind: unknown): boolean { + return typeof kind === 'string' && TRANSIENT_API_ERROR_KINDS.includes(kind); +} + +/** + * True when the HTTP status of an *API-error turn* (`terminal_reason: 'api_error'`) + * describes a transient failure worth resuming. Callers must have established the + * turn ended on an API error first — this only judges the status. + * + * An explicit `null` means the request never got an HTTP response: the SDK documents + * exactly that for connection-level failures ("error_status is null for connection + * errors (e.g. timeouts) that had no HTTP response"), which is the `Connection closed + * mid-response` case. Otherwise only 5xx, 408 (request timeout) and 429 count — a 4xx + * like 400 (invalid request) never clears by retrying and stays `failed`. 429 normally + * never reaches here (the rate-limit classifiers run first); it is listed so a missed + * wording still lands on a resumable state. + * + * `undefined` (the field absent) is deliberately NOT transient: `api_error_status` + * exists only on the SDK's *success* result variant, so on an `error_during_execution` + * result its absence says nothing about the failure — treating that as "no HTTP + * response" would make every error result resumable, including a hard 400. + */ +export function isTransientApiStatus(status: unknown): boolean { + if (status === null) { + return true; + } + return typeof status === 'number' && (status >= 500 || status === 408 || status === 429); +} + +/** + * True when an error/result string signals a genuine usage- or rate-limit stop + * (rather than an ordinary failure). We match the SDK's own `getLimitReachedText` + * prefixes so we stay in sync with the CLI wording, plus a loose "rate limit" / + * "usage limit" fallback for messages that arrive wrapped (e.g. `Error: …`). + * + * 旧 `core/status-reducer.ts` から移設。状態機械が特定エージェントの SDK 定数を + * import している状態を解消するのが目的で、判定内容は変えていない。 + */ +export function isRateLimitError(text: string): boolean { + return ( + USAGE_LIMIT_ERROR_PREFIXES.some((p) => text.includes(p)) || + /rate.?limit|usage limit/i.test(text) + ); +} + +/** + * 構造を持たない失敗(query が throw した / `errors[]` の 1 行)を + * {@link AgentStopCause} へ分類する。`AgentAdapter.classifyError` の Claude 実装。 + * + * **順序に意味がある**: + * 1. 認証切れ — 待っても再試行しても直らないので最優先。レート制限の文言と + * 紛らわしいケース("request timed out" を含む認証エラー)で誤分類しないため。 + * 2. レート制限 — 待てば直る。 + * 3. 通信断 — 再開すれば続きから走る。 + * 4. それ以外は本物の失敗。 + */ +export function classifyClaudeError(text: string): AgentStopCause { + if (isAuthError(text)) { + return 'auth'; + } + if (isRateLimitError(text)) { + return 'rate_limit'; + } + if (isConnectionError(text)) { + return 'connection'; + } + return 'failed'; +} diff --git a/src/core/sdk-parse.spec.ts b/src/core/claude-parse.spec.ts similarity index 97% rename from src/core/sdk-parse.spec.ts rename to src/core/claude-parse.spec.ts index 714b951..104046f 100644 --- a/src/core/sdk-parse.spec.ts +++ b/src/core/claude-parse.spec.ts @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { beforeAll, describe, expect, it } from 'vitest'; +import { applyClaudeMessage, summarizeToolUse, toolResultSummary } from '@/core/claude-parse'; import { MAX_LOG_ENTRIES, MAX_LOG_ENTRY_CHARS, MAX_STREAM_PREVIEW_CHARS } from '@/core/log-buffer'; -import { applySdkMessage, summarizeToolUse, toolResultSummary } from '@/core/sdk-parse'; import { initialState, reduce } from '@/core/status-reducer'; import type { CreateSessionInput, PermissionRequest, SessionState } from '@/core/types'; @@ -24,23 +24,23 @@ const BASE: CreateSessionInput = { startedAt: 1000, }; -/** Replay an SDK message stream through applySdkMessage with synthetic timestamps. */ +/** Replay an SDK message stream through applyClaudeMessage with synthetic timestamps. */ function replay(messages: SDKMessage[], from = initialState(BASE)): SessionState { let state = from; let at = BASE.startedAt; for (const message of messages) { at += 1; - state = applySdkMessage(state, message, at); + state = applyClaudeMessage(state, message, at); } return state; } /** Apply a single (possibly synthetic) SDK message. */ function sdk(state: SessionState, message: unknown, at = 1): SessionState { - return applySdkMessage(state, message as SDKMessage, at); + return applyClaudeMessage(state, message as SDKMessage, at); } -describe('applySdkMessage over real fixtures', () => { +describe('applyClaudeMessage over real fixtures', () => { let basic: SDKMessage[]; let followup: SDKMessage[]; let interrupted: SDKMessage[]; @@ -121,7 +121,7 @@ describe('applySdkMessage over real fixtures', () => { }); }); -describe('applySdkMessage interaction with pending control state', () => { +describe('applyClaudeMessage interaction with pending control state', () => { it('captures the resolved model from system/init even when config left it unset', () => { const init = { type: 'system', @@ -129,7 +129,7 @@ describe('applySdkMessage interaction with pending control state', () => { session_id: 'abc', model: 'claude-haiku-4-5', } as unknown as SDKMessage; - const state = applySdkMessage(initialState(BASE), init, 1); + const state = applyClaudeMessage(initialState(BASE), init, 1); expect(state.model).toBe('claude-haiku-4-5'); }); @@ -139,7 +139,7 @@ describe('applySdkMessage interaction with pending control state', () => { type: 'assistant', message: { model: 'claude-sonnet-4-5', content: [{ type: 'text', text: 'hi' }] }, } as unknown as SDKMessage; - const state = applySdkMessage(s0, assistant, 2); + const state = applyClaudeMessage(s0, assistant, 2); expect(state.model).toBe('claude-sonnet-4-5'); }); @@ -204,7 +204,7 @@ describe('applySdkMessage interaction with pending control state', () => { }); }); -describe('applySdkMessage over synthetic SDK messages', () => { +describe('applyClaudeMessage over synthetic SDK messages', () => { it('supports the legacy TodoWrite tool (whole-list replace)', () => { const msg = { type: 'assistant', @@ -316,7 +316,7 @@ describe('applySdkMessage over synthetic SDK messages', () => { }); }); -describe('applySdkMessage over rate-limit signals', () => { +describe('applyClaudeMessage over rate-limit signals', () => { const running: SessionState = { ...initialState(BASE), status: 'running' }; it('a rejected rate_limit_event stops the session as rate_limited with its reset time', () => { @@ -383,7 +383,7 @@ describe('applySdkMessage over rate-limit signals', () => { }); }); -describe('applySdkMessage over authentication failures', () => { +describe('applyClaudeMessage over authentication failures', () => { const running: SessionState = { ...initialState(BASE), status: 'running' }; const AUTH = 'Failed to authenticate: OAuth session expired and could not be refreshed'; @@ -497,7 +497,7 @@ describe('applySdkMessage over authentication failures', () => { }); }); -describe('applySdkMessage over mid-response API errors', () => { +describe('applyClaudeMessage over mid-response API errors', () => { const running: SessionState = { ...initialState(BASE), status: 'running', sdkSessionId: 'sdk-1' }; const CUT = 'API Error: Connection closed mid-response. The response above may be incomplete.'; @@ -737,7 +737,7 @@ describe('applySdkMessage over mid-response API errors', () => { }); }); -describe('applySdkMessage gates completion on in-flight sub-agent tasks', () => { +describe('applyClaudeMessage gates completion on in-flight sub-agent tasks', () => { const running: SessionState = { ...initialState(BASE), status: 'running' }; const taskStarted = (task_id: string, extra: Record = {}) => ({ type: 'system', @@ -832,7 +832,7 @@ function streamText(text: string) { }; } -describe('applySdkMessage over streaming partial messages', () => { +describe('applyClaudeMessage over streaming partial messages', () => { it('accumulates text_delta into streamingText and flips to running', () => { let state = sdk(initialState(BASE), streamText('Hel')); expect(state.status).toBe('running'); @@ -900,7 +900,7 @@ describe('applySdkMessage over streaming partial messages', () => { }); }); -describe('applySdkMessage does not double the final message on completion', () => { +describe('applyClaudeMessage does not double the final message on completion', () => { const assistant = (text: string) => ({ type: 'assistant', message: { content: [{ type: 'text', text }] }, @@ -946,7 +946,7 @@ describe('applySdkMessage does not double the final message on completion', () = // ログが無制限に伸びる(追記ごとに全体コピー)のがヒープ枯渇の原因だったので、 // SDK 経路の追記も必ず上限を通ることを担保する(`core/log-buffer.ts`)。 -describe('applySdkMessage keeps the log bounded', () => { +describe('applyClaudeMessage keeps the log bounded', () => { it('caps the number of entries, keeping the newest', () => { let state: SessionState = { ...initialState(BASE), status: 'running' }; for (let i = 0; i < MAX_LOG_ENTRIES + 20; i += 1) { diff --git a/src/core/claude-parse.ts b/src/core/claude-parse.ts new file mode 100644 index 0000000..fab58fc --- /dev/null +++ b/src/core/claude-parse.ts @@ -0,0 +1,481 @@ +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import { type AgentEvent, type AgentToolKind, applyAgentEvent, type TodoOp } from './agent-events'; +import { + isAuthError, + isAuthErrorKind, + isConnectionError, + isRateLimitError, + isTransientApiErrorKind, + isTransientApiStatus, +} from './claude-errors'; +import { MAX_LOG_ENTRY_CHARS } from './log-buffer'; +import { isPrCreateTool, PR_DETECT_SCAN_CHARS } from './pr-detect'; +import type { RateLimitInfoJson } from './rate-limit'; +import { USER_INTERRUPT_DETAIL } from './status-reducer'; +import type { AgentId, AgentStopCause, SessionState, TaskStatus } from './types'; + +/** + * Claude Agent SDK のメッセージの**形**を知る唯一の場所。 + * + * ここの仕事は「`SDKMessage` を読んで {@link AgentEvent} の列に写す」ことだけで、 + * 状態をどう変えるかは持たない(畳み込みは `core/agent-events.ts` の + * `applyAgentEvent` が全 provider 共通で行う)。この分割のおかげで、Codex / Grok の + * アダプタは自分のストリームをこの語彙へ写すだけで済み、ログの上限・サブエージェントの + * 完了ゲート・PR 検出・コスト集計を再実装しなくてよい。 + * + * 形は想定で書かない — `src/core/__fixtures__/*.jsonl` の実データでテストする + * (規約: `.claude/rules/sdk-integration.md`)。 + */ + +/** Minimal shapes we read out of the (loosely-typed) SDK content blocks. */ +interface TextBlock { + type: 'text'; + text: string; +} +interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: Record; +} +interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content: unknown; + is_error?: boolean; +} + +function asString(v: unknown): string { + if (typeof v === 'string') { + return v; + } + if (Array.isArray(v)) { + return v + .map((b) => + b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '', + ) + .join(''); + } + return v == null ? '' : JSON.stringify(v); +} + +/** + * Like {@link asString} but materializes at most `limit` characters. Tool results + * carry whole file reads and command outputs (megabytes), and only their first + * few hundred characters are ever shown: flattening the entire payload — and then + * splitting it into every one of its lines — allocated the whole thing on the + * heap just to throw it away, once per tool call. + */ +function asStringHead(v: unknown, limit: number): string { + if (typeof v === 'string') { + return v.slice(0, limit); + } + if (Array.isArray(v)) { + let out = ''; + for (const b of v) { + if (out.length >= limit) { + break; + } + if (b && typeof b === 'object' && 'text' in b) { + out += String((b as { text: unknown }).text).slice(0, limit - out.length); + } + } + return out; + } + return v == null ? '' : JSON.stringify(v).slice(0, limit); +} + +/** + * Flatten an error `result`'s `errors: string[]` into one string. The error result + * variants have no `result` field, so this is the only description they carry. + */ +function joinErrors(errors: unknown): string { + return Array.isArray(errors) ? errors.map((e) => String(e)).join('\n') : ''; +} + +/** + * A tool input field as a string, cut to what the log can hold. `Bash` commands + * carry heredocs with whole file bodies, so building the full string first would + * allocate megabytes per tool call only for `pushLogEntry` to clip them. + */ +function inputText(value: unknown): string { + return value == null ? '' : String(value).slice(0, MAX_LOG_ENTRY_CHARS); +} + +/** One-line log summary for a tool_use block. Shared with `transcript.ts` (history restore). */ +export function summarizeToolUse(name: string, input: Record): string { + switch (name) { + case 'Write': + case 'Edit': + return `${name} ${inputText(input.file_path ?? input.path)}`.trim(); + case 'Bash': + return `Bash ${inputText(input.command)}`.trim(); + case 'TaskCreate': + return `TaskCreate "${inputText(input.subject)}"`; + case 'TaskUpdate': + return `TaskUpdate #${String(input.taskId ?? '')} → ${String(input.status ?? '')}`; + case 'AskUserQuestion': { + const questions = (input.questions as { question?: string }[] | undefined) ?? []; + return `AskUserQuestion: ${questions[0]?.question ?? ''}`; + } + default: + return name; + } +} + +/** How many characters of a tool_result's first line the log keeps. */ +const TOOL_RESULT_SUMMARY_CHARS = 200; + +/** First line of a flattened payload, capped — the shared log summary shape. */ +function firstLine(head: string): string { + const cut = head.slice(0, TOOL_RESULT_SUMMARY_CHARS); + const br = cut.search(/[\r\n]/); + return br === -1 ? cut : cut.slice(0, br); +} + +/** + * One-line log summary for a tool_result block's content (first line, capped). + * Shared with `transcript.ts` so restored history matches the live log format. + * Only the first {@link TOOL_RESULT_SUMMARY_CHARS} characters are read out of the + * payload — the rest of a multi-megabyte result is never materialized. + */ +export function toolResultSummary(content: unknown): string { + return firstLine(asStringHead(content, TOOL_RESULT_SUMMARY_CHARS)); +} + +/** Claude のツール名を provider 非依存の「意味」へ写す。 */ +function toolKindOf(name: string): AgentToolKind { + switch (name) { + case 'Write': + case 'Edit': + return 'edit'; + case 'Bash': + return 'shell'; + case 'TaskCreate': + case 'TaskUpdate': + case 'TodoWrite': + return 'todo'; + case 'AskUserQuestion': + return 'question'; + default: + return 'other'; + } +} + +/** TaskCreate / TaskUpdate / TodoWrite の入力を中立の {@link TodoOp} へ写す。 */ +function todoOpOf(block: ToolUseBlock): TodoOp | undefined { + if (block.name === 'TaskCreate') { + return { + op: 'create', + subject: String(block.input.subject ?? ''), + activeForm: block.input.activeForm ? String(block.input.activeForm) : undefined, + }; + } + if (block.name === 'TaskUpdate') { + return { + op: 'update', + id: String(block.input.taskId ?? ''), + status: block.input.status as TaskStatus | undefined, + subject: block.input.subject ? String(block.input.subject) : undefined, + activeForm: block.input.activeForm ? String(block.input.activeForm) : undefined, + }; + } + if (block.name === 'TodoWrite') { + const list = + (block.input.todos as { content?: string; status?: string; activeForm?: string }[]) ?? []; + return { + op: 'replace', + items: list.map((t) => ({ + subject: String(t.content ?? ''), + status: (t.status as TaskStatus | undefined) ?? 'pending', + activeForm: t.activeForm ? String(t.activeForm) : undefined, + })), + }; + } + return undefined; +} + +/** Log-line prefix for `system/api_retry`; also the key for coalescing them. */ +const API_RETRY_PREFIX = 'api retry'; + +/** `system/*` を写す。 */ +function fromSystem(message: Record): AgentEvent[] { + if (message.subtype === 'init') { + return [ + { + kind: 'session_started', + sessionId: typeof message.session_id === 'string' ? message.session_id : undefined, + // init carries the *resolved* model even when config left it unset. + model: typeof message.model === 'string' ? message.model : undefined, + }, + ]; + } + // サブエージェント(Task ツール)のライフサイクル。バックグラウンド化された Task が + // 走っている間にトップレベルの result が届くため、完了ゲートとして数える。 + // `skip_transcript` の雑務タスクはゲート対象外。 + if (message.subtype === 'task_started') { + if (message.skip_transcript === true || typeof message.task_id !== 'string') { + return []; + } + return [{ kind: 'task_started', taskId: message.task_id }]; + } + if (message.subtype === 'task_notification') { + return [ + { + kind: 'task_settled', + taskId: typeof message.task_id === 'string' ? message.task_id : undefined, + }, + ]; + } + // リトライ可能な API 失敗。CLI が再試行するのでセッションは走ったままで、 + // ログに 1 行残すだけ(連発するので直前の同種行を書き換える)。 + if (message.subtype === 'api_retry') { + const attempt = typeof message.attempt === 'number' ? message.attempt : undefined; + const max = typeof message.max_retries === 'number' ? message.max_retries : undefined; + const of = attempt !== undefined && max !== undefined ? ` ${attempt}/${max}` : ''; + const kind = typeof message.error === 'string' ? message.error : 'error'; + // `error_status` は HTTP 応答すら無かった接続断では null なので、あるときだけ出す。 + const status = typeof message.error_status === 'number' ? ` ${message.error_status}` : ''; + return [ + { + kind: 'notice', + text: `${API_RETRY_PREFIX}${of}: ${kind}${status}`, + coalesceKey: API_RETRY_PREFIX, + }, + ]; + } + return []; +} + +/** `assistant` の本体(content ブロック)を写す。 */ +function fromAssistantBlocks(message: Record): AgentEvent[] { + const inner = message.message as { content?: unknown; model?: unknown } | undefined; + const content = Array.isArray(inner?.content) ? inner.content : []; + const timestamp = typeof message.timestamp === 'number' ? message.timestamp : undefined; + // 各アシスタントメッセージは自分を生成したモデルを報告するので、途中のモデル切替も + // 追える(init は最初にしか来ない)。 + const model = + typeof inner?.model === 'string' && inner.model.length > 0 ? inner.model : undefined; + + const events: AgentEvent[] = [{ kind: 'assistant_message', model }]; + for (const raw of content) { + if (!raw || typeof raw !== 'object') { + continue; + } + const block = raw as { type?: string }; + if (block.type === 'text') { + events.push({ kind: 'assistant_text', text: (raw as TextBlock).text, timestamp }); + } else if (block.type === 'tool_use') { + const tu = raw as ToolUseBlock; + events.push({ + kind: 'tool_use', + id: typeof tu.id === 'string' ? tu.id : undefined, + summary: summarizeToolUse(tu.name, tu.input ?? {}), + tool: toolKindOf(tu.name), + todo: todoOpOf(tu), + // 「このセッションが出した PR」は結果にしか URL が無いので、作成コマンドの + // tool_use id を控えて次の tool_result と突き合わせる(core/pr-detect.ts)。 + prCreate: isPrCreateTool(tu.name, tu.input ?? {}) || undefined, + timestamp, + }); + } + } + return events; +} + +/** `user`(= tool_result の運び手)を写す。 */ +function fromUser(message: Record): AgentEvent[] { + const inner = message.message as { content?: unknown } | undefined; + const content = Array.isArray(inner?.content) ? inner.content : []; + const events: AgentEvent[] = []; + for (const raw of content) { + if (raw && typeof raw === 'object' && (raw as { type?: string }).type === 'tool_result') { + const tr = raw as ToolResultBlock; + // ログ用の要約は先頭 1 行しか使わないが、PR の URL は数行下に出るので少し深く + // 読む(上限付き)。1 回の走査で両方を作り、巨大な payload は決して展開しない。 + const head = asStringHead(tr.content, PR_DETECT_SCAN_CHARS); + events.push({ + kind: 'tool_result', + toolUseId: tr.tool_use_id, + summary: firstLine(head), + scanText: head, + }); + } + } + return events; +} + +/** + * `includePartialMessages` の部分メッセージ。ライブプレビューに使う増分テキストだけを + * 拾う(ツール入力の JSON や thinking の delta は UI 状態を変えない)。 + */ +function fromStreamEvent(message: Record): AgentEvent[] { + const event = message.event; + if (!event || typeof event !== 'object') { + return []; + } + const ev = event as { type?: string; delta?: unknown }; + if (ev.type === 'message_start') { + return [{ kind: 'stream_reset' }]; + } + if (ev.type === 'content_block_delta') { + const delta = ev.delta as { type?: string; text?: string } | undefined; + if (delta?.type === 'text_delta' && typeof delta.text === 'string' && delta.text.length > 0) { + return [{ kind: 'stream_text', text: delta.text }]; + } + } + return []; +} + +/** + * ターン終了の `result` を写す。 + * + * `subtype: 'success'` **だけでは成功を意味しない** — CLI は認証切れや拒否された + * リクエストも success + `is_error: true` で報告する(`terminal_reason` は `api_error`)。 + * subtype だけを信じたせいで、何も作業していないセッションが緑の "Completed" に + * なる不具合が実際に起きた。 + */ +function fromResult(message: Record): AgentEvent[] { + const totalCostUsd = + typeof message.total_cost_usd === 'number' ? message.total_cost_usd : undefined; + const subtype = String(message.subtype ?? 'error'); + // `result` は success 版にしか無く、エラー版は `errors[]` を持つので両方読む。 + const resultText = asString(message.result) || joinErrors(message.errors); + const isError = message.is_error === true; + + if (subtype === 'success' && !isError) { + return [{ kind: 'turn_completed', text: resultText, totalCostUsd }]; + } + + // is_error な success では subtype に情報が無いので、結果テキストが唯一の説明。 + const error = subtype === 'success' ? resultText || 'error' : subtype; + const stop = (cause: AgentStopCause, detail: string): AgentEvent[] => [ + { kind: 'turn_stopped', cause, detail, totalCostUsd, rollup: true }, + ]; + + // 認証切れを最優先(待っても再試行しても直らない唯一の失敗)。 + if (isAuthError(error) || isAuthError(resultText)) { + return stop('auth', resultText || error); + } + if (isRateLimitError(error) || isRateLimitError(resultText)) { + return stop('rate_limit', resultText || error); + } + if (isConnectionError(error) || isConnectionError(resultText)) { + return stop('connection', resultText || error); + } + // ユーザーの Ctrl+C。CLI は `terminal_reason: 'aborted_streaming'` でターンを閉じる + // (実測: `__fixtures__/session-interrupt.jsonl`)。**自分で止めたのだから失敗ではない** + // ので resumable にする。判定は文言ではなく構造で行い、`errors[]` の内部診断は + // ユーザーに見せる意味がないので固定文言を書く。 + if (message.terminal_reason === 'aborted_streaming') { + return stop('connection', USER_INTERRUPT_DETAIL); + } + // 同じ種類の停止の構造的フォールバック。CLI は API エラーのターンを + // `terminal_reason: 'api_error'` で閉じ、HTTP 応答が無かった接続断では + // `api_error_status` が明示的に null になる。文言は何通りもあり変わるので、 + // 知らない言い回しでも resumable に着地させる。 + if (message.terminal_reason === 'api_error' && isTransientApiStatus(message.api_error_status)) { + return stop('connection', resultText || error); + } + return stop('failed', error); +} + +/** + * 生の `SDKMessage` 1 通を中立イベント列へ写す。**アダプタの入口**。 + * 状態は見ない(純粋・メッセージ単位で決まる)。 + */ +export function parseClaudeMessage(message: SDKMessage): AgentEvent[] { + const raw = message as unknown as Record; + const type = raw.type as string; + + if (type === 'system') { + return fromSystem(raw); + } + + if (type === 'rate_limit_event') { + const info = raw.rate_limit_info as RateLimitInfoJson | undefined; + const events: AgentEvent[] = []; + if (info) { + // アカウント全体の使用状況(セッション状態ではない)は横へ流す。 + events.push({ kind: 'usage', info }); + } + // `rejected` は「リクエストが弾かれている」= セッションが止まっている。 + // `allowed` / `allowed_warning` はまだ通っているので状態は変えない。 + if (info?.status === 'rejected') { + events.push({ + kind: 'turn_stopped', + cause: 'rate_limit', + detail: 'rate limit reached', + resetsAt: info.resetsAt, + }); + } + return events; + } + + if (type === 'assistant') { + // ターンがレート/使用量制限で弾かれた(トップレベルの error フィールド)。 + if (raw.error === 'rate_limit') { + return [{ kind: 'turn_stopped', cause: 'rate_limit', detail: 'rate limit reached' }]; + } + // 認証できなかった。typed な `error` kind が主信号で、人が読める理由は本文にある。 + // ここで捕まえるので文言に依存せず、これを要約する `result` は同じ失敗として + // no-op になる(`rollup`)。 + if (isAuthErrorKind(raw.error)) { + const inner = raw.message as { content?: unknown } | undefined; + const text = asString(inner?.content).trim(); + return [{ kind: 'turn_stopped', cause: 'auth', detail: text || String(raw.error) }]; + } + // API 呼び出しが一過性の理由で失敗した(多くは応答ストリームが途中で切れた)。 + // 届いていた内容は通常のアシスタントメッセージとして既に配られていて、この + // フラグ付きメッセージは通知だけを運ぶ。緑の "Completed" にせず resumable にする。 + // + // トップレベルのターンのみ(`parent_tool_use_id` が null): 同じ失敗が + // サブエージェント内で起きた場合は失敗した tool_result として本流へ報告され、 + // Claude が再試行や回避をできるので、セッションは走り続けてよい。 + if (isTransientApiErrorKind(raw.error) && raw.parent_tool_use_id == null) { + const inner = raw.message as { content?: unknown } | undefined; + const text = asString(inner?.content).trim(); + return [{ kind: 'turn_stopped', cause: 'connection', detail: text || String(raw.error) }]; + } + return fromAssistantBlocks(raw); + } + + if (type === 'user') { + return fromUser(raw); + } + + if (type === 'stream_event') { + return fromStreamEvent(raw); + } + + if (type === 'result') { + return fromResult(raw); + } + + // thinking_tokens やその他の未処理メッセージ — 状態は変わらない。 + return []; +} + +/** + * 生の `SDKMessage` 1 通をセッション状態へ畳み込む(parse → 共通の fold)。 + * + * 旧 `applySdkMessage`。中身は 2 段に分かれたが**外から見た振る舞いは同じ**で、 + * 1,100 行超の実データテスト(`claude-parse.spec.ts` + `__fixtures__/*.jsonl`)が + * そのままこの入口を叩き続けられるようにしてある — 分割のリグレッション網。 + * + * `agent` を渡すと各ログ行に発言者を刻む。単一エージェントのセッションでは + * undefined のまま(既存のログ行の形を変えないため。刻むのは + * `Session.setAgent()` で切り替えが起きた後だけ)。 + */ +export function applyClaudeMessage( + state: SessionState, + message: SDKMessage, + at: number, + agent?: AgentId, +): SessionState { + let next = state; + for (const event of parseClaudeMessage(message)) { + next = applyAgentEvent(next, event, at, agent); + } + return next; +} diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index f577b04..007ce6a 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -1,5 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { type CodivaConfig, resolveIgnoredFilesMode, toConfig } from '@/core/config'; +import { + type CodivaConfig, + type EffortLevel, + type PermissionMode, + resolveIgnoredFilesMode, + toConfig, +} from '@/core/config'; + +/** + * `Record` のキーで表を作ることで、union に値が増えたら**型エラー**で + * ここに気付ける(検証配列 = union = テストの三者一致を保つ番人)。値が実際に通ることは + * 下の `it.each` が実行時に確かめる。 + */ +const EFFORT_CASES: Record = { + low: true, + medium: true, + high: true, + xhigh: true, + max: true, +}; + +const PERMISSION_MODE_CASES: Record = { + default: true, + acceptEdits: true, + bypassPermissions: true, + plan: true, + dontAsk: true, + auto: true, +}; describe('toConfig', () => { it.each([ @@ -35,7 +63,7 @@ describe('toConfig', () => { expect(toConfig({ model })).toEqual({}); }); - it.each([['low'], ['medium'], ['high'], ['xhigh'], ['max']])('keeps effort %s', (effort) => { + it.each(Object.keys(EFFORT_CASES))('keeps effort %s', (effort) => { expect(toConfig({ effort })).toEqual({ effort }); }); @@ -43,12 +71,9 @@ describe('toConfig', () => { expect(toConfig({ effort })).toEqual({}); }); - it.each([['default'], ['acceptEdits'], ['bypassPermissions'], ['plan'], ['dontAsk'], ['auto']])( - 'keeps permissionMode %s', - (permissionMode) => { - expect(toConfig({ permissionMode })).toEqual({ permissionMode }); - }, - ); + it.each(Object.keys(PERMISSION_MODE_CASES))('keeps permissionMode %s', (permissionMode) => { + expect(toConfig({ permissionMode })).toEqual({ permissionMode }); + }); it.each([['yolo'], [1], [null]])('drops invalid permissionMode: %o', (permissionMode) => { expect(toConfig({ permissionMode })).toEqual({}); diff --git a/src/core/config.ts b/src/core/config.ts index 9e1c3bb..b34f085 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,7 +1,40 @@ -import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import type { Lang } from './i18n'; import type { IgnoredFilesMode } from './worktree'; +/** + * 推論の effort レベル。**この配列が唯一の出所**で、型(`EffortLevel`)も実行時検証も + * ここから導出する。 + * + * 値の集合は Claude Agent SDK の同名 union と同じだが、`core/` を特定エージェントの + * SDK から独立させるため(規約: architecture.md)あえて自前で持つ。したがって + * **SDK 側に値が増えたらここへ追従させる必要がある**(型で気付けないので、SDK 更新時に + * 目視で確認する)。将来エージェントを増やすときも、解釈の差はアダプタ側で吸収する。 + */ +const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; + +/** 設定で指定できる effort レベル。`EFFORT_LEVELS` から導出(追加はそちらへ)。 */ +export type EffortLevel = (typeof EFFORT_LEVELS)[number]; + +/** + * ツール実行の許可モード。**この配列が唯一の出所**で、型(`PermissionMode`)も実行時検証も + * ここから導出する。 + * + * `EFFORT_LEVELS` と同じ理由で自前定義(`core/` を SDK から独立させる / SDK に値が増えたら + * ここへ追従)。とくに permissionMode は Claude Code 固有の概念なので、**エージェントが + * 増えれば解釈が変わりうる**(同じ文字列を別エージェントがどう扱うかはアダプタの責任)。 + */ +const PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'dontAsk', + 'auto', +] as const; + +/** 設定で指定できる許可モード。`PERMISSION_MODES` から導出(追加はそちらへ)。 */ +export type PermissionMode = (typeof PERMISSION_MODES)[number]; + /** * 永続設定のドメイン型。表示言語に加え、セッション起動時に SDK へ渡す * model / effort / permissionMode / maxBudgetUsd と、通知の on/off を持つ。 @@ -103,16 +136,6 @@ export interface CodivaConfig { copyIgnored?: boolean; } -/** SDK 由来 union の実行時検証用リテラル。型が変われば型エラーで気付ける。 */ -const EFFORT_LEVELS: readonly EffortLevel[] = ['low', 'medium', 'high', 'xhigh', 'max']; -const PERMISSION_MODES: readonly PermissionMode[] = [ - 'default', - 'acceptEdits', - 'bypassPermissions', - 'plan', - 'dontAsk', - 'auto', -]; const IGNORED_FILES_MODES: readonly IgnoredFilesMode[] = ['symlink', 'copy', 'none']; /** 設定ファイルの生 JSON 形(各フィールドは unknown として受ける)。 */ diff --git a/src/core/errors.spec.ts b/src/core/errors.spec.ts index 05e2123..4e6d52d 100644 --- a/src/core/errors.spec.ts +++ b/src/core/errors.spec.ts @@ -1,13 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - errorMessage, - errorStack, - isAuthError, - isAuthErrorKind, - isConnectionError, - isTransientApiErrorKind, - isTransientApiStatus, -} from '@/core/errors'; +import { errorMessage, errorStack } from './errors'; describe('errorMessage', () => { it('uses an Error message', () => { @@ -20,181 +12,6 @@ describe('errorMessage', () => { }); }); -describe('isConnectionError', () => { - it.each([ - 'fetch failed', - 'terminated', - 'socket hang up', - 'read ECONNRESET', - 'connect ECONNREFUSED 127.0.0.1:443', - 'request to https://api.anthropic.com failed, reason: ETIMEDOUT', - 'getaddrinfo ENOTFOUND api.anthropic.com', - 'getaddrinfo EAI_AGAIN api.anthropic.com', - 'Connection error.', - 'network error', - 'Premature close', - 'Error: 503 Service Unavailable', - 'Overloaded', - 'The operation timed out', - // The CLI's own wordings for a stream that died after part of the answer had - // already been delivered (recovered from the binary). It finalizes the partial - // response as an `API Error:` assistant message and ends the turn. - 'API Error: Connection closed mid-response. The response above may be incomplete.', - 'API Error: Server error mid-response. The response above may be incomplete.', - 'API Error: Response stalled mid-stream. The response above may be incomplete.', - 'API Error: Connection closed while thinking, before producing a response. Try again.', - 'API Error: Response stalled while thinking, before producing a response. Try again.', - 'API Error: Connection to the API was lost (ECONNRESET). This is usually temporary — try again.', - ])('classifies %j as a connection interruption', (text) => { - expect(isConnectionError(text)).toBe(true); - }); - - it.each([ - 'stream boom', - 'invalid x-api-key', - 'permission denied', - 'error_during_execution', - "You've hit your usage limit", - 'TypeError: cannot read property of undefined', - '', - ])('does not misclassify a genuine failure %j', (text) => { - expect(isConnectionError(text)).toBe(false); - }); -}); - -describe('isAuthError', () => { - it.each([ - // The CLI's own wordings (recovered from the binary). The first is what a - // codiva session gets, since the CLI treats it as non-interactive. - 'Failed to authenticate: OAuth session expired and could not be refreshed', - 'Login expired · Please run /login', - 'Failed to authenticate. API Error: 401', - 'Your account does not have access to Claude. Please login again or contact your administrator.', - 'OAuth token revoked · Please run /login', - 'Not logged in · Please run /login', - 'Invalid API key · Fix external API key', - 'Authentication error · This may be a temporary network issue, please try again', - 'Your organization has disabled API key authentication · Run /login to sign in with your claude.ai account', - 'AWS credentials expired or invalid', - 'Google Cloud authentication failed', - 'Your apiKeyHelper script is failing · This usually means you need to re-authenticate with your provider', - // Auth errors that reach us as a raw kind / thrown message. - 'authentication_failed', - 'oauth_org_not_allowed', - 'Failed to authenticate through the broker: boom', - 'invalid x-api-key', - '401 Unauthorized', - ])('classifies %j as an auth failure', (text) => { - expect(isAuthError(text)).toBe(true); - }); - - it.each([ - // Ordinary failures and limits must keep their own classification. - 'error_during_execution', - "You've hit your usage limit", - 'rate limit reached', - 'fetch failed', - 'socket hang up', - 'permission denied', - 'TypeError: cannot read property of undefined', - 'merge conflict in src/app.tsx', - // Billing, not auth: no login fixes an empty credit balance. - 'Credit balance is too low', - '', - ])('does not misclassify %j as an auth failure', (text) => { - expect(isAuthError(text)).toBe(false); - }); -}); - -describe('isAuthErrorKind', () => { - it.each(['authentication_failed', 'oauth_org_not_allowed'])( - 'treats the SDK error kind %j as needing a login', - (kind) => { - expect(isAuthErrorKind(kind)).toBe(true); - }, - ); - - it.each([ - // Other SDKAssistantMessageError kinds keep their own handling: rate_limit has - // its own state, server/overloaded errors are transient interruptions - // (isTransientApiErrorKind), and billing errors are genuine failures. - 'rate_limit', - 'billing_error', - 'overloaded', - 'invalid_request', - 'model_not_found', - 'server_error', - 'unknown', - ])('does not treat %j as an auth failure', (kind) => { - expect(isAuthErrorKind(kind)).toBe(false); - }); - - it('is safe for non-string values', () => { - expect(isAuthErrorKind(undefined)).toBe(false); - expect(isAuthErrorKind(null)).toBe(false); - expect(isAuthErrorKind(42)).toBe(false); - }); -}); - -describe('isTransientApiErrorKind', () => { - it.each(['server_error', 'overloaded'])( - 'treats the SDK error kind %j as a resumable interruption', - (kind) => { - expect(isTransientApiErrorKind(kind)).toBe(true); - }, - ); - - it.each([ - // Auth and rate limits have their own dedicated states. - 'authentication_failed', - 'oauth_org_not_allowed', - 'rate_limit', - // Real failures that retrying never fixes. - 'billing_error', - 'invalid_request', - 'model_not_found', - // The CLI continues the turn after this one (max-output-tokens recovery), so - // it must never stop the session. - 'max_output_tokens', - // Too vague to promise a resume — the result's terminal_reason classifies it. - 'unknown', - ])('does not treat %j as a transient API failure', (kind) => { - expect(isTransientApiErrorKind(kind)).toBe(false); - }); - - it('is safe for non-string values', () => { - expect(isTransientApiErrorKind(undefined)).toBe(false); - expect(isTransientApiErrorKind(null)).toBe(false); - expect(isTransientApiErrorKind(42)).toBe(false); - }); -}); - -describe('isTransientApiStatus', () => { - it('treats an explicit null as a connection-level failure (no HTTP response)', () => { - expect(isTransientApiStatus(null)).toBe(true); - }); - - it('does not treat an absent status as transient', () => { - // The field only exists on the SDK's success result variant, so on an error - // result its absence carries no information — assuming "no HTTP response" there - // would make a hard 400 look resumable. - expect(isTransientApiStatus(undefined)).toBe(false); - }); - - it.each([500, 502, 503, 504, 529, 408, 429])('treats %i as transient', (status) => { - expect(isTransientApiStatus(status)).toBe(true); - }); - - it.each([400, 401, 403, 404, 413, 422])('treats %i as a real failure', (status) => { - expect(isTransientApiStatus(status)).toBe(false); - }); - - it('is safe for non-numeric values', () => { - expect(isTransientApiStatus('503')).toBe(false); - expect(isTransientApiStatus({})).toBe(false); - }); -}); - describe('errorStack', () => { it('returns the stack of an Error', () => { const stack = errorStack(new Error('boom')); diff --git a/src/core/errors.ts b/src/core/errors.ts index c57ad94..4a652dc 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -24,171 +24,3 @@ export function errorStack(err: unknown): string | undefined { } return parts.join('\ncaused by: '); } - -/** - * Transport / connectivity failure signatures. A mid-stream query throw that - * matches one of these is a *connection interruption* — the network dropped - * while Claude was working (moving between networks, flaky wifi, a server - * hiccup) rather than a genuine, unrecoverable error. Such a session can be - * resumed (the SDK keeps the transcript), so we classify it as `interrupted` - * instead of `failed` (see Session.consume / status-reducer `interrupted`). - * - * Kept deliberately broad on transport-level wording (socket/network/timeout, - * common Node errno codes, and transient upstream 5xx / "overloaded") but never - * matches ordinary application errors, which stay `failed`. - * - * The last two entries cover the CLI's *own* synthesized wordings for a stream that - * died mid-answer, which it reports as an assistant message prefixed `API Error:` - * before ending the turn. The wordings (recovered from the CLI binary) are: - * `API Error: Connection closed mid-response. The response above may be incomplete.` - * `API Error: Server error mid-response. The response above may be incomplete.` - * `API Error: Response stalled mid-stream. The response above may be incomplete.` - * `API Error: Connection closed while thinking, before producing a response. Try again.` - * `API Error: Response stalled while thinking, before producing a response. Try again.` - * `API Error: Connection to the API was lost (ECONNRESET). This is usually temporary — try again.` - * The `connection closed` ones already matched the pattern above; the new entries add - * the `mid-response` / `mid-stream` / `stalled` / `lost` phrasings. - * - * Text matching is only the *fallback* here. The wording-independent signals are the - * typed `error` kind on that assistant message (`isTransientApiErrorKind`) and the - * `terminal_reason` / `api_error_status` pair on the result (`isTransientApiStatus`). - */ -const CONNECTION_ERROR_PATTERNS: readonly RegExp[] = [ - /econnreset|econnrefused|econnaborted|etimedout|enotfound|eai_again|enetunreach|ehostunreach|epipe/i, - /socket hang up|getaddrinfo|network error|network request failed|fetch failed/i, - /connection (?:error|closed|reset|refused|timed out|terminated)/i, - /premature close|stream (?:error|closed)|terminated|read econn/i, - /timeout|timed out/i, - /\b(?:502|503|504)\b|bad gateway|gateway timeout|service unavailable|overloaded/i, - /\bmid-(?:response|stream)\b|connection to the api was lost/i, - /response stalled|stalled while thinking/i, -]; - -/** - * True when an error string looks like a network/connection interruption rather - * than a real failure. Used to route a dropped-connection session to the - * resumable `interrupted` state. See CONNECTION_ERROR_PATTERNS. - */ -export function isConnectionError(text: string): boolean { - return CONNECTION_ERROR_PATTERNS.some((re) => re.test(text)); -} - -/** - * Authentication-failure signatures. The Claude CLI stops a turn with one of - * these when its credentials are gone or stale — for a codiva session (which the - * CLI treats as non-interactive) that is most often - * `Failed to authenticate: OAuth session expired and could not be refreshed`, - * i.e. the OAuth login simply aged out and could not be refreshed. - * - * This is neither a completion nor a real failure of the *task*: nothing is wrong - * with the worktree or the prompt, the user just has to log in again (`claude` → - * `/login`) and resume. So we route it to the dedicated `needs_login` state, - * which tells the user what to do instead of showing a green "Completed" badge - * for work that never ran. - * - * This is the *fallback* classifier, for text that reaches us without structure - * (a thrown error from the query, an `errors[]` entry). The primary signal is the - * SDK's own typed `SDKAssistantMessageError` — see `isAuthErrorKind` — which is - * language-independent and covers every variant the CLI can emit. The patterns - * here mirror the CLI's actual wordings (OAuth expired/revoked, bad or missing - * API key, expired cloud credentials, "run /login", re-authenticate) while - * staying narrow enough that ordinary application errors — and Claude merely - * *writing about* authentication — stay `failed`. - */ -const AUTH_ERROR_PATTERNS: readonly RegExp[] = [ - /failed to authenticate|authentication failed|not authenticated|unauthenticated/i, - /re-?authenticate|please (?:re-?)?log ?in again/i, - /oauth[^\n]{0,40}(?:expired|invalid|revoked|refresh)/i, - /authentication[ _]error|authentication_failed|oauth_org_not_allowed|invalid_api_key/i, - /(?:invalid|missing|expired|revoked)\s+(?:x-)?api[- ]key/i, - /(?:session|token|credential)s?[^\n]{0,20}expired/i, - /please (?:re-?)?(?:run|log ?in)[^\n]{0,20}\/login|run `?\/login`?/i, - /\bunauthorized\b|\b401\b/i, -]; - -/** - * True when an error string signals that Claude could not authenticate — the - * user needs to log in again before this session can continue. Used to route the - * session to `needs_login` (see AUTH_ERROR_PATTERNS). - */ -export function isAuthError(text: string): boolean { - return AUTH_ERROR_PATTERNS.some((re) => re.test(text)); -} - -/** - * The `SDKAssistantMessageError` kinds that mean "this session cannot continue - * until the user authenticates again". This is the *primary* auth signal: the SDK - * sets it as a typed field on the assistant message (alongside the human-readable - * text), so it is independent of the CLI's wording and of the user's locale. - * - * `oauth_org_not_allowed` is included because it is equally fatal and equally - * fixable only by signing in differently (with an API key or after an admin - * enables access) — the user has to go and deal with credentials either way. - * `billing_error` (low credit balance) is deliberately NOT here: no login fixes - * it, so it stays a plain `failed`. - */ -const AUTH_ERROR_KINDS: readonly string[] = ['authentication_failed', 'oauth_org_not_allowed']; - -/** True for an SDK assistant-message `error` kind that means "log in again". */ -export function isAuthErrorKind(kind: unknown): boolean { - return typeof kind === 'string' && AUTH_ERROR_KINDS.includes(kind); -} - -/** - * The `SDKAssistantMessageError` kinds that mean "the API call itself failed for a - * transient reason". When the response stream dies (or the upstream is at capacity / - * returns 5xx) the CLI synthesizes an assistant message flagged with one of these, - * carrying the human-readable reason as its text — `API Error: Connection closed - * mid-response. The response above may be incomplete.` — and ends the turn. - * - * Nothing is wrong with the work: the transcript is intact, so resuming continues - * the same conversation. We route it to `interrupted` (idle & resumable) rather than - * letting the roll-up `result` land on a green "Completed" for a truncated answer. - * - * This is the *primary* signal for such a stop — typed, so it is independent of the - * CLI's wording and the user's locale (the same failure has half a dozen phrasings; - * see CONNECTION_ERROR_PATTERNS for the text fallback). - * - * Deliberately NOT here: - * - `max_output_tokens` — the CLI recovers from it by continuing the turn - * (`resumed_from_incomplete_thinking`), so it must not stop the session. - * - `invalid_request` / `model_not_found` / `billing_error` — real, non-transient - * failures that retrying never fixes; they stay `failed`. - * - `rate_limit` / auth kinds — they have their own dedicated states. - * - `unknown` — too vague to promise a resume; the result's `terminal_reason` / - * `api_error_status` pair classifies it instead (`isTransientApiStatus`). - */ -const TRANSIENT_API_ERROR_KINDS: readonly string[] = ['server_error', 'overloaded']; - -/** - * True for an SDK assistant-message `error` kind that means "the API call failed - * transiently — resume it" (see TRANSIENT_API_ERROR_KINDS). - */ -export function isTransientApiErrorKind(kind: unknown): boolean { - return typeof kind === 'string' && TRANSIENT_API_ERROR_KINDS.includes(kind); -} - -/** - * True when the HTTP status of an *API-error turn* (`terminal_reason: 'api_error'`) - * describes a transient failure worth resuming. Callers must have established the - * turn ended on an API error first — this only judges the status. - * - * An explicit `null` means the request never got an HTTP response: the SDK documents - * exactly that for connection-level failures ("error_status is null for connection - * errors (e.g. timeouts) that had no HTTP response"), which is the `Connection closed - * mid-response` case. Otherwise only 5xx, 408 (request timeout) and 429 count — a 4xx - * like 400 (invalid request) never clears by retrying and stays `failed`. 429 normally - * never reaches here (the rate-limit classifiers run first); it is listed so a missed - * wording still lands on a resumable state. - * - * `undefined` (the field absent) is deliberately NOT transient: `api_error_status` - * exists only on the SDK's *success* result variant, so on an `error_during_execution` - * result its absence says nothing about the failure — treating that as "no HTTP - * response" would make every error result resumable, including a hard 400. - */ -export function isTransientApiStatus(status: unknown): boolean { - if (status === null) { - return true; - } - return typeof status === 'number' && (status >= 500 || status === 408 || status === 429); -} diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 1d2c4f7..899d1f0 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -11,6 +11,22 @@ export type Lang = 'ja' | 'en'; /** サポート言語の一覧(順序は UI での並びに使える)。 */ export const LANGS: readonly Lang[] = ['ja', 'en']; +/** + * 文言に差し込むエージェントの識別情報。将来 Claude 以外(Codex / Grok)の + * セッションを扱えるようにするため、表示名とログインコマンドをカタログから + * 追い出して引数にする。値の出所はアダプタ(`core/agent-ports.ts` の + * `AgentAdapter`)で、カタログ側は「どう並べるか」だけを持つ。 + */ +export interface AgentLabel { + /** 表示名(例: 'Claude')。SDK/CLI 由来の固有名詞なので翻訳しない。 */ + name: string; + /** 再ログインに使う CLI コマンド名(例: 'claude')。 */ + loginCommand: string; +} + +/** 既定のエージェント表示情報(現状は Claude のみ)。 */ +export const DEFAULT_AGENT_LABEL: AgentLabel = { name: 'Claude', loginCommand: 'claude' }; + /** * 全 UI 文字列の型。ja/en 両カタログはこの型を満たすため、キー欠落は型エラーで検知できる * (加えて i18n.spec.ts が両カタログのキー集合の一致も検証する)。 @@ -75,9 +91,9 @@ export interface Messages { /** * 一括再開の確認文。`n` = 対象件数、`auth` = そのうち認証切れの件数。 * 認証切れには「ログインし直した」という指示文を送るので、まだログインして - * いないなら先にログインするよう促す(0 件なら触れない)。 + * いないなら先にログインするよう促す(0 件なら触れない = `agent` も出ない)。 */ - resumeAllPrompt: (n: number, auth: number) => string; + resumeAllPrompt: (agent: AgentLabel, n: number, auth: number) => string; confirmRun: string; busySuffix: string; }; @@ -151,7 +167,7 @@ export interface Messages { /** 通信断でセッションが中断された(再開可能)ときの通知。 */ interrupted: string; /** 認証切れで停止した(ログインが必要な)ときの通知。 */ - needsLogin: string; + needsLogin: (agent: AgentLabel) => string; }; /** * 中断されたセッションの再開(continue)。通信断で `interrupted` になった、または @@ -213,15 +229,16 @@ export interface Messages { allDone: (n: number) => string; }; /** - * 認証切れ(`needs_login`)の案内。Claude の OAuth セッションが失効すると - * セッションは何もできないので、「別ターミナルで `claude` にログインし直して - * r で再開する」という手順そのものを提示する。 + * 認証切れ(`needs_login`)の案内。エージェントの OAuth セッションが失効すると + * セッションは何もできないので、「別ターミナルでそのエージェントの CLI に + * ログインし直して r で再開する」という手順そのものを提示する。 + * エージェント名・コマンド名は `AgentLabel` で差し込む。 */ auth: { /** 一覧で needs_login 行を選択中のフッタヒント(再開キー r を含む)。 */ - listHint: string; + listHint: (agent: AgentLabel) => string; /** ログイン手順の案内文(一覧・詳細で共有)。 */ - hint: string; + hint: (agent: AgentLabel) => string; }; /** 起動バナー(banner.tsx) */ banner: { @@ -411,9 +428,9 @@ const ja: Messages = { removePrompt: 'このセッションを一覧から削除します(worktree とブランチも消えます)。', clearPrompt: (n) => `完了したセッション ${n} 件を一覧から削除します(worktree とブランチも消えます)。`, - resumeAllPrompt: (n, auth) => + resumeAllPrompt: (agent, n, auth) => auth > 0 - ? `中断中の ${n} 件を続きから再開します(認証切れ ${auth} 件を含む — 先に別ターミナルで claude にログインしてください)。` + ? `中断中の ${n} 件を続きから再開します(認証切れ ${auth} 件を含む — 先に別ターミナルで ${agent.loginCommand} にログインしてください)。` : `中断中の ${n} 件を続きから再開します。`, confirmRun: '実行しますか?', busySuffix: '…実行中', @@ -468,7 +485,7 @@ const ja: Messages = { rateLimited: 'レート制限に達しました', failed: '失敗しました', interrupted: '接続が中断されました(再開できます)', - needsLogin: 'Claude のログインが必要です', + needsLogin: (agent) => `${agent.name} のログインが必要です`, }, resume: { instruction: '接続が切れて中断しました。中断したところから作業を続けてください。', @@ -519,8 +536,10 @@ const ja: Messages = { allDone: (n) => `${n} 件の立て直しを実行しました`, }, auth: { - listHint: '認証切れ ・ 別ターミナルで claude にログイン後 Ctrl+R: 再開 ・ Tab/Esc: 入力へ', - hint: 'Claude の認証が切れています。別のターミナルで claude を起動して /login し、Ctrl+R で再開してください。', + listHint: (agent) => + `認証切れ ・ 別ターミナルで ${agent.loginCommand} にログイン後 Ctrl+R: 再開 ・ Tab/Esc: 入力へ`, + hint: (agent) => + `${agent.name} の認証が切れています。別のターミナルで ${agent.loginCommand} を起動して /login し、Ctrl+R で再開してください。`, }, banner: { model: (name) => `モデル: ${name}`, @@ -643,9 +662,9 @@ const en: Messages = { removePrompt: 'Remove this session from the list (its worktree and branch are deleted too).', clearPrompt: (n) => `Remove ${n} finished session${n === 1 ? '' : 's'} from the list (worktrees and branches are deleted too).`, - resumeAllPrompt: (n, auth) => + resumeAllPrompt: (agent, n, auth) => auth > 0 - ? `Resume all ${n} interrupted sessions from where they stopped (${auth} need a login first — log in to claude in another terminal).` + ? `Resume all ${n} interrupted sessions from where they stopped (${auth} need a login first — log in to ${agent.loginCommand} in another terminal).` : `Resume all ${n} interrupted sessions from where they stopped.`, confirmRun: 'Proceed?', busySuffix: '…running', @@ -697,7 +716,7 @@ const en: Messages = { rateLimited: 'Rate limit reached', failed: 'Failed', interrupted: 'Connection interrupted (resumable)', - needsLogin: 'Claude login required', + needsLogin: (agent) => `${agent.name} login required`, }, resume: { instruction: @@ -749,9 +768,10 @@ const en: Messages = { allDone: (n) => `Started recovery for ${n} session(s)`, }, auth: { - listHint: - 'Login expired · log in to claude in another terminal, then Ctrl+R: resume · Tab/Esc: input', - hint: 'Claude authentication expired. Run `claude` in another terminal, use /login, then press Ctrl+R to resume.', + listHint: (agent) => + `Login expired · log in to ${agent.loginCommand} in another terminal, then Ctrl+R: resume · Tab/Esc: input`, + hint: (agent) => + `${agent.name} authentication expired. Run \`${agent.loginCommand}\` in another terminal, use /login, then press Ctrl+R to resume.`, }, banner: { model: (name) => `Model: ${name}`, diff --git a/src/core/index.ts b/src/core/index.ts index 37cd37b..d1858ea 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,8 +1,13 @@ export * from './account'; +export * from './agent-events'; +export * from './agent-ports'; export * from './ansi'; export * from './async-queue'; export * from './banner-lines'; export * from './choice-lines'; +export * from './claude-adapter'; +export * from './claude-errors'; +export * from './claude-parse'; export * from './cli'; export * from './commands'; export * from './composer-layout'; @@ -37,7 +42,6 @@ export * from './repo-prompt'; export * from './resume'; export * from './run-mode'; export * from './scroll'; -export * from './sdk-parse'; export * from './session'; export * from './session-actions'; export * from './session-manager'; diff --git a/src/core/notify.ts b/src/core/notify.ts index 5a773b3..82a990b 100644 --- a/src/core/notify.ts +++ b/src/core/notify.ts @@ -1,4 +1,4 @@ -import type { Messages } from './i18n'; +import { type AgentLabel, DEFAULT_AGENT_LABEL, type Messages } from './i18n'; import { STATUS_META } from './status-meta'; import type { SessionState, SessionStatus } from './types'; @@ -8,9 +8,16 @@ export interface NotificationSpec { body: string; } -function labelFor(status: SessionStatus, m: Messages): string | undefined { +// Some notification labels name the agent (login required), so the catalog entry is a +// template function while the rest stay plain strings. Resolve both shapes here instead +// of forcing every notify key to take an argument. +function labelFor(status: SessionStatus, m: Messages, agent: AgentLabel): string | undefined { const key = STATUS_META[status].notifyKey; - return key ? m.notify[key] : undefined; + if (!key) { + return undefined; + } + const label = m.notify[key]; + return typeof label === 'function' ? label(agent) : label; } /** @@ -24,10 +31,11 @@ export function notificationFor( prev: SessionState, next: SessionState, m: Messages, + agent: AgentLabel = DEFAULT_AGENT_LABEL, ): NotificationSpec | undefined { if (prev.status === next.status) { return undefined; } - const label = labelFor(next.status, m); + const label = labelFor(next.status, m, agent); return label ? { title: `codiva: ${label}`, body: next.title } : undefined; } diff --git a/src/core/persistence.ts b/src/core/persistence.ts index ce755fb..32efe38 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -2,7 +2,15 @@ import { MAX_SESSION_PRS } from './pr-detect'; import type { WorktreeMeta } from './session-ports'; import { STATUS_META } from './status-meta'; import { activeElapsedMs, progressOf } from './status-reducer'; -import type { LogEntry, PrRef, SessionState, SessionStatus, TaskStatus, TodoItem } from './types'; +import type { + AgentId, + LogEntry, + PrRef, + SessionState, + SessionStatus, + TaskStatus, + TodoItem, +} from './types'; /** * On-disk snapshot of a session, enough to rebuild it and resume its SDK @@ -22,6 +30,17 @@ export interface PersistedSession { base: string; /** SDK session id for `resume`. Always present — only sessions that reached init (and are thus truly resumable) are persisted. */ sdkSessionId: string; + /** + * このセッションを最後に駆動していたエージェント。この項目が無い(=切替対応より + * 前に書かれた)スナップショットは `'claude'` として復元する。 + */ + agent?: AgentId; + /** + * エージェントごとの resume 用セッション id。**永続化する**のは、再起動をまたいで + * 「Codex に切り替えて、また Claude に戻す」ができるようにするため — 落とすと + * 戻ったときに過去の会話が消えて新規セッションから始まってしまう。 + */ + agentSessions?: Partial>; /** Only idle/terminal states are restorable (see restorableStatus). */ status: 'completed' | 'interrupted' | 'failed'; startedAt: number; @@ -104,6 +123,12 @@ export function toPersistedSession( worktreePath: state.worktreePath, base: meta.base, sdkSessionId: state.sdkSessionId, + agent: state.agent, + // 現在のエージェントの id も控えに畳んでおく(`agent_switched` は切替の瞬間に + // しか畳まないので、切替せずに終了したセッションの id がここから漏れる)。 + agentSessions: state.agent + ? { ...state.agentSessions, [state.agent]: state.sdkSessionId } + : state.agentSessions, status, startedAt: state.startedAt, finishedAt: state.finishedAt, @@ -138,6 +163,9 @@ export function restoredSessionState(p: PersistedSession, history: LogEntry[] = progress: progressOf(p.todos), messages: history, sdkSessionId: p.sdkSessionId, + // 切替対応より前のスナップショットには無いので Claude 扱い(唯一の選択肢だった)。 + agent: p.agent ?? 'claude', + agentSessions: p.agentSessions, startedAt: p.startedAt, // In-flight sessions persisted as `interrupted` have no finishedAt; freeze the // elapsed clock at startedAt so a restored (idle) row doesn't show an @@ -256,6 +284,32 @@ function toPrRefs(v: unknown): readonly PrRef[] | undefined { return refs.length > 0 ? refs : undefined; } +/** 既知のエージェント id だけを通す(未知の provider 名は捨てる)。 */ +function toAgentId(v: unknown): AgentId | undefined { + return v === 'claude' || v === 'codex' || v === 'grok' ? v : undefined; +} + +/** + * エージェントごとの resume id を untrusted JSON から拾う。未知のキー・非文字列の + * 値は 1 件ずつ捨てる(1 つ壊れていても他の provider の続きは守る)。 + */ +function toAgentSessions(v: unknown): Partial> | undefined { + if (typeof v !== 'object' || v === null) { + return undefined; + } + const out: Partial> = {}; + let found = false; + for (const [key, value] of Object.entries(v as Record)) { + const agent = toAgentId(key); + const id = str(value); + if (agent && id) { + out[agent] = id; + found = true; + } + } + return found ? out : undefined; +} + function toPersistedSessionJson(v: unknown): PersistedSession | undefined { if (typeof v !== 'object' || v === null) { return undefined; @@ -292,6 +346,8 @@ function toPersistedSessionJson(v: unknown): PersistedSession | undefined { worktreePath, base: base ?? 'HEAD', sdkSessionId, + agent: toAgentId(o.agent), + agentSessions: toAgentSessions(o.agentSessions), status, startedAt: startedAt ?? 0, finishedAt: num(o.finishedAt), diff --git a/src/core/pr-detect.ts b/src/core/pr-detect.ts index e8f86e9..b6b9fd0 100644 --- a/src/core/pr-detect.ts +++ b/src/core/pr-detect.ts @@ -12,7 +12,7 @@ import type { PrRef } from './types'; * `gh pr list` の出力や、他人の PR を `gh pr view` / WebFetch で覗いただけのものまで * 「このセッションが出した PR」に化ける(一覧の `+n` が意味を失う)。 * - * すべて純粋関数。SDK メッセージの形を知っているのは `core/sdk-parse.ts` だけなので、 + * すべて純粋関数。SDK メッセージの形を知っているのは `core/claude-parse.ts` だけなので、 * ここは「文字列 → PrRef[]」の変換に徹する。 */ diff --git a/src/core/rate-limit.ts b/src/core/rate-limit.ts index 046364f..4ecbfa3 100644 --- a/src/core/rate-limit.ts +++ b/src/core/rate-limit.ts @@ -1,7 +1,7 @@ /** * claude.ai subscription usage limits, as reported by the SDK's `rate_limit_event` * (see `SDKRateLimitInfo`). Pure domain: parsing, normalization, and display - * selectors live here so `sdk-parse` / `session-manager` stay free of shape logic + * selectors live here so `claude-parse` / `session-manager` stay free of shape logic * and the UI stays free of arithmetic. This is account-wide data (not per-session): * every live session's SDK stream reports the same limits, so the manager keeps the * latest window per type and the banner renders them. diff --git a/src/core/sdk-parse.ts b/src/core/sdk-parse.ts deleted file mode 100644 index 2e409fe..0000000 --- a/src/core/sdk-parse.ts +++ /dev/null @@ -1,675 +0,0 @@ -import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; -import { - isAuthError, - isAuthErrorKind, - isConnectionError, - isTransientApiErrorKind, - isTransientApiStatus, -} from './errors'; -import { clipLogText, clipStreamText, MAX_LOG_ENTRY_CHARS, pushLogEntry } from './log-buffer'; -import { addPrRefs, extractPrRefs, isPrCreateTool, PR_DETECT_SCAN_CHARS } from './pr-detect'; -import { isResumable } from './status-meta'; -import { - appendLog, - isRateLimitError, - progressOf, - toInterrupted, - toNeedsLogin, - toRateLimited, - USER_INTERRUPT_DETAIL, -} from './status-reducer'; -import type { SessionState, TaskStatus, TodoItem } from './types'; - -/** - * All knowledge of the SDK message *shape* lives here. `Session.consume` feeds each - * raw `SDKMessage` to `applySdkMessage`, which parses it (content blocks, subtypes, - * stream events) and folds it into the session state via the same log/state helpers - * the pure reducer uses. This keeps `status-reducer.ts` free of `message.type` / - * `message.subtype` parsing — it only handles the typed `CodivaEvent` union. - */ - -/** Minimal shapes we read out of the (loosely-typed) SDK content blocks. */ -interface TextBlock { - type: 'text'; - text: string; -} -interface ToolUseBlock { - type: 'tool_use'; - id: string; - name: string; - input: Record; -} -interface ToolResultBlock { - type: 'tool_result'; - tool_use_id: string; - content: unknown; - is_error?: boolean; -} - -function asString(v: unknown): string { - if (typeof v === 'string') { - return v; - } - if (Array.isArray(v)) { - return v - .map((b) => - b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '', - ) - .join(''); - } - return v == null ? '' : JSON.stringify(v); -} - -/** - * Like {@link asString} but materializes at most `limit` characters. Tool results - * carry whole file reads and command outputs (megabytes), and only their first - * ~200 characters are ever shown: flattening the entire payload — and then - * splitting it into every one of its lines — allocated the whole thing on the - * heap just to throw it away, once per tool call. - */ -function asStringHead(v: unknown, limit: number): string { - if (typeof v === 'string') { - return v.slice(0, limit); - } - if (Array.isArray(v)) { - let out = ''; - for (const b of v) { - if (out.length >= limit) { - break; - } - if (b && typeof b === 'object' && 'text' in b) { - out += String((b as { text: unknown }).text).slice(0, limit - out.length); - } - } - return out; - } - return v == null ? '' : JSON.stringify(v).slice(0, limit); -} - -/** - * Flatten an error `result`'s `errors: string[]` into one string. The error result - * variants have no `result` field, so this is the only description they carry. - */ -function joinErrors(errors: unknown): string { - return Array.isArray(errors) ? errors.map((e) => String(e)).join('\n') : ''; -} - -/** - * A tool input field as a string, cut to what the log can hold. `Bash` commands - * carry heredocs with whole file bodies, so building the full string first would - * allocate megabytes per tool call only for `pushLogEntry` to clip them. - */ -function inputText(value: unknown): string { - return value == null ? '' : String(value).slice(0, MAX_LOG_ENTRY_CHARS); -} - -/** One-line log summary for a tool_use block. Shared with `transcript.ts` (history restore). */ -export function summarizeToolUse(name: string, input: Record): string { - switch (name) { - case 'Write': - case 'Edit': - return `${name} ${inputText(input.file_path ?? input.path)}`.trim(); - case 'Bash': - return `Bash ${inputText(input.command)}`.trim(); - case 'TaskCreate': - return `TaskCreate "${inputText(input.subject)}"`; - case 'TaskUpdate': - return `TaskUpdate #${String(input.taskId ?? '')} → ${String(input.status ?? '')}`; - case 'AskUserQuestion': { - const questions = (input.questions as { question?: string }[] | undefined) ?? []; - return `AskUserQuestion: ${questions[0]?.question ?? ''}`; - } - default: - return name; - } -} - -/** How many characters of a tool_result's first line the log keeps. */ -const TOOL_RESULT_SUMMARY_CHARS = 200; - -/** - * One-line log summary for a tool_result block's content (first line, capped). - * Shared with `transcript.ts` so restored history matches the live log format. - * Only the first {@link TOOL_RESULT_SUMMARY_CHARS} characters are read out of the - * payload — the rest of a multi-megabyte result is never materialized. - */ -export function toolResultSummary(content: unknown): string { - const head = asStringHead(content, TOOL_RESULT_SUMMARY_CHARS); - const br = head.search(/[\r\n]/); - return br === -1 ? head : head.slice(0, br); -} - -/** - * How many un-answered `gh pr create` calls we remember at once. A tool_use is - * normally answered by the very next user message, so this only has to survive - * parallel calls — keeping the set bounded means a session that never gets its - * results back can't grow state without limit. - */ -const MAX_PENDING_PR_CREATES = 8; - -/** Remember a `gh pr create` tool_use id until its result arrives (oldest drops out). */ -function trackPrCreate(ids: readonly string[] | undefined, id: string): readonly string[] { - const current = ids ?? []; - return current.includes(id) ? current : [...current, id].slice(-MAX_PENDING_PR_CREATES); -} - -/** Apply a TaskCreate/TaskUpdate/TodoWrite tool_use block to the todo list. */ -function applyTaskTool(todos: TodoItem[], block: ToolUseBlock): TodoItem[] { - if (block.name === 'TaskCreate') { - const next: TodoItem = { - id: String(todos.length + 1), - subject: String(block.input.subject ?? ''), - status: 'pending', - activeForm: block.input.activeForm ? String(block.input.activeForm) : undefined, - }; - return [...todos, next]; - } - - if (block.name === 'TaskUpdate') { - const taskId = String(block.input.taskId ?? ''); - return todos.map((t) => { - if (t.id !== taskId) { - return t; - } - return { - ...t, - status: (block.input.status as TaskStatus | undefined) ?? t.status, - subject: block.input.subject ? String(block.input.subject) : t.subject, - activeForm: block.input.activeForm ? String(block.input.activeForm) : t.activeForm, - }; - }); - } - - if (block.name === 'TodoWrite') { - const list = - (block.input.todos as { content?: string; status?: string; activeForm?: string }[]) ?? []; - return list.map((t, i) => ({ - id: String(i + 1), - subject: String(t.content ?? ''), - status: (t.status as TaskStatus | undefined) ?? 'pending', - activeForm: t.activeForm ? String(t.activeForm) : undefined, - })); - } - - return todos; -} - -/** - * Finalize a successful turn into `completed`, appending the result text (if any) - * to the log. Shared by the direct path (no sub-agent work in flight) and the - * deferred path (a `result` that had to wait for the last sub-agent task to settle). - */ -function completeWith( - state: SessionState, - result: { at: number; totalCostUsd?: number; resultText: string }, -): SessionState { - // The SDK's success `result` text echoes the final assistant message, which is - // already in the log as an `assistant_text` entry (verified against real - // fixtures — the two strings are identical). Appending it again as a `result` - // line doubles the last message on screen (white assistant_text + green - // result). Log the result only when it carries something new, matching the - // restore path (transcript.ts never emits a `result` entry). assistant_text is - // stored trimmed, so trim the result before comparing — and stored *clipped* - // (log-buffer), so compare the clipped forms: otherwise an answer longer than - // MAX_LOG_ENTRY_CHARS stops matching its own echo and shows up twice. - const resultText = result.resultText.trim(); - const lastAssistantText = state.messages.findLast((m) => m.kind === 'assistant_text')?.text; - const isEcho = resultText.length > 0 && clipLogText(resultText) === lastAssistantText; - const withLog = - resultText.length > 0 && !isEcho - ? appendLog(state, 'result', resultText) - : { messages: state.messages, logSeq: state.logSeq }; - // Drop the transient deferral bookkeeping — the turn is genuinely done now. - const { deferredResult, activeTaskIds, ...rest } = state; - void deferredResult; - void activeTaskIds; - return { - ...rest, - status: 'completed', - finishedAt: result.at, - totalCostUsd: result.totalCostUsd, - streamingText: undefined, - messages: withLog.messages, - logSeq: withLog.logSeq, - }; -} - -/** - * `system/task_started`: a sub-agent (Task tool) began. Track its id so a `result` - * that arrives while it is still running is recognized as premature (a backgrounded - * Task returns its tool_result immediately and the top-level turn continues). Ambient - * housekeeping tasks (`skip_transcript`) are ignored — they must not gate completion. - */ -function onTaskStarted(state: SessionState, message: Record): SessionState { - if (message.skip_transcript === true) { - return state; - } - const taskId = typeof message.task_id === 'string' ? message.task_id : undefined; - if (taskId === undefined) { - return state; - } - const active = state.activeTaskIds ?? []; - if (active.includes(taskId)) { - return state; - } - return { ...state, activeTaskIds: [...active, taskId] }; -} - -/** - * `system/task_notification`: a sub-agent task settled (completed/failed/stopped). - * Drop it from the in-flight set; if that empties the set and a `result` was already - * deferred, finalize the completion now (the turn really is done). We only finalize - * a still-`running` session — a session that meanwhile failed/was aborted must not be - * flipped to completed by a late notification. - */ -function onTaskSettled( - state: SessionState, - message: Record, - at: number, -): SessionState { - const taskId = typeof message.task_id === 'string' ? message.task_id : undefined; - const active = state.activeTaskIds ?? []; - const nextActive = taskId ? active.filter((id) => id !== taskId) : active; - if (nextActive.length === 0 && state.deferredResult && state.status === 'running') { - return completeWith(state, { ...state.deferredResult, at }); - } - if (nextActive.length === active.length) { - return state; - } - return { ...state, activeTaskIds: nextActive }; -} - -/** Log-line prefix for `system/api_retry`; also the key for coalescing them. */ -const API_RETRY_PREFIX = 'api retry'; - -/** - * `system/api_retry`: an API request failed with a retryable error and the CLI is - * about to retry it after a delay. Informational only (state doesn't change), but - * worth a log line: without it a flaky connection looks exactly like the session - * hanging, and when the retries do run out the `interrupted` notice arrives with no - * trace of what led up to it. `error_status` is null for connection-level failures - * that never got an HTTP response, so it is only shown when present. - * - * Retries arrive in bursts (up to `max_retries` per request), so consecutive ones - * *rewrite* the same log line (keeping its seq) instead of appending one per attempt - * — a flaky connection must not push the actual conversation out of the viewport. - */ -function onApiRetry(state: SessionState, message: Record): SessionState { - const attempt = typeof message.attempt === 'number' ? message.attempt : undefined; - const max = typeof message.max_retries === 'number' ? message.max_retries : undefined; - const of = attempt !== undefined && max !== undefined ? ` ${attempt}/${max}` : ''; - const kind = typeof message.error === 'string' ? message.error : 'error'; - const status = typeof message.error_status === 'number' ? ` ${message.error_status}` : ''; - const text = `${API_RETRY_PREFIX}${of}: ${kind}${status}`; - // Matching on our own prefix is enough to tell "the previous line is a retry - // counter" — no other producer writes it. - const last = state.messages.at(-1); - if (last?.kind === 'system' && last.text.startsWith(API_RETRY_PREFIX)) { - return { ...state, messages: [...state.messages.slice(0, -1), { ...last, text }] }; - } - const withLog = appendLog(state, 'system', text); - return { ...state, messages: withLog.messages, logSeq: withLog.logSeq }; -} - -function reduceAssistant(state: SessionState, message: Record): SessionState { - const inner = message.message as { content?: unknown; model?: unknown } | undefined; - const content = Array.isArray(inner?.content) ? inner.content : []; - const timestamp = typeof message.timestamp === 'number' ? message.timestamp : undefined; - // Each assistant message reports the model that produced it — track it so a - // mid-session model switch is reflected (init only fires at the start). - const model = - typeof inner?.model === 'string' && inner.model.length > 0 ? inner.model : state.model; - - let todos = state.todos; - let messages = state.messages; - let logSeq = state.logSeq; - let prCreateToolIds = state.prCreateToolIds; - - for (const raw of content) { - if (!raw || typeof raw !== 'object') { - continue; - } - const block = raw as { type?: string }; - if (block.type === 'text') { - const text = (raw as TextBlock).text.trim(); - if (text.length > 0) { - const seq = logSeq + 1; - messages = pushLogEntry(messages, { seq, kind: 'assistant_text', text, timestamp }); - logSeq = seq; - } - } else if (block.type === 'tool_use') { - const tu = raw as ToolUseBlock; - todos = applyTaskTool(todos, tu); - // 「このセッションが出した PR」は結果にしか URL が無いので、作成コマンドの - // tool_use id を控えて次の tool_result と突き合わせる(core/pr-detect.ts)。 - if (typeof tu.id === 'string' && isPrCreateTool(tu.name, tu.input ?? {})) { - prCreateToolIds = trackPrCreate(prCreateToolIds, tu.id); - } - const seq = logSeq + 1; - messages = pushLogEntry(messages, { - seq, - kind: 'tool_use', - text: summarizeToolUse(tu.name, tu.input ?? {}), - timestamp, - }); - logSeq = seq; - } - } - - // Don't downgrade a blocked session back to running. The `assistant` message - // that carries an AskUserQuestion/tool_use arrives out-of-band from the - // canUseTool control callback that set pendingPermission; if canUseTool won - // the race we're already awaiting_input/awaiting_permission and must stay - // there (otherwise the badge flips back to "Running" with a question pending). - const nextStatus = state.pendingPermission ? state.status : 'running'; - - // The full assistant message is authoritative — drop the streamed preview. - if (messages === state.messages && todos === state.todos) { - if ( - state.status === nextStatus && - state.streamingText === undefined && - model === state.model && - prCreateToolIds === state.prCreateToolIds - ) { - return state; - } - return { ...state, status: nextStatus, streamingText: undefined, model, prCreateToolIds }; - } - return { - ...state, - status: nextStatus, - todos, - progress: progressOf(todos), - messages, - logSeq, - streamingText: undefined, - model, - prCreateToolIds, - }; -} - -/** - * A partial (streaming) assistant message from `includePartialMessages`. We only - * surface incremental text so the detail view can show a live "typing" preview; - * the full `assistant` message that follows replaces it. Non-text deltas - * (tool-input JSON, thinking, etc.) don't change UI state. - */ -function reduceStreamEvent(state: SessionState, message: Record): SessionState { - const event = message.event; - if (!event || typeof event !== 'object') { - return state; - } - const ev = event as { type?: string; delta?: unknown }; - if (ev.type === 'message_start') { - // A new assistant message begins — start its preview fresh. - return state.streamingText === undefined ? state : { ...state, streamingText: undefined }; - } - if (ev.type === 'content_block_delta') { - const delta = ev.delta as { type?: string; text?: string } | undefined; - // Guard non-empty text so an empty delta stays a no-op (same reference). - if (delta?.type === 'text_delta' && typeof delta.text === 'string' && delta.text.length > 0) { - return { - ...state, - // Keep a blocked session (pendingPermission) in its awaiting_* status; - // only an unblocked stream implies the model is actively running. - status: state.pendingPermission ? state.status : 'running', - // Only the tail is ever rendered (one preview line), and the buffer is - // re-split on every frame — so don't carry a whole message around. - streamingText: clipStreamText((state.streamingText ?? '') + delta.text), - }; - } - } - return state; -} - -function reduceUser(state: SessionState, message: Record): SessionState { - const inner = message.message as { content?: unknown } | undefined; - const content = Array.isArray(inner?.content) ? inner.content : []; - let messages = state.messages; - let logSeq = state.logSeq; - let extraPrs = state.extraPrs; - let prCreateToolIds = state.prCreateToolIds; - for (const raw of content) { - if (raw && typeof raw === 'object' && (raw as { type?: string }).type === 'tool_result') { - const tr = raw as ToolResultBlock; - // `gh pr create` の結果だけを走査する(ログ全体から URL を拾うと `gh pr list` の - // 出力や他人の PR まで「このセッションの PR」になる)。ログ用の要約は先頭 1 行しか - // 読まないが、URL は数行下の最終行に出るので少し深く読む(上限付き)。 - if (prCreateToolIds?.includes(tr.tool_use_id)) { - const rest = prCreateToolIds.filter((id) => id !== tr.tool_use_id); - prCreateToolIds = rest.length > 0 ? rest : undefined; - const head = asStringHead(tr.content, PR_DETECT_SCAN_CHARS); - // ブランチの PR は `pr` が持つので extras には入れない。`gh pr create` は - // 「既に PR がある」場合もその PR の URL を出す(`a pull request for branch … - // already exists: …`)ので、ここで弾かないと同じ PR が `+1` として二重に数えられる - // (reducer 側の畳み込みは `pr` が変わったときしか走らない)。 - const found = extractPrRefs(head).filter((ref) => ref.url !== state.pr?.url); - extraPrs = addPrRefs(extraPrs, found); - } - const text = toolResultSummary(tr.content); - if (text.length > 0) { - const seq = logSeq + 1; - messages = pushLogEntry(messages, { seq, kind: 'tool_result', text }); - logSeq = seq; - } - } - } - if ( - messages === state.messages && - extraPrs === state.extraPrs && - prCreateToolIds === state.prCreateToolIds - ) { - return state; - } - return { ...state, messages, logSeq, extraPrs, prCreateToolIds }; -} - -function reduceSdk( - state: SessionState, - message: Record, - at: number, -): SessionState { - const type = message.type as string; - - if (type === 'system') { - if (message.subtype === 'init') { - const sid = typeof message.session_id === 'string' ? message.session_id : state.sdkSessionId; - // init carries the *resolved* model even when config left it unset. - const model = typeof message.model === 'string' ? message.model : state.model; - return { - ...state, - // pendingPermission がある間は awaiting_* を維持する(#37 と同じ不変条件)。 - // 通常の初回 init は pending 無し(creating → running)で通り、保留中に - // 別の init が来ても質問ダイアログの裏で "Running" に戻さない。 - status: state.pendingPermission ? state.status : 'running', - sdkSessionId: sid ?? state.sdkSessionId, - model, - }; - } - // Sub-agent (Task tool) lifecycle — track in-flight tasks so a backgrounded - // Task can't let the top-level `result` mark the session completed early. - if (message.subtype === 'task_started') { - return onTaskStarted(state, message); - } - if (message.subtype === 'task_notification') { - return onTaskSettled(state, message, at); - } - // A retryable API failure (the connection dropped, the upstream is at - // capacity): the CLI is retrying, so the session stays running — we only note - // it in the log. - if (message.subtype === 'api_retry') { - return onApiRetry(state, message); - } - return state; - } - - if (type === 'rate_limit_event') { - // Structured signal: `rejected` means requests are being turned away — the - // session is blocked. `allowed` / `allowed_warning` are informational (still - // serving), so they leave state untouched. - const info = message.rate_limit_info as { status?: string; resetsAt?: number } | undefined; - if (info?.status === 'rejected') { - return toRateLimited(state, at, 'rate limit reached', info.resetsAt); - } - return state; - } - - if (type === 'assistant') { - // The turn was rejected by a rate/usage limit (top-level SDK error field). - if (message.error === 'rate_limit') { - return toRateLimited(state, at, 'rate limit reached'); - } - // Claude could not authenticate. This typed `error` kind is the primary auth - // signal: the CLI flags the (virtual) assistant message it synthesizes for the - // failure, and the human-readable reason is its text content. Catching it here - // means we don't depend on the wording — the `result` that rolls this up is - // then recognized as the same failure and stays a no-op (see toNeedsLogin). - if (isAuthErrorKind(message.error)) { - const inner = message.message as { content?: unknown } | undefined; - const text = asString(inner?.content).trim(); - return toNeedsLogin(state, at, text || String(message.error)); - } - // The API call failed transiently — most often the response stream was cut - // partway (`API Error: Connection closed mid-response. The response above may - // be incomplete.`). Whatever content had already arrived was delivered as - // ordinary assistant messages before this one; this flagged message carries - // only the notice, and it ends the turn with a truncated answer. So land on - // `interrupted` (resumable) rather than logging the notice as ordinary - // assistant text and letting the roll-up result show a green "Completed". - // - // Only for the top-level turn (`parent_tool_use_id` null): the same failure - // inside a sub-agent (Task) is reported to the main turn as a failed - // tool_result, which Claude can retry or work around — the session keeps - // running and its own `result` decides the end state. (The auth check above - // needs no such guard: credentials are global, so a sub-agent hitting an - // expired login means the whole session is stuck.) - if (isTransientApiErrorKind(message.error) && message.parent_tool_use_id == null) { - const inner = message.message as { content?: unknown } | undefined; - const text = asString(inner?.content).trim(); - return toInterrupted(state, at, text || String(message.error)); - } - return reduceAssistant(state, message); - } - - if (type === 'user') { - return reduceUser(state, message); - } - - if (type === 'stream_event') { - return reduceStreamEvent(state, message); - } - - if (type === 'result') { - const cost = - typeof message.total_cost_usd === 'number' ? message.total_cost_usd : state.totalCostUsd; - const subtype = String(message.subtype ?? 'error'); - // `result` is only carried by the success variant; the error variants carry - // `errors[]` instead (SDKResultSuccess vs SDKResultError), so read both. - const resultText = asString(message.result) || joinErrors(message.errors); - // `subtype: 'success'` on its own does NOT mean the turn succeeded — the CLI - // also reports API-level stops (an expired login, a refused request) with a - // success subtype plus `is_error: true`, putting the message in `result` (its - // `terminal_reason` is then `api_error`). Trusting the subtype alone is what - // made an expired OAuth session show up as a green "Completed" for a session - // that never did any work. - const isError = message.is_error === true; - if (subtype === 'success' && !isError) { - // A sub-agent (Task) is still running: this top-level `result` arrived - // because the Task was backgrounded and returned its tool_result early. The - // session is NOT actually done — hold the result and stay `running` until - // the last task settles (`task_notification` → onTaskSettled finalizes it). - if ((state.activeTaskIds?.length ?? 0) > 0) { - return { - ...state, - totalCostUsd: cost, - streamingText: undefined, - deferredResult: { at, totalCostUsd: cost, resultText }, - }; - } - return completeWith(state, { at, totalCostUsd: cost, resultText }); - } - // For an `is_error` success the subtype carries no information, so the result - // text is the only description of what stopped the turn. - const error = subtype === 'success' ? resultText || 'error' : subtype; - // The stop was already diagnosed while the turn was ending: the CLI flags the - // assistant message it synthesizes for an expired login / a usage limit / a cut - // response with a *typed* error kind, which is more precise than any wording - // check. This result only rolls that up, so keep the diagnosis and take nothing - // from it but the cost — re-classifying from its text would downgrade a "log in - // again" (or a "wait for the reset") to a dead-end `failed` whenever the CLI's - // phrasing isn't one we recognize. Only the resumable stops qualify: `failed` - // and `completed` are not set from a flagged assistant message. - if (isResumable(state.status)) { - return cost === state.totalCostUsd ? state : { ...state, totalCostUsd: cost }; - } - // An expired/invalid login is neither a completion nor a failure of the task: - // checked first because — unlike a rate limit or a dropped connection — it - // never clears by itself, so retrying or waiting is the wrong advice. The user - // logs in again and resumes (see isAuthError / needs_login). - if (isAuthError(error) || isAuthError(resultText)) { - return { ...toNeedsLogin(state, at, resultText || error), totalCostUsd: cost }; - } - // A usage/rate-limit stop is not a real failure — surface it distinctly so - // the user can wait for the reset and resume rather than treating it as an error. - if (isRateLimitError(error) || isRateLimitError(resultText)) { - return { ...toRateLimited(state, at, resultText || error), totalCostUsd: cost }; - } - // A dropped connection surfaced as an error result — resumable, not a real - // failure (same treatment as the thrown-error path in Session.consume). - if (isConnectionError(error) || isConnectionError(resultText)) { - return { ...toInterrupted(state, at, resultText || error), totalCostUsd: cost }; - } - // 中断(ユーザーの Ctrl+C → `Query.interrupt()`): CLI は走っていたターンを畳んで - // `subtype: 'error_during_execution'` + `terminal_reason: 'aborted_streaming'` の - // result で閉じる(実測: `__fixtures__/session-interrupt.jsonl`)。**自分で止めたのだから - // 失敗ではない** — resumable な `interrupted` にして、追加指示 / Ctrl+R で同じ会話を - // 続けられるようにする(`failed` だと再開アクションが出ない)。 - // - // 判定は文言ではなく構造(`terminal_reason`)で行う。`errors[]` には CLI の内部診断 - // (`[ede_diagnostic] result_type=user …`)が入るだけで、ユーザーに見せる意味がないので - // ログには {@link USER_INTERRUPT_DETAIL} を書く。`Session.interrupt` が先に同じ文言で - // 診断を立てていれば `toInterrupted` の重複畳み込みでここは no-op になる。 - if (message.terminal_reason === 'aborted_streaming') { - return { ...toInterrupted(state, at, USER_INTERRUPT_DETAIL), totalCostUsd: cost }; - } - // Structured fallback for the same class of stop: the CLI ends an API-error - // turn with `terminal_reason: 'api_error'` and reports the HTTP status in - // `api_error_status` — explicitly `null` when there was no HTTP response at all - // (a dropped connection). A transient status (no response / 5xx) means the turn - // can simply be resumed, so it lands on `interrupted` even when the wording is - // one we don't recognize — the CLI has many phrasings for this ("Server error - // mid-response", "Please wait a moment and try again", …) and they change. - // (An expired login is reported with `terminal_reason: 'api_error'` too, but it - // can't reach here: the typed assistant kind already moved the session to - // needs_login, which the roll-up guard above returns on.) - if (message.terminal_reason === 'api_error' && isTransientApiStatus(message.api_error_status)) { - return { ...toInterrupted(state, at, resultText || error), totalCostUsd: cost }; - } - const withLog = appendLog(state, 'error', error); - return { - ...state, - status: 'failed', - finishedAt: at, - totalCostUsd: cost, - error, - streamingText: undefined, - messages: withLog.messages, - logSeq: withLog.logSeq, - }; - } - - // thinking_tokens and other unhandled message types — no state change. - return state; -} - -/** - * Fold one raw SDK message into the session state. The single entry point for SDK - * output; `Session.consume` calls this for every message on the stream. - */ -export function applySdkMessage( - state: SessionState, - message: SDKMessage, - at: number, -): SessionState { - return reduceSdk(state, message as unknown as Record, at); -} diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 473a53c..d73580f 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -1,4 +1,6 @@ import { type AccountSummary, sameAccountSummary } from './account'; +import type { AgentAdapter } from './agent-ports'; +import type { QueryFn } from './claude-adapter'; import { errorMessage } from './errors'; import type { Messages } from './i18n'; import { assemblePersistedState, type PersistedState, restoredSessionState } from './persistence'; @@ -20,7 +22,7 @@ import { toRateLimitWindow, } from './rate-limit'; import { createModePolicy, type RunMode } from './run-mode'; -import { type PermissionPolicy, type QueryFn, Session, type SessionOptions } from './session'; +import { type PermissionPolicy, Session, type SessionOptions } from './session'; import { discardSession, mergeSession, sessionDiffStat } from './session-actions'; import type { ActionResult, @@ -41,7 +43,13 @@ import type { DiffStat, SyncBaseResult, Worktree } from './worktree'; export interface SessionManagerDeps { worktrees: WorktreeService; - queryFn: QueryFn; + /** + * 新規セッションを駆動するエージェント。省略時は `queryFn` から Claude アダプタを + * 組み立てる。ここを差し替えるだけで provider が変わる(`core/agent-ports.ts`)。 + */ + agent?: AgentAdapter; + /** Claude Agent SDK の `query`。`agent` を渡す場合は不要。 */ + queryFn?: QueryFn; /** Optional Claude-backed title generator; forwarded to each fresh session. */ generateTitle?: (prompt: string) => Promise; now?: () => number; @@ -117,6 +125,10 @@ function persistRelevantChanged(prev: SessionState, next: SessionState): boolean return ( prev.status !== next.status || prev.sdkSessionId !== next.sdkSessionId || + // エージェントの切替は state.json に残す必要がある(戻ったときに前の会話を + // resume できるのは、この対応表が生き残っていればこそ)。 + prev.agent !== next.agent || + prev.agentSessions !== next.agentSessions || prev.title !== next.title || prev.finishedAt !== next.finishedAt || prev.totalCostUsd !== next.totalCostUsd || @@ -354,6 +366,7 @@ export class SessionManager { return this.deps.createSession({ input, onChange, onRateLimit, ...extra }); } return new Session({ + agent: this.deps.agent, queryFn: this.deps.queryFn, input, options: this.options, diff --git a/src/core/session-ports.ts b/src/core/session-ports.ts index 0b58470..217b7e4 100644 --- a/src/core/session-ports.ts +++ b/src/core/session-ports.ts @@ -1,3 +1,4 @@ +import type { AgentAdapter } from './agent-ports'; import type { PrInfo, PrLookupResult, PrLookupState, SessionState } from './types'; import type { DiffStat, SyncBaseResult, Worktree } from './worktree'; @@ -25,6 +26,15 @@ export interface WorktreeService { /** The subset of Session the manager drives (for DI in tests). */ export interface SessionHandle { getState(): SessionState; + /** + * 現在のエージェントと、その差し替え(Claude → Codex)。UI は `getAgent()` から + * capability を引いて、持たない機能のキー操作を隠す。 + * + * optional なのは、状態だけを動かすテスト用フェイクにエージェントの概念が + * 要らないため(`tests/helpers.ts` の `noopSession`)。 + */ + getAgent?(): AgentAdapter; + setAgent?(adapter: AgentAdapter): void; start(): void; send(text: string): void; answerPending(answers: Record): void; diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 1c047c9..0d620c7 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -1,7 +1,8 @@ import type { Options, PermissionResult, Query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { describe, expect, it, vi } from 'vitest'; import { AsyncQueue } from '@/core/async-queue'; -import { type PermissionPolicy, type QueryFn, Session } from '@/core/session'; +import type { QueryFn } from '@/core/claude-adapter'; +import { type PermissionPolicy, Session } from '@/core/session'; import { initialState } from '@/core/status-reducer'; import { SHARED_IGNORED_FILES_NOTICE } from '@/core/system-prompt'; import type { CreateSessionInput } from '@/core/types'; diff --git a/src/core/session.ts b/src/core/session.ts index 7391abe..8fc83f7 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -1,35 +1,24 @@ -import type { - EffortLevel, - Options, - PermissionMode, - PermissionResult, - Query, - SDKMessage, - SDKUserMessage, -} from '@anthropic-ai/claude-agent-sdk'; +import { applyAgentEvent } from './agent-events'; +import type { AgentAdapter, AgentRun, PermissionDecision } from './agent-ports'; import { AsyncQueue } from './async-queue'; -import { errorMessage, isAuthError, isConnectionError } from './errors'; +import { createClaudeAdapter, type QueryFn } from './claude-adapter'; +import type { EffortLevel, PermissionMode } from './config'; +import { errorMessage } from './errors'; import type { RateLimitInfoJson } from './rate-limit'; -import { applySdkMessage } from './sdk-parse'; import { isInterruptible } from './status-meta'; import { accrueActive, initialState, reduce, USER_INTERRUPT_DETAIL } from './status-reducer'; import { composeSystemPrompt } from './system-prompt'; import type { + AgentId, CodivaEvent, CreateSessionInput, PermissionRequest, PrInfo, PrLookupState, - QuestionSpec, SessionState, } from './types'; import type { IgnoredFilesMode } from './worktree'; -export type QueryFn = (params: { - prompt: AsyncIterable; - options: Options; -}) => Query; - /** Decide whether a tool runs automatically or is escalated to the user. */ export type PermissionPolicy = ( toolName: string, @@ -66,7 +55,13 @@ export interface SessionOptions { } export interface SessionDeps { - queryFn: QueryFn; + /** + * このセッションを駆動するエージェント。省略時は `queryFn` から Claude アダプタを + * 組み立てる(合成ルートと既存テストのための短縮形)。 + */ + agent?: AgentAdapter; + /** Claude Agent SDK の `query`。`agent` を渡す場合は不要。 */ + queryFn?: QueryFn; input: CreateSessionInput; options?: SessionOptions; now?: () => number; @@ -92,36 +87,33 @@ export interface SessionDeps { restored?: SessionState; } -function toUserMessage(text: string): SDKUserMessage { - return { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null }; -} - -function parseQuestions(input: Record): QuestionSpec[] { - const raw = (input.questions as Record[] | undefined) ?? []; - return raw.map((q) => ({ - question: String(q.question ?? ''), - header: String(q.header ?? ''), - multiSelect: Boolean(q.multiSelect), - options: ((q.options as { label?: string; description?: string }[] | undefined) ?? []).map( - (o) => ({ label: String(o.label ?? ''), description: String(o.description ?? '') }), - ), - })); -} - /** - * One live Claude session bound to a worktree. Owns the streaming-input queue, - * consumes the SDK message stream into the pure reducer, and bridges canUseTool - * to the UI (auto-allowing routine tools, blocking on user-facing questions). + * One live agent session bound to a worktree. Owns the streaming-input queue, + * consumes the agent's normalized event stream into the pure fold, and bridges + * permission requests to the UI (auto-allowing routine tools, blocking on + * user-facing questions). + * + * どのエージェントで走るかは {@link AgentAdapter} が決める。worktree(=成果物)は + * provider に依存しないので、`setAgent()` で**途中から別のエージェントへ引き継ぐ** + * ことができる。 */ export class Session { private state: SessionState; - private readonly inputQueue = new AsyncQueue(); + private readonly inputQueue = new AsyncQueue(); private readonly abortController = new AbortController(); private readonly now: () => number; private readonly policy: PermissionPolicy; private readonly onChange?: (state: SessionState) => void; - private handle?: Query; - private pending?: { request: PermissionRequest; resolve: (r: PermissionResult) => void }; + /** 現在のエージェント。`setAgent()` で差し替わる。 */ + private adapter: AgentAdapter; + /** + * ログ行に刻む発言者。**切替が起きるまでは undefined** にしておく — 単一 + * エージェントで完結するセッションのログ行の形を変えないため(切替を使って + * いないユーザーには何も増えない)。 + */ + private attribution?: AgentId; + private run?: AgentRun; + private pending?: { request: PermissionRequest; resolve: (r: PermissionDecision) => void }; private reqSeq = 0; /** True once the initial prompt has been enqueued (start / first send); keeps start() idempotent. */ private startedOnce = false; @@ -149,6 +141,53 @@ export class Session { this.now = deps.now ?? Date.now; this.policy = deps.policy ?? defaultPolicy; this.onChange = deps.onChange; + if (deps.agent) { + this.adapter = deps.agent; + } else if (deps.queryFn) { + this.adapter = createClaudeAdapter({ + queryFn: deps.queryFn, + generateTitle: deps.generateTitle, + }); + } else { + throw new Error('Session requires either `agent` or `queryFn`'); + } + // 「誰が駆動しているか」は状態に載せる(一覧のバッジ・復元・切替の起点)。 + // 復元されたセッションは既に持っているのでそのまま。 + if (this.state.agent === undefined) { + this.state = { ...this.state, agent: this.adapter.id }; + } + } + + /** 現在のエージェント(UI が capability を引くための読み取り口)。 */ + getAgent(): AgentAdapter { + return this.adapter; + } + + /** + * 駆動するエージェントを差し替える(Claude で進めた作業を Codex に引き継ぐ等)。 + * + * 走っているストリームは閉じ、次の `send()` で新しいエージェントが起動する。 + * **モデル側の文脈は provider をまたげない** — 各 CLI が自分のトランスクリプトを + * 持っているため。切替先で過去にセッションを持っていればその id で resume し、 + * 初めてなら新しい会話として始まる(`agent_switched` の reducer 参照)。 + * 共通しているのは worktree と codiva 側のログで、そこが引き継ぎの土台になる。 + */ + setAgent(adapter: AgentAdapter): void { + if (adapter.id === this.adapter.id) { + return; + } + // 走っているターンを畳んでからでないと、2 本のストリームが同じ worktree を + // 触ることになる。保留中の許可も解決しておく(未応答の tool_use で終わる + // トランスクリプトは後の resume を壊す)。 + if (this.pending) { + this.pending.resolve({ behavior: 'deny', message: 'agent switched' }); + this.pending = undefined; + } + this.run = undefined; + this.adapter = adapter; + // ここから先のログ行には発言者を刻む(どこからが別エージェントか分かるように)。 + this.attribution = adapter.id; + this.dispatch({ kind: 'agent_switched', agent: adapter.id, at: this.now() }); } getState(): SessionState { @@ -170,7 +209,7 @@ export class Session { // AI の応答が先頭になり「自分が何を指示したか」が見えない。復元経路は transcript から // 既に user ログを持ち start() を通らないため、二重記録にはならない。 this.dispatch({ kind: 'user_input', text: this.state.prompt, at: this.state.startedAt }); - this.inputQueue.push(toUserMessage(this.state.prompt)); + this.inputQueue.push(this.state.prompt); this.ensureConsuming(); void this.runTitleGen(); } @@ -203,7 +242,7 @@ export class Session { */ send(text: string): void { this.startedOnce = true; - this.inputQueue.push(toUserMessage(text)); + this.inputQueue.push(text); this.ensureConsuming(); this.dispatch({ kind: 'user_input', text, at: this.now() }); } @@ -226,13 +265,13 @@ export class Session { answerPending(answers: Record): void { this.resolvePending({ behavior: 'allow', - updatedInput: { ...(this.pending?.request.input ?? {}), answers }, + input: { ...(this.pending?.request.input ?? {}), answers }, }); } /** Allow a pending tool permission request. */ allowPending(): void { - this.resolvePending({ behavior: 'allow', updatedInput: this.pending?.request.input ?? {} }); + this.resolvePending({ behavior: 'allow', input: this.pending?.request.input ?? {} }); } /** Deny a pending tool permission request with a reason shown to Claude. */ @@ -264,7 +303,7 @@ export class Session { } this.dispatch({ kind: 'interrupted', error: USER_INTERRUPT_DETAIL, at: this.now() }); try { - await this.handle?.interrupt?.(); + await this.run?.interrupt?.(); } catch { // best-effort: サブプロセスがもう居ない transport への write は reject する // (setModel と同じ)。中断できなかった場合もストリームは生きているので、 @@ -286,7 +325,7 @@ export class Session { // 残るので、終了済みセッションの詳細で /model を押すと裸の void が unhandled // rejection になりアプリごと落ちていた。切替えは best-effort(下の dispatch で // 次回起動時のモデルは確定する)なので握り潰す。 - void Promise.resolve(this.handle?.setModel?.(model)).catch(() => undefined); + void Promise.resolve(this.run?.setModel?.(model)).catch(() => undefined); this.dispatch({ kind: 'model', model, at: this.now() }); } @@ -349,7 +388,7 @@ export class Session { this.dispatch({ kind: 'conflict', files, at: this.now() }); } - private resolvePending(result: PermissionResult): void { + private resolvePending(result: PermissionDecision): void { const pending = this.pending; if (!pending) { return; @@ -359,24 +398,19 @@ export class Session { this.dispatch({ kind: 'permission_resolved', at: this.now() }); } - private canUseTool = ( - toolName: string, - input: Record, - ): Promise => { - const decision = this.policy(toolName, input); - if (decision === 'allow') { - return Promise.resolve({ behavior: 'allow', updatedInput: input }); + /** + * アダプタから上がってきた許可要求。ルーチンツールはポリシーで即 allow し、 + * ユーザーに聞くべきものだけ UI へ上げる(解決するまでエージェントはブロック + * してよい)。「何が質問か」といったツール名の意味づけはアダプタ側で済んでいる + * ので、ここは codiva 自身のポリシー(`core/run-mode.ts`)だけを見る。 + */ + private requestPermission = (req: Omit): Promise => { + if (this.policy(req.toolName, req.input) === 'allow') { + return Promise.resolve({ behavior: 'allow', input: req.input }); } this.reqSeq += 1; - const isQuestion = toolName === 'AskUserQuestion'; - const request: PermissionRequest = { - id: `${this.state.id}:${this.reqSeq}`, - toolName, - input, - kind: isQuestion ? 'question' : 'tool', - questions: isQuestion ? parseQuestions(input) : undefined, - }; - return new Promise((resolve) => { + const request: PermissionRequest = { ...req, id: `${this.state.id}:${this.reqSeq}` }; + return new Promise((resolve) => { this.pending = { request, resolve }; this.dispatch({ kind: 'permission_request', request, at: this.now() }); }); @@ -390,62 +424,55 @@ export class Session { // Resume the prior SDK conversation when we have one: `deps.resume` for a // restored session, or the live `sdkSessionId` when restarting after a // connection interruption. Absent on a fresh session's first start. - const resume = this.deps.resume ?? this.state.sdkSessionId; + // 切替後は「その provider が過去に発行した id」(`agent_switched` が据えた + // `sdkSessionId`)だけを使う。`deps.resume` は復元時の初期エージェント用なので、 + // 別の provider へ持ち込むと存在しない会話を resume しようとして壊れる。 + const resume = this.attribution + ? this.state.sdkSessionId + : (this.deps.resume ?? this.state.sdkSessionId); // worktree の環境説明(symlink 共有の注意書き)とリポジトリ追加指示をまとめた // systemPrompt。どちらも無ければ undefined で、その場合は渡さない。 const systemPrompt = composeSystemPrompt({ ignoredFiles: opts?.ignoredFiles, repoPrompt: opts?.appendSystemPrompt, }); - this.handle = this.deps.queryFn({ + this.run = this.adapter.open({ + cwd: this.state.worktreePath, prompt: this.inputQueue, + resume, options: { - cwd: this.state.worktreePath, - permissionMode: opts?.permissionMode ?? 'acceptEdits', - canUseTool: this.canUseTool, - abortController: this.abortController, - settingSources: ['project'], - // Stream partial assistant text so the detail view shows a live preview - // (reduced into state.streamingText). See sdk-parse reduceStreamEvent. - includePartialMessages: true, - // worktree の環境説明 + リポジトリ追加指示を systemPrompt として注入する。SDK は - // systemPrompt 省略時に空文字("")へ写像する(claude_code プリセットは使わない)ため、 - // ここに文字列を渡すのは「空への追記」と等価。将来ベースの systemPrompt を - // 足すなら、この行は array / preset-append 形へ切り替える必要がある。 - ...(systemPrompt ? { systemPrompt } : {}), - ...(model ? { model } : {}), - ...(opts?.effort ? { effort: opts.effort } : {}), - ...(opts?.maxBudgetUsd != null ? { maxBudgetUsd: opts.maxBudgetUsd } : {}), - ...(resume ? { resume } : {}), + model, + effort: opts?.effort, + permissionMode: opts?.permissionMode, + maxBudgetUsd: opts?.maxBudgetUsd, + systemPrompt, }, + requestPermission: this.requestPermission, + abortController: this.abortController, }); - for await (const message of this.handle) { - const msg = message as SDKMessage; + for await (const event of this.run) { // Account-wide subscription usage is surfaced out-of-band (it isn't // per-session state) so the manager can aggregate it for the banner. - if (msg.type === 'rate_limit_event') { - this.deps.onRateLimit?.(msg.rate_limit_info); + if (event.kind === 'usage') { + this.deps.onRateLimit?.(event.info); } - // Raw SDK output is folded straight into state by sdk-parse (not routed - // through the reducer's event union) — see core/sdk-parse.ts. - this.commit(applySdkMessage(this.state, msg, this.now())); + // 正規化済みイベントの畳み込みは全 provider 共通(core/agent-events.ts)。 + this.commit(applyAgentEvent(this.state, event, this.now(), this.attribution)); } } catch (err) { if (!this.abortController.signal.aborted) { const error = errorMessage(err); - // An expired login is checked first: the CLI's auth errors can *mention* a - // timeout ("Failed to authenticate through the broker: request timed out"), - // and treating that as a dropped connection would silently offer a plain - // resume when what's needed is a login. It goes through `aborted`, which - // the reducer classifies as `needs_login`. - const auth = isAuthError(error); - // A connection drop mid-flight is not a failure either: mark the session - // `interrupted` (idle & resumable) so a follow-up / the resume action - // continues the same SDK conversation. Require an sdkSessionId — without - // one there's nothing to resume, so it's a genuine early failure. - // Rate-limit throws fall through to `aborted` too, which the reducer - // classifies as `rate_limited`. - const dropped = !auth && isConnectionError(error) && this.state.sdkSessionId !== undefined; + // 文言から分類するのはアダプタの仕事(provider ごとに言い回しが違う)。 + // 認証切れが最優先なのは、CLI の認証エラーがタイムアウトに*言及する*ことが + // あり("Failed to authenticate through the broker: request timed out")、 + // 通信断と読み違えると「ログインし直せ」と言うべき場面で素の再開を勧めて + // しまうため。 + const cause = this.adapter.classifyError?.(error) ?? 'failed'; + const auth = cause === 'auth'; + // 通信断も失敗ではない: `interrupted`(idle & resumable)にして、追加指示 / + // 再開アクションで同じ会話を続けられるようにする。ただし resume 先の id が + // 無ければ続けようがないので、そのときは本物の初期失敗として扱う。 + const dropped = cause === 'connection' && this.state.sdkSessionId !== undefined; // Both leave a *resumable* session, so a pending permission from the dead // turn (which can never resolve now) must be denied rather than left // dangling: a transcript ending on an unanswered tool_use can make the @@ -458,7 +485,14 @@ export class Session { this.dispatch( dropped ? { kind: 'interrupted', error, at: this.now() } - : { kind: 'aborted', error, at: this.now() }, + : { + kind: 'aborted', + error, + // resume 先が無い通信断は「続きから」ができないので、resumable な + // 分類を渡さず素直に失敗にする(旧実装と同じ着地)。 + cause: cause === 'connection' ? 'failed' : cause, + at: this.now(), + }, ); } } finally { diff --git a/src/core/status-reducer.spec.ts b/src/core/status-reducer.spec.ts index b65b679..b89002b 100644 --- a/src/core/status-reducer.spec.ts +++ b/src/core/status-reducer.spec.ts @@ -162,19 +162,33 @@ describe('control events', () => { }); }); -describe('reduce classifies aborted rate-limit errors', () => { +// 分類そのもの(どの文言がどの `cause` か)はアダプタの仕事なので +// `claude-errors.spec.ts` の `classifyClaudeError` が担当する。ここで見るのは +// 「`cause` を受け取った reducer がどの状態へ落とすか」だけ。 +describe('reduce routes aborted stops by the adapter-supplied cause', () => { const running: SessionState = { ...initialState(BASE), status: 'running' }; - it('an aborted event carrying a rate-limit error is rate_limited, not failed', () => { + it('a rate-limit cause is rate_limited, not failed', () => { const state = reduce(running, { kind: 'aborted', error: "Error: You've hit your limit", + cause: 'rate_limit', at: 5000, }); expect(state.status).toBe('rate_limited'); }); - it('a genuine (non-limit) error still fails', () => { + it('a connection cause is interrupted (resumable), not failed', () => { + const state = reduce(running, { + kind: 'aborted', + error: 'connection reset', + cause: 'connection', + at: 5000, + }); + expect(state.status).toBe('interrupted'); + }); + + it('an unclassified abort (no cause) still fails', () => { const state = reduce(running, { kind: 'aborted', error: 'connection reset', at: 5000 }); expect(state.status).toBe('failed'); }); @@ -195,12 +209,12 @@ describe('reduce classifies aborted rate-limit errors', () => { }); }); -describe('reduce classifies aborted auth errors', () => { +describe('reduce routes an auth cause to needs_login', () => { const running: SessionState = { ...initialState(BASE), status: 'running' }; - it('an aborted event carrying an expired login is needs_login, not failed', () => { + it('an aborted event with an auth cause is needs_login, not failed', () => { const error = 'Failed to authenticate: OAuth session expired and could not be refreshed'; - const state = reduce(running, { kind: 'aborted', error, at: 5000 }); + const state = reduce(running, { kind: 'aborted', error, cause: 'auth', at: 5000 }); expect(state.status).toBe('needs_login'); expect(state.finishedAt).toBe(5000); // The reason is kept so the detail view can show what the CLI reported. @@ -215,20 +229,48 @@ describe('reduce classifies aborted auth errors', () => { pendingPermission: { id: 'p1', toolName: 'Bash', input: {}, kind: 'tool' }, streamingText: 'half', }; - const state = reduce(pending, { kind: 'aborted', error: 'invalid x-api-key', at: 7 }); + const state = reduce(pending, { + kind: 'aborted', + error: 'invalid x-api-key', + cause: 'auth', + at: 7, + }); expect(state.status).toBe('needs_login'); expect(state.pendingPermission).toBeUndefined(); expect(state.streamingText).toBeUndefined(); }); +}); - it('an auth error wins over the rate-limit / connection classifiers', () => { - // Auth is checked first: waiting or retrying never fixes an expired login. - const state = reduce(running, { - kind: 'aborted', - error: 'Failed to authenticate through the broker: request timed out', - at: 5000, - }); - expect(state.status).toBe('needs_login'); +describe('agent_switched', () => { + const running: SessionState = { + ...initialState(BASE), + status: 'running', + sdkSessionId: 'claude-1', + model: 'claude-opus-4-8', + }; + + it('stashes the current resume id under the outgoing agent', () => { + const state = reduce(running, { kind: 'agent_switched', agent: 'codex', at: 1 }); + expect(state.agent).toBe('codex'); + expect(state.agentSessions?.claude).toBe('claude-1'); + // Codex は初めてなので resume 先が無い(= 次のターンは新しい会話)。 + expect(state.sdkSessionId).toBeUndefined(); + // 解決済みモデルは provider ごとに別物なので持ち越さない。 + expect(state.model).toBeUndefined(); + }); + + it('restores the target agent’s own resume id when switching back', () => { + const switched = reduce(running, { kind: 'agent_switched', agent: 'codex', at: 1 }); + const withCodex: SessionState = { ...switched, sdkSessionId: 'codex-1' }; + const back = reduce(withCodex, { kind: 'agent_switched', agent: 'claude', at: 2 }); + expect(back.agent).toBe('claude'); + expect(back.sdkSessionId).toBe('claude-1'); + expect(back.agentSessions?.codex).toBe('codex-1'); + }); + + it('is a no-op when the agent is unchanged', () => { + const same = reduce(running, { kind: 'agent_switched', agent: 'claude', at: 1 }); + expect(same).toBe(running); }); }); diff --git a/src/core/status-reducer.ts b/src/core/status-reducer.ts index 6bf421f..8ef4f11 100644 --- a/src/core/status-reducer.ts +++ b/src/core/status-reducer.ts @@ -1,5 +1,3 @@ -import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk'; -import { isAuthError } from './errors'; import { pushLogEntry } from './log-buffer'; import { withoutPrRef } from './pr-detect'; import { makeTitle } from './slug'; @@ -34,19 +32,6 @@ function sameChecks( return a.every((check, i) => check.name === b[i]?.name && check.url === b[i]?.url); } -/** - * True when an error/result string signals a genuine usage- or rate-limit stop - * (rather than an ordinary failure). We match the SDK's own `getLimitReachedText` - * prefixes so we stay in sync with the CLI wording, plus a loose "rate limit" / - * "usage limit" fallback for messages that arrive wrapped (e.g. `Error: …`). - */ -export function isRateLimitError(text: string): boolean { - return ( - USAGE_LIMIT_ERROR_PREFIXES.some((p) => text.includes(p)) || - /rate.?limit|usage limit/i.test(text) - ); -} - export function initialState(input: CreateSessionInput): SessionState { return { id: input.id, @@ -107,7 +92,7 @@ export function progressOf(todos: TodoItem[]): { done: number; total: number } | } /** - * Append a log entry and bump the monotonic seq. Shared with `sdk-parse.ts` so the + * Append a log entry and bump the monotonic seq. Shared with `claude-parse.ts` so the * live SDK stream and the reducer's own events produce identically-sequenced logs. * The log is bounded (`pushLogEntry`): oversized texts are clipped and the oldest * entries fall off, so a long-lived session can't grow the heap without limit @@ -129,7 +114,7 @@ export function appendLog( * rate limit was hit. Idle & resumable once the limit resets (like a completed * turn, it can receive more input) — but flagged distinctly so the user sees it * wasn't a clean finish and can wait for the reset. Records the reason in the log. - * Shared with `sdk-parse.ts` (a limit can surface both as an SDK message and as a + * Shared with `claude-parse.ts` (a limit can surface both as an SDK message and as a * thrown error caught by the reducer's `aborted` event). */ export function toRateLimited( @@ -155,7 +140,7 @@ export function toRateLimited( * * 同じ中断が **2 経路**で届く: (1) `Session.interrupt()` が SDK へ interrupt 制御要求を * 出す前に立てる診断(UI を即座に「中断」にするため)、(2) CLI がターンを閉じる - * `result`(`terminal_reason: 'aborted_streaming'`。`sdk-parse.ts`)。両方で**同じ文言**を + * `result`(`terminal_reason: 'aborted_streaming'`。`claude-parse.ts`)。両方で**同じ文言**を * 使うことで `toInterrupted` の重複畳み込みが効き、ログが二重にならない。 */ export const USER_INTERRUPT_DETAIL = 'interrupted by user'; @@ -165,7 +150,7 @@ export const USER_INTERRUPT_DETAIL = 'interrupted by user'; * because the connection was interrupted (not a clean finish, not a real * failure). Idle & resumable — sending a follow-up (or the explicit "resume" * action) restarts the query with `resume` so Claude continues where it left - * off. Records the reason in the log. Shared with `sdk-parse.ts` (a connection + * off. Records the reason in the log. Shared with `claude-parse.ts` (a connection * drop can surface both as a thrown error caught by `Session.consume` and as an * error `result` on the stream). Transient bookkeeping (`pendingPermission` from * a turn that can never resolve now, deferred sub-agent results) is dropped so a @@ -211,7 +196,7 @@ export function toInterrupted(state: SessionState, at: number, detail: string): * This is neither a completion nor a failure of the work: the user logs in again * (`claude` → `/login`) and resumes, so the state is idle & resumable and the UI * points at the login step. Records the reason in the log. Shared with - * `sdk-parse.ts` (an auth failure can surface as an SDK `result` / `auth_status` + * `claude-parse.ts` (an auth failure can surface as an SDK `result` / `auth_status` * message as well as a thrown error caught by `Session.consume`). * * Transient bookkeeping (a `pendingPermission` from a turn that can never resolve @@ -249,7 +234,7 @@ export function reduce(state: SessionState, event: CodivaEvent): SessionState { const status = event.request.kind === 'question' ? 'awaiting_input' : 'awaiting_permission'; // The question text is already parsed onto the request (QuestionSpec[]), // so we read it directly rather than re-parsing the raw tool input here — - // that keeps SDK-shape parsing out of the reducer (see sdk-parse.ts). + // that keeps SDK-shape parsing out of the reducer (see claude-parse.ts). const summary = event.request.kind === 'question' ? `AskUserQuestion: ${event.request.questions?.[0]?.question ?? ''}` @@ -368,19 +353,22 @@ export function reduce(state: SessionState, event: CodivaEvent): SessionState { case 'aborted': { const error = event.error ?? 'aborted'; - // An expired login can surface as a thrown error (caught in consume) — - // classify it as needs_login so the user is told to log in rather than - // being shown a dead-end "failed" (or, worse, a green "completed"). - // Checked before the limit/connection classifiers: an auth failure is - // never fixed by waiting or retrying. - if (isAuthError(error)) { + // 分類は**アダプタが済ませて** `cause` で運んでくる(`AgentAdapter.classifyError`)。 + // かつてはここで文言の正規表現を回していたが、それは Claude CLI の言い回しの + // 知識であって状態機械の仕事ではない — provider が増えると判定が混ざる。 + // + // 認証切れは「待っても再試行しても直らない」ので、行き止まりの `failed` では + // なく再ログインを促す `needs_login` へ。レート制限は待てば直るので同様に + // 区別する。`cause` 省略時(UI 起点の abort など)は素直に失敗扱い。 + if (event.cause === 'auth') { return toNeedsLogin(state, event.at, error); } - // A rate/usage limit can surface as a thrown error (caught in consume) — - // classify it as rate_limited rather than a generic failure. - if (isRateLimitError(error)) { + if (event.cause === 'rate_limit') { return toRateLimited(state, event.at, error); } + if (event.cause === 'connection') { + return toInterrupted(state, event.at, error); + } const withLog = appendLog(state, 'error', error); return { ...state, @@ -396,6 +384,32 @@ export function reduce(state: SessionState, event: CodivaEvent): SessionState { case 'interrupted': return toInterrupted(state, event.at, event.error ?? 'connection interrupted'); + case 'agent_switched': { + const current = state.agent ?? 'claude'; + if (current === event.agent) { + return state; + } + // 今の provider の resume id を退避し、切替先の id(過去に使っていれば)を + // 現在値に据える。worktree(=成果物)はそのままなので、切替は「別の + // エージェントに同じ作業場を引き継ぐ」だけ。モデル側の文脈は provider を + // またげないため、`agentSessions` に無い provider へ切り替えたときは + // `sdkSessionId` が undefined になり、次のターンは新しい会話として始まる。 + const carried = state.sdkSessionId + ? { ...state.agentSessions, [current]: state.sdkSessionId } + : state.agentSessions; + const next = carried?.[event.agent]; + return { + ...state, + agent: event.agent, + agentSessions: carried, + sdkSessionId: next, + // 直前のエージェントのストリーミング途中表示は引き継がない。 + streamingText: undefined, + // 解決済みモデルは provider ごとに別物なので捨てる(次のターンが埋める)。 + model: undefined, + }; + } + case 'archived': return state.status === 'archived' ? state diff --git a/src/core/transcript.ts b/src/core/transcript.ts index ff5de8b..c3ef653 100644 --- a/src/core/transcript.ts +++ b/src/core/transcript.ts @@ -1,5 +1,5 @@ +import { summarizeToolUse, toolResultSummary } from './claude-parse'; import { capLogEntries, clipLogText, MAX_LOG_ENTRIES } from './log-buffer'; -import { summarizeToolUse, toolResultSummary } from './sdk-parse'; import type { LogEntry } from './types'; /** diff --git a/src/core/types.ts b/src/core/types.ts index 4c7d317..0e72fea 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -12,6 +12,29 @@ export type SessionStatus = | 'conflict' // a merge into base hit conflicts; needs manual resolution | 'archived'; // merged or discarded; kept for reference +/** + * どのコーディングエージェントがセッションを駆動しているか。 + * + * **セッション単位で固定ではない**: worktree(=実際の成果物)は provider に依存しない + * ので、Claude で始めた作業を途中から Codex に引き継ぐことができる。モデル側の文脈は + * provider をまたいで移せない(各 CLI が自分のトランスクリプトを持つ)ため、切替は + * 「今の provider のターンを終える → 別 provider の新しいセッションを同じ worktree で + * 開く」という形になる。だから id ごとの resume 用セッション id を + * {@link SessionState.agentSessions} に控えておき、戻ってきたときは続きから再開する。 + */ +export type AgentId = 'claude' | 'codex' | 'grok'; + +/** + * ターンが「完了以外」で終わった理由の分類。`failed` だけが終端で、他の 3 つは + * resumable な idle(`core/status-meta.ts` の `resumable`)へ落ちる。 + * + * **文言ではなく分類を運ぶ**のが要点。どの文言がどれに当たるかは provider ごとに + * 違う(Claude CLI の "OAuth session expired" は Codex には存在しない)ので、 + * 判定はアダプタ(`AgentAdapter.classifyError`)に閉じ込め、状態機械はこの 4 値 + * だけを見る。 + */ +export type AgentStopCause = 'auth' | 'rate_limit' | 'connection' | 'failed'; + export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; /** One item of Claude's own task list (from TaskCreate/TaskUpdate, or legacy TodoWrite). */ @@ -37,6 +60,12 @@ export interface LogEntry { kind: LogKind; text: string; timestamp?: number; + /** + * この行を出したエージェント。セッション途中で切り替えた(Claude → Codex)とき、 + * どこからが別のエージェントの発言かをログに残すためのもの。切替を使っていない + * セッションでは undefined のまま(既存の行・復元した行も undefined)。 + */ + agent?: AgentId; } /** @@ -175,7 +204,22 @@ export interface SessionState { progress?: { done: number; total: number }; messages: LogEntry[]; pendingPermission?: PermissionRequest; + /** + * 今このセッションを駆動しているエージェント。未設定は `'claude'` 相当 + * (この項目が無かった頃に保存されたセッションの復元経路のため optional)。 + */ + agent?: AgentId; + /** + * 現在のエージェントの resume 用セッション id。`agentSessions[agent]` と同じ値で、 + * 「今どれを resume すればよいか」を 1 か所で読めるようにした写し。 + */ sdkSessionId?: string; + /** + * エージェントごとの resume 用セッション id。切り替えて戻ってきたときに、その + * provider の会話を**続きから**再開するために保持する(新規セッションを開き直すと + * それまでの文脈が消える)。**永続化される**。 + */ + agentSessions?: Partial>; /** * The model this session is actually running on, as reported by the SDK * (`system/init` and each `assistant` message). This is the *resolved* model — @@ -282,9 +326,12 @@ export interface SessionState { /** * Everything that can change a session's state via the pure reducer. `Session` * dispatches these for its own lifecycle actions (user input, permissions, model, - * abort, …). Raw SDK output is NOT an event: `Session.consume` folds each SDK - * message straight into state via `applySdkMessage` (see core/sdk-parse.ts), which - * keeps all SDK message-shape parsing out of the reducer. + * abort, …). + * + * エージェントの出力はここには来ない: provider のストリームはアダプタが + * `AgentEvent`(`core/agent-events.ts`)へ正規化し、`applyAgentEvent` が畳み込む。 + * 2 本に分けているのは役割が違うため — `CodivaEvent` は「codiva(UI/manager)が + * 起こしたこと」、`AgentEvent` は「エージェントに起きたこと」。 */ export type CodivaEvent = | { kind: 'permission_request'; request: PermissionRequest; at: number } @@ -307,7 +354,14 @@ export type CodivaEvent = // A merge of this session's branch into base hit conflicts (detected out of // band during the merge action). Carries the conflicted file paths. | { kind: 'conflict'; files: string[]; at: number } - | { kind: 'aborted'; error?: string; at: number } + // ストリームが例外で終わった。`cause` は**アダプタが分類した**停止理由 + // (`AgentAdapter.classifyError`)。reducer が文言を見て分類し直さないのは、 + // 「認証切れ」「レート制限」「通信断」の見分け方が provider ごとに違うため。 + // 省略時は `failed`(UI 起点の abort など、分類する材料が無いケース)。 + | { kind: 'aborted'; error?: string; cause?: AgentStopCause; at: number } + // 駆動するエージェントを切り替えた(Claude → Codex)。worktree はそのままで、 + // 直前の provider の resume id を退避し、切替先の id(あれば)を現在値にする。 + | { kind: 'agent_switched'; agent: AgentId; at: number } // The live query dropped mid-flight because the connection was interrupted // (see isConnectionError). Unlike `aborted` this is not a failure: the session // becomes `interrupted` (idle & resumable) so the user can continue it. diff --git a/src/ui/confirm-prompt.tsx b/src/ui/confirm-prompt.tsx index 2044dfd..78c56fc 100644 --- a/src/ui/confirm-prompt.tsx +++ b/src/ui/confirm-prompt.tsx @@ -1,5 +1,6 @@ import { Text } from 'ink'; import type { FC } from 'react'; +import { DEFAULT_AGENT_LABEL } from '@/core'; import { useMessages } from './i18n-context'; import { theme } from './theme'; @@ -33,7 +34,7 @@ export const ConfirmPrompt: FC = (props) => { // 切れ、lifecycle 側を先に除外する書き方では count/authCount を読めない)。 const prompt = props.kind === 'resumeAll' - ? m.action.resumeAllPrompt(props.count, props.authCount) + ? m.action.resumeAllPrompt(DEFAULT_AGENT_LABEL, props.count, props.authCount) : props.kind === 'recoverAll' ? m.recover.allPrompt(props.syncCount, props.ciCount) : props.kind === 'clear' diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index f7f3554..7ff1c8f 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -4,6 +4,7 @@ import { ARROW_SCROLL_LINES, COMMANDS, composerRowCount, + DEFAULT_AGENT_LABEL, type DiffStat, type DisplayLine, isFullscreenViewport, @@ -709,7 +710,7 @@ export const SessionDetail: FC<{ ここはターンが終わるたびに出入りするので、条件付きにするとログが 1 行跳ねる。 */} {status === 'needs_login' ? ( - {m.auth.hint} + {m.auth.hint(DEFAULT_AGENT_LABEL)} ) : resumable ? ( {m.resume.oneKeyHint} ) : interruptible ? ( diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index 8a4092b..d2a83b1 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -11,6 +11,7 @@ import { bufferOf, COMMANDS, canSelfUpdate, + DEFAULT_AGENT_LABEL, errorMessage, formatDuration, formatModel, @@ -844,7 +845,7 @@ export const SessionList: FC<{ // 見せても再開できないため)。それ以外の再開可能な行は再開キー(r)を // 含むヒントに切り替える。 target?.status === 'needs_login' - ? m.auth.listHint + ? m.auth.listHint(DEFAULT_AGENT_LABEL) : target && isResumable(target.status) ? m.resume.listHint : m.list.helpList @@ -945,7 +946,7 @@ export const SessionList: FC<{ flexGrow のセッション一覧(内部スクロールで収まる)に任せる。 */} {target?.status === 'needs_login' ? ( - {m.auth.hint} + {m.auth.hint(DEFAULT_AGENT_LABEL)} ) : targetResumable ? ( {m.resume.oneKeyHint} ) : null} diff --git a/tests/app.test.tsx b/tests/app.test.tsx index 1a24fb5..c65d4eb 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -3,9 +3,9 @@ import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; import { App } from '@/app'; import { AsyncQueue } from '@/core/async-queue'; -import { messages } from '@/core/i18n'; +import type { QueryFn } from '@/core/claude-adapter'; +import { DEFAULT_AGENT_LABEL, messages } from '@/core/i18n'; import { PR_POLL_STABLE_MS } from '@/core/pr-refresh'; -import type { QueryFn } from '@/core/session'; import { SessionManager } from '@/core/session-manager'; import type { PrLookup, WorktreeService } from '@/core/session-ports'; import { reduce } from '@/core/status-reducer'; @@ -1826,7 +1826,7 @@ describe('App one-key resume', () => { stdin.write('\x01'); // Ctrl+A await flush(); // 一括は課金に直結するので件数を見せて確認する(単体の Ctrl+R は確認なし)。 - expect(lastFrame()).toContain(messages.ja.action.resumeAllPrompt(3, 0)); + expect(lastFrame()).toContain(messages.ja.action.resumeAllPrompt(DEFAULT_AGENT_LABEL, 3, 0)); expect(sends).toEqual([]); stdin.write('y'); await flush(); @@ -1842,7 +1842,9 @@ describe('App one-key resume', () => { await flush(); stdin.write('n'); await flush(); - expect(lastFrame()).not.toContain(messages.ja.action.resumeAllPrompt(2, 0)); + expect(lastFrame()).not.toContain( + messages.ja.action.resumeAllPrompt(DEFAULT_AGENT_LABEL, 2, 0), + ); expect(sends).toEqual([]); }); it('ignores a held-down resume key: the instruction is sent once', async () => { diff --git a/tests/restore.test.tsx b/tests/restore.test.tsx index f095f38..7e29ad2 100644 --- a/tests/restore.test.tsx +++ b/tests/restore.test.tsx @@ -1,7 +1,7 @@ import type { Options, Query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { describe, expect, it, vi } from 'vitest'; import { AsyncQueue } from '@/core/async-queue'; -import type { QueryFn } from '@/core/session'; +import type { QueryFn } from '@/core/claude-adapter'; import { SessionManager } from '@/core/session-manager'; import { flush, fakeWorktrees as worktrees } from './helpers'; diff --git a/tests/update.test.tsx b/tests/update.test.tsx index f488253..c2b2b89 100644 --- a/tests/update.test.tsx +++ b/tests/update.test.tsx @@ -157,12 +157,17 @@ describe('/update', () => { const updater = fakeUpdater({ kind: 'unavailable' }); const { stdin, lastFrame } = render(); await runUpdateCommand(stdin); + // ダイアログはまず「確認中…」を描いてから結果に差し替わるので、1 tick 余分に + // 流す(他の /update テストと同じ待ち方。これが無いと負荷の高い並列実行で + // 確認中のまま assert してしまう)。 + await flush(); expect(lastFrame() ?? '').toContain(m.update.unavailable); }); it('says nothing could be checked when no updater is injected (no network)', async () => { const { stdin, lastFrame } = render(); await runUpdateCommand(stdin); + await flush(); expect(lastFrame() ?? '').toContain(m.update.unavailable); });