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.
- package/CHANGELOG.md +50 -0
- package/README.ja.md +1 -1
- package/README.md +3 -3
- package/bin/throughline.mjs +3 -3
- package/docs/PUBLIC_RELEASE_PLAN.md +4 -4
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +1 -1
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +3 -3
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +2 -2
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +4 -4
- package/docs/throughline-rollback-context-trim-insight.md +1 -1
- package/package.json +1 -1
- package/src/cli/codex-handoff-smoke.mjs +1 -1
- package/src/cli/codex-hook.test.mjs +4 -4
- package/src/cli/doctor.mjs +117 -3
- package/src/cli/doctor.test.mjs +25 -1
- package/src/cli/install.mjs +2 -2
- package/src/codex-auto-refresh.mjs +1 -1
- package/src/codex-auto-refresh.test.mjs +4 -4
- package/src/codex-handoff-smoke.mjs +15 -12
- package/src/codex-handoff-smoke.test.mjs +25 -6
- package/src/codex-handoff.mjs +82 -58
- package/src/codex-handoff.test.mjs +69 -28
- package/src/codex-resume.test.mjs +11 -2
- package/src/handoff-record.mjs +51 -8
- package/src/l3-summary.mjs +72 -0
- package/src/resume-context.mjs +58 -56
- package/src/resume-context.test.mjs +332 -36
|
@@ -13,9 +13,12 @@ function addCheck(checks, { id, status, reason }) {
|
|
|
13
13
|
checks.push({ id, status, reason });
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
115
|
-
uniqueRenderedDetailCommands
|
|
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:
|
|
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
|
|
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
|
-
|
|
98
|
-
assert.equal(result.
|
|
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')
|
|
101
|
-
|
|
119
|
+
result.checks.find((check) => check.id === 'detail_commands_deduplicated'),
|
|
120
|
+
undefined,
|
|
102
121
|
);
|
|
103
122
|
});
|
package/src/codex-handoff.mjs
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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 (
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
lines.push(
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
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.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
lines.push(
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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;
|
|
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: [
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
assert.match(text,
|
|
112
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
159
|
-
assert.
|
|
160
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/src/handoff-record.mjs
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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,
|