throughline 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -634,32 +634,95 @@ test('INJECTION_BUDGET_CHARS stays under the measured 10k persisted-output limit
634
634
  assert.ok(INJECTION_BUDGET_CHARS <= 9_501, '実測 inline 通過上限 9,501 字以下であること');
635
635
  });
636
636
 
637
- test('budgeted: under budget output matches the unbudgeted renderer, nothing dropped', () => {
637
+ test('budgeted: under budget keeps all L2 turns, no L1 section, guidance always present', () => {
638
638
  const db = makeDb();
639
639
  insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'user', text: 'short question', createdAt: 1000 });
640
640
  insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'assistant', text: 'short answer', createdAt: 1100 });
641
641
 
642
- const full = buildResumeContext(db, { sessionId: 'new', isInheritance: true });
643
642
  const budgeted = buildBudgetedResumeContext(db, { sessionId: 'new', isInheritance: true });
644
643
 
645
644
  assert.ok(budgeted);
646
- assert.equal(budgeted.text, full);
647
- assert.equal(budgeted.droppedL1Rows, 0);
648
- assert.equal(budgeted.droppedL2Rows, 0);
645
+ assert.equal(budgeted.injectedL2Turns, 1);
646
+ assert.equal(budgeted.remainingL2Turns, 0);
647
+ assert.equal(budgeted.olderTurns, 0);
649
648
  assert.equal(budgeted.truncatedNewestL2, false);
650
649
  assert.ok(budgeted.totalChars <= INJECTION_BUDGET_CHARS);
650
+ // 案内セクションは無条件表示 (データ無し側も「なし」と明示)
651
+ assert.ok(budgeted.text.includes('### さらに前の記憶(必要な時だけ取得)'));
652
+ assert.match(budgeted.text, /これより前の続き: なし/);
653
+ assert.match(budgeted.text, /それ以前のターン: なし/);
654
+ // L1 セクションは注入しない
655
+ assert.ok(!budgeted.text.includes('### それ以前の要約 (L1)'));
651
656
  });
652
657
 
653
- test('budgeted: drops oldest L2 rows first, keeps newest, and stays within maxChars', () => {
658
+ test('budgeted: packs whole turns newest-first, bakes --before/--last/--session into guidance', () => {
654
659
  const db = makeDb();
655
- // 各 ~800 字 × 10 = 本文だけで ~8,000 字 → maxChars 4000 で古い行が落ちる
660
+ // 各 ~800 字 × 10 ターン = 本文だけで ~8,000 字 → maxChars 5000 で古いターンが落ちる
656
661
  for (let turn = 1; turn <= 10; turn += 1) {
662
+ insertBody(db, {
663
+ session: 'new',
664
+ origin: 'old',
665
+ turn,
666
+ role: 'user',
667
+ text: `q-${String(turn).padStart(2, '0')} question`,
668
+ createdAt: 1000 + turn * 100,
669
+ });
657
670
  insertBody(db, {
658
671
  session: 'new',
659
672
  origin: 'old',
660
673
  turn,
661
674
  role: 'assistant',
662
675
  text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(800),
676
+ createdAt: 1050 + turn * 100,
677
+ });
678
+ }
679
+
680
+ const budgeted = buildBudgetedResumeContext(db, {
681
+ sessionId: 'new',
682
+ isInheritance: true,
683
+ maxChars: 5000,
684
+ });
685
+
686
+ assert.ok(budgeted);
687
+ assert.ok(budgeted.totalChars <= 5000, `totalChars ${budgeted.totalChars} must fit budget`);
688
+ assert.ok(budgeted.injectedL2Turns > 0, 'newest turns must be injected');
689
+ assert.ok(budgeted.remainingL2Turns > 0, 'some old turns must be left for pull');
690
+ assert.equal(budgeted.injectedL2Turns + budgeted.remainingL2Turns, 10);
691
+ assert.ok(budgeted.text.includes('turn-10'), 'newest L2 turn must survive');
692
+ assert.ok(!budgeted.text.includes('turn-01 '), 'oldest L2 turn must be left for pull');
693
+
694
+ // ターン原子性: 注入されたターンは user 行と assistant 行が揃っている
695
+ const oldestInjectedTurn = 10 - budgeted.injectedL2Turns + 1;
696
+ const tag = String(oldestInjectedTurn).padStart(2, '0');
697
+ assert.ok(budgeted.text.includes(`q-${tag} question`), 'user row of injected turn must be present');
698
+ assert.ok(budgeted.text.includes(`turn-${tag} `), 'assistant row of injected turn must be present');
699
+
700
+ // 案内: --before は実注入最古ターンの min(created_at) の ISO ms、--last は残り件数
701
+ const boundaryMs = 1000 + oldestInjectedTurn * 100; // 最古注入ターンの user 行時刻
702
+ const iso = new Date(boundaryMs).toISOString().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
703
+ assert.match(
704
+ budgeted.text,
705
+ new RegExp(
706
+ `これより前の続き${budgeted.remainingL2Turns}ターン .*` +
707
+ '`throughline recall --l2 --session new ' +
708
+ `--before ${iso} --last ${budgeted.remainingL2Turns}\``,
709
+ ),
710
+ 'guidance must bake session, ISO ms boundary, and remaining count',
711
+ );
712
+ // 全 10 ターンとも窓内なので、窓より古い側は正直に「なし」と明示される
713
+ assert.match(budgeted.text, /それ以前のターン: なし/);
714
+ });
715
+
716
+ test('budgeted: --l1 guidance bakes the same boundary and the --l2 skip count', () => {
717
+ const db = makeDb();
718
+ // 窓 (20) + 窓外 5 ターン。予算を絞って窓内にも pull 残りを作る
719
+ for (let turn = 1; turn <= 25; turn += 1) {
720
+ insertBody(db, {
721
+ session: 'new',
722
+ origin: 'old',
723
+ turn,
724
+ role: 'assistant',
725
+ text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(700),
663
726
  createdAt: 1000 + turn * 100,
664
727
  });
665
728
  }
@@ -671,22 +734,25 @@ test('budgeted: drops oldest L2 rows first, keeps newest, and stays within maxCh
671
734
  });
672
735
 
673
736
  assert.ok(budgeted);
674
- assert.ok(budgeted.totalChars <= 4000, `totalChars ${budgeted.totalChars} must fit budget`);
675
- assert.ok(budgeted.droppedL2Rows > 0, 'some old L2 rows must be dropped');
676
- assert.ok(budgeted.text.includes('turn-10'), 'newest L2 row must survive');
677
- assert.ok(!budgeted.text.includes('turn-01 '), 'oldest L2 row must be dropped');
678
- assert.match(budgeted.text, /古い L2 \d+ 行省略/, 'omission must be announced, not silent');
679
- // 「削るときは取り出すための参照を必ず残す」: 落とした行の [時刻 role] リストが
680
- // 告知に載っていること (窓内の行にはまだ L1 が無く、時刻が無いと detail で引けない)
737
+ assert.ok(budgeted.remainingL2Turns > 0);
738
+ assert.equal(budgeted.olderTurns, 5);
739
+ const oldestInjectedTurn = 25 - budgeted.injectedL2Turns + 1;
740
+ const boundaryMs = 1000 + oldestInjectedTurn * 100;
741
+ const iso = new Date(boundaryMs).toISOString().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
742
+ assert.match(
743
+ budgeted.text,
744
+ new RegExp(`--l2 --session new --before ${iso} --last ${budgeted.remainingL2Turns}`),
745
+ );
681
746
  assert.match(
682
747
  budgeted.text,
683
- /省略分 \(新しい順\): (\[\d{2}:\d{2}:\d{2} assistant\][ ]?)+/,
684
- 'dropped rows must be listed with [time role] refs for throughline detail',
748
+ new RegExp(`--l1 --session new --before ${iso} --skip ${budgeted.remainingL2Turns}`),
749
+ '--l1 guidance must bake the same boundary and skip count',
685
750
  );
686
751
  });
687
752
 
688
- test('budgeted: very many dropped rows fold into ほかN行 keeping the note bounded', () => {
753
+ test('budgeted: window turns beyond budget never fall into a blank band (guidance covers them)', () => {
689
754
  const db = makeDb();
755
+ // 40 ターン: 窓は最新 20 ターン、そこからさらに予算落ちが出る構成
690
756
  for (let turn = 1; turn <= 40; turn += 1) {
691
757
  insertBody(db, {
692
758
  session: 'new',
@@ -706,7 +772,35 @@ test('budgeted: very many dropped rows fold into ほかN行 keeping the note bou
706
772
 
707
773
  assert.ok(budgeted);
708
774
  assert.ok(budgeted.totalChars <= 4000);
709
- assert.match(budgeted.text, /ほか\d+行/, 'overflow refs must fold into a count');
775
+ // 20 ターンのうち注入できなかった分は全部 recall --l2 の担当として案内される
776
+ assert.equal(budgeted.injectedL2Turns + budgeted.remainingL2Turns, 20);
777
+ // 窓より古い 20 ターンは --l1 側 (未要約でも件数として見える)
778
+ assert.equal(budgeted.olderTurns, 20);
779
+ assert.match(budgeted.text, /それ以前の全20ターン(要約済み 0 \/ 未要約 20)/);
780
+ });
781
+
782
+ test('budgeted: older summarized/unsummarized counts are honest in the guidance', () => {
783
+ const db = makeDb();
784
+ for (let turn = 1; turn <= 25; turn += 1) {
785
+ insertBody(db, {
786
+ session: 'new',
787
+ origin: 'old',
788
+ turn,
789
+ role: 'assistant',
790
+ text: `turn-${turn}`,
791
+ createdAt: 1000 + turn * 1000,
792
+ });
793
+ }
794
+ // 窓 (最新 20 ターン = turn 6..25) より古い turn 1..5 のうち 2 件だけ要約済み
795
+ insertSkeleton(db, { session: 'new', origin: 'old', turn: 1, role: 'assistant', summary: 's1', createdAt: 90_000 });
796
+ insertSkeleton(db, { session: 'new', origin: 'old', turn: 2, role: 'assistant', summary: 's2', createdAt: 90_001 });
797
+
798
+ const budgeted = buildBudgetedResumeContext(db, { sessionId: 'new', isInheritance: true });
799
+
800
+ assert.ok(budgeted);
801
+ assert.equal(budgeted.olderTurns, 5);
802
+ assert.equal(budgeted.olderSummarized, 2);
803
+ assert.match(budgeted.text, /それ以前の全5ターン(要約済み 2 \/ 未要約 3)/);
710
804
  });
711
805
 
712
806
  test('budgeted: a single oversized newest L2 row is truncated with a detail pointer', () => {
@@ -25,7 +25,8 @@ export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unav
25
25
  const DEFAULT_SNAPSHOT_LIMIT = 256;
26
26
  const BEST_EFFORT_TIMEOUT_MS = 750;
27
27
  const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
28
- const WINDOWS_ACL_TIMEOUT_MS = 3_000;
28
+ // CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
29
+ const WINDOWS_ACL_TIMEOUT_MS = 15_000;
29
30
  const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
30
31
  const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
31
32
 
@@ -101,7 +101,7 @@ test('runtime error store: one Windows mutation spends ACL processes only on dis
101
101
 
102
102
  assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
103
103
  assert.equal(calls.length, 4, 'directory apply, existing lock/store verify, and replacement store apply are distinct');
104
- assert.ok(calls.every((call) => call.options.timeout === 3_000));
104
+ assert.ok(calls.every((call) => call.options.timeout === 15_000));
105
105
  });
106
106
 
107
107
  test('runtime error store: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
@@ -157,9 +157,10 @@ test('publishCapturedClaudeCompletionReceipt: L2 capture済みpairをL1/L3より
157
157
  assert.deepEqual(second, first, 'Stop retry must return the original receipt');
158
158
  assert.equal(first.completed_at, 1234);
159
159
  if (process.platform !== 'win32') {
160
- // POSIX permission 契約。Windows stat mode は 0o666 系で chmod 契約を
161
- // 表現できない (receipt store Windows private 化は runtime-error-store の
162
- // ACL 方式に倣う Observer 側の未着手課題)。
160
+ // POSIX permission 契約 (0700/0600) の表現形式検査。Windows の秘匿は stat mode
161
+ // ではなく owner-only ACL で担保され、writeCompletedTurnReceipt 内部の
162
+ // applyAndVerifyWindowsAcl が書き込みのたびに apply + verify して失敗を throw
163
+ // するため、win32 ではこのテストが receipt を作れた時点で ACL 検証済み。
163
164
  assert.equal(statSync(join(root, 'state')).mode & 0o777, 0o700);
164
165
  assert.equal(statSync(storePath).mode & 0o777, 0o600);
165
166
  }
@@ -17,7 +17,7 @@ if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO
17
17
  '-NoProfile', '-NonInteractive', '-Command', script,
18
18
  ], {
19
19
  encoding: 'utf8',
20
- timeout: 3_000,
20
+ timeout: 15_000,
21
21
  windowsHide: true,
22
22
  env: {
23
23
  ...process.env,
@@ -46,7 +46,7 @@ if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow
46
46
  '-NoProfile', '-NonInteractive', '-Command', script,
47
47
  ], {
48
48
  encoding: 'utf8',
49
- timeout: 3_000,
49
+ timeout: 15_000,
50
50
  windowsHide: true,
51
51
  env: {
52
52
  ...process.env,