throughline 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { _internal } from './token-monitor.mjs';
5
+ import { normalizeProjectPath } from './state-file.mjs';
6
+
7
+ const {
8
+ parseArgs,
9
+ filterStates,
10
+ cellWidth,
11
+ truncateToCells,
12
+ padCellsEnd,
13
+ formatNumber,
14
+ renderBar,
15
+ formatLine,
16
+ } = _internal;
17
+
18
+ // state-file は projectPath を resolve + lowercase 正規化する。
19
+ // filterStates は cwd 引数を内部で正規化するので、テストでも同じ関数を使って揃える。
20
+ const CWD_FOO = normalizeProjectPath('/tmp/foo');
21
+ const CWD_BAR = normalizeProjectPath('/tmp/bar');
22
+
23
+ // ─── parseArgs ─────────────────────────────────────────────────────
24
+
25
+ test('parseArgs: 引数なしは defaults', () => {
26
+ assert.deepEqual(parseArgs([]), { all: false, session: null });
27
+ });
28
+
29
+ test('parseArgs: --all フラグ', () => {
30
+ assert.deepEqual(parseArgs(['--all']), { all: true, session: null });
31
+ });
32
+
33
+ test('parseArgs: --session <id>', () => {
34
+ assert.deepEqual(parseArgs(['--session', 'abc123']), { all: false, session: 'abc123' });
35
+ });
36
+
37
+ test('parseArgs: --all と --session の組み合わせ', () => {
38
+ assert.deepEqual(parseArgs(['--all', '--session', 'abc']), { all: true, session: 'abc' });
39
+ });
40
+
41
+ test('parseArgs: --session 値欠落は throw する', () => {
42
+ assert.throws(() => parseArgs(['--session']), /requires a session id/);
43
+ });
44
+
45
+ test('parseArgs: --session の次が別フラグなら throw する', () => {
46
+ assert.throws(() => parseArgs(['--session', '--all']), /requires a session id/);
47
+ });
48
+
49
+ test('parseArgs: 未知の引数は黙殺', () => {
50
+ // 将来 --help などを足す余地を残すため、現状は黙殺で OK
51
+ assert.deepEqual(parseArgs(['--unknown', 'value']), { all: false, session: null });
52
+ });
53
+
54
+ // ─── filterStates ─────────────────────────────────────────────────
55
+
56
+ function makeState({ sessionId, projectPath, stale = false }) {
57
+ return {
58
+ sessionId,
59
+ projectPath,
60
+ transcriptPath: null,
61
+ updatedAt: Date.now(),
62
+ stale,
63
+ };
64
+ }
65
+
66
+ test('filterStates: --all なしでは stale を隠す', () => {
67
+ const states = [
68
+ makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
69
+ makeState({ sessionId: 'b', projectPath: CWD_FOO, stale: true }),
70
+ ];
71
+ const result = filterStates(states, { all: false, session: null }, CWD_FOO);
72
+ assert.equal(result.length, 1);
73
+ assert.equal(result[0].sessionId, 'a');
74
+ });
75
+
76
+ test('filterStates: --all は stale も含む', () => {
77
+ const states = [
78
+ makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
79
+ makeState({ sessionId: 'b', projectPath: CWD_BAR, stale: true }),
80
+ ];
81
+ const result = filterStates(states, { all: true, session: null }, CWD_FOO);
82
+ assert.equal(result.length, 2);
83
+ });
84
+
85
+ test('filterStates: --session は base (stale フィルタ済み) 上でプレフィックス一致', () => {
86
+ const states = [
87
+ makeState({ sessionId: 'abc123', projectPath: CWD_FOO, stale: false }),
88
+ makeState({ sessionId: 'abc999', projectPath: CWD_FOO, stale: true }), // stale は除外される
89
+ makeState({ sessionId: 'def456', projectPath: CWD_FOO, stale: false }),
90
+ ];
91
+ const result = filterStates(states, { all: false, session: 'abc' }, CWD_FOO);
92
+ assert.equal(result.length, 1);
93
+ assert.equal(result[0].sessionId, 'abc123');
94
+ });
95
+
96
+ test('filterStates: --session + --all なら stale 含めたうえでプレフィックス一致', () => {
97
+ const states = [
98
+ makeState({ sessionId: 'abc123', projectPath: CWD_FOO, stale: false }),
99
+ makeState({ sessionId: 'abc999', projectPath: CWD_FOO, stale: true }),
100
+ ];
101
+ const result = filterStates(states, { all: true, session: 'abc' }, CWD_FOO);
102
+ assert.equal(result.length, 2);
103
+ });
104
+
105
+ test('filterStates: --session 完全一致もプレフィックス一致の一部として拾う', () => {
106
+ const states = [
107
+ makeState({ sessionId: 'exact-match-id', projectPath: CWD_FOO, stale: false }),
108
+ ];
109
+ const result = filterStates(states, { all: false, session: 'exact-match-id' }, CWD_FOO);
110
+ assert.equal(result.length, 1);
111
+ });
112
+
113
+ test('filterStates: cwd 不一致は除外(--session も --all もなし)', () => {
114
+ const states = [
115
+ makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
116
+ makeState({ sessionId: 'b', projectPath: CWD_BAR, stale: false }),
117
+ ];
118
+ const result = filterStates(states, { all: false, session: null }, CWD_FOO);
119
+ assert.equal(result.length, 1);
120
+ assert.equal(result[0].sessionId, 'a');
121
+ });
122
+
123
+ // ─── cellWidth ─────────────────────────────────────────────────────
124
+
125
+ test('cellWidth: ASCII は 1 セル', () => {
126
+ assert.equal(cellWidth('abc'), 3);
127
+ assert.equal(cellWidth(''), 0);
128
+ assert.equal(cellWidth('Hello World'), 11);
129
+ });
130
+
131
+ test('cellWidth: CJK は 2 セル', () => {
132
+ assert.equal(cellWidth('あ'), 2);
133
+ assert.equal(cellWidth('漢字'), 4);
134
+ assert.equal(cellWidth('한글'), 4);
135
+ });
136
+
137
+ test('cellWidth: 絵文字は 2 セル', () => {
138
+ assert.equal(cellWidth('😀'), 2);
139
+ assert.equal(cellWidth('🚀🎉'), 4);
140
+ });
141
+
142
+ test('cellWidth: ANSI エスケープは 0 セル', () => {
143
+ assert.equal(cellWidth('\x1b[32mhello\x1b[0m'), 5);
144
+ assert.equal(cellWidth('\x1b[1;36m★\x1b[0m'), 1);
145
+ });
146
+
147
+ test('cellWidth: 混在 (ASCII + CJK + 絵文字 + ANSI)', () => {
148
+ const line = '\x1b[32m▶\x1b[0m Throughline プロジェクト';
149
+ // ▶ (U+25B6) は "Geometric Shapes" で現状 1 セル扱い、ASCII 12 + ひらがな 4 * 2
150
+ // "Throughline " = 12, "プロジェクト" = 12 (6 文字 * 2), ▶ = 1, 空白 = 1
151
+ assert.equal(cellWidth(line), 1 + 1 + 12 + 12);
152
+ });
153
+
154
+ test('cellWidth: 制御文字は 0', () => {
155
+ assert.equal(cellWidth('\x00\x01\x02'), 0);
156
+ });
157
+
158
+ test('cellWidth: ZWJ は 0', () => {
159
+ assert.equal(cellWidth('a\u200db'), 2); // a + ZWJ (0) + b
160
+ });
161
+
162
+ // ─── truncateToCells ──────────────────────────────────────────────
163
+
164
+ test('truncateToCells: ASCII で単純切り詰め', () => {
165
+ const result = truncateToCells('abcdefghij', 5);
166
+ // 切り詰め後に reset が付く
167
+ assert.ok(result.startsWith('abcde'));
168
+ });
169
+
170
+ test('truncateToCells: CJK 境界で 1 セル余る場合は空白で埋める', () => {
171
+ // maxCells=5 で "あいう" (6 セル) を切ると "あい" (4 セル) + 1 セル分空白
172
+ const result = truncateToCells('あいうえお', 5);
173
+ const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
174
+ // CJK 2 セル × 2 = 4 セル + 空白 1 = 5 セル
175
+ assert.ok(stripped.startsWith('あい '));
176
+ });
177
+
178
+ test('truncateToCells: 既に収まっていればそのまま', () => {
179
+ assert.equal(truncateToCells('abc', 10), 'abc');
180
+ });
181
+
182
+ test('truncateToCells: ANSI コードを破壊しない', () => {
183
+ const input = '\x1b[32mhello\x1b[0m world';
184
+ const result = truncateToCells(input, 7);
185
+ // ANSI がそのまま残り、可視部分は "hello w" で切れる
186
+ assert.ok(result.includes('\x1b[32m'));
187
+ });
188
+
189
+ test('truncateToCells: maxCells=0 は空文字列', () => {
190
+ assert.equal(truncateToCells('hello', 0), '');
191
+ });
192
+
193
+ // ─── padCellsEnd ──────────────────────────────────────────────────
194
+
195
+ test('padCellsEnd: ASCII を右端パディング', () => {
196
+ assert.equal(padCellsEnd('abc', 6), 'abc ');
197
+ });
198
+
199
+ test('padCellsEnd: CJK でも正しく幅を計算してパディング', () => {
200
+ // "あい" = 4 セル、target 6 → 空白 2 個付加
201
+ assert.equal(padCellsEnd('あい', 6), 'あい ');
202
+ });
203
+
204
+ test('padCellsEnd: ちょうど target なら変化なし', () => {
205
+ assert.equal(padCellsEnd('abc', 3), 'abc');
206
+ assert.equal(padCellsEnd('漢字', 4), '漢字');
207
+ });
208
+
209
+ test('padCellsEnd: target 超過なら切り詰め', () => {
210
+ const result = padCellsEnd('abcdefgh', 4);
211
+ const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
212
+ assert.equal(stripped.slice(0, 4), 'abcd');
213
+ });
214
+
215
+ // ─── formatNumber ─────────────────────────────────────────────────
216
+
217
+ test('formatNumber: 1000 未満はそのまま(小数なし)', () => {
218
+ assert.equal(formatNumber(0), '0');
219
+ assert.equal(formatNumber(42), '42');
220
+ assert.equal(formatNumber(999), '999');
221
+ });
222
+
223
+ test('formatNumber: 1_000 以上は k 表記', () => {
224
+ assert.equal(formatNumber(1_000), '1.0k');
225
+ assert.equal(formatNumber(1_234), '1.2k');
226
+ assert.equal(formatNumber(999_499), '999.5k');
227
+ });
228
+
229
+ test('formatNumber: 999_500 以上は M 表記にジャンプ("1000.0k" 回避)', () => {
230
+ assert.equal(formatNumber(999_500), '1.00M');
231
+ assert.equal(formatNumber(999_950), '1.00M');
232
+ assert.equal(formatNumber(999_999), '1.00M');
233
+ assert.equal(formatNumber(1_000_000), '1.00M');
234
+ assert.equal(formatNumber(1_234_567), '1.23M');
235
+ });
236
+
237
+ test('formatNumber: 無効値は "0"', () => {
238
+ assert.equal(formatNumber(NaN), '0');
239
+ assert.equal(formatNumber(-1), '0');
240
+ assert.equal(formatNumber(Infinity), '0');
241
+ });
242
+
243
+ // ─── renderBar ────────────────────────────────────────────────────
244
+
245
+ test('renderBar: ratio=0 は全部 ░', () => {
246
+ assert.equal(renderBar(0, 5), '░░░░░');
247
+ });
248
+
249
+ test('renderBar: ratio=1 は全部 █', () => {
250
+ assert.equal(renderBar(1, 5), '█████');
251
+ });
252
+
253
+ test('renderBar: ratio=0.5 は半々', () => {
254
+ // width=10, 0.5 * 10 = 5 filled
255
+ assert.equal(renderBar(0.5, 10), '█████░░░░░');
256
+ });
257
+
258
+ test('renderBar: NaN でもバーが消えない', () => {
259
+ const result = renderBar(NaN, 5);
260
+ assert.equal(result.length, 5);
261
+ assert.ok(result.includes('░'));
262
+ });
263
+
264
+ test('renderBar: Infinity / 負値は安全にクランプ', () => {
265
+ assert.equal(renderBar(Infinity, 5), '█████');
266
+ assert.equal(renderBar(-1, 5), '░░░░░');
267
+ assert.equal(renderBar(1.5, 5), '█████');
268
+ });
269
+
270
+ // ─── formatLine 警告表示(色覚配慮) ────────────────────────────
271
+
272
+ function stripColors(s) {
273
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
274
+ }
275
+
276
+ function makeLineArgs(ratio) {
277
+ const max = 200_000;
278
+ const tokens = Math.round(ratio * max);
279
+ return {
280
+ state: {
281
+ sessionId: 'abc12345-xxxx',
282
+ projectPath: '/tmp/foo',
283
+ transcriptPath: null,
284
+ updatedAt: Date.now(),
285
+ },
286
+ usage: {
287
+ tokens,
288
+ model: 'test-model',
289
+ contextWindowSize: max,
290
+ outputTokens: 0,
291
+ },
292
+ isActive: true,
293
+ };
294
+ }
295
+
296
+ test('formatLine: 70% 未満は警告テキストなし', () => {
297
+ const out = stripColors(formatLine(makeLineArgs(0.5)));
298
+ assert.ok(!out.includes('!!'));
299
+ assert.ok(!out.includes('! '));
300
+ assert.ok(!out.includes('/clear'));
301
+ });
302
+
303
+ test('formatLine: 70% 以上で "!" マーカーと弱めの文言', () => {
304
+ const out = stripColors(formatLine(makeLineArgs(0.75)));
305
+ assert.ok(out.includes('!'), 'should include ! marker');
306
+ assert.ok(out.includes('そろそろ /clear'), 'should show soft warning');
307
+ assert.ok(!out.includes('!!'), 'should not include critical marker yet');
308
+ });
309
+
310
+ test('formatLine: 90% 以上で "!!" マーカーと強い文言', () => {
311
+ const out = stripColors(formatLine(makeLineArgs(0.95)));
312
+ assert.ok(out.includes('!!'), 'should include !! critical marker');
313
+ assert.ok(out.includes('強く推奨'), 'should show strong warning');
314
+ });
315
+
316
+ test('formatLine: 色付きで警告が赤 / 黄になる(色覚配慮の裏付け)', () => {
317
+ const critical = formatLine(makeLineArgs(0.95));
318
+ assert.ok(critical.includes('\x1b[31m'), 'critical should use red');
319
+ const warning = formatLine(makeLineArgs(0.75));
320
+ assert.ok(warning.includes('\x1b[33m'), 'warning should use yellow');
321
+ });
322
+
323
+ test('formatLine: プロジェクト名に CJK が含まれてもセル幅で整形される', () => {
324
+ const args = makeLineArgs(0.5);
325
+ args.state.projectPath = '/tmp/プロジェクト名';
326
+ const out = formatLine(args);
327
+ // basename で "プロジェクト名" (7 文字, 14 セル) が project 欄に入る。
328
+ // padCellsEnd(..., 18) で 14 セル + 4 セル空白になる。セル幅を数える
329
+ // のは難しいがクラッシュしないことと想定文字列が含まれることを最低限確認
330
+ assert.ok(stripColors(out).includes('プロジェクト名'));
331
+ });
@@ -197,7 +197,7 @@ export function sliceCurrentTurnEntries(entries) {
197
197
  * 分類ルール:
198
198
  * - assistant の tool_use ブロック → tool_input (name, input を JSON 化して input_text に)
199
199
  * - user の tool_result ブロック → tool_output (content を output_text に、ANSI 剥離)
200
- * - assistant/user の thinking ブロック → 破棄
200
+ * - assistant の thinking ブロック → thinking (b.thinking を output_text に)
201
201
  * - assistant/user の text ブロック → 扱わない(L2 bodies 側の責務)
202
202
  * - attachment entry (hook_success) → system (hookName + content を出力に)
203
203
  * - system entry (stop_hook_summary) → skip(hook タイミング情報で意味なし)
@@ -215,7 +215,8 @@ export function extractDetailBlocks(turnEntries) {
215
215
  if (e.type === 'assistant') {
216
216
  const blocks = e.message?.content;
217
217
  if (!Array.isArray(blocks)) continue;
218
- for (const b of blocks) {
218
+ for (let i = 0; i < blocks.length; i++) {
219
+ const b = blocks[i];
219
220
  if (!b || !b.type) continue;
220
221
  if (b.type === 'tool_use' && typeof b.id === 'string') {
221
222
  toolNameById.set(b.id, b.name ?? 'unknown');
@@ -226,6 +227,16 @@ export function extractDetailBlocks(turnEntries) {
226
227
  input_text: JSON.stringify(b.input ?? null),
227
228
  output_text: null,
228
229
  });
230
+ } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
231
+ // 固有 id が無いため entry uuid + block index で冪等キーを合成
232
+ const sourceId = e.uuid ? `${e.uuid}:thinking:${i}` : null;
233
+ out.push({
234
+ kind: DETAIL_KIND.THINKING,
235
+ tool_name: 'thinking',
236
+ source_id: sourceId,
237
+ input_text: null,
238
+ output_text: b.thinking,
239
+ });
229
240
  } else if (b.type === 'image') {
230
241
  out.push({
231
242
  kind: DETAIL_KIND.IMAGE,
@@ -235,7 +246,7 @@ export function extractDetailBlocks(turnEntries) {
235
246
  output_text: '[image]',
236
247
  });
237
248
  }
238
- // text / thinking は扱わない
249
+ // text は扱わない
239
250
  }
240
251
  } else if (e.type === 'user') {
241
252
  const blocks = e.message?.content;
@@ -114,22 +114,80 @@ test('extractDetailBlocks: tool_use と tool_result をペアで抽出', () => {
114
114
  assert.equal(output.output_text, 'hi\n');
115
115
  });
116
116
 
117
- test('extractDetailBlocks: thinking / text ブロックは L3 に入れない', () => {
117
+ test('extractDetailBlocks: assistant の thinking ブロックを kind=thinking で抽出、text は無視', () => {
118
118
  const entries = [
119
119
  userEntry('prompt'),
120
120
  {
121
121
  type: 'assistant',
122
+ uuid: 'asst-1',
122
123
  message: {
123
124
  role: 'assistant',
124
125
  content: [
125
- { type: 'thinking', thinking: 'internal thoughts' },
126
+ { type: 'thinking', thinking: 'internal thoughts', signature: 'sig' },
126
127
  { type: 'text', text: 'response' },
127
128
  ],
128
129
  },
129
130
  },
130
131
  ];
131
132
  const details = extractDetailBlocks(entries);
132
- assert.equal(details.length, 0);
133
+ assert.equal(details.length, 1);
134
+ assert.equal(details[0].kind, DETAIL_KIND.THINKING);
135
+ assert.equal(details[0].tool_name, 'thinking');
136
+ assert.equal(details[0].source_id, 'asst-1:thinking:0');
137
+ assert.equal(details[0].input_text, null);
138
+ assert.equal(details[0].output_text, 'internal thoughts');
139
+ });
140
+
141
+ test('extractDetailBlocks: 同 entry 内で thinking + tool_use + image が混在しても全て抽出', () => {
142
+ const entries = [
143
+ userEntry('prompt'),
144
+ {
145
+ type: 'assistant',
146
+ uuid: 'asst-2',
147
+ message: {
148
+ role: 'assistant',
149
+ content: [
150
+ { type: 'thinking', thinking: 'first thought' },
151
+ { type: 'tool_use', id: 'toolu_1', name: 'Read', input: { path: '/x' } },
152
+ { type: 'thinking', thinking: 'second thought' },
153
+ { type: 'image', source: {} },
154
+ { type: 'text', text: 'done' },
155
+ ],
156
+ },
157
+ },
158
+ asstTextEntry('wrap'),
159
+ ];
160
+ const details = extractDetailBlocks(entries);
161
+ // thinking x2, tool_input x1, image x1 = 4
162
+ assert.equal(details.length, 4);
163
+ const thinkings = details.filter((d) => d.kind === DETAIL_KIND.THINKING);
164
+ assert.equal(thinkings.length, 2);
165
+ assert.equal(thinkings[0].source_id, 'asst-2:thinking:0');
166
+ assert.equal(thinkings[1].source_id, 'asst-2:thinking:2');
167
+ assert.equal(thinkings[0].output_text, 'first thought');
168
+ assert.equal(thinkings[1].output_text, 'second thought');
169
+ });
170
+
171
+ test('extractDetailBlocks: thinking エントリに uuid が無くても source_id=null で通過する', () => {
172
+ const entries = [
173
+ userEntry('prompt'),
174
+ {
175
+ type: 'assistant',
176
+ // uuid 欠損
177
+ message: {
178
+ role: 'assistant',
179
+ content: [
180
+ { type: 'thinking', thinking: 'thought without uuid' },
181
+ { type: 'text', text: 'reply' },
182
+ ],
183
+ },
184
+ },
185
+ ];
186
+ const details = extractDetailBlocks(entries);
187
+ const thinking = details.find((d) => d.kind === DETAIL_KIND.THINKING);
188
+ assert.ok(thinking);
189
+ assert.equal(thinking.source_id, null);
190
+ assert.equal(thinking.output_text, 'thought without uuid');
133
191
  });
134
192
 
135
193
  test('extractDetailBlocks: attachment (hook_success) を system として抽出', () => {
@@ -72,20 +72,26 @@ function hasContextWindowHint(raw) {
72
72
  return /\[1m\]|1M context/i.test(raw);
73
73
  }
74
74
 
75
- /** @type {Map<string, {size: number, sample: UsageSample|null}>} */
75
+ /** @type {Map<string, {size: number, mtimeMs: number, sample: UsageSample|null, stickyWindow: number}>} */
76
76
  const cache = new Map();
77
77
 
78
78
  /**
79
- * transcript JSONL から最新の assistant usage を抽出する
79
+ * transcript JSONL から最新の assistant usage を抽出する。
80
+ *
81
+ * キャッシュ無効化は size + mtimeMs の両方を比較する(同サイズで内容差し替えも検出)。
82
+ * sticky 1M: 一度でも 1M 文脈窓と判定した path は以後 1M のまま(200k→1M 境界での
83
+ * バー急変を防ぐ)。プロセス再起動でリセット、monitor 再起動時でも初回サンプルで
84
+ * 即復帰するので実害なし。
85
+ *
80
86
  * @param {string} transcriptPath
81
87
  * @returns {UsageSample | null}
82
88
  */
83
89
  export function readLatestUsage(transcriptPath) {
84
90
  if (!transcriptPath || !existsSync(transcriptPath)) return null;
85
91
 
86
- const { size } = statSync(transcriptPath);
92
+ const stat = statSync(transcriptPath);
87
93
  const cached = cache.get(transcriptPath);
88
- if (cached && cached.size === size) {
94
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
89
95
  return cached.sample;
90
96
  }
91
97
 
@@ -110,15 +116,30 @@ export function readLatestUsage(transcriptPath) {
110
116
  (usage.cache_creation_input_tokens ?? 0) +
111
117
  (usage.cache_read_input_tokens ?? 0);
112
118
  const model = entry.message?.model ?? '';
119
+ const detected = inferContextWindowSize(model, tokens, rawHint);
113
120
  latest = {
114
121
  tokens,
115
122
  model,
116
- contextWindowSize: inferContextWindowSize(model, tokens, rawHint),
123
+ contextWindowSize: detected,
117
124
  outputTokens: usage.output_tokens ?? 0,
118
125
  };
119
126
  }
120
127
 
121
- cache.set(transcriptPath, { size, sample: latest });
128
+ // sticky 1M: 既に 1M を観測していたらその値を維持
129
+ const priorSticky = cached?.stickyWindow ?? 0;
130
+ const stickyWindow = latest
131
+ ? Math.max(latest.contextWindowSize, priorSticky)
132
+ : priorSticky;
133
+ if (latest && stickyWindow > latest.contextWindowSize) {
134
+ latest.contextWindowSize = stickyWindow;
135
+ }
136
+
137
+ cache.set(transcriptPath, {
138
+ size: stat.size,
139
+ mtimeMs: stat.mtimeMs,
140
+ sample: latest,
141
+ stickyWindow,
142
+ });
122
143
  return latest;
123
144
  }
124
145
 
@@ -0,0 +1,159 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, rmSync, utimesSync, statSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ import { readLatestUsage, clearUsageCache, inferContextWindowSize } from './transcript-usage.mjs';
8
+
9
+ function writeTranscript(path, entries) {
10
+ writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
11
+ }
12
+
13
+ function assistantEntry({ model, inputTokens, cacheCreate = 0, cacheRead = 0, outputTokens = 0 }) {
14
+ return {
15
+ type: 'assistant',
16
+ message: {
17
+ model,
18
+ usage: {
19
+ input_tokens: inputTokens,
20
+ cache_creation_input_tokens: cacheCreate,
21
+ cache_read_input_tokens: cacheRead,
22
+ output_tokens: outputTokens,
23
+ },
24
+ },
25
+ };
26
+ }
27
+
28
+ function withFixture(fn) {
29
+ const dir = mkdtempSync(join(tmpdir(), 'tl-usage-'));
30
+ const path = join(dir, 'transcript.jsonl');
31
+ clearUsageCache();
32
+ try {
33
+ fn({ dir, path });
34
+ } finally {
35
+ clearUsageCache();
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ }
39
+
40
+ // ─── inferContextWindowSize ────────────────────────────────────────
41
+
42
+ test('inferContextWindowSize: [1m] サフィックスは 1M', () => {
43
+ assert.equal(inferContextWindowSize('claude-opus-4-6[1m]', 0, false), 1_000_000);
44
+ assert.equal(inferContextWindowSize('claude-sonnet-4-7[1M]', 0, false), 1_000_000);
45
+ });
46
+
47
+ test('inferContextWindowSize: rawHint=true なら 1M', () => {
48
+ assert.equal(inferContextWindowSize('claude-opus-4-6', 0, true), 1_000_000);
49
+ });
50
+
51
+ test('inferContextWindowSize: 200k 超観測でも 1M', () => {
52
+ assert.equal(inferContextWindowSize('claude-opus-4-6', 250_000, false), 1_000_000);
53
+ });
54
+
55
+ test('inferContextWindowSize: デフォルトは 200k', () => {
56
+ assert.equal(inferContextWindowSize('claude-opus-4-6', 100_000, false), 200_000);
57
+ assert.equal(inferContextWindowSize('', 0, false), 200_000);
58
+ });
59
+
60
+ // ─── readLatestUsage ──────────────────────────────────────────────
61
+
62
+ test('readLatestUsage: 存在しないファイルは null', () => {
63
+ assert.equal(readLatestUsage('/nonexistent/path'), null);
64
+ assert.equal(readLatestUsage(''), null);
65
+ assert.equal(readLatestUsage(null), null);
66
+ });
67
+
68
+ test('readLatestUsage: 最新の assistant エントリを返す', () => {
69
+ withFixture(({ path }) => {
70
+ writeTranscript(path, [
71
+ assistantEntry({ model: 'claude-opus-4-6', inputTokens: 1000 }),
72
+ { type: 'user', message: { content: 'hi' } },
73
+ assistantEntry({ model: 'claude-opus-4-6', inputTokens: 5000, cacheRead: 1000, outputTokens: 100 }),
74
+ ]);
75
+ const result = readLatestUsage(path);
76
+ assert.ok(result);
77
+ assert.equal(result.tokens, 6000);
78
+ assert.equal(result.model, 'claude-opus-4-6');
79
+ assert.equal(result.outputTokens, 100);
80
+ assert.equal(result.contextWindowSize, 200_000);
81
+ });
82
+ });
83
+
84
+ test('readLatestUsage: usage なしエントリは skip', () => {
85
+ withFixture(({ path }) => {
86
+ writeTranscript(path, [
87
+ { type: 'assistant', message: { model: 'x', content: [] } },
88
+ assistantEntry({ model: 'claude-opus-4-6', inputTokens: 500 }),
89
+ ]);
90
+ const result = readLatestUsage(path);
91
+ assert.equal(result.tokens, 500);
92
+ });
93
+ });
94
+
95
+ test('readLatestUsage: キャッシュが mtime 変化で無効化される', () => {
96
+ withFixture(({ path }) => {
97
+ writeTranscript(path, [assistantEntry({ model: 'x', inputTokens: 100 })]);
98
+ const first = readLatestUsage(path);
99
+ assert.equal(first.tokens, 100);
100
+
101
+ // 同じサイズで中身を差し替え、mtime も進める
102
+ writeTranscript(path, [assistantEntry({ model: 'x', inputTokens: 999 })]);
103
+ // 強制的に mtime を 2 秒後にする(OS によっては書き込み直後でも mtime が同じことがある)
104
+ const future = new Date(Date.now() + 2000);
105
+ utimesSync(path, future, future);
106
+
107
+ const second = readLatestUsage(path);
108
+ assert.equal(second.tokens, 999, 'cache should be invalidated by mtime change');
109
+ });
110
+ });
111
+
112
+ // ─── sticky 1M ──────────────────────────────────────────────────
113
+
114
+ test('readLatestUsage: 一度 1M を観測したら以後は下がらない', () => {
115
+ withFixture(({ path }) => {
116
+ // 初回: 250k 観測 → 1M 判定
117
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 250_000 })]);
118
+ const first = readLatestUsage(path);
119
+ assert.equal(first.contextWindowSize, 1_000_000);
120
+
121
+ // 次: 100k に下がっても window は 1M のまま維持(sticky)
122
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 100_000 })]);
123
+ const future = new Date(Date.now() + 2000);
124
+ utimesSync(path, future, future);
125
+
126
+ const second = readLatestUsage(path);
127
+ assert.equal(second.tokens, 100_000);
128
+ assert.equal(second.contextWindowSize, 1_000_000, 'sticky 1M must remain after observation');
129
+ });
130
+ });
131
+
132
+ test('readLatestUsage: clearUsageCache で sticky もリセット', () => {
133
+ withFixture(({ path }) => {
134
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 250_000 })]);
135
+ readLatestUsage(path);
136
+ clearUsageCache();
137
+
138
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 100_000 })]);
139
+ const future = new Date(Date.now() + 2000);
140
+ utimesSync(path, future, future);
141
+
142
+ const result = readLatestUsage(path);
143
+ assert.equal(result.contextWindowSize, 200_000, 'sticky should reset after clearUsageCache');
144
+ });
145
+ });
146
+
147
+ test('readLatestUsage: partial-write JSONL 行は skip', () => {
148
+ withFixture(({ path }) => {
149
+ // 最後の行が壊れている
150
+ writeFileSync(
151
+ path,
152
+ JSON.stringify(assistantEntry({ model: 'x', inputTokens: 42 })) +
153
+ '\n' +
154
+ '{"type":"assistant","message":{"model":"x","us',
155
+ );
156
+ const result = readLatestUsage(path);
157
+ assert.equal(result.tokens, 42);
158
+ });
159
+ });