throughline 0.4.8 → 0.4.9

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.
@@ -13,9 +13,12 @@ function addCheck(checks, { id, status, reason }) {
13
13
  checks.push({ id, status, reason });
14
14
  }
15
15
 
16
- function findDetailCommands(text) {
17
- const matches = text.match(/throughline detail \d\d:\d\d:\d\d/g) ?? [];
18
- return matches;
16
+ /**
17
+ * 新仕様: 各 L1/L2 行末尾の `(詳細:…)` suffix を抽出する。
18
+ * 旧版の `throughline detail HH:MM:SS` 列挙は廃止 (groupL3ByTurn が turn 単位で集約)。
19
+ */
20
+ function findDetailSuffixes(text) {
21
+ return text.match(/\(詳細:[^)]+\)/g) ?? [];
19
22
  }
20
23
 
21
24
  export function buildCodexHandoffSmoke(
@@ -39,8 +42,7 @@ export function buildCodexHandoffSmoke(
39
42
  maxBodyChars,
40
43
  });
41
44
  const checks = [];
42
- const detailCommands = findDetailCommands(prompt);
43
- const uniqueDetailCommands = new Set(detailCommands);
45
+ const detailSuffixes = findDetailSuffixes(prompt);
44
46
 
45
47
  addCheck(checks, {
46
48
  id: 'new_thread_handoff_header',
@@ -85,11 +87,11 @@ export function buildCodexHandoffSmoke(
85
87
  status: prompt.includes('## Throughline: Active Work Context') ? 'fail' : 'pass',
86
88
  reason: 'fresh-thread smoke must not accidentally use the full active-work renderer',
87
89
  });
88
- addCheck(checks, {
89
- id: 'detail_commands_deduplicated',
90
- status: detailCommands.length === uniqueDetailCommands.size ? 'pass' : 'fail',
91
- reason: 'handoff prompt should not repeat the same detail command',
92
- });
90
+ // 旧 `detail_commands_deduplicated` check は L3 の literal command 列挙を
91
+ // dedup するためのもの。新版は groupL3ByTurn が構造的に turn 単位に集約するため
92
+ // 不要。「L3 が存在すれば suffix も存在する」は localizeL3Part の挙動次第で
93
+ // 偽陽性 (例: tool_output だけの turn label null で suffix 空) になるので
94
+ // smoke check には入れない。
93
95
  addCheck(checks, {
94
96
  id: 'prompt_size_within_limit',
95
97
  status: prompt.length <= maxPromptChars ? 'pass' : 'fail',
@@ -111,8 +113,9 @@ export function buildCodexHandoffSmoke(
111
113
  l1Summaries: record.memory.l1Summaries.length,
112
114
  recentBodies: record.memory.recentBodies.length,
113
115
  l3References: record.references.l3.length,
114
- renderedDetailCommands: detailCommands.length,
115
- uniqueRenderedDetailCommands: uniqueDetailCommands.size,
116
+ // 新仕様: per-line `(詳細:…)` suffix の出現回数 (turn 単位に集約済み)。
117
+ // 旧 renderedDetailCommands / uniqueRenderedDetailCommands は廃止。
118
+ renderedDetailSuffixes: detailSuffixes.length,
116
119
  checks,
117
120
  };
118
121
 
@@ -68,7 +68,11 @@ test('buildCodexHandoffSmoke: fails when prompt exceeds max size', () => {
68
68
  );
69
69
  });
70
70
 
71
- test('buildCodexHandoffSmoke: reports rendered detail command deduplication', () => {
71
+ test('buildCodexHandoffSmoke: aggregates same-turn L3 into a single inline (詳細:…) suffix', () => {
72
+ // 旧版は L3 を独立 `### Detail References` セクションで列挙し、同一 detail command
73
+ // を dedup していた。新版は groupL3ByTurn が turn 単位に集約して L2 ターンの
74
+ // 最終 role 行に inline suffix を 1 つ貼る。結果として「同 turn の tool_input +
75
+ // tool_output」は (詳細:exec_command) 1 件になる。
72
76
  const detailRefs = [
73
77
  {
74
78
  kind: 'tool_input',
@@ -90,14 +94,29 @@ test('buildCodexHandoffSmoke: reports rendered detail command deduplication', ()
90
94
  },
91
95
  ];
92
96
 
93
- const result = buildCodexHandoffSmoke(makeRecord({ detailRefs }), { includePrompt: true });
97
+ const record = makeRecord({ detailRefs });
98
+ // L3 と turn key が一致する L2 行に上書き (smoke の makeRecord 既定では
99
+ // recentBodies が originSessionId/turnNumber を持たない)
100
+ record.memory.recentBodies = [
101
+ {
102
+ time: '12:00:02',
103
+ role: 'assistant',
104
+ text: 'body of turn 1',
105
+ originSessionId: 'codex:thread-smoke',
106
+ turnNumber: 1,
107
+ },
108
+ ];
109
+
110
+ const result = buildCodexHandoffSmoke(record, { includePrompt: true });
94
111
 
95
112
  assert.equal(result.status, 'ready');
96
113
  assert.equal(result.l3References, 2);
97
- assert.equal(result.renderedDetailCommands, 1);
98
- assert.equal(result.uniqueRenderedDetailCommands, 1);
114
+ // 2 件の L3 (tool_input + tool_output) は同一 turn なので 1 件の suffix にまとまる
115
+ assert.equal(result.renderedDetailSuffixes, 1);
116
+ assert.match(result.prompt, /\(詳細:exec_command\)/);
117
+ // 旧 detail_commands_deduplicated check は廃止 (構造的に重複しない)
99
118
  assert.equal(
100
- result.checks.find((check) => check.id === 'detail_commands_deduplicated')?.status,
101
- 'pass',
119
+ result.checks.find((check) => check.id === 'detail_commands_deduplicated'),
120
+ undefined,
102
121
  );
103
122
  });
@@ -6,6 +6,8 @@
6
6
  * can consume it.
7
7
  */
8
8
 
9
+ import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
10
+
9
11
  export const THROUGHLINE_HANDOFF_SCHEMA_VERSION = 1;
10
12
  export const DEFAULT_CODEX_HANDOFF_DETAIL_REF_LIMIT = 20;
11
13
  export const DEFAULT_CODEX_HANDOFF_RECENT_BODY_LIMIT = 8;
@@ -76,14 +78,46 @@ function truncateText(text, maxChars) {
76
78
  };
77
79
  }
78
80
 
79
- function uniqueDetailRefsByCommand(refs) {
80
- const seen = new Set();
81
+ /**
82
+ * 追加: Codex 用 inline detail suffix の組み立て補助。
83
+ * - L1: bodyTime (= 元ターン時刻) を行頭に出して `本文` 起点の詳細を案内
84
+ * - L2: ターン内の最終 role 行 (通常 user→assistant の assistant) にだけ suffix を貼る
85
+ * (同じ turn_number に紐付く L3 を user / assistant 両方に貼る冗長を回避)
86
+ */
87
+ function appendL1Lines(lines, l1Summaries, l3ByTurn) {
88
+ const l1Lines = [];
89
+ for (const row of l1Summaries) {
90
+ if (!row.summary || row.summary === '(no content)') continue;
91
+ const summary = singleLine(row.summary);
92
+ const key = `${row.originSessionId}\x00${row.turnNumber}`;
93
+ const displayTime = row.bodyTime ?? row.time;
94
+ const partCounts = l3ByTurn.get(key)?.partCounts ?? new Map();
95
+ // bodyTime が無い defensive ケース (元 body が消えている等) は detail 解決できないので
96
+ // suffix を付けない (誤誘導しない)。
97
+ const suffix = row.bodyTime != null
98
+ ? buildPartsSummary(partCounts, { includeBody: true })
99
+ : '';
100
+ l1Lines.push(`[${displayTime}] [${row.role}] ${summary}${suffix}`);
101
+ }
102
+ return l1Lines;
103
+ }
104
+
105
+ function appendL2Lines(bodies, l3ByTurn) {
106
+ const lastIdxPerTurn = new Map();
107
+ for (let i = 0; i < bodies.length; i += 1) {
108
+ const r = bodies[i];
109
+ if (!r.text) continue;
110
+ lastIdxPerTurn.set(`${r.originSessionId}\x00${r.turnNumber}`, i);
111
+ }
81
112
  const out = [];
82
- for (const ref of refs) {
83
- const key = ref.detailCommand;
84
- if (seen.has(key)) continue;
85
- seen.add(key);
86
- out.push(ref);
113
+ for (let i = 0; i < bodies.length; i += 1) {
114
+ const r = bodies[i];
115
+ if (!r.text) continue;
116
+ const key = `${r.originSessionId}\x00${r.turnNumber}`;
117
+ const isLastOfTurn = lastIdxPerTurn.get(key) === i;
118
+ const partCounts = isLastOfTurn ? (l3ByTurn.get(key)?.partCounts ?? new Map()) : new Map();
119
+ const suffix = buildPartsSummary(partCounts);
120
+ out.push({ row: r, suffix });
87
121
  }
88
122
  return out;
89
123
  }
@@ -93,6 +127,8 @@ export function renderCodexActiveWorkContext(record) {
93
127
  throw new Error('renderCodexActiveWorkContext: record is required');
94
128
  }
95
129
 
130
+ const l3ByTurn = groupL3ByTurn(record.references.l3);
131
+
96
132
  const lines = [];
97
133
  lines.push('## Throughline: Active Work Context');
98
134
  lines.push('');
@@ -109,6 +145,10 @@ export function renderCodexActiveWorkContext(record) {
109
145
  lines.push(
110
146
  'Do not treat every older line as still-current truth. Prefer the latest actionable state.',
111
147
  );
148
+ lines.push(
149
+ 'For each L1/L2 entry, the time prefix `[HH:MM:SS]` can be passed to ' +
150
+ '`throughline detail HH:MM:SS` (run via shell) to retrieve the full body and L3 detail of that turn.',
151
+ );
112
152
  lines.push('');
113
153
  lines.push('### Source');
114
154
  lines.push(`Throughline session: ${record.session.id}`);
@@ -130,32 +170,23 @@ export function renderCodexActiveWorkContext(record) {
130
170
  }
131
171
 
132
172
  if (record.memory.l1Summaries.length > 0) {
133
- lines.push('');
134
- lines.push('### L1 Summaries');
135
- for (const row of record.memory.l1Summaries) {
136
- if (!row.summary || row.summary === '(no content)') continue;
137
- lines.push(`[${row.time}] [${row.role}] ${row.summary.replace(/\n+/g, ' ').trim()}`);
173
+ const l1Lines = appendL1Lines(lines, record.memory.l1Summaries, l3ByTurn);
174
+ if (l1Lines.length > 0) {
175
+ lines.push('');
176
+ lines.push('### L1 Summaries');
177
+ lines.push(...l1Lines);
138
178
  }
139
179
  }
140
180
 
141
181
  if (record.memory.recentBodies.length > 0) {
142
- lines.push('');
143
- lines.push('### Active Work Thread (L2)');
144
- lines.push('Entries are oldest-to-newest; later entries may supersede earlier hypotheses.');
145
- for (const row of record.memory.recentBodies) {
146
- if (!row.text) continue;
147
- lines.push(`[${row.time}] [${row.role}] ${row.text}`);
148
- }
149
- }
150
-
151
- if (record.references.l3.length > 0) {
152
- lines.push('');
153
- lines.push('### Detail References');
154
- lines.push(
155
- 'Use these only when L1/L2 are insufficient. Run the command locally; do not guess missing tool output.',
156
- );
157
- for (const ref of record.references.l3) {
158
- lines.push(`- ${ref.kind}:${ref.toolName} turn ${ref.turnNumber}: ${ref.detailCommand}`);
182
+ const l2 = appendL2Lines(record.memory.recentBodies, l3ByTurn);
183
+ if (l2.length > 0) {
184
+ lines.push('');
185
+ lines.push('### Active Work Thread (L2)');
186
+ lines.push('Entries are oldest-to-newest; later entries may supersede earlier hypotheses.');
187
+ for (const { row, suffix } of l2) {
188
+ lines.push(`[${row.time}] [${row.role}] ${row.text}${suffix}`);
189
+ }
159
190
  }
160
191
  }
161
192
 
@@ -163,7 +194,7 @@ export function renderCodexActiveWorkContext(record) {
163
194
  lines.push('### Continuation Instruction');
164
195
  lines.push(
165
196
  'Continue from the latest actionable state represented above. Preserve user instructions and repository constraints. ' +
166
- 'If details are missing, inspect local files or Throughline detail references before acting.',
197
+ 'If details are missing, run `throughline detail HH:MM:SS` on the relevant entry before acting.',
167
198
  );
168
199
 
169
200
  return lines.join('\n');
@@ -172,6 +203,9 @@ export function renderCodexActiveWorkContext(record) {
172
203
  export function renderCodexNewThreadHandoff(
173
204
  record,
174
205
  {
206
+ // 旧 Detail References セクション用の制限。L3 が各 L1/L2 行末尾の
207
+ // `(詳細:…)` suffix に集約された後は描画には影響しないが、CLI flags との
208
+ // 互換維持のためバリデーションだけ残す。
175
209
  maxDetailRefs = DEFAULT_CODEX_HANDOFF_DETAIL_REF_LIMIT,
176
210
  maxRecentBodies = DEFAULT_CODEX_HANDOFF_RECENT_BODY_LIMIT,
177
211
  maxBodyChars = DEFAULT_CODEX_HANDOFF_BODY_MAX_CHARS,
@@ -222,13 +256,15 @@ export function renderCodexNewThreadHandoff(
222
256
  }
223
257
  }
224
258
 
259
+ const l3ByTurn = groupL3ByTurn(record.references.l3);
260
+
225
261
  if (record.memory.l1Summaries.length > 0) {
226
- lines.push('');
227
- lines.push('### L1 Memory Summaries');
228
- lines.push('Oldest-to-newest; use later entries when summaries disagree.');
229
- for (const row of record.memory.l1Summaries) {
230
- if (!row.summary || row.summary === '(no content)') continue;
231
- lines.push(`[${row.time}] [${row.role}] ${singleLine(row.summary)}`);
262
+ const l1Lines = appendL1Lines(lines, record.memory.l1Summaries, l3ByTurn);
263
+ if (l1Lines.length > 0) {
264
+ lines.push('');
265
+ lines.push('### L1 Memory Summaries');
266
+ lines.push('Oldest-to-newest; use later entries when summaries disagree.');
267
+ lines.push(...l1Lines);
232
268
  }
233
269
  }
234
270
 
@@ -242,45 +278,33 @@ export function renderCodexNewThreadHandoff(
242
278
  lines.push(
243
279
  `Long entries are truncated for handoff; full context: throughline codex-resume --session ${record.session.id}`,
244
280
  );
281
+ lines.push(
282
+ 'For each entry, the `[HH:MM:SS]` time prefix can be passed to ' +
283
+ '`throughline detail HH:MM:SS` (run via shell) to retrieve full body + L3 of that turn.',
284
+ );
245
285
  if (shownBodies.length === 0) {
246
286
  lines.push(`${bodies.length} active L2 entries available; omitted from this fresh-thread handoff.`);
247
287
  } else if (omittedBodies > 0) {
248
288
  lines.push(`Showing latest ${shownBodies.length} of ${bodies.length} active L2 entries; ${omittedBodies} older omitted.`);
249
289
  }
250
- for (const row of shownBodies) {
251
- if (!row.text) continue;
290
+ const l2 = appendL2Lines(shownBodies, l3ByTurn);
291
+ for (const { row, suffix } of l2) {
252
292
  const body = truncateText(row.text, maxBodyChars);
253
- lines.push(`[${row.time}] [${row.role}] ${body.text}`);
293
+ lines.push(`[${row.time}] [${row.role}] ${body.text}${suffix}`);
254
294
  if (body.truncated) {
255
295
  lines.push(`[entry truncated to ${maxBodyChars} chars]`);
256
296
  }
257
297
  }
258
298
  }
259
299
 
260
- if (record.references.l3.length > 0) {
261
- const refs = uniqueDetailRefsByCommand(record.references.l3);
262
- const shown = maxDetailRefs === 0 ? [] : refs.slice(-maxDetailRefs);
263
- const omitted = refs.length - shown.length;
264
- lines.push('');
265
- lines.push('### Detail References');
266
- lines.push('L3 bodies are not pasted here. Use local detail commands only when L1/L2 are insufficient.');
267
- if (shown.length === 0) {
268
- lines.push(`${refs.length} detail commands available; omitted from this fresh-thread handoff.`);
269
- } else {
270
- if (omitted > 0) {
271
- lines.push(`Showing latest ${shown.length} of ${refs.length} detail commands; ${omitted} older omitted.`);
272
- }
273
- for (const ref of shown) {
274
- lines.push(`- ${ref.kind}:${ref.toolName} turn ${ref.turnNumber}: ${ref.detailCommand}`);
275
- }
276
- }
277
- }
300
+ // Detail References セクションは廃止 ( L1/L2 行末尾の `(詳細:…)` suffix で
301
+ // 同じ情報が turn 単位に集約されるため重複)
278
302
 
279
303
  lines.push('');
280
304
  lines.push('### Start Instruction');
281
305
  lines.push(
282
306
  'Continue from the latest actionable state above. Preserve user instructions and repository constraints. ' +
283
- 'Do not mutate the original Codex thread; inspect local files or detail references before acting when context is missing.',
307
+ 'Do not mutate the original Codex thread; run `throughline detail HH:MM:SS` on the relevant entry before acting when context is missing.',
284
308
  );
285
309
 
286
310
  return lines.join('\n');
@@ -28,8 +28,28 @@ function makeRecord() {
28
28
  memory: {
29
29
  inflightMemo: 'Next: implement projection',
30
30
  latestThinking: [{ time: '12:00:03', text: 'latest hidden reasoning note' }],
31
- l1Summaries: [{ time: '12:00:01', role: 'assistant', summary: 'old summary' }],
32
- recentBodies: [{ time: '12:00:02', role: 'assistant', text: 'recent body' }],
31
+ l1Summaries: [
32
+ {
33
+ time: '12:00:01',
34
+ role: 'assistant',
35
+ summary: 'old summary',
36
+ originSessionId: 'old',
37
+ turnNumber: 1,
38
+ // bodyTime は handoff-record が bodies テーブル MIN(created_at) から付ける。
39
+ // test では原ターン時刻として固定値を入れる。
40
+ bodyTime: '11:59:50',
41
+ bodyTimeMs: null,
42
+ },
43
+ ],
44
+ recentBodies: [
45
+ {
46
+ time: '12:00:02',
47
+ role: 'assistant',
48
+ text: 'recent body',
49
+ originSessionId: 'old',
50
+ turnNumber: 2,
51
+ },
52
+ ],
33
53
  },
34
54
  references: {
35
55
  l3: [
@@ -93,28 +113,32 @@ test('toThroughlineHandoffBlock: rejects missing record', () => {
93
113
  assert.throws(() => toThroughlineHandoffBlock(null), /record is required/);
94
114
  });
95
115
 
96
- test('renderCodexActiveWorkContext: renders persisted memory as active work context', () => {
116
+ test('renderCodexActiveWorkContext: renders persisted memory with inline (詳細:…) suffix', () => {
97
117
  const text = renderCodexActiveWorkContext(makeRecord());
98
118
 
99
119
  assert.match(text, /## Throughline: Active Work Context/);
100
120
  assert.match(text, /### Reading Contract/);
101
121
  assert.match(text, /current-task context for continuation/);
122
+ // 詳細取得方法 (throughline detail HH:MM:SS) はヘッダーで announce 済み
123
+ assert.match(text, /throughline detail HH:MM:SS/);
102
124
  assert.match(text, /Throughline session: sess-1/);
103
125
  assert.match(text, /Source agent: claude/);
104
126
  assert.match(text, /### In-flight Memo\nNext: implement projection/);
105
127
  assert.match(text, /### Latest Thinking/);
106
128
  assert.match(text, /latest hidden reasoning note/);
107
129
  assert.match(text, /### L1 Summaries/);
108
- assert.match(text, /old summary/);
130
+ // L1 行頭は body 時刻 (bodyTime)、suffix `本文` を含む (元 body が引ける案内)
131
+ assert.match(text, /\[11:59:50\] \[assistant\] old summary \(詳細:本文\)/);
109
132
  assert.match(text, /### Active Work Thread \(L2\)/);
110
- assert.match(text, /\[12:00:02\] \[assistant\] recent body/);
111
- assert.match(text, /### Detail References/);
112
- assert.match(text, /throughline detail 12:00:01/);
133
+ // L2 末尾には L3 集約 suffix (turn 2 Bash 入力 1 件 → "Bash")
134
+ assert.match(text, /\[12:00:02\] \[assistant\] recent body \(詳細:Bash\)/);
135
+ // `### Detail References` セクションは廃止
136
+ assert.ok(!text.includes('### Detail References'));
113
137
  assert.match(text, /### Continuation Instruction/);
114
138
  assert.match(text, /Continue from the latest actionable state/);
115
139
  });
116
140
 
117
- test('renderCodexNewThreadHandoff: renders concise fresh-thread handoff context', () => {
141
+ test('renderCodexNewThreadHandoff: renders concise fresh-thread handoff context with inline suffix', () => {
118
142
  const record = makeRecord();
119
143
  const text = renderCodexNewThreadHandoff(record);
120
144
 
@@ -126,11 +150,19 @@ test('renderCodexNewThreadHandoff: renders concise fresh-thread handoff context'
126
150
  assert.match(text, /### In-flight Memo\nNext: implement projection/);
127
151
  assert.match(text, /### L1 Memory Summaries/);
128
152
  assert.match(text, /### Recent Active Thread \(L2\)/);
129
- assert.match(text, /throughline detail 12:00:01/);
153
+ // 詳細取得方法はヘッダーで announce
154
+ assert.match(text, /throughline detail HH:MM:SS/);
155
+ // L2 行に inline suffix
156
+ assert.match(text, /\[12:00:02\] \[assistant\] recent body \(詳細:Bash\)/);
157
+ // 旧 `### Detail References` セクションは廃止
158
+ assert.ok(!text.includes('### Detail References'));
130
159
  assert.match(text, /Do not mutate the original Codex thread/);
131
160
  });
132
161
 
133
- test('renderCodexNewThreadHandoff: caps detail references for pasteable new-thread prompts', () => {
162
+ test('renderCodexNewThreadHandoff: maxDetailRefs option is accepted but no longer renders a Detail References section', () => {
163
+ // 旧 ### Detail References セクションは廃止された。CLI flag 互換のため
164
+ // maxDetailRefs はバリデーションだけ通り、描画には影響しない (per-line suffix で
165
+ // turn 単位に集約済みのため)。
134
166
  const record = makeRecord();
135
167
  record.references.l3 = [
136
168
  {
@@ -138,26 +170,20 @@ test('renderCodexNewThreadHandoff: caps detail references for pasteable new-thre
138
170
  toolName: 'Bash',
139
171
  sourceId: 'toolu_1',
140
172
  originSessionId: 'old',
141
- turnNumber: 1,
142
- createdAt: 1000,
143
- detailCommand: 'throughline detail 12:00:01',
144
- },
145
- {
146
- kind: 'tool_output',
147
- toolName: 'Bash',
148
- sourceId: 'toolu_2',
149
- originSessionId: 'old',
150
173
  turnNumber: 2,
151
- createdAt: 2000,
174
+ createdAt: 1000,
152
175
  detailCommand: 'throughline detail 12:00:02',
153
176
  },
154
177
  ];
155
178
 
156
179
  const text = renderCodexNewThreadHandoff(record, { maxDetailRefs: 1 });
157
180
 
158
- assert.match(text, /Showing latest 1 of 2 detail commands; 1 older omitted/);
159
- assert.doesNotMatch(text, /throughline detail 12:00:01/);
160
- assert.match(text, /throughline detail 12:00:02/);
181
+ assert.ok(!text.includes('### Detail References'));
182
+ assert.ok(!text.includes('detail commands; '));
183
+ // ヘッダーの placeholder のみで、行末に固有時刻の throughline detail は出ない
184
+ assert.ok(!text.includes('throughline detail 12:00:02'));
185
+ // 代わりに inline suffix で turn 集約された情報が出る
186
+ assert.match(text, /\(詳細:Bash\)/);
161
187
  });
162
188
 
163
189
  test('renderCodexNewThreadHandoff: caps recent L2 entries and long bodies', () => {
@@ -180,15 +206,22 @@ test('renderCodexNewThreadHandoff: caps recent L2 entries and long bodies', () =
180
206
  assert.doesNotMatch(text, /long tail/);
181
207
  });
182
208
 
183
- test('renderCodexNewThreadHandoff: deduplicates repeated detail commands', () => {
209
+ test('renderCodexNewThreadHandoff: aggregates same-turn L3 into a single (詳細:…) suffix on the assistant line', () => {
210
+ // 旧版は同一 detail command の dedup (uniqueDetailRefsByCommand) を独立 Detail
211
+ // References セクションで行っていた。新版は groupL3ByTurn が turn 単位に集約し、
212
+ // L2 ターンの最終 role 行 (assistant) にだけ inline suffix を付ける。
184
213
  const record = makeRecord();
214
+ record.memory.recentBodies = [
215
+ { time: '12:00:02', role: 'user', text: 'user body', originSessionId: 'old', turnNumber: 2 },
216
+ { time: '12:00:02', role: 'assistant', text: 'assistant body', originSessionId: 'old', turnNumber: 2 },
217
+ ];
185
218
  record.references.l3 = [
186
219
  {
187
220
  kind: 'tool_input',
188
221
  toolName: 'Bash',
189
222
  sourceId: 'toolu_1',
190
223
  originSessionId: 'old',
191
- turnNumber: 1,
224
+ turnNumber: 2,
192
225
  createdAt: 1000,
193
226
  detailCommand: 'throughline detail 12:00:01',
194
227
  },
@@ -197,7 +230,7 @@ test('renderCodexNewThreadHandoff: deduplicates repeated detail commands', () =>
197
230
  toolName: 'Bash',
198
231
  sourceId: 'toolu_2',
199
232
  originSessionId: 'old',
200
- turnNumber: 1,
233
+ turnNumber: 2,
201
234
  createdAt: 1000,
202
235
  detailCommand: 'throughline detail 12:00:01',
203
236
  },
@@ -205,8 +238,16 @@ test('renderCodexNewThreadHandoff: deduplicates repeated detail commands', () =>
205
238
 
206
239
  const text = renderCodexNewThreadHandoff(record);
207
240
 
208
- assert.equal(text.match(/throughline detail 12:00:01/g)?.length, 1);
209
- assert.doesNotMatch(text, /Showing latest 1 of 2/);
241
+ // user 行には suffix が出ない (重複排除)
242
+ const userLine = text.split('\n').find((l) => l.includes('user body'));
243
+ assert.ok(userLine);
244
+ assert.ok(!userLine.includes('詳細:'));
245
+
246
+ // assistant 行 (turn 内最終 role) に suffix が 1 つだけ
247
+ const assistantLine = text.split('\n').find((l) => l.includes('assistant body'));
248
+ assert.ok(assistantLine);
249
+ // tool_input + tool_output の 1:1 ペアは tool 名 (Bash) で 1 つに集約される
250
+ assert.match(assistantLine, /\(詳細:Bash\)$/);
210
251
  });
211
252
 
212
253
  test('toCodexDeveloperMessageItem: wraps active work context as a developer message item', () => {
@@ -85,7 +85,12 @@ test('codex-resume prints active-work context text for explicit Codex session',
85
85
  assert.match(result.stdout, /Source agent: codex/);
86
86
  assert.match(result.stdout, /older codex summary/);
87
87
  assert.match(result.stdout, /latest codex body/);
88
- assert.match(result.stdout, /throughline detail \d\d:\d\d:\d\d/);
88
+ // 新仕様: 詳細取得方法は header の placeholder で announce
89
+ assert.match(result.stdout, /throughline detail HH:MM:SS/);
90
+ // 新仕様: L2 行末尾に inline `(詳細:…)` suffix (turn 2 の tool_input=exec_command 1 件)
91
+ assert.match(result.stdout, /latest codex body \(詳細:exec_command\)/);
92
+ // 旧 `### Detail References` セクションは廃止されている
93
+ assert.ok(!result.stdout.includes('### Detail References'));
89
94
  } finally {
90
95
  rmSync(project, { recursive: true, force: true });
91
96
  rmSync(home, { recursive: true, force: true });
@@ -142,7 +147,11 @@ test('codex-resume can print a fresh-thread handoff prompt', async () => {
142
147
  assert.match(result.stdout, /latest/);
143
148
  assert.match(result.stdout, /\[entry truncated to 6 chars\]/);
144
149
  assert.doesNotMatch(result.stdout, /codex body/);
145
- assert.match(result.stdout, /1 detail commands available; omitted from this fresh-thread handoff/);
150
+ // 新仕様: Detail References セクションは廃止されたので「N detail commands
151
+ // available; omitted」メッセージは出ない。--max-detail-refs はバリデーションだけ
152
+ // 残る no-op フラグになっている。
153
+ assert.ok(!result.stdout.includes('### Detail References'));
154
+ assert.ok(!result.stdout.includes('detail commands available; omitted'));
146
155
  assert.doesNotMatch(result.stdout, /^\{/);
147
156
  } finally {
148
157
  rmSync(project, { recursive: true, force: true });
@@ -123,6 +123,41 @@ function loadL1Summaries(db, { sessionId, excludeOriginId, bodyRows }) {
123
123
  return all.filter((s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`));
124
124
  }
125
125
 
126
+ /**
127
+ * L1 ターンの元 body 時刻 (created_at MIN) を batch lookup する。
128
+ * skeletons.created_at は要約実行時刻なので `throughline detail HH:MM:SS` 解決に
129
+ * 使えない。元 body は trim 後も bodies テーブルに残っているのが通常で、
130
+ * (session_id, origin_session_id, turn_number) で MIN を引けば原ターンの時刻が得られる。
131
+ */
132
+ function loadL1BodyTimes(db, sessionId, l1Rows) {
133
+ if (!l1Rows || l1Rows.length === 0) return new Map();
134
+ const tuples = l1Rows
135
+ .filter((r) => r.origin_session_id != null && r.turn_number != null)
136
+ .map((r) => [r.origin_session_id, Number(r.turn_number)]);
137
+ if (tuples.length === 0) return new Map();
138
+
139
+ const placeholders = tuples.map(() => '(?, ?, ?)').join(', ');
140
+ const params = tuples.flatMap(([origin, turn]) => [sessionId, origin, turn]);
141
+
142
+ const out = new Map();
143
+ try {
144
+ const rows = db
145
+ .prepare(
146
+ `SELECT origin_session_id, turn_number, MIN(created_at) AS created_at
147
+ FROM bodies
148
+ WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
149
+ GROUP BY origin_session_id, turn_number`,
150
+ )
151
+ .all(...params);
152
+ for (const r of rows) {
153
+ out.set(`${r.origin_session_id}\x00${r.turn_number}`, r.created_at);
154
+ }
155
+ } catch {
156
+ // body が無い defensive ケースでは bodyTime null のまま (renderer 側で skeleton 時刻 fallback)
157
+ }
158
+ return out;
159
+ }
160
+
126
161
  function loadLatestThinking(db, { sessionId, excludeOriginId }) {
127
162
  const hasExclude = Boolean(excludeOriginId);
128
163
  const latestQuery = hasExclude
@@ -234,6 +269,7 @@ export function buildHandoffRecord(
234
269
  }
235
270
 
236
271
  const l3References = loadL3References(db, { sessionId, bodyRows });
272
+ const l1BodyTimes = loadL1BodyTimes(db, sessionId, l1Rows);
237
273
  const originSessionIds = distinctOriginSessionIds(bodyRows, l1Rows, thinkingRows);
238
274
 
239
275
  return {
@@ -263,14 +299,21 @@ export function buildHandoffRecord(
263
299
  time: formatTime(r.created_at),
264
300
  sourceId: r.source_id ?? null,
265
301
  })),
266
- l1Summaries: l1Rows.map((r) => ({
267
- originSessionId: r.origin_session_id,
268
- turnNumber: r.turn_number,
269
- role: r.role,
270
- summary: r.summary,
271
- createdAt: r.created_at,
272
- time: formatTime(r.created_at),
273
- })),
302
+ l1Summaries: l1Rows.map((r) => {
303
+ const bodyTimeMs = l1BodyTimes.get(`${r.origin_session_id}\x00${r.turn_number}`);
304
+ return {
305
+ originSessionId: r.origin_session_id,
306
+ turnNumber: r.turn_number,
307
+ role: r.role,
308
+ summary: r.summary,
309
+ createdAt: r.created_at,
310
+ time: formatTime(r.created_at),
311
+ // 元ターンの body 時刻。`throughline detail HH:MM:SS` 解決に使える時刻。
312
+ // body が無い defensive ケースでは null。
313
+ bodyTimeMs: bodyTimeMs ?? null,
314
+ bodyTime: bodyTimeMs != null ? formatTime(bodyTimeMs) : null,
315
+ };
316
+ }),
274
317
  recentBodies: bodyRows.map((r) => ({
275
318
  originSessionId: r.origin_session_id,
276
319
  turnNumber: r.turn_number,