throughline 0.14.2 → 0.15.0

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,184 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+
10
+ import { detailRangeOn, parseDetailArg } from './sc-detail.mjs';
11
+
12
+ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
13
+ const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
14
+
15
+ function pad(n) {
16
+ return String(n).padStart(2, '0');
17
+ }
18
+
19
+ function clock(date) {
20
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
21
+ }
22
+
23
+ function ymd(date) {
24
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
25
+ }
26
+
27
+ /** 今日から daysAgo 日前の、指定した時刻。 */
28
+ function daysAgoAt(daysAgo, hours, minutes, seconds) {
29
+ const now = new Date();
30
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, hours, minutes, seconds);
31
+ }
32
+
33
+ function createBox() {
34
+ const home = mkdtempSync(join(tmpdir(), 'tl-sc-detail-'));
35
+ const project = join(home, 'project');
36
+ mkdirSync(project);
37
+ const env = {
38
+ ...process.env,
39
+ HOME: home,
40
+ USERPROFILE: home,
41
+ XDG_CONFIG_HOME: join(home, '.config'),
42
+ XDG_STATE_HOME: join(home, '.local', 'state'),
43
+ };
44
+ const run = (args) =>
45
+ spawnSync(process.execPath, [BIN_PATH, 'detail', ...args], { cwd: project, env, encoding: 'utf8' });
46
+ // detail は cwd の project だけを対象にする。子 process から見える cwd をそのまま記録する
47
+ const cwd = spawnSync(process.execPath, ['-e', 'process.stdout.write(process.cwd())'], {
48
+ cwd: project, env, encoding: 'utf8',
49
+ }).stdout;
50
+ // 最初の実行で製品の schema が作られる
51
+ assert.equal(run(['00:00']).status, 0);
52
+ const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
53
+ db.prepare(
54
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
55
+ VALUES ('detail-session', ?, 'active', 1, 1)`,
56
+ ).run(cwd);
57
+ const insertBody = db.prepare(
58
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
59
+ VALUES ('detail-session', 'detail-session', ?, ?, ?, 1, ?)`,
60
+ );
61
+ const insertDetail = db.prepare(
62
+ `INSERT INTO details
63
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text, token_count, created_at, kind, source_id)
64
+ VALUES ('detail-session', 'detail-session', ?, 'Bash', ?, NULL, 1, ?, 'tool_input', ?)`,
65
+ );
66
+ let turn = 0;
67
+ const addTurn = (date, label) => {
68
+ turn += 1;
69
+ insertBody.run(turn, 'user', `${label}_REQUEST`, date.getTime());
70
+ insertBody.run(turn, 'assistant', `${label}_ANSWER`, date.getTime() + 400);
71
+ insertDetail.run(turn, `${label}_COMMAND`, date.getTime() + 200, `tool-${turn}`);
72
+ };
73
+ return { home, run, addTurn, close: () => db.close() };
74
+ }
75
+
76
+ test('parseDetailArg: 時刻だけ、日付つき、範囲を解釈する', () => {
77
+ assert.deepEqual(parseDetailArg('19:20:25'), {
78
+ from: { date: null, hours: 19, minutes: 20, seconds: 25 },
79
+ to: { date: null, hours: 19, minutes: 20, seconds: 25 },
80
+ });
81
+ assert.deepEqual(parseDetailArg('9:05').from, { date: null, hours: 9, minutes: 5, seconds: null });
82
+ const date = { year: 2026, month: 10, day: 4 };
83
+ assert.deepEqual(parseDetailArg('2026-10-04T19:20:25').from, { date, hours: 19, minutes: 20, seconds: 25 });
84
+ assert.deepEqual(parseDetailArg('2026-10-04 19:20').from, { date, hours: 19, minutes: 20, seconds: null });
85
+ // 範囲: 終了の日付を省くと開始と同じ日
86
+ assert.deepEqual(parseDetailArg('19:20:25-19:30:00'), {
87
+ from: { date: null, hours: 19, minutes: 20, seconds: 25 },
88
+ to: { date: null, hours: 19, minutes: 30, seconds: 0 },
89
+ });
90
+ assert.deepEqual(parseDetailArg('2026-10-04T23:50:00-23:59:59'), {
91
+ from: { date, hours: 23, minutes: 50, seconds: 0 },
92
+ to: { date, hours: 23, minutes: 59, seconds: 59 },
93
+ });
94
+ assert.deepEqual(parseDetailArg('2026-10-04T23:50:00-2026-10-05T00:10:00').to, {
95
+ date: { year: 2026, month: 10, day: 5 }, hours: 0, minutes: 10, seconds: 0,
96
+ });
97
+ for (const invalid of ['', 'abc', '19', '2026-10-04', '2026-10-04T', '19:20:25-', '19:20:25-abc', '10-04T19:20:25']) {
98
+ assert.equal(parseDetailArg(invalid), null, invalid);
99
+ }
100
+ });
101
+
102
+ test('detailRangeOn: 日付の無い時刻は渡した日に、日付つきはその日に置く', () => {
103
+ const base = new Date(2026, 9, 5, 3, 0, 0);
104
+ assert.deepEqual(detailRangeOn(parseDetailArg('19:20:25'), base), {
105
+ start: new Date(2026, 9, 5, 19, 20, 25, 0).getTime(),
106
+ end: new Date(2026, 9, 5, 19, 20, 25, 999).getTime(),
107
+ });
108
+ // 秒を省くと、その分の全部
109
+ assert.deepEqual(detailRangeOn(parseDetailArg('19:20'), base), {
110
+ start: new Date(2026, 9, 5, 19, 20, 0, 0).getTime(),
111
+ end: new Date(2026, 9, 5, 19, 20, 59, 999).getTime(),
112
+ });
113
+ assert.deepEqual(detailRangeOn(parseDetailArg('2026-10-04T19:20:25-19:30'), base), {
114
+ start: new Date(2026, 9, 4, 19, 20, 25, 0).getTime(),
115
+ end: new Date(2026, 9, 4, 19, 30, 59, 999).getTime(),
116
+ });
117
+ });
118
+
119
+ test('detail: 日付を省いた時刻は、今日に無ければ遡って最も新しい日のターンを返す', () => {
120
+ const box = createBox();
121
+ try {
122
+ const yesterday = daysAgoAt(1, 19, 20, 25);
123
+ const older = daysAgoAt(3, 19, 20, 25);
124
+ box.addTurn(older, 'OLDER');
125
+ box.addTurn(yesterday, 'YESTERDAY');
126
+ box.close();
127
+
128
+ const bare = box.run([clock(yesterday)]);
129
+ assert.equal(bare.status, 0, bare.stderr);
130
+ assert.match(bare.stdout, new RegExp(`指定時刻: 19:20:25(${ymd(yesterday)}) 対象ターン数: 1\\n`));
131
+ assert.match(bare.stdout, /\[19:20:25\] \[user\]: YESTERDAY_REQUEST/);
132
+ assert.match(bare.stdout, /YESTERDAY_ANSWER/);
133
+ assert.match(bare.stdout, /IN {2}Bash\n {2}IN: {2}YESTERDAY_COMMAND/);
134
+ // 同じ時刻のもっと古い日のターンは混ぜない
135
+ assert.doesNotMatch(bare.stdout, /OLDER_/);
136
+
137
+ // 日付を付ければ、その日のターンを引ける
138
+ const dated = box.run([`${ymd(older)}T19:20:25`]);
139
+ assert.equal(dated.status, 0, dated.stderr);
140
+ assert.match(dated.stdout, new RegExp(`指定時刻: ${ymd(older)}T19:20:25 対象ターン数: 1\\n`));
141
+ assert.match(dated.stdout, /OLDER_REQUEST/);
142
+ assert.match(dated.stdout, /OLDER_COMMAND/);
143
+ assert.doesNotMatch(dated.stdout, /YESTERDAY_/);
144
+
145
+ const range = box.run([`${ymd(older)}T19:20-19:21`]);
146
+ assert.equal(range.status, 0, range.stderr);
147
+ assert.match(range.stdout, /OLDER_REQUEST/);
148
+ assert.doesNotMatch(range.stdout, /YESTERDAY_/);
149
+ } finally {
150
+ rmSync(box.home, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ test('detail: 今日のターンはそのまま返し、無い時刻と無効な書き方は今までどおり知らせる', () => {
155
+ const box = createBox();
156
+ try {
157
+ const earlier = daysAgoAt(2, 8, 15, 0);
158
+ // 今日の 00:00:00 は、いつ実行しても過去の時刻
159
+ const today = daysAgoAt(0, 0, 0, 0);
160
+ box.addTurn(earlier, 'EARLIER');
161
+ box.addTurn(today, 'TODAY');
162
+ box.close();
163
+
164
+ const found = box.run(['00:00:00']);
165
+ assert.equal(found.status, 0, found.stderr);
166
+ assert.match(found.stdout, /指定時刻: 00:00:00 {2}対象ターン数: 1\n/);
167
+ assert.match(found.stdout, /TODAY_REQUEST/);
168
+
169
+ // この project の記録に無い時刻
170
+ const missing = box.run(['13:13:13']);
171
+ assert.equal(missing.status, 0, missing.stderr);
172
+ assert.match(missing.stdout, /指定時刻 13:13:13 に該当するターンが見つかりませんでした。/);
173
+ // 日付を付けた時は、その日だけを見る
174
+ const wrongDay = box.run([`${ymd(daysAgoAt(1, 0, 0, 0))}T08:15:00`]);
175
+ assert.equal(wrongDay.status, 0, wrongDay.stderr);
176
+ assert.match(wrongDay.stdout, /に該当するターンが見つかりませんでした。/);
177
+
178
+ const invalid = box.run(['yesterday']);
179
+ assert.equal(invalid.status, 1);
180
+ assert.match(invalid.stderr, /時刻フォーマットが無効: yesterday/);
181
+ } finally {
182
+ rmSync(box.home, { recursive: true, force: true });
183
+ }
184
+ });