throughline 0.4.8 → 0.4.10

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.
@@ -397,6 +397,89 @@ function countCapturedCodexSessions(db, projectPath) {
397
397
  }
398
398
  }
399
399
 
400
+ function parseCodexTrustedHookState(configText) {
401
+ const trustedKeys = new Set();
402
+ let currentKey = null;
403
+ let currentTrusted = false;
404
+
405
+ function flush() {
406
+ if (currentKey && currentTrusted) trustedKeys.add(currentKey);
407
+ }
408
+
409
+ for (const line of configText.split(/\r?\n/)) {
410
+ const section = line.match(/^\s*\[hooks\.state\."([^"]+)"\]\s*$/);
411
+ if (section) {
412
+ flush();
413
+ currentKey = section[1];
414
+ currentTrusted = false;
415
+ continue;
416
+ }
417
+ if (!currentKey) continue;
418
+ if (/^\s*\[/.test(line)) {
419
+ flush();
420
+ currentKey = null;
421
+ currentTrusted = false;
422
+ continue;
423
+ }
424
+ if (/^\s*trusted_hash\s*=\s*"sha256:[^"]+"\s*$/.test(line)) {
425
+ currentTrusted = true;
426
+ }
427
+ }
428
+ flush();
429
+ return trustedKeys;
430
+ }
431
+
432
+ function listHooksWithTrust(parsed, eventName, eventKey, hooksPath, trustedStateKeys) {
433
+ const groups = parsed.hooks?.[eventName] ?? [];
434
+ return groups.flatMap((group, groupIndex) =>
435
+ (group.hooks ?? []).map((hook, hookIndex) => {
436
+ const trustKey = `${hooksPath}:${eventKey}:${groupIndex}:${hookIndex}`;
437
+ return {
438
+ ...hook,
439
+ throughlineDoctorTrustKey: trustKey,
440
+ throughlineDoctorTrusted: trustedStateKeys.has(trustKey),
441
+ };
442
+ }),
443
+ );
444
+ }
445
+
446
+ function summarizeHookTrust(hooks, { hooksFeatureEnabled, codexHooksFeatureEnabled } = {}) {
447
+ if (hooks.length === 0) {
448
+ return {
449
+ status: 'no managed hooks',
450
+ trustedCount: 0,
451
+ totalCount: 0,
452
+ };
453
+ }
454
+ const trustedCount = hooks.filter(h => h.throughlineDoctorTrusted).length;
455
+ if (trustedCount === hooks.length) {
456
+ return {
457
+ status: 'trusted',
458
+ trustedCount,
459
+ totalCount: hooks.length,
460
+ };
461
+ }
462
+ if (hooksFeatureEnabled) {
463
+ return {
464
+ status: `${trustedCount}/${hooks.length} trusted - accept hooks in Codex menu`,
465
+ trustedCount,
466
+ totalCount: hooks.length,
467
+ };
468
+ }
469
+ if (codexHooksFeatureEnabled) {
470
+ return {
471
+ status: 'not recorded (legacy codex_hooks)',
472
+ trustedCount,
473
+ totalCount: hooks.length,
474
+ };
475
+ }
476
+ return {
477
+ status: 'not trusted',
478
+ trustedCount,
479
+ totalCount: hooks.length,
480
+ };
481
+ }
482
+
400
483
  function readCodexHookDiagnosis(codexHome) {
401
484
  const hooksPath = join(codexHome, 'hooks.json');
402
485
  const configPath = join(codexHome, 'config.toml');
@@ -413,6 +496,12 @@ function readCodexHookDiagnosis(codexHome) {
413
496
  featureEnabled: false,
414
497
  codexHooksFeatureEnabled: false,
415
498
  hooksFeatureEnabled: false,
499
+ trustedStateKeys: new Set(),
500
+ managedHookTrust: {
501
+ status: 'no managed hooks',
502
+ trustedCount: 0,
503
+ totalCount: 0,
504
+ },
416
505
  managedPromptHooks: [],
417
506
  legacyManagedPromptHooks: [],
418
507
  managedPostToolUseHooks: [],
@@ -427,6 +516,7 @@ function readCodexHookDiagnosis(codexHome) {
427
516
  out.codexHooksFeatureEnabled = /^\s*codex_hooks\s*=\s*true\s*$/m.test(config);
428
517
  out.hooksFeatureEnabled = /^\s*hooks\s*=\s*true\s*$/m.test(config);
429
518
  out.featureEnabled = out.codexHooksFeatureEnabled || out.hooksFeatureEnabled;
519
+ out.trustedStateKeys = parseCodexTrustedHookState(config);
430
520
  } catch {
431
521
  out.featureEnabled = false;
432
522
  }
@@ -441,9 +531,21 @@ function readCodexHookDiagnosis(codexHome) {
441
531
  }
442
532
 
443
533
  out.hooksReadable = true;
444
- const promptHooks = (parsed.hooks?.UserPromptSubmit ?? []).flatMap(group => group.hooks ?? []);
445
- const postToolUseHooks = (parsed.hooks?.PostToolUse ?? []).flatMap(group => group.hooks ?? []);
446
- const stopHooks = (parsed.hooks?.Stop ?? []).flatMap(group => group.hooks ?? []);
534
+ const promptHooks = listHooksWithTrust(
535
+ parsed,
536
+ 'UserPromptSubmit',
537
+ 'user_prompt_submit',
538
+ hooksPath,
539
+ out.trustedStateKeys,
540
+ );
541
+ const postToolUseHooks = listHooksWithTrust(
542
+ parsed,
543
+ 'PostToolUse',
544
+ 'post_tool_use',
545
+ hooksPath,
546
+ out.trustedStateKeys,
547
+ );
548
+ const stopHooks = listHooksWithTrust(parsed, 'Stop', 'stop', hooksPath, out.trustedStateKeys);
447
549
  out.managedPromptHooks = promptHooks.filter(h => isThroughlineCodexHookCommand(h.command));
448
550
  out.legacyManagedPromptHooks = out.managedPromptHooks.filter(h => h.command !== expectedPromptCommand);
449
551
  out.managedPostToolUseHooks = postToolUseHooks.filter(h => isThroughlineCodexPostToolUseCommand(h.command));
@@ -452,6 +554,14 @@ function readCodexHookDiagnosis(codexHome) {
452
554
  );
453
555
  out.managedStopHooks = stopHooks.filter(h => isThroughlineCodexStopCommand(h.command));
454
556
  out.legacyManagedStopHooks = out.managedStopHooks.filter(h => h.command !== expectedStopCommand);
557
+ out.managedHookTrust = summarizeHookTrust(
558
+ [
559
+ ...out.managedPromptHooks,
560
+ ...out.managedPostToolUseHooks,
561
+ ...out.managedStopHooks,
562
+ ],
563
+ out,
564
+ );
455
565
  return out;
456
566
  }
457
567
 
@@ -484,6 +594,7 @@ function runCodexDiagnosis({
484
594
  console.log(` project: ${cwd}`);
485
595
  console.log(` CODEX_HOME: ${codexHome}`);
486
596
  console.log(` Codex hooks feature: ${hookDiagnosis.featureEnabled ? 'enabled' : 'not enabled'}`);
597
+ console.log(` Codex hook trust: ${hookDiagnosis.managedHookTrust.status}`);
487
598
  console.log(` Codex UserPrompt hook: ${
488
599
  hookDiagnosis.managedPromptHooks.length === 0
489
600
  ? 'not registered'
@@ -496,6 +607,7 @@ function runCodexDiagnosis({
496
607
  console.log(` command: ${h.command}`);
497
608
  console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
498
609
  console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
610
+ console.log(` trusted: ${h.throughlineDoctorTrusted ? 'yes' : 'no'}`);
499
611
  }
500
612
  console.log(` Codex PostTool hook: ${
501
613
  hookDiagnosis.managedPostToolUseHooks.length === 0
@@ -509,6 +621,7 @@ function runCodexDiagnosis({
509
621
  console.log(` command: ${h.command}`);
510
622
  console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
511
623
  console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
624
+ console.log(` trusted: ${h.throughlineDoctorTrusted ? 'yes' : 'no'}`);
512
625
  }
513
626
  console.log(` Codex Stop hook: ${
514
627
  hookDiagnosis.managedStopHooks.length === 0
@@ -522,6 +635,7 @@ function runCodexDiagnosis({
522
635
  console.log(` command: ${h.command}`);
523
636
  console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
524
637
  console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
638
+ console.log(` trusted: ${h.throughlineDoctorTrusted ? 'yes' : 'no'}`);
525
639
  }
526
640
  console.log(` VSCode monitor task: ${monitorTaskDiagnosis.status}`);
527
641
  if (monitorTaskDiagnosis.path) {
@@ -381,17 +381,41 @@ test('readCodexHookDiagnosis detects Codex prompt and Stop hooks', () => {
381
381
  2,
382
382
  ) + '\n',
383
383
  );
384
- writeFileSync(join(codexHome, 'config.toml'), '[features]\ncodex_hooks = true\nhooks = true\n');
384
+ const hooksPath = join(codexHome, 'hooks.json');
385
+ writeFileSync(
386
+ join(codexHome, 'config.toml'),
387
+ [
388
+ '[features]',
389
+ 'codex_hooks = true',
390
+ 'hooks = true',
391
+ '',
392
+ `[hooks.state."${hooksPath}:user_prompt_submit:0:0"]`,
393
+ 'trusted_hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"',
394
+ '',
395
+ `[hooks.state."${hooksPath}:post_tool_use:0:0"]`,
396
+ 'trusted_hash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"',
397
+ '',
398
+ `[hooks.state."${hooksPath}:stop:0:0"]`,
399
+ 'trusted_hash = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"',
400
+ '',
401
+ ].join('\n'),
402
+ );
385
403
 
386
404
  const diagnosis = readCodexHookDiagnosis(codexHome);
387
405
  assert.equal(diagnosis.featureEnabled, true);
388
406
  assert.equal(diagnosis.codexHooksFeatureEnabled, true);
389
407
  assert.equal(diagnosis.hooksFeatureEnabled, true);
408
+ assert.equal(diagnosis.managedHookTrust.status, 'trusted');
409
+ assert.equal(diagnosis.managedHookTrust.trustedCount, 3);
410
+ assert.equal(diagnosis.managedHookTrust.totalCount, 3);
390
411
  assert.equal(diagnosis.managedPromptHooks.length, 1);
412
+ assert.equal(diagnosis.managedPromptHooks[0].throughlineDoctorTrusted, true);
391
413
  assert.equal(diagnosis.legacyManagedPromptHooks.length, 1);
392
414
  assert.equal(diagnosis.managedPostToolUseHooks.length, 1);
415
+ assert.equal(diagnosis.managedPostToolUseHooks[0].throughlineDoctorTrusted, true);
393
416
  assert.equal(diagnosis.legacyManagedPostToolUseHooks.length, 1);
394
417
  assert.equal(diagnosis.managedStopHooks.length, 1);
418
+ assert.equal(diagnosis.managedStopHooks[0].throughlineDoctorTrusted, true);
395
419
  assert.equal(diagnosis.legacyManagedStopHooks.length, 1);
396
420
  } finally {
397
421
  rmSync(codexHome, { recursive: true, force: true });
@@ -490,8 +490,8 @@ export async function run(args = []) {
490
490
  console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
491
491
  console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
492
492
  if (codex) {
493
- console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (80% 到達時に current session へ $throughline 指示を注入)`);
494
- console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (tool loop 中も 80% 到達時に $throughline 指示を注入)`);
493
+ console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (75% 到達時に current session へ $throughline 指示を注入)`);
494
+ console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (tool loop 中も 75% 到達時に $throughline 指示を注入)`);
495
495
  console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
496
496
  }
497
497
  console.log('');
package/src/cli/trim.mjs CHANGED
@@ -366,15 +366,6 @@ async function runPreflight(parsed, plan) {
366
366
  expectedTurns: expectedCodexAppServerTurns(plan),
367
367
  command,
368
368
  });
369
- const turnCountStatus = preflight.turnCountCheck?.status;
370
- if (turnCountStatus === 'mismatch' || turnCountStatus === 'unknown') {
371
- return {
372
- status: 'preflight-refused',
373
- reason: preflight.turnCountCheck.reason,
374
- plan,
375
- preflight,
376
- };
377
- }
378
369
 
379
370
  return {
380
371
  status: 'preflight-ready',
@@ -209,6 +209,48 @@ export function buildThreadRollbackRequest({ id, threadId, numTurns }) {
209
209
  };
210
210
  }
211
211
 
212
+ export function resolveRollbackTurnsForAppServer({
213
+ plannedRollbackTurns,
214
+ expectedTurns = null,
215
+ readTurns = null,
216
+ resumedTurns = null,
217
+ } = {}) {
218
+ if (!Number.isInteger(plannedRollbackTurns) || plannedRollbackTurns < 1) {
219
+ throw new Error('resolveRollbackTurnsForAppServer: plannedRollbackTurns must be an integer >= 1');
220
+ }
221
+ assertOptionalTurnCount(expectedTurns, 'resolveRollbackTurnsForAppServer: expectedTurns');
222
+
223
+ const result = {
224
+ plannedRollbackTurns,
225
+ requestedRollbackTurns: plannedRollbackTurns,
226
+ adjustment: 0,
227
+ basis: 'planned',
228
+ reason: 'using_planned_rollback_turns',
229
+ };
230
+
231
+ if (
232
+ Number.isInteger(expectedTurns) &&
233
+ Number.isInteger(readTurns) &&
234
+ Number.isInteger(resumedTurns) &&
235
+ readTurns === resumedTurns &&
236
+ readTurns !== expectedTurns
237
+ ) {
238
+ const adjustment = readTurns - expectedTurns;
239
+ const adjustedTurns = plannedRollbackTurns + adjustment;
240
+ if (adjustedTurns >= 1) {
241
+ return {
242
+ plannedRollbackTurns,
243
+ requestedRollbackTurns: Math.min(adjustedTurns, readTurns),
244
+ adjustment,
245
+ basis: 'app_server_turn_count',
246
+ reason: 'adjusted_by_app_server_turn_delta',
247
+ };
248
+ }
249
+ }
250
+
251
+ return result;
252
+ }
253
+
212
254
  export function buildThreadInjectItemsRequest({ id, threadId, items }) {
213
255
  assertRequestId(id, 'buildThreadInjectItemsRequest');
214
256
  assertNonEmptyString(threadId, 'buildThreadInjectItemsRequest: threadId');
@@ -313,6 +355,12 @@ export async function runCodexTrimPreflight({
313
355
  );
314
356
  const readTurns = countTurns(beforeRead);
315
357
  const resumedTurns = countTurns(resumed);
358
+ const rollbackResolution = resolveRollbackTurnsForAppServer({
359
+ plannedRollbackTurns: rollbackTurns,
360
+ expectedTurns,
361
+ readTurns,
362
+ resumedTurns,
363
+ });
316
364
 
317
365
  return {
318
366
  status: 'preflight-ready',
@@ -321,6 +369,8 @@ export async function runCodexTrimPreflight({
321
369
  injectSent: false,
322
370
  readTurns,
323
371
  resumedTurns,
372
+ rollbackRequestedTurns: rollbackResolution.requestedRollbackTurns,
373
+ rollbackResolution,
324
374
  turnCountCheck: compareTurnCounts({
325
375
  expectedTurns,
326
376
  readTurns,
@@ -329,7 +379,7 @@ export async function runCodexTrimPreflight({
329
379
  rollbackRequestPreview: buildThreadRollbackRequest({
330
380
  id: 'rollback-preview',
331
381
  threadId,
332
- numTurns: rollbackTurns,
382
+ numTurns: rollbackResolution.requestedRollbackTurns,
333
383
  }),
334
384
  notifications: [...new Set(client.notifications)],
335
385
  stderr: client.stderr,
@@ -964,27 +1014,17 @@ export async function runCodexTrimExecution({
964
1014
  readTurns,
965
1015
  resumedTurns,
966
1016
  });
967
- if (turnCountCheck.status === 'mismatch' || turnCountCheck.status === 'unknown') {
968
- return {
969
- status: 'refused',
970
- reason: 'codex_rollout_app_server_turn_mismatch',
971
- threadId,
972
- rollbackSent: false,
973
- injectSent: false,
974
- injectedItems: 0,
975
- readTurns,
976
- resumedTurns,
977
- rollbackRequestedTurns: rollbackTurns,
978
- turnCountCheck,
979
- notifications: [...new Set(client.notifications)],
980
- stderr: client.stderr,
981
- };
982
- }
1017
+ const rollbackResolution = resolveRollbackTurnsForAppServer({
1018
+ plannedRollbackTurns: rollbackTurns,
1019
+ expectedTurns,
1020
+ readTurns,
1021
+ resumedTurns,
1022
+ });
983
1023
  const rollback = await client.request(
984
1024
  buildThreadRollbackRequest({
985
1025
  id: randomUUID(),
986
1026
  threadId,
987
- numTurns: rollbackTurns,
1027
+ numTurns: rollbackResolution.requestedRollbackTurns,
988
1028
  }),
989
1029
  );
990
1030
  const inject = await client.request(
@@ -1016,7 +1056,8 @@ export async function runCodexTrimExecution({
1016
1056
  injectedItems: 1,
1017
1057
  readTurns,
1018
1058
  resumedTurns,
1019
- rollbackRequestedTurns: rollbackTurns,
1059
+ rollbackRequestedTurns: rollbackResolution.requestedRollbackTurns,
1060
+ rollbackResolution,
1020
1061
  rollbackResultTurns,
1021
1062
  injectResultTurns,
1022
1063
  afterTurns: postInjectRead.turns,
@@ -19,6 +19,7 @@ import {
19
19
  compareTurnCounts,
20
20
  encodeAppServerMessage,
21
21
  parseAppServerLine,
22
+ resolveRollbackTurnsForAppServer,
22
23
  runCodexModelVisibilitySmoke,
23
24
  runCodexRollbackModelVisiblePrepare,
24
25
  runCodexRollbackModelVisibleVerify,
@@ -203,6 +204,50 @@ test('compareTurnCounts: rejects invalid expected turn count', () => {
203
204
  );
204
205
  });
205
206
 
207
+ test('resolveRollbackTurnsForAppServer adjusts planned rollback by app-server turn delta', () => {
208
+ assert.deepEqual(
209
+ resolveRollbackTurnsForAppServer({
210
+ plannedRollbackTurns: 6,
211
+ expectedTurns: 6,
212
+ readTurns: 7,
213
+ resumedTurns: 7,
214
+ }),
215
+ {
216
+ plannedRollbackTurns: 6,
217
+ requestedRollbackTurns: 7,
218
+ adjustment: 1,
219
+ basis: 'app_server_turn_count',
220
+ reason: 'adjusted_by_app_server_turn_delta',
221
+ },
222
+ );
223
+
224
+ assert.deepEqual(
225
+ resolveRollbackTurnsForAppServer({
226
+ plannedRollbackTurns: 2,
227
+ expectedTurns: 22,
228
+ readTurns: 21,
229
+ resumedTurns: 21,
230
+ }),
231
+ {
232
+ plannedRollbackTurns: 2,
233
+ requestedRollbackTurns: 1,
234
+ adjustment: -1,
235
+ basis: 'app_server_turn_count',
236
+ reason: 'adjusted_by_app_server_turn_delta',
237
+ },
238
+ );
239
+
240
+ assert.equal(
241
+ resolveRollbackTurnsForAppServer({
242
+ plannedRollbackTurns: 6,
243
+ expectedTurns: 6,
244
+ readTurns: 7,
245
+ resumedTurns: 8,
246
+ }).requestedRollbackTurns,
247
+ 6,
248
+ );
249
+ });
250
+
206
251
  test('summarizeAppServerStderr: compacts repeated unknown-turn item warnings', () => {
207
252
  const stderr = [
208
253
  '2026-05-06T00:00:00Z WARN codex_app_server_protocol::protocol::thread_history: dropping turn-scoped item for unknown turn id `turn-a` item_id="call_1"',
@@ -2,7 +2,7 @@ import { runCodexTrimExecution } from './codex-app-server.mjs';
2
2
  import { buildCodexRolloutTrimSource } from './codex-rollout-memory.mjs';
3
3
  import { buildTrimPlan } from './trim-model.mjs';
4
4
 
5
- export const CODEX_AUTO_REFRESH_THRESHOLD = 0.8;
5
+ export const CODEX_AUTO_REFRESH_THRESHOLD = 0.75;
6
6
 
7
7
  export function evaluateCodexAutoRefreshUsage(usage, { threshold = CODEX_AUTO_REFRESH_THRESHOLD } = {}) {
8
8
  if (!usage) {
@@ -7,10 +7,10 @@ import {
7
7
  runCodexAutoRefresh,
8
8
  } from './codex-auto-refresh.mjs';
9
9
 
10
- test('evaluateCodexAutoRefreshUsage: default threshold is 80%', () => {
11
- assert.equal(CODEX_AUTO_REFRESH_THRESHOLD, 0.8);
10
+ test('evaluateCodexAutoRefreshUsage: default threshold is 75%', () => {
11
+ assert.equal(CODEX_AUTO_REFRESH_THRESHOLD, 0.75);
12
12
  const below = evaluateCodexAutoRefreshUsage({
13
- tokens: 206_719,
13
+ tokens: 193_799,
14
14
  contextWindowSize: 258_400,
15
15
  estimated: false,
16
16
  contextWindowEstimated: false,
@@ -19,7 +19,7 @@ test('evaluateCodexAutoRefreshUsage: default threshold is 80%', () => {
19
19
  assert.equal(below.reason, 'below_threshold');
20
20
 
21
21
  const atThreshold = evaluateCodexAutoRefreshUsage({
22
- tokens: 206_720,
22
+ tokens: 193_800,
23
23
  contextWindowSize: 258_400,
24
24
  estimated: false,
25
25
  contextWindowEstimated: false,
@@ -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
  });