throughline 0.16.27 → 0.16.29

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.
@@ -2,10 +2,10 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createHash, createHmac } from 'node:crypto';
5
- import { chmodSync, existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
5
+ import { chmodSync, existsSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
6
6
  import { createServer } from 'node:http';
7
- import { tmpdir } from 'node:os';
8
7
  import { join } from 'node:path';
8
+ import { testTempDir } from './test-temp-dir.mjs';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
11
  import {
@@ -37,7 +37,7 @@ const KEY_ID = 'test-host.throughline';
37
37
  const NOW = Date.parse('2026-10-03T08:00:00.000Z');
38
38
 
39
39
  function createEnvironment(prefix = 'throughline-report-') {
40
- const root = mkdtempSync(join(tmpdir(), prefix));
40
+ const root = testTempDir(prefix);
41
41
  const env = {
42
42
  ...process.env,
43
43
  HOME: root,
@@ -1,9 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import childProcess, { spawn } from 'node:child_process';
4
- import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
6
5
  import { dirname, join } from 'node:path';
6
+ import { testTempDir } from './test-temp-dir.mjs';
7
7
 
8
8
  import {
9
9
  acknowledgeRuntimeErrors,
@@ -22,7 +22,7 @@ import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './os/windows-ac
22
22
  const TEST_PLATFORM = process.platform === 'win32' ? 'win32' : 'darwin';
23
23
 
24
24
  function sandbox() {
25
- const root = mkdtempSync(join(tmpdir(), 'throughline-runtime-errors-'));
25
+ const root = testTempDir('throughline-runtime-errors-');
26
26
  const env = {
27
27
  HOME: root,
28
28
  USERPROFILE: root,
@@ -1,9 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
- import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
4
+ import { readFileSync, writeFileSync } from 'node:fs';
6
5
  import { join } from 'node:path';
6
+ import { testTempDir } from './test-temp-dir.mjs';
7
7
  import { spikeInject, generateSpikeTracer } from './spike-transcript-writer.mjs';
8
8
 
9
9
  function makeDb() {
@@ -76,7 +76,7 @@ function seedSession(db, { newSessionId = 'NEW', originId = 'ORIG' } = {}) {
76
76
  }
77
77
 
78
78
  function makeTempJsonl() {
79
- const dir = mkdtempSync(join(tmpdir(), 'spike-writer-test-'));
79
+ const dir = testTempDir('spike-writer-test-');
80
80
  return join(dir, 'session.jsonl');
81
81
  }
82
82
 
@@ -0,0 +1,24 @@
1
+ // 試験が作る一時フォルダを、その試験 file の process が終わる時に消す。
2
+ // CI のランナーは使い捨てではないので、消さないと回ごとに OS の一時フォルダへ積もる
3
+ // (rabbit の /tmp に 3,026 個。2026-10-13 の計測)。
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ const created = [];
9
+
10
+ process.on('exit', () => {
11
+ for (const dir of created) {
12
+ try {
13
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
14
+ } catch {
15
+ // 開いたままの file が残る時(Windows)は消せない。試験の成否は変えない。
16
+ }
17
+ }
18
+ });
19
+
20
+ export function testTempDir(prefix) {
21
+ const dir = mkdtempSync(join(tmpdir(), prefix));
22
+ created.push(dir);
23
+ return dir;
24
+ }
@@ -5,7 +5,9 @@
5
5
  * 実際のフォーマット(確認済み):
6
6
  * {type: "user", message: {role: "user", content: [{type:"text", text:"..."}]}, ...}
7
7
  * {type: "assistant", message: {role: "assistant", content: [{type:"text", text:"..."}, {type:"thinking", ...}]}, ...}
8
- * 他に queue-operation, attachment, file-history-snapshot 等があるが無視する
8
+ * {type: "attachment", attachment: {type: "queued_command", prompt, origin: {kind}}, ...}
9
+ * ターンの途中で届いた入力。届いた先のターンの依頼へ足す (ADR 0060)
10
+ * 他に queue-operation, file-history-snapshot, ほかの attachment 等があるが、本文としては無視する
9
11
  */
10
12
 
11
13
  import { readFileSync, existsSync } from 'fs';
@@ -34,6 +36,59 @@ export function isCompactSummaryEntry(entry) {
34
36
  return entry?.isCompactSummary === true;
35
37
  }
36
38
 
39
+ /**
40
+ * Claude Code が自分で書く meta の user 行か(ADR 0060)。skill を読んだ時の本文、端末で打った命令の注意書きなど。
41
+ * type は user だが、人の発言でも、外から届いた入力でもない。ターンの始まりにも本文にもしない。
42
+ * 別の会話から届いた発言も `isMeta: true` だが、`origin.kind` を持つ。届いた入力なので、ここには入れない。
43
+ * @param {object} entry
44
+ */
45
+ export function isClaudeHostWrittenMetaEntry(entry) {
46
+ return entry?.type === 'user'
47
+ && entry.message !== null && typeof entry.message === 'object'
48
+ && entry.isMeta === true
49
+ && typeof entry.origin?.kind !== 'string';
50
+ }
51
+
52
+ /** ターンの始まりにも本文にもしない user 行か。圧縮の要約(ADR 0032)と、host が書いた meta の行(ADR 0060)。 */
53
+ function isHostWrittenClaudeUserEntry(entry) {
54
+ return isCompactSummaryEntry(entry) || isClaudeHostWrittenMetaEntry(entry);
55
+ }
56
+
57
+ // ターンの途中で届いた入力のうち、依頼へ足す物。人が打った文と、別の会話から届いた発言。
58
+ // 裏の作業の完了通知(task-notification)は、そのターン自身の作業の結果なので足さない。
59
+ const CLAUDE_MID_TURN_INPUT_ORIGINS = Object.freeze(['human', 'peer']);
60
+
61
+ /** ターンの途中で届いた入力を、依頼の後ろへ足す時の見出し。記憶を読む側が、最初の依頼と見分けられる様にする。 */
62
+ export const MID_TURN_INPUT_MARK = '(作業の途中で届いた入力)';
63
+
64
+ /**
65
+ * Claude Code が、動いているターンへ届けた入力の本文(ADR 0060)。当たらない行は null。
66
+ * 手すきの時に届けば user 行になる入力が、ターンの途中では `attachment` の `queued_command` で書かれる。
67
+ * @param {object} entry transcript JSONL の 1 行
68
+ * @returns {string|null}
69
+ */
70
+ function claudeMidTurnInputText(entry) {
71
+ if (entry?.type !== 'attachment' || entry.isSidechain === true) return null;
72
+ const attachment = entry.attachment;
73
+ if (attachment?.type !== 'queued_command') return null;
74
+ if (!CLAUDE_MID_TURN_INPUT_ORIGINS.includes(attachment.origin?.kind)) return null;
75
+ if (typeof attachment.prompt !== 'string' && !Array.isArray(attachment.prompt)) return null;
76
+ const text = normalizeTerminalText(compactClaudePeerMessage(extractText(attachment.prompt)));
77
+ return text ? text : null;
78
+ }
79
+
80
+ /**
81
+ * ターンの終わりに、待っていた入力が続けて user 行になった物か(ADR 0060)。Claude Code は、同じ番へ届けた
82
+ * user 行に同じ `turnPosition.promptIndex` を付ける。両方が `origin.kind` を持つ(届いた入力である)時だけ当たる。
83
+ */
84
+ function isSameClaudePromptDelivery(previous, entry) {
85
+ const index = entry.turnPosition?.promptIndex;
86
+ return Number.isSafeInteger(index)
87
+ && previous?.promptIndex === index
88
+ && typeof entry.origin?.kind === 'string'
89
+ && previous.hasOrigin === true;
90
+ }
91
+
37
92
  const GROK_HOST_WRITTEN_SYNTHETIC_REASONS = Object.freeze(['compaction_meta', 'system_reminder']);
38
93
 
39
94
  /**
@@ -72,9 +127,14 @@ export function readTranscript(transcriptPath) {
72
127
  const raw = readFileSync(transcriptPath, 'utf8');
73
128
 
74
129
  const turns = [];
75
- // turn_number は transcript 内の本文行の通し番号。圧縮の要約行は返さないが番号は進める。
76
- // 番号を詰めると、要約行を数えていた頃に保存した bodies の turn_number と食い違う。
130
+ // turn_number は transcript 内の本文行の通し番号。圧縮の要約行と host が書いた meta の行は返さないが、番号は進める。
131
+ // 番号を詰めると、その行を数えていた頃に保存した bodies の turn_number と食い違う。
77
132
  let position = 0;
133
+ // 直前に返した user の行。ターンの途中で届いた入力と、続けて届いた待ち入力を、この行の本文へ足す (ADR 0060)。
134
+ let currentUser = null;
135
+ // currentUser の後に assistant の本文が出たか。出た後の user 行は、次のターンの始まり。
136
+ let currentUserAnswered = false;
137
+ let currentUserDelivery = null;
78
138
  for (const line of raw.split('\n')) {
79
139
  const trimmed = line.trim();
80
140
  if (!trimmed) continue;
@@ -86,6 +146,13 @@ export function readTranscript(transcriptPath) {
86
146
  continue;
87
147
  }
88
148
 
149
+ // ターンの途中で届いた入力。本文行ではないので、番号は進めない。
150
+ const midTurnInput = claudeMidTurnInputText(entry);
151
+ if (midTurnInput !== null) {
152
+ if (currentUser) currentUser.content += `\n\n${MID_TURN_INPUT_MARK}\n${midTurnInput}`;
153
+ continue;
154
+ }
155
+
89
156
  // user / assistant エントリのみ対象。Claude は type、Cursor jsonl は role。
90
157
  const kind = entryKind(entry);
91
158
  if (kind !== 'user' && kind !== 'assistant') continue;
@@ -113,10 +180,18 @@ export function readTranscript(transcriptPath) {
113
180
  if (isCompactSummaryEntry(entry)) continue;
114
181
  // Grok の合成行も host が書いた行。番号は進める(数えていた頃に保存した turn_number と食い違わせない)。
115
182
  if (isGrokHostWrittenUserEntry(entry)) continue;
183
+ // Claude Code の meta の行も host が書いた行。ここで区切ると、その前の依頼が本文の無い群になって消える。
184
+ if (isClaudeHostWrittenMetaEntry(entry)) continue;
185
+
186
+ if (role === 'user' && currentUser && !currentUserAnswered && isSameClaudePromptDelivery(currentUserDelivery, entry)) {
187
+ // 待っていた入力が、同じ番へ続けて届いた。1つの依頼にまとめる。別の行にすると、手前の入力が本文の無い群になって消える。
188
+ currentUser.content += `\n\n${text}`;
189
+ continue;
190
+ }
116
191
 
117
192
  const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
118
193
  // grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
119
- turns.push({
194
+ const turn = {
120
195
  role,
121
196
  content: text,
122
197
  turn_number: turnNumber,
@@ -128,7 +203,18 @@ export function readTranscript(transcriptPath) {
128
203
  prompt_id: typeof entry.promptId === 'string' ? entry.promptId : null,
129
204
  }
130
205
  : {}),
131
- });
206
+ };
207
+ turns.push(turn);
208
+ if (role === 'user') {
209
+ currentUser = turn;
210
+ currentUserAnswered = false;
211
+ currentUserDelivery = {
212
+ promptIndex: entry.turnPosition?.promptIndex,
213
+ hasOrigin: typeof entry.origin?.kind === 'string',
214
+ };
215
+ } else {
216
+ currentUserAnswered = true;
217
+ }
132
218
  }
133
219
 
134
220
  return turns;
@@ -403,8 +489,8 @@ export function sliceCurrentTurnEntries(entries) {
403
489
  for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
404
490
  const e = entries[i];
405
491
  if (entryKind(e) !== 'user') continue;
406
- // 圧縮の要約行はターンの始まりではない。圧縮より前の tool 入出力も同じターンに含める。
407
- if (isCompactSummaryEntry(e)) continue;
492
+ // 圧縮の要約行と host が書いた meta の行は、ターンの始まりではない。その前の tool 入出力も同じターンに含める。
493
+ if (isHostWrittenClaudeUserEntry(e)) continue;
408
494
  const blocks = e.message?.content;
409
495
  if (Array.isArray(blocks)) {
410
496
  if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
@@ -433,7 +519,7 @@ export function sliceCurrentTurnEntries(entries) {
433
519
  export function sliceInFlightTurnEntries(entries) {
434
520
  for (let i = entries.length - 1; i >= 0; i--) {
435
521
  const e = entries[i];
436
- if (entryKind(e) !== 'user' || isCompactSummaryEntry(e)) continue;
522
+ if (entryKind(e) !== 'user' || isHostWrittenClaudeUserEntry(e)) continue;
437
523
  const blocks = e.message?.content;
438
524
  const hasText = Array.isArray(blocks)
439
525
  ? blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)
@@ -1,8 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
3
+ import { writeFileSync } from 'node:fs';
5
4
  import { join } from 'node:path';
5
+ import { testTempDir } from './test-temp-dir.mjs';
6
6
  import {
7
7
  normalizeTerminalText,
8
8
  normalizeToolResultContent,
@@ -36,7 +36,7 @@ test('normalizeTerminalText: CRLF を LF にし、行内 CR は最後の上書
36
36
  });
37
37
 
38
38
  function writeTranscript(entries) {
39
- const dir = mkdtempSync(join(tmpdir(), 'tl-transcript-'));
39
+ const dir = testTempDir('tl-transcript-');
40
40
  const path = join(dir, 't.jsonl');
41
41
  writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
42
42
  return path;
@@ -552,3 +552,162 @@ test('sliceInFlightTurnEntries: 最後の user 本文から末尾まで。最後
552
552
  assert.deepEqual(sliceInFlightTurnEntries([entries[1]]), []);
553
553
  });
554
554
 
555
+
556
+ // --- ADR 0060: Claude Code の、ターンの途中で届いた入力・host が書く meta の行・続けて届いた待ち入力 ---
557
+
558
+ function humanEntry(text, extra = {}) {
559
+ return { type: 'user', origin: { kind: 'human' }, promptSource: 'typed', message: { role: 'user', content: text }, ...extra };
560
+ }
561
+ function queuedCommandEntry(prompt, extra = {}) {
562
+ return {
563
+ type: 'attachment',
564
+ attachment: { type: 'queued_command', prompt, commandMode: 'prompt', origin: { kind: 'human' }, humanTurn: true, ...extra },
565
+ };
566
+ }
567
+ /** skill を読んだ時などに Claude Code が書く行。origin が無い。 */
568
+ function hostMetaEntry(text) {
569
+ return { type: 'user', isMeta: true, message: { role: 'user', content: [{ type: 'text', text }] } };
570
+ }
571
+
572
+ test('readTranscript: ターンの途中で届いた人の入力は、そのターンの依頼へ届いた順に足す。番号は進めない', () => {
573
+ const path = writeTranscript([
574
+ humanEntry('最初の依頼'),
575
+ asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
576
+ queuedCommandEntry('途中の追加1'),
577
+ userToolResultEntry('toolu_1', 'ok'),
578
+ asstTextEntry('途中の報告'),
579
+ queuedCommandEntry([{ type: 'text', text: '途中の追加2' }, { type: 'image', source: {} }]),
580
+ asstTextEntry('最終回答'),
581
+ humanEntry('次の依頼'),
582
+ asstTextEntry('次の回答'),
583
+ ]);
584
+ const turns = readTranscript(path);
585
+ assert.deepEqual(turns.map((t) => [t.role, t.turn_number]), [
586
+ ['user', 0], ['assistant', 1], ['assistant', 2], ['user', 3], ['assistant', 4],
587
+ ]);
588
+ assert.equal(turns[0].content,
589
+ '最初の依頼\n\n(作業の途中で届いた入力)\n途中の追加1\n\n(作業の途中で届いた入力)\n途中の追加2');
590
+ assert.equal(turns[0].start, 'prompt');
591
+ assert.equal(turns[3].content, '次の依頼');
592
+ const groups = getLogicalTurnGroups(path);
593
+ assert.equal(groups[0].representative.index, 2);
594
+ assert.equal(groups[0].user.content.startsWith('最初の依頼\n\n(作業の途中で届いた入力)\n途中の追加1'), true);
595
+ });
596
+
597
+ test('readTranscript: 途中で届いた完了通知と、subagent の中の入力は依頼へ足さない。別の会話からの文は注意書きを落として足す', () => {
598
+ const path = writeTranscript([
599
+ humanEntry('依頼'),
600
+ queuedCommandEntry('<task-notification>\n<status>completed</status>\n</task-notification>',
601
+ { commandMode: 'task-notification', origin: { kind: 'task-notification' }, humanTurn: undefined }),
602
+ { ...queuedCommandEntry('subagent への入力'), isSidechain: true },
603
+ queuedCommandEntry('印の無い入力', { origin: undefined }),
604
+ queuedCommandEntry('Another Claude session sent a message:\n本文\n\nThis came from another Claude session, not a human.',
605
+ { origin: { kind: 'peer' } }),
606
+ asstTextEntry('回答'),
607
+ ]);
608
+ assert.equal(readTranscript(path)[0].content,
609
+ '依頼\n\n(作業の途中で届いた入力)\nAnother Claude session sent a message:\n本文');
610
+ });
611
+
612
+ test('readTranscript: 依頼より前に書かれた途中の入力は、足す先が無いので捨てる', () => {
613
+ const path = writeTranscript([queuedCommandEntry('宙に浮いた入力'), asstTextEntry('回答')]);
614
+ assert.deepEqual(readTranscript(path).map((t) => [t.role, t.content]), [['assistant', '回答']]);
615
+ });
616
+
617
+ test('readTranscript: host が書いた meta の行(origin なし)は返さず、ターンも区切らない。番号は詰めない', () => {
618
+ const path = writeTranscript([
619
+ humanEntry('依頼'),
620
+ asstToolUseEntry('toolu_1', 'Skill', { skill: 'x' }),
621
+ userToolResultEntry('toolu_1', 'Launching skill: x'),
622
+ hostMetaEntry('Base directory for this skill: /x\n\n手順…'),
623
+ asstTextEntry('最終回答'),
624
+ ]);
625
+ assert.deepEqual(readTranscript(path).map((t) => [t.role, t.content, t.turn_number]), [
626
+ ['user', '依頼', 0],
627
+ ['assistant', '最終回答', 2],
628
+ ]);
629
+ const groups = getLogicalTurnGroups(path);
630
+ assert.equal(groups.length, 1);
631
+ assert.equal(groups[0].user.content, '依頼');
632
+ assert.equal(groups[0].representative.index, 2);
633
+ assert.equal(readLatestUserGroup(path).user.content, '依頼');
634
+ });
635
+
636
+ test('readTranscript: origin を持つ meta の行(別の会話から届いた発言)は、今までどおりターンの始まり', () => {
637
+ const path = writeTranscript([
638
+ humanEntry('依頼'),
639
+ asstTextEntry('回答'),
640
+ { type: 'user', isMeta: true, origin: { kind: 'peer', from: 'x' },
641
+ message: { role: 'user', content: 'Another Claude session sent a message:\n本文\n\nThis came from another Claude session.' } },
642
+ asstTextEntry('返事'),
643
+ ]);
644
+ assert.deepEqual(getLogicalTurnGroups(path).map((g) => [g.user.content, g.user.start]), [
645
+ ['依頼', 'prompt'],
646
+ ['Another Claude session sent a message:\n本文', 'unknown'],
647
+ ]);
648
+ });
649
+
650
+ test('readTranscript: ターンの終わりに続けて届いた待ち入力(同じ promptIndex)は、1つの依頼にまとめる', () => {
651
+ const queued = (text, promptIndex, id) => humanEntry(text,
652
+ { promptSource: 'queued', promptId: id, turnPosition: { promptIndex, turnIndex: promptIndex } });
653
+ const path = writeTranscript([
654
+ humanEntry('前の依頼', { promptId: 'p0', turnPosition: { promptIndex: 8, turnIndex: 8 } }),
655
+ asstTextEntry('前の回答'),
656
+ queued('待っていた1通目', 9, 'p1'),
657
+ { type: 'attachment', attachment: { type: 'hook_success', hookEvent: 'UserPromptSubmit', content: '' } },
658
+ queued('待っていた2通目', 9, 'p2'),
659
+ asstTextEntry('回答'),
660
+ queued('別の番の入力', 10, 'p3'),
661
+ asstTextEntry('次の回答'),
662
+ ]);
663
+ const turns = readTranscript(path);
664
+ assert.deepEqual(turns.map((t) => [t.role, t.content, t.turn_number]), [
665
+ ['user', '前の依頼', 0],
666
+ ['assistant', '前の回答', 1],
667
+ ['user', '待っていた1通目\n\n待っていた2通目', 2],
668
+ ['assistant', '回答', 4],
669
+ ['user', '別の番の入力', 5],
670
+ ['assistant', '次の回答', 6],
671
+ ]);
672
+ assert.equal(turns[2].prompt_id, 'p1');
673
+ assert.equal(getLogicalTurnGroups(path)[1].representative.index, 4);
674
+ });
675
+
676
+ test('readTranscript: promptIndex が違う・印が無い・間に本文がある user 行は、まとめない', () => {
677
+ const at = (promptIndex) => ({ turnPosition: { promptIndex, turnIndex: promptIndex } });
678
+ assert.deepEqual(readTranscript(writeTranscript([
679
+ humanEntry('止められた依頼', at(1)), humanEntry('打ち直した依頼', at(2)), asstTextEntry('回答'),
680
+ ])).map((t) => t.content), ['止められた依頼', '打ち直した依頼', '回答']);
681
+ assert.deepEqual(readTranscript(writeTranscript([
682
+ userEntry('印の無い行'), userEntry('印の無い行2'), asstTextEntry('回答'),
683
+ ])).map((t) => t.content), ['印の無い行', '印の無い行2', '回答']);
684
+ // 端末で打った命令の行(origin なし)は、同じ promptIndex を持っていても人の入力とまとめない
685
+ assert.deepEqual(readTranscript(writeTranscript([
686
+ { type: 'user', message: { role: 'user', content: '<local-command-stdout>x</local-command-stdout>' }, ...at(3) },
687
+ humanEntry('依頼', at(3)), asstTextEntry('回答'),
688
+ ])).map((t) => t.content), ['<local-command-stdout>x</local-command-stdout>', '依頼', '回答']);
689
+ });
690
+
691
+ test('sliceCurrentTurnEntries / sliceInFlightTurnEntries: host が書いた meta の行で区切らず、その前の道具も同じターンに含める', () => {
692
+ const entries = [
693
+ humanEntry('前の依頼'),
694
+ asstTextEntry('前の回答'),
695
+ humanEntry('今の依頼'),
696
+ asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
697
+ userToolResultEntry('toolu_1', 'before skill'),
698
+ asstToolUseEntry('toolu_2', 'Skill', { skill: 'x' }),
699
+ userToolResultEntry('toolu_2', 'Launching skill: x'),
700
+ hostMetaEntry('skill の本文'),
701
+ asstToolUseEntry('toolu_3', 'Write', { file_path: '/b' }),
702
+ userToolResultEntry('toolu_3', 'after skill'),
703
+ ];
704
+ const inFlight = sliceInFlightTurnEntries(entries);
705
+ assert.equal(inFlight[0].message.content, '今の依頼');
706
+ assert.deepEqual(
707
+ extractDetailBlocks(inFlight).filter((d) => d.kind === DETAIL_KIND.TOOL_INPUT).map((d) => d.tool_name),
708
+ ['Read', 'Skill', 'Write'],
709
+ );
710
+ const done = sliceCurrentTurnEntries([...entries, asstTextEntry('最終回答')]);
711
+ assert.equal(done[0].message.content, '今の依頼');
712
+ assert.equal(done.length, 9);
713
+ });
@@ -504,3 +504,63 @@ test('captureInFlightTurn: 発言が1つも無いターンは、user の行と
504
504
  );
505
505
  });
506
506
 
507
+
508
+ // --- ADR 0060: host が書いた meta の行でターンを区切っていた頃の行と、途中で届いた入力 ---
509
+
510
+ test('backfillBodies: meta の行で区切っていた頃に保存したターンは、群がまとまっても重ねて入れない', () => {
511
+ const meta = { type: 'user', isMeta: true, message: { role: 'user', content: [{ type: 'text', text: 'skill の本文' }] } };
512
+ const human = (text) => entry('user', text, undefined, { origin: { kind: 'human' } });
513
+ const seeded = (turns) => {
514
+ const db = makeDb();
515
+ const insert = db.prepare(`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, 1)`);
516
+ for (const [turn, user] of turns) {
517
+ insert.run('target', 'origin', turn, 'user', user);
518
+ insert.run('target', 'origin', turn, 'assistant', 'saved');
519
+ }
520
+ return db;
521
+ };
522
+ const run = (db, entries) => withData(entries, (path) =>
523
+ backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
524
+
525
+ // 前の版: [依頼, 途中の報告] と [skill の本文, 最終回答] の2群。今の版: 1群(断片 1 と 3)。
526
+ const both = seeded([[1, '依頼'], [3, 'skill の本文']]);
527
+ const split = [human('依頼'), entry('assistant', '途中の報告'), meta, entry('assistant', '最終回答')];
528
+ assert.deepEqual(pick(run(both, split)), { groups: 1, insertedTurns: 0, skippedExisting: 1 });
529
+ assert.equal(both.prepare('SELECT COUNT(*) AS n FROM bodies').get().n, 4);
530
+
531
+ // 前の版: 依頼は本文の無い群で消え、[skill の本文, 最終回答] だけが入っていた。
532
+ const lost = seeded([[2, 'skill の本文']]);
533
+ assert.deepEqual(pick(run(lost, [human('依頼'), meta, entry('assistant', '最終回答')])),
534
+ { groups: 1, insertedTurns: 0, skippedExisting: 1 });
535
+
536
+ // 新しい会話では、依頼が user の行になる。
537
+ const fresh = makeDb();
538
+ assert.deepEqual(pick(run(fresh, [human('依頼'), meta, entry('assistant', '最終回答')])),
539
+ { groups: 1, insertedTurns: 1, skippedExisting: 0 });
540
+ assert.deepEqual(fresh.prepare('SELECT turn_number, role, text, turn_start FROM bodies ORDER BY role DESC').all().map((r) => ({ ...r })), [
541
+ { turn_number: 2, role: 'user', text: '依頼', turn_start: 'prompt' },
542
+ { turn_number: 2, role: 'assistant', text: '最終回答', turn_start: null },
543
+ ]);
544
+ });
545
+
546
+ function pick(result) {
547
+ return { groups: result.groups, insertedTurns: result.insertedTurns, skippedExisting: result.skippedExisting };
548
+ }
549
+
550
+ test('backfillBodies / captureInFlightTurn: ターンの途中で届いた入力は、依頼の行へ入る', () => {
551
+ const steer = (prompt) => ({ type: 'attachment', attachment: { type: 'queued_command', prompt, origin: { kind: 'human' } } });
552
+ const entries = [
553
+ entry('user', '依頼', undefined, { origin: { kind: 'human' } }),
554
+ steer('追加の指示'),
555
+ entry('assistant', '回答'),
556
+ ];
557
+ const done = makeDb();
558
+ withData(entries, (path) => backfillBodies(done, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
559
+ assert.equal(done.prepare(`SELECT text FROM bodies WHERE role = 'user'`).get().text, '依頼\n\n(作業の途中で届いた入力)\n追加の指示');
560
+
561
+ const stopped = makeDb();
562
+ stopped.exec(`CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, origin_session_id TEXT, turn_number INTEGER,
563
+ tool_name TEXT, input_text TEXT, output_text TEXT, token_count INTEGER, created_at INTEGER, kind TEXT, source_id TEXT)`);
564
+ withData(entries.slice(0, 2), (path) => captureInFlightTurn(stopped, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
565
+ assert.equal(stopped.prepare(`SELECT text FROM bodies WHERE role = 'user'`).get().text, '依頼\n\n(作業の途中で届いた入力)\n追加の指示');
566
+ });