Claude Code の長いテストログを圧縮すると、エラーはどこまで残る?Context Diet の境界を検証
Claude Code で npm test や pytest を実行すると、何百行もの PASS が流れる一方で、本当に直したい失敗は一か所だけ、ということがある。Agent は原因を探すためにコマンドを再実行したり、ログ全体を読み直したりする。最後の数十行だけを渡す方法も安全とは限らない。重要な例外が途中に出ているかもしれないからだ。
Hacker News の Show HN で見つけた Context Diet は、この問題に取り組む Claude Code 用の Mod だ。ツール出力を短い要約に置き換えつつ、全文を読み返せるファイルパスを残す。最初は合理的だと思った。ただ README にある「every failure」という説明は気になった。表示できる文字数が限られるのに、異なるエラーが大量に発生したら本当にすべて残せるのだろうか。
そこで対象を v0.3.0、commit 689284a に固定し、Hook、圧縮ロジック、テストを読んだ。そのうえで汎用ログ圧縮部分を手作業で JavaScript に移し、人工的に作ったログで確認した。
最初に範囲を明確にしておく。実際の Claude Code に Context Diet Mod をインストールして実行したわけではない。Claude モデルの A/B テストもしていない。 以降の数値は、ソース由来の圧縮アルゴリズムと人工ログに対するテスト結果であり、実環境でのエラー欠落率や Token 削減率ではない。
モデルに入るツール出力を短くする仕組み
固定したバージョンの session.append Hook は、一定以上の長さになった Bash、Grep、Glob、JSON 形式の MCP 出力などを対象とする。Read、Edit、Write、WebFetch、JSON ではない MCP の説明文は、この経路では圧縮しない。既定のしきい値は 8,000 文字で、8,000 Token ではない。
すべてに単純な tail を使うわけでもない。パーサーのコード には Jest、Vitest、pytest、Go test、Cargo、TypeScript チェッカーなどを認識し、成功行より失敗箇所や位置情報を優先する処理がある。JSON は構造を残し、Diff、Grep、ファイル一覧にも別の要約方式がある。ただし、これはソース上で確認した処理方針であり、私が各テストフレームワークを Claude Code 上で動かした結果ではない。
重要なのは元データへの戻り道だ。Hook は要約へ置き換える前に全文を .context-diet/out-NNN.txt に保存しようとする。ファイル名は 300 個のスロットで循環する。保存に失敗した場合は、存在しないファイルへのリンクを返さず、元のツール結果をそのまま通す。
もっとも、ローカル保存は永続保管ではない。古いスロットは再利用されるし、ログ内の秘密情報や環境変数、プロジェクトのパスがディスクに残る可能性もある。.gitignore は誤って Git に追加するリスクを下げるが、暗号化でもバックアップでもない。本番インシデントで必要な原本は別に保管するべきだ。
エラー優先でも、予算を超えれば省略される
diet.ts の汎用ログ圧縮処理 を読むと、重複する行をまとめ、冒頭と末尾に文字数を割り当て、後方からエラーを探して前後の文脈を残そうとしている。
決定的なのは次の条件だ。
const cost = window.reduce((n, i) => n + (folded[i]?.text.length ?? 0) + 1, 0)
if (used + cost > target) continue
あるエラーの周辺情報が残りの予算に収まらなければ、その部分は飛ばされる。エラーが一つなら効果的なこともある。しかし、異なる失敗が数十件あって長いスタックトレースまで付けば、有限の要約にすべてを入れることはできない。
Jest や pytest として認識される出力には専用パーサーがあり、別の優先順位付けを行う。以下の検証は汎用ログのフォールバック処理を対象にしており、Jest/pytest 専用パーサーの欠落率を測っていない。 「Jest のエラーが 95% 消える」などと読み替えるのは誤りだ。
README の「every failure」は、実装上は「失敗を優先し、全文に戻れるようにする」と捉えるのが妥当だと思う。「どんな入力でもすべての失敗行を短い要約に表示する」という保証にはならない。
エラーが一件の場合と、145 件ある場合
完全な Claude Code 環境を構築する代わりに、固定ソースから汎用 dietLog と関連テキスト関数を JavaScript に手動移植した。Node.js v22.16.0 の隔離環境で実行した合成テストは 4/4 成功、終了コードは 0。公式 Mod の統合テストではない。
| 人工的な入力 | 原文の長さ | 要約の長さ | 観察結果 |
|---|---|---|---|
| 長いログの中央に重要なエラー一件 | 32,087 文字 | 1,175 文字 | 1/1 件のマーカーが残った |
| 145 種類のエラー、要約予算 1,450 文字 | 23,146 文字 | 1,419 文字 | 元のエラー行は 7/145 行だけ表示 |
| 短い出力 | 45 文字 | 45 文字 | 変更なし |
PASS errorHandler.test.ts という分類確認 | 52 文字 | 52 文字 | PASS をエラーと誤判定しない |
二番目のケースでは、元のエラー行 138 行が表示用の要約には含まれなかった。以下は実際に記録した人工ログの要約から抜き出したものだ。実プロジェクトで起きた障害ではない。
… [225 lines cut] …
ERROR CASEei unhandled category_ts in /app/modules/amy/workflow.ts:40 expected stable result bci
ERROR CASEej unhandled category_tt in /app/modules/amz/workflow.ts:40 expected stable result bcj
ERROR CASEek unhandled category_tu in /app/modules/ana/workflow.ts:40 expected stable result bck
…
… [79 lines cut] …
ここで小さな罠がある。圧縮器自身の表示は「5 of 145 error lines with context」だったのに、最終的な要約に存在する元のエラー行を数えると 7 行だった。冒頭・末尾として保持された行にもエラーが含まれ得るからだ。「明示的に選んだエラー周辺の数」と「目に見えるエラー行数」は同じ指標ではない。
1,450 文字という要約予算は、境界を見るため意図的に小さくした人工的な条件であり、既定値ではない。 v0.3.0 の既定しきい値は 8,000 文字。通常の長い出力は Math.max(2_000, Math.round(threshold / 2)) により要約目標 4,000 文字、Claude Code がすでにファイルへ保存した出力なら 3,000 文字を使う。予算が増えれば表示できる情報も増え得るが、この数字から実際のプラグインのエラー欠落率は計算できない。
さらに、文字数の選択部分だけを切り出した Node.js 標準ライブラリのみの二件のテストも実行した。こちらは人工エラー行が少し長いため、同じ 1,450 文字で 8/145 行が表示された。2/2 成功しているが、先ほどの 7/145 と同じ入力ではない。二つを性能比較のように扱うべきではない。
次はその最小再現コードの全文だ。budget-replay.test.mjs として保存し、node --test budget-replay.test.mjs で実行できる。
/**
* First-party negative control for Context Diet v0.3.0's generic dietLog budget path.
* Pin: drkokorev/context-diet @ 689284a893f1aa509b9c6c1c06b91ef1ca52279c.
* Only the error-window budget logic is mirrored, for controlled ASCII log lines.
* This is NOT an upstream Mod invocation, a benchmark, or a production loss rate.
* The full adapted dietLog fixture and observed digests are in the separate evidence bundle.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
const id = i => { let s='',n=i+1; while(n){ n--;s=String.fromCharCode(97+n%26)+s;n=Math.floor(n/26); } return s; };
const noise = n => Array.from({length:n},(_,i)=>'step '+id(i)+' completed '+id(i+1200)+' '+i);
const critical = line => /^ERROR\b|^\s+(Expected|Received):/.test(line);
function select(raw,target){
const folded=raw.split('\n').map(text=>({text,level:critical(text)?2:0}));
const errorHits=folded.map((l,i)=>l.level===2?i:-1).filter(i=>i>=0);
const keep=new Set();let used=0;
const headBudget=target*(errorHits.length?0.1:0.2);
const tailBudget=target*(errorHits.length?0.25:0.35);
for(let i=0;i<folded.length&&used<headBudget;i++){keep.add(i);used+=folded[i].text.length+1;}
let tailUsed=0;
for(let i=folded.length-1;i>=0&&tailUsed<tailBudget;i--){
if(!keep.has(i)){keep.add(i);tailUsed+=folded[i].text.length+1;}
}
used+=tailUsed;
let windowsShown=0;
for(let h=errorHits.length-1;h>=0;h--){
const at=errorHits[h];const window=[];
for(let i=at-2;i<=at+3;i++)if(i>=0&&i<folded.length&&!keep.has(i))window.push(i);
const cost=window.reduce((n,i)=>n+folded[i].text.length+1,0);
if(used+cost>target)continue; // EXACT budget guard from upstream dietLog.take()
window.forEach(i=>keep.add(i));used+=cost;windowsShown++;
}
return {visible:folded.filter((_,i)=>keep.has(i)).map(l=>l.text).join('\n'),totalCritical:errorHits.length,windowsShown};
}
test('single late/central failure remains visible',()=>{
const text=[...noise(310),'ERROR ECASEONE impossible transaction transition',' Expected: completed',' Received: pending',...noise(310)].join('\n');
const out=select(text,2000);
assert.ok(out.visible.includes('ECASEONE'));
});
test('many distinct failures cannot all fit finite digest budget',()=>{
const failures=Array.from({length:145},(_,i)=>'ERROR CASE'+id(i)+' unhandled category_'+id(i+400)+' in /app/modules/'+id(i+900)+'/workflow.ts:40 expected stable result '+id(i+1300));
const raw=[...noise(90),...failures,...noise(90)].join('\n');
const out=select(raw,1450);
const retained=failures.filter(line=>out.visible.includes(line)).length;
assert.ok(retained>0&&retained<failures.length);
process.stdout.write(JSON.stringify({failure_lines:failures.length,retained,missing:failures.length-retained,digest_budget_chars:1450,mode:'source-budget-window-control-not-plugin-runtime'})+'\n');
});
これは汎用ログの予算判定の再現であり、Claude Code の Mod API は呼んでいない。テストが通っても、実際の Agent が正しく問題を解決した証拠にはならない。
私が重視したいのは、圧縮の是非よりも要約は証拠の代わりではなく、原文に戻るための索引であるという点だ。
まず dry を使い、capture は別に判断する
Mod に対応した Claude Code を使っているなら、固定した版の README と現在のクライアント文書を確認してほしい。当時の指定は Claude Code 2.1.287 以降。私は実際のクライアントでインストールを試していない。
/plugin marketplace add drkokorev/context-diet
/plugin install context-diet@context-diet
まず /diet dry で日頃のテストを流し、/diet report でどれだけ省略される予定かを確認するのがよさそうだ。dry は元のツール結果を置き換えず、削減量だけを記録する設計になっている。完全なログが必要なコマンドには /diet keep <コマンドの一部>、あるいは /diet off を使う。
一方 /diet capture on は別の問題に対応する。Claude Code 自身が長い出力を先に切ってしまう場合、対象の単純なテスト・ビルドコマンドをラップし、stdout と stderr をまとめて .context-diet/run-NN.log に記録してから読み返し、終了コードを維持しようとする。既定では無効だ。
この書き換えにより、以前許可した Bash(pytest:*) などの権限パターンに一致しなくなり、追加の確認が必要になる場合がある。出力の結合は表示順にも影響し得る。パイプ、複数コマンド、リダイレクト、watch やバックグラウンド実行などは対象外だ。厳密な権限管理やリアルタイムの出力に依存する作業では、無条件に有効化しない方がいい。無効化するには /diet capture off を使う。
README に載る原作者の結果では、七つの質問は両条件で全問正解、ツール呼び出し総数は 13 回から 8 回に減ったという。また 18 回の Agent 修正試行で平均呼び出し回数は、未使用時 8.0、使用時 6.0、capture 併用時 5.8 と報告されている。これは原作者による小規模な集計で、私は実行履歴やコストを独立に検証していない。
成功ログが大量に出る作業なら Context Diet は評価する価値がある。ただ、障害調査や複数エラーが絡むケース、自動 PR マージの判断では、要約だけを根拠にせず全文を読むべきだ。Agent が実際に全文を再読するのか、見えた数件だけ直して PR を出してしまうのかは、今回まだ検証していない振る舞いの問題である。
以前取り上げた CLAUDE.md / AGENTS.md / Skills の指示分割や Agent 間の状態引き継ぎは、「何を読み込ませるか」「誰が担当するか」の話だった。今回はもっと身近な問いだ。目の前のツール結果は完全な証拠なのか。それとも、証拠へ戻るためのメモなのか。