throughline 0.16.25 → 0.16.26
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 +33 -1
- package/docs/adr/0021-grok-host-capture.md +5 -0
- package/docs/adr/0026-stop-flush-barrier-next-user.md +5 -0
- package/docs/adr/0055-stop-flush-barrier-skips-queued-inputs.md +58 -0
- package/docs/adr/0056-grok-rewritten-chat-history-keeps-captured-turns-apart.md +79 -0
- package/package.json +1 -1
- package/src/hosts/claude.mjs +2 -0
- package/src/hosts/codex.mjs +1 -0
- package/src/hosts/cursor.mjs +2 -0
- package/src/hosts/grok.mjs +2 -0
- package/src/hosts/identity.test.mjs +6 -0
- package/src/transcript-reader-grok.test.mjs +80 -1
- package/src/transcript-reader.mjs +42 -8
- package/src/turn-backfill.mjs +96 -0
- package/src/turn-backfill.test.mjs +154 -1
- package/src/turn-processor.test.mjs +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,37 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.26] — 2026-10-12
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **Grokが会話を自動圧縮した後、圧縮をまたいだターンが保存されず、その会話のStopが毎回失敗していた**([ADR 0056](docs/adr/0056-grok-rewritten-chat-history-keeps-captured-turns-apart.md))。Linuxの実機(BellTeamコンテナ、Grok 1.0.46)で2026-10-12に起きた。失敗の文面は`completed pair was not captured before receipt publication`。
|
|
18
|
+
- Grokは自動圧縮で`chat_history.jsonl`を書き直す。Throughlineはターンの番号を記録の中の位置で振っているので、書き直した後の位置が、保存済みの別のターンの番号と重なった。重なった群を「保存済み」として飛ばし、完了受領を書く所で失敗していた。
|
|
19
|
+
- 代表の断片の位置が重なる形では、失敗も出ずに、圧縮をまたいだターンが落ちていた(2026-09-30の別の会話)。
|
|
20
|
+
- 直した後は、Grokの記録では、同じ位置に同じ本文のassistant行がある時だけ保存済みとする。書き直しの後のターンは、世代を分けた番号(世代×1,000,000+位置)で保存する。すでに番号が重なっている会話は、この版の最初のStopで、落ちていたターンが入る。
|
|
21
|
+
- **Grokが自分で書いたuser行(`system_reminder`・`compaction_meta`)を、人の発言として保存していた**(同じADR)。依頼の直後に注意書きが来ると、そのターンのuserの本文が注意書きになっていた。ターンの途中に来ると、1つのターンが2つに割れていた。直した後は、ターンの始まりにも本文にもしない。Throughlineが引き継ぎで注入する行も、この印で書いている。
|
|
22
|
+
- **Claude Codeで、ターンが終わった直後に待っていた入力が2通以上届くと、Stopが`Claude Stop transcript completion was not visible before deadline`で失敗していた**([ADR 0055](docs/adr/0055-stop-flush-barrier-skips-queued-inputs.md))。Linuxの実機(BellTeamコンテナ)で2026-10-12に起きた。
|
|
23
|
+
- 完了したターンを探す処理が、直後の入力を1通までしか遡らなかった(ADR 0026)。2通が続けて書かれると、完了したターンは2つ前の群になる。
|
|
24
|
+
- 保存の欠けは無かった。次のStopが、そのターンをまとめて取り込む。受領がそのぶん遅れ、失敗が1回数えられていた。
|
|
25
|
+
- 直した後は、assistantの本文が無い群(続けて届いた入力)を飛ばして、完了したターンを探す。採用の条件(未捕捉である事、次のターンが本文を書き始めていない事)は変えていない。
|
|
26
|
+
|
|
27
|
+
### 対応の範囲
|
|
28
|
+
|
|
29
|
+
| | macOS | Linux | Windows |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| Grok(記録の書き直し・合成行) | 直した。単体試験と、実機で本物のhookの命令(`process-turn`)へ合成した記録を通した。本物のGrokの自動圧縮をまたぐ場面は見ていない | 直した。起きた端末(コンテナ)。同じ確認と、コンテナの本物のDBのGrokの会話110件をメモリへ写した空実行(書き直しと判定したのは圧縮の跡がある2件だけ)。本物の自動圧縮をまたぐ場面は、直した版では見ていない | 直した。macOSと同じ確認。本物の自動圧縮をまたぐ場面は見ていない |
|
|
32
|
+
| Claude Code(待っていた入力が2通以上) | 直した。単体試験と、実機で本物のhookの命令へ合成した記録を通した。本物のClaude Codeでこの形が起きた事は無い | 直した。起きた端末(コンテナ)。同じ確認。直した版で本物の形が起きた事は、まだ無い | 直した。macOSと同じ確認 |
|
|
33
|
+
| Codex | 変更なし(Stopは別の入口で、rolloutから取り込む。rolloutは追記だけ) | 同じ | 同じ |
|
|
34
|
+
| Cursor | 変更なし(Stopの待ちを使わない。記録を書き直すかは確かめていない。コンテナの取り込みの記録169会話に、群が減った跡は無い) | 同じ | 同じ |
|
|
35
|
+
|
|
36
|
+
### 確かめていない事
|
|
37
|
+
|
|
38
|
+
- 本物のGrokが自動圧縮をまたぐ場面での、この版の動き。狙って起こせるかを確かめていない。コンテナの、番号が重なっている会話の次のStopで分かる。
|
|
39
|
+
- Grokの巻き戻しが`chat_history.jsonl`をどう書き直すか。実物を見ていない。
|
|
40
|
+
- 圧縮をまたいだターンの、元の依頼の文が`chat_history.jsonl`に残っているか。保存されるuserの本文は、圧縮の後に残った印の無いuser行。
|
|
41
|
+
- 本物のClaude Codeが、待っていた入力を2通続けて書く場面での、この版の動き。
|
|
42
|
+
- Cursorが記録を書き直すか。
|
|
43
|
+
|
|
13
44
|
## [0.16.25] — 2026-10-12
|
|
14
45
|
|
|
15
46
|
### 修正
|
|
@@ -2461,7 +2492,8 @@ two attempts, instrument first instead of patching again.
|
|
|
2461
2492
|
|
|
2462
2493
|
---
|
|
2463
2494
|
|
|
2464
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2495
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.26...HEAD
|
|
2496
|
+
[0.16.26]: https://github.com/kitepon/Throughline/compare/v0.16.25...v0.16.26
|
|
2465
2497
|
[0.16.25]: https://github.com/kitepon/Throughline/compare/v0.16.24...v0.16.25
|
|
2466
2498
|
[0.16.24]: https://github.com/kitepon/Throughline/compare/v0.16.23...v0.16.24
|
|
2467
2499
|
[0.16.23]: https://github.com/kitepon/Throughline/compare/v0.16.22...v0.16.23
|
|
@@ -65,3 +65,8 @@ Grok 1.0.4 バイナリの hook 出力型は `GateHookJson`(PreToolUse deny/up
|
|
|
65
65
|
2026-08-17 実機受入: `grok:01a00ff1-3f97-70e2-ba76-5acd90561a84` の `/tl` が
|
|
66
66
|
`c01a2689-5b4f-4977-97ce-f73fcf317f94` を Dotagents 棚に立て、初手末尾は待機、
|
|
67
67
|
後継は待って止まった。v0.10.0 で公開。
|
|
68
|
+
|
|
69
|
+
## 追記(2026-10-12、ADR 0056)
|
|
70
|
+
|
|
71
|
+
Grok は自動圧縮で `chat_history.jsonl` を書き直す。位置だけで保存済みを決めると、圧縮をまたいだターンが落ちる。
|
|
72
|
+
Grok が自分で書く user 行(`compaction_meta`・`system_reminder`)の扱いと合わせて、ADR 0056 で直した。
|
|
@@ -58,3 +58,8 @@ latest user group は次の turn になっていて、assistant 本文はまだ
|
|
|
58
58
|
|
|
59
59
|
L1 の要約が走る Stop では、L3 の抽出が transcript を読み直すまでに数秒かかる。その間に次の turn が本文を
|
|
60
60
|
書くと、L3 が次の turn の行から取られる可能性がある。コードを読んだ見立てで、再現は確かめていない。
|
|
61
|
+
|
|
62
|
+
## 追記(2026-10-12、ADR 0055)
|
|
63
|
+
|
|
64
|
+
Decision 5(2つ以上前の group は見ない)は、待っていた入力が2通以上続けて届く形を落としていた。ADR 0055 で、
|
|
65
|
+
assistant 断片の無い group を飛ばして探すように変えた。assistant 断片を持つ group を2つ以上遡らない事は変えていない。
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ADR 0055: Stop の直後に待っていた入力が2通以上届いた時、flush barrier は本文の無い group を飛ばして完了した turn を探す
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
ADR 0026 は、Stop の直後に次の user 行(queue から届いた入力)が書かれた時、1つ前の group を完了した turn として
|
|
9
|
+
採用する。「2つ以上前の group は見ない」と決めていた(Decision 5)。
|
|
10
|
+
|
|
11
|
+
2026-10-12 に BellTeam コンテナ(Linux、0.16.5)で、Claude Code の会話の Stop が
|
|
12
|
+
`Claude Stop transcript completion was not visible before deadline` で1回失敗した。transcript の並びは次のとおり
|
|
13
|
+
(本文は読んでいない。行の種類・時刻・長さだけ)。
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
10:22:59.869 queue-operation enqueue
|
|
17
|
+
10:23:30.015 queue-operation enqueue
|
|
18
|
+
10:23:32.062 assistant の最終行(end_turn)
|
|
19
|
+
10:23:32.147 stop_hook_summary
|
|
20
|
+
10:23:32.172 queue-operation dequeue(2件、同じ時刻)
|
|
21
|
+
10:23:32.216 user 行(待っていた入力の1通目)
|
|
22
|
+
10:23:32.217 user 行(待っていた入力の2通目)
|
|
23
|
+
10:23:34.215 HOOK_PROCESS_TURN_FAILED(Stop から 2.0 秒後)
|
|
24
|
+
10:23:39.699 次の turn の assistant 行
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
turn の間に入力が2通たまり、turn が終わった時に2通が続けて user 行になった。latest group は2通目、1つ前の group は
|
|
28
|
+
1通目で、どちらにも assistant 本文が無い。完了した turn は2つ前の group にあるので、marker は一致しない。
|
|
29
|
+
|
|
30
|
+
失敗した turn の L2 と完了受領は、19 秒後の次の Stop(10:23:53.167)の backfill が書いた(`groups` 88 → 90、
|
|
31
|
+
`inserted_turns` 2)。保存の欠けは無い。害は、受領が次の Stop まで遅れる事と、失敗が1回数えられる事。
|
|
32
|
+
|
|
33
|
+
最新の 0.16.25 でも同じ(合成した transcript で、待っていた入力が1通なら採用、2通と3通は失敗)。
|
|
34
|
+
|
|
35
|
+
## Decision
|
|
36
|
+
|
|
37
|
+
1. 1つ前の group を探す時、assistant 断片が1つも無い group(続けて届いた入力)は飛ばす。assistant 断片を持つ
|
|
38
|
+
一番近い group を「1つ前」として扱う。
|
|
39
|
+
2. 採用の条件は ADR 0026 のまま。latest group に assistant 断片が無い、その group の非 junk assistant 本文が marker と
|
|
40
|
+
一致する、その turn が bodies に未捕捉、の全部を満たす時だけ採用する。
|
|
41
|
+
3. assistant 断片を持つ group は飛ばさない。その group より前は見ない。ADR 0026 の Decision 5 は
|
|
42
|
+
「assistant 断片を持つ group を、2つ以上は遡らない」と読み替える。
|
|
43
|
+
4. 根拠は ADR 0026 と同じで、transcript が追記だけである事。後ろに user 行が見えている group は、行が出そろっている。
|
|
44
|
+
|
|
45
|
+
## Consequences
|
|
46
|
+
|
|
47
|
+
- 待っていた入力が何通あっても、完了した turn の L2・完了受領・L3 が同じ Stop で書かれる。
|
|
48
|
+
- 途中で止められて本文を1つも書かなかった turn(user 行だけの group)も、同じ扱いで飛ばす。その手前の turn が
|
|
49
|
+
今回の marker と同文で、まだ捕捉されていない時だけ採用される。捕捉済みなら、今までどおり待って deadline で失敗する。
|
|
50
|
+
- この待ちを使うのは Claude Code の Stop だけ。Grok と Cursor は使わない(`waitsForStopTranscriptFlush: false`)。
|
|
51
|
+
Codex の Stop は別の入口(rollout からの取り込み)で処理する。
|
|
52
|
+
|
|
53
|
+
## 確かめた事・確かめていない事
|
|
54
|
+
|
|
55
|
+
- 確かめた: 単体試験(2通・3通・捕捉済み・次の turn が本文を書き始めた後・本文を持つ group が無い)。
|
|
56
|
+
本物の hook の命令(`process-turn`)へ、合成した transcript を通した(切り離した HOME)。
|
|
57
|
+
- 確かめていない: 本物の Claude Code が、待っていた入力を2通続けて書く場面での、この版の動き。狙って起こすには、
|
|
58
|
+
動いている turn の間に入力を2通ためる必要がある。次に起きた時は、失敗が記録されない事で分かる。
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# ADR 0056: Grok が書き直した chat_history は、保存済みのターンと本文で見分け、世代ごとに番号を分ける
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Throughline は、会話の記録(transcript)の中の位置を `turn_number` にして、発言を bodies へ保存する
|
|
9
|
+
(`backfillBodies`)。記録を読むたびに全部の群を見て、群の断片の位置が bodies にあれば「保存済み」として飛ばす。
|
|
10
|
+
Claude Code と Codex の記録は追記だけなので、位置は変わらない。
|
|
11
|
+
|
|
12
|
+
Grok は、文脈がいっぱいになると会話を自動圧縮し、`chat_history.jsonl` を書き直す。書き直した後のファイルは、
|
|
13
|
+
要約の行(`synthetic_reason: "compaction_meta"`)、印の無い user 行1つ、続きの指示(`compaction_meta`)、
|
|
14
|
+
注意書き(`system_reminder`)、圧縮をまたいだターンの assistant の行、の並びになる。会話の置き場には
|
|
15
|
+
`compaction/segment_NNN.md` と `compaction_checkpoints/` が出来る。位置は 0 から振り直しになる。
|
|
16
|
+
|
|
17
|
+
2026-10-12 に BellTeam コンテナ(Linux、0.16.5、Grok 1.0.46)で、Stop が
|
|
18
|
+
`completed pair was not captured before receipt publication` で2回失敗した(Grok は Stop の hook を2本起こす)。
|
|
19
|
+
|
|
20
|
+
- 会話は毎朝の予定で動く Grok の席。bodies には 6 ターン(位置 8・13・19・23・30・36)が保存済みだった。
|
|
21
|
+
- 08:17:41 に Grok が自動圧縮した(`compaction/` の時刻)。ファイルは 105 行、群は 6 → 1 になった。
|
|
22
|
+
- 08:24:04 の Stop で、その1群の断片は位置 5〜9。位置 8 が保存済みの番号と重なり、群ごと「保存済み」として飛ばした。
|
|
23
|
+
代表の断片は位置 9 で、bodies に無い。完了受領を書く所で、対の行が見つからず失敗した。
|
|
24
|
+
- 圧縮をまたいだターンの依頼と回答は、Throughline の記憶に入らなかった。この会話の後の Stop も、同じ群で毎回失敗する。
|
|
25
|
+
|
|
26
|
+
同じコンテナの別の Grok の会話(2026-09-30、圧縮3回)では、代表の位置が保存済みの番号と重なっていた。この形は
|
|
27
|
+
失敗が出ず、古いターンの受領が出し直されるだけで、圧縮をまたいだターンは黙って落ちていた。その後のターンは、
|
|
28
|
+
古い番号の間に入って保存されていた(位置 14 の後に 12)。
|
|
29
|
+
|
|
30
|
+
コンテナの Grok の記録 101 件のうち、圧縮の跡があるのはこの2件。最新の 0.16.25 でも同じ(合成した記録で再現)。
|
|
31
|
+
|
|
32
|
+
もう1つ、Grok が自分で書く user 行の扱いがあった。`system_reminder` の行は、Throughline が user の発言として
|
|
33
|
+
読んでいた。記録の中の 238 行の前後を種類だけで数えると、依頼(`prompt_index` を持つ行)の直後が 23、道具の結果の
|
|
34
|
+
後で assistant の行が続く(ターンの途中)のが 37、依頼の前が 165、残りの 13 は圧縮の直後(4)と、後ろに何も無い行(9)
|
|
35
|
+
だった。依頼の直後に来ると、そのターンの user の本文が注意書きになる。ターンの途中に来ると、1つのターンが2つの群に
|
|
36
|
+
割れる。終わったターンの後に注意書きだけが来て、依頼なしで次のターンが始まる形は、1つも無かった。
|
|
37
|
+
|
|
38
|
+
## Decision
|
|
39
|
+
|
|
40
|
+
1. **Grok が自分で書いた user 行は、ターンの始まりにも本文にもしない。** 対象は `synthetic_reason` が
|
|
41
|
+
`compaction_meta` と `system_reminder` の行(`message` を持たない Grok の形の行だけ)。位置は進める。
|
|
42
|
+
Claude Code の圧縮の要約行(ADR 0032)と同じ扱い。`task_completed` は host が自分から始めるターンなので、
|
|
43
|
+
今までどおりターンの始まりにする。知らない印の行も今までどおり。
|
|
44
|
+
2. **書き直される記録では、保存済みかを本文でも確かめる。** host の adapter に `rewritesTranscript` を足し、
|
|
45
|
+
Grok だけ true にする。true の host では、群の断片と同じ位置に、同じ本文の assistant 行がある時だけ「保存済み」とする。
|
|
46
|
+
3. **書き直しの後のターンは、世代を分けた番号で保存する。** 世代 g のターンは
|
|
47
|
+
`g × 1,000,000 + 記録の中の位置`。世代 0 は今までの番号と同じ。今の世代は、保存済みの番号の最大から読む
|
|
48
|
+
(`floor(turn_number / 1,000,000)`)。別の表や置き場は持たない。
|
|
49
|
+
4. **書き直されたかは、今の世代の保存済みの行で決める。** 今の世代で保存した assistant 行のうち、記録の同じ位置に
|
|
50
|
+
同じ本文が無い行が1つでもあれば、書き直されたとして次の世代へ進む。保存していない群は、新しい世代の番号で入れる。
|
|
51
|
+
古い世代の行と本文が合う群(巻き戻しで残った前半など)は、保存済みとして飛ばす。
|
|
52
|
+
5. **完了受領は、行が実際にある番号で出す。** 保存済みの群は、合った行の番号を返す。合成行の扱いが変わる前に
|
|
53
|
+
2つの群として保存されていたターンは、後ろの行の番号で出す。
|
|
54
|
+
6. Claude Code・Codex・Cursor は今までどおり、位置だけで決める。
|
|
55
|
+
|
|
56
|
+
## Consequences
|
|
57
|
+
|
|
58
|
+
- Grok の自動圧縮をまたいだターンが保存され、受領も出る。その会話の後の Stop も失敗しない。
|
|
59
|
+
- すでに番号が重なっている会話(上の2件)は、この版の最初の Stop で、落ちていたターンが世代 1 の番号で入る。
|
|
60
|
+
圧縮の後に古い番号の間へ入っていたターンは、そのままの番号で残る(並びは直さない)。
|
|
61
|
+
- 圧縮をまたいだターンの user の本文は、圧縮の後に残った印の無い user 行になる(始まり方は `unknown`)。元の依頼の
|
|
62
|
+
文が `chat_history.jsonl` に残っているかは、確かめていない。
|
|
63
|
+
- 巻き戻しで消されたターンは、bodies に残る。起きた会話として記憶に残す。
|
|
64
|
+
- `turn_number` は 1,000,000 以上になる事がある。番号を記録の位置として使う処理は、Grok には無い(L3 の抽出と
|
|
65
|
+
flush barrier は Claude Code の記録の形だけを読む)。1つの記録に 1,000,000 行以上の本文がある事は想定しない。
|
|
66
|
+
- 保存済みかの判定が assistant の本文の一致に頼る。Grok の assistant の本文の取り出し方を変える時は、保存済みの
|
|
67
|
+
行と合わなくなって、全部の群が次の世代へ重ねて入る。変える時は、この判定も一緒に見直す。
|
|
68
|
+
- 同じ位置に同じ本文の行が、書き直しの前後でたまたま並ぶと、後の方を保存済みと見て落とす。位置と本文の両方が
|
|
69
|
+
合う必要があるので、起きにくい。
|
|
70
|
+
|
|
71
|
+
## 確かめた事・確かめていない事
|
|
72
|
+
|
|
73
|
+
- 確かめた: 単体試験(圧縮の2つの形、2回目の圧縮、巻き戻し、合成行を user として保存していた頃の行との両立、
|
|
74
|
+
追記だけの host は不変)。本物の hook の命令(`process-turn`)へ、合成した記録を通した(切り離した HOME)。
|
|
75
|
+
コンテナの本物の DB の Grok の会話 110 件を、メモリへ写して空実行した(書き直しと判定したのは圧縮の跡がある2件だけ、
|
|
76
|
+
2回目の実行で増えない)。
|
|
77
|
+
- 確かめていない: 本物の Grok が圧縮をまたぐ場面での、この版の動き(狙って起こせるかを確かめていない)。
|
|
78
|
+
Grok の巻き戻しが `chat_history.jsonl` をどう書き直すか(実物を見ていない。試験の形は想定)。
|
|
79
|
+
Cursor が記録を書き直すか(コンテナの取り込みの記録 169 会話に、群が減った跡は無い)。
|
package/package.json
CHANGED
package/src/hosts/claude.mjs
CHANGED
|
@@ -77,6 +77,8 @@ export const claudeHostAdapter = Object.freeze({
|
|
|
77
77
|
// Claude Stop payload の last_assistant_message を transcript 可視化の
|
|
78
78
|
// barrier に使う (ADR 0012)。
|
|
79
79
|
waitsForStopTranscriptFlush: true,
|
|
80
|
+
// Claude Code の transcript は追記だけ。圧縮は要約行を足す (ADR 0032)。
|
|
81
|
+
rewritesTranscript: false,
|
|
80
82
|
// 完了受領はセッションを起動した project に書く。hook payload の cwd は Bash の cd に
|
|
81
83
|
// 追従するため、作業中に下位ディレクトリへ移ると起動 project の feed から漏れる。
|
|
82
84
|
// Claude Code は hook の環境変数 CLAUDE_PROJECT_DIR に起動 project を渡す。
|
package/src/hosts/codex.mjs
CHANGED
|
@@ -17,6 +17,7 @@ export const codexHostAdapter = Object.freeze({
|
|
|
17
17
|
completionProjectPath: ({ cwd }) => cwd,
|
|
18
18
|
// 既存挙動: Claude Stop hook の flush barrier は grok 以外 (codex: 含む) に適用される。
|
|
19
19
|
waitsForStopTranscriptFlush: true,
|
|
20
|
+
rewritesTranscript: false,
|
|
20
21
|
deliverHandoffInjection({ text, stdout = process.stdout }) {
|
|
21
22
|
stdout.write(text + '\n');
|
|
22
23
|
return { delivered: true };
|
package/src/hosts/cursor.mjs
CHANGED
|
@@ -116,6 +116,8 @@ export const cursorHostAdapter = Object.freeze({
|
|
|
116
116
|
// transcript path も cwd から導くため、hook の cwd がそのまま起動 project である。
|
|
117
117
|
completionProjectPath: ({ cwd }) => cwd,
|
|
118
118
|
waitsForStopTranscriptFlush: false,
|
|
119
|
+
// Cursor が transcript を書き直すかは確かめていない。書き直した跡を見ていないので、今までの扱いのまま (ADR 0056)。
|
|
120
|
+
rewritesTranscript: false,
|
|
119
121
|
consumesHandoffAtSessionStart: true,
|
|
120
122
|
deliverHandoffInjection({ text, stdout = process.stdout }) {
|
|
121
123
|
stdout.write(`${JSON.stringify({ additional_context: text })}\n`);
|
package/src/hosts/grok.mjs
CHANGED
|
@@ -77,6 +77,8 @@ export const grokHostAdapter = Object.freeze({
|
|
|
77
77
|
// Claude Stop transcript flush barrier は Claude transcript の完了行を待つ機構。
|
|
78
78
|
// Grok の chat_history には適用しない (既存挙動)。
|
|
79
79
|
waitsForStopTranscriptFlush: false,
|
|
80
|
+
// Grok は自動圧縮と巻き戻しで chat_history.jsonl を書き直す。位置だけでは保存済みのターンを見分けられない (ADR 0056)。
|
|
81
|
+
rewritesTranscript: true,
|
|
80
82
|
// Grok は UserPromptSubmit stdout をモデルへ渡さないため chat_history に直接注入する。
|
|
81
83
|
deliverHandoffInjection({ payload, text }) {
|
|
82
84
|
const injected = injectGrokHandoffContext(payload.transcript_path, text);
|
|
@@ -66,6 +66,7 @@ test('every host adapter satisfies the shared hook contract', () => {
|
|
|
66
66
|
assert.equal(typeof adapter.host, 'string');
|
|
67
67
|
assert.equal(adapter.matchesSessionId(sessionId), true);
|
|
68
68
|
assert.equal(typeof adapter.waitsForStopTranscriptFlush, 'boolean');
|
|
69
|
+
assert.equal(typeof adapter.rewritesTranscript, 'boolean');
|
|
69
70
|
assert.equal(typeof adapter.consumesHandoffAtSessionStart, 'boolean');
|
|
70
71
|
assert.equal(typeof adapter.deliverHandoffInjection, 'function');
|
|
71
72
|
assert.equal(typeof adapter.resolveCommandPrompt, 'function');
|
|
@@ -78,6 +79,11 @@ test('flush barrier applies to claude and codex sessions, not grok or cursor', (
|
|
|
78
79
|
assert.equal(hostAdapterForSessionId('codex:t1').waitsForStopTranscriptFlush, true);
|
|
79
80
|
assert.equal(hostAdapterForSessionId('grok:t1').waitsForStopTranscriptFlush, false);
|
|
80
81
|
assert.equal(hostAdapterForSessionId('cursor:t1').waitsForStopTranscriptFlush, false);
|
|
82
|
+
// transcript を書き直すと分かっている host は Grok だけ(ADR 0056)。
|
|
83
|
+
assert.equal(hostAdapterForSessionId('claude-session').rewritesTranscript, false);
|
|
84
|
+
assert.equal(hostAdapterForSessionId('codex:t1').rewritesTranscript, false);
|
|
85
|
+
assert.equal(hostAdapterForSessionId('grok:t1').rewritesTranscript, true);
|
|
86
|
+
assert.equal(hostAdapterForSessionId('cursor:t1').rewritesTranscript, false);
|
|
81
87
|
});
|
|
82
88
|
|
|
83
89
|
test('only Cursor consumes handoff at sessionStart', () => {
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
|
|
7
|
-
import { getLogicalTurnGroups, readTranscript } from './transcript-reader.mjs';
|
|
7
|
+
import { getLogicalTurnGroups, isGrokHostWrittenUserEntry, readTranscript } from './transcript-reader.mjs';
|
|
8
8
|
|
|
9
9
|
test('readTranscript accepts Grok chat_history.jsonl user/assistant rows', () => {
|
|
10
10
|
const dir = mkdtempSync(join(tmpdir(), 'tl-grok-transcript-'));
|
|
@@ -35,3 +35,82 @@ test('readTranscript accepts Grok chat_history.jsonl user/assistant rows', () =>
|
|
|
35
35
|
rmSync(dir, { recursive: true, force: true });
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
function withGrokHistory(rows, fn) {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-grok-transcript-'));
|
|
41
|
+
const path = join(dir, 'chat_history.jsonl');
|
|
42
|
+
writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n'));
|
|
43
|
+
try {
|
|
44
|
+
return fn(path);
|
|
45
|
+
} finally {
|
|
46
|
+
rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const grokUser = (text, extra = {}) => ({ type: 'user', content: [{ type: 'text', text }], ...extra });
|
|
50
|
+
const grokAssistant = (text) => ({ type: 'assistant', content: text, model_id: 'm' });
|
|
51
|
+
|
|
52
|
+
test('Grok が自分で書いた user 行(system_reminder・compaction_meta)は、ターンの始まりにも本文にもしない。番号は進める', () => {
|
|
53
|
+
withGrokHistory([
|
|
54
|
+
{ type: 'system', content: 'system prompt' },
|
|
55
|
+
grokUser('preamble'),
|
|
56
|
+
grokUser('reminder before the prompt', { synthetic_reason: 'system_reminder' }),
|
|
57
|
+
grokUser('request one', { prompt_index: 0 }),
|
|
58
|
+
grokUser('reminder right after the prompt', { synthetic_reason: 'system_reminder' }),
|
|
59
|
+
grokAssistant('working'),
|
|
60
|
+
{ type: 'tool_result', tool_call_id: 't', content: 'out' },
|
|
61
|
+
grokUser('reminder in the middle of the turn', { synthetic_reason: 'system_reminder' }),
|
|
62
|
+
grokAssistant('answer one'),
|
|
63
|
+
grokUser('background task finished', { synthetic_reason: 'task_completed' }),
|
|
64
|
+
grokAssistant('answer to the notification'),
|
|
65
|
+
], (path) => {
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
readTranscript(path).map((t) => [t.turn_number, t.role, t.content]),
|
|
68
|
+
[
|
|
69
|
+
[0, 'user', 'preamble'],
|
|
70
|
+
[2, 'user', 'request one'],
|
|
71
|
+
[4, 'assistant', 'working'],
|
|
72
|
+
[6, 'assistant', 'answer one'],
|
|
73
|
+
[7, 'user', 'background task finished'],
|
|
74
|
+
[8, 'assistant', 'answer to the notification'],
|
|
75
|
+
],
|
|
76
|
+
);
|
|
77
|
+
const groups = getLogicalTurnGroups(path);
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
groups.map((g) => [g.user.content, g.user.start, g.fragments.map((f) => f.index), g.representative.index]),
|
|
80
|
+
[
|
|
81
|
+
['request one', 'prompt', [4, 6], 6],
|
|
82
|
+
['background task finished', 'self', [8], 8],
|
|
83
|
+
],
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('Grok の自動圧縮で書き直された chat_history は、要約の行を飛ばして、残った user 行からのターンとして読む', () => {
|
|
89
|
+
withGrokHistory([
|
|
90
|
+
{ type: 'system', content: 'system prompt' },
|
|
91
|
+
grokUser('summary of the conversation so far', { synthetic_reason: 'compaction_meta' }),
|
|
92
|
+
grokUser('line kept by the compaction'),
|
|
93
|
+
grokUser('instructions to continue', { synthetic_reason: 'compaction_meta' }),
|
|
94
|
+
grokUser('reminder', { synthetic_reason: 'system_reminder' }),
|
|
95
|
+
grokAssistant('continuing'),
|
|
96
|
+
grokAssistant('final answer'),
|
|
97
|
+
], (path) => {
|
|
98
|
+
const groups = getLogicalTurnGroups(path);
|
|
99
|
+
assert.equal(groups.length, 1);
|
|
100
|
+
assert.equal(groups[0].user.content, 'line kept by the compaction');
|
|
101
|
+
assert.equal(groups[0].user.start, 'unknown');
|
|
102
|
+
assert.deepEqual(groups[0].fragments.map((f) => f.index), [4, 5]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('isGrokHostWrittenUserEntry は Grok の印だけを見る。Claude の行と、知らない印の行は対象にしない', () => {
|
|
107
|
+
assert.equal(isGrokHostWrittenUserEntry(grokUser('x', { synthetic_reason: 'system_reminder' })), true);
|
|
108
|
+
assert.equal(isGrokHostWrittenUserEntry(grokUser('x', { synthetic_reason: 'compaction_meta' })), true);
|
|
109
|
+
assert.equal(isGrokHostWrittenUserEntry(grokUser('x', { synthetic_reason: 'task_completed' })), false);
|
|
110
|
+
assert.equal(isGrokHostWrittenUserEntry(grokUser('x', { synthetic_reason: 'something_new' })), false);
|
|
111
|
+
assert.equal(isGrokHostWrittenUserEntry(grokUser('x', { prompt_index: 3 })), false);
|
|
112
|
+
assert.equal(isGrokHostWrittenUserEntry({ type: 'assistant', content: 'x', synthetic_reason: 'system_reminder' }), false);
|
|
113
|
+
assert.equal(isGrokHostWrittenUserEntry({
|
|
114
|
+
type: 'user', synthetic_reason: 'system_reminder', message: { role: 'user', content: [{ type: 'text', text: 'x' }] },
|
|
115
|
+
}), false);
|
|
116
|
+
});
|
|
@@ -34,6 +34,21 @@ export function isCompactSummaryEntry(entry) {
|
|
|
34
34
|
return entry?.isCompactSummary === true;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
const GROK_HOST_WRITTEN_SYNTHETIC_REASONS = Object.freeze(['compaction_meta', 'system_reminder']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Grok が chat_history.jsonl へ自分で書く user 行か(ADR 0056)。type は user だが、人の発言ではない。
|
|
41
|
+
* `compaction_meta` は自動圧縮の要約と続きの指示、`system_reminder` は host の注意書き(Throughline の
|
|
42
|
+
* 引き継ぎの注入もこの印で書く)。ターンの始まりにも本文にもしない。`task_completed` は host が自分から
|
|
43
|
+
* 始めるターンの始まりなので、ここには入れない。
|
|
44
|
+
* @param {object} entry
|
|
45
|
+
*/
|
|
46
|
+
export function isGrokHostWrittenUserEntry(entry) {
|
|
47
|
+
return entry?.type === 'user'
|
|
48
|
+
&& entry.message === undefined
|
|
49
|
+
&& GROK_HOST_WRITTEN_SYNTHETIC_REASONS.includes(entry.synthetic_reason);
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
function extractText(content) {
|
|
38
53
|
if (typeof content === 'string') return content;
|
|
39
54
|
if (Array.isArray(content)) {
|
|
@@ -96,6 +111,8 @@ export function readTranscript(transcriptPath) {
|
|
|
96
111
|
const turnNumber = position++;
|
|
97
112
|
// 圧縮の要約は host が書いた行。ターンは圧縮をまたいで続くので、ここで区切らない。
|
|
98
113
|
if (isCompactSummaryEntry(entry)) continue;
|
|
114
|
+
// Grok の合成行も host が書いた行。番号は進める(数えていた頃に保存した turn_number と食い違わせない)。
|
|
115
|
+
if (isGrokHostWrittenUserEntry(entry)) continue;
|
|
99
116
|
|
|
100
117
|
const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
101
118
|
// grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
|
|
@@ -221,6 +238,10 @@ export function readLatestUserGroup(transcriptPath) {
|
|
|
221
238
|
* Stopの直後に次のuser行(queueから届いた入力)が書かれた時、完了したturnは
|
|
222
239
|
* latestではなく1つ前のgroupになる。fragmentTurnNumbers はgroup内の全assistant断片のindex。
|
|
223
240
|
*
|
|
241
|
+
* queueの入力は2通以上が続けて書かれることがある(ADR 0055)。その時、latestの手前には
|
|
242
|
+
* assistant断片の無いgroupが並ぶ。previous は、それらを飛ばして、assistant断片を持つ
|
|
243
|
+
* 一番近いgroupにする。
|
|
244
|
+
*
|
|
224
245
|
* @param {string} transcriptPath
|
|
225
246
|
* @returns {{
|
|
226
247
|
* latest: {userTurnNumber: number, assistantTurnNumber: number|null, assistantContent: string|null, fragmentTurnNumbers: number[]},
|
|
@@ -229,11 +250,14 @@ export function readLatestUserGroup(transcriptPath) {
|
|
|
229
250
|
*/
|
|
230
251
|
export function readLatestLogicalTurnCompletions(transcriptPath) {
|
|
231
252
|
const turns = readTranscript(transcriptPath);
|
|
232
|
-
|
|
233
|
-
for (let index = turns.length - 1; index >= 0
|
|
234
|
-
if (turns[index].role === 'user')
|
|
253
|
+
let latestUserIndex = -1;
|
|
254
|
+
for (let index = turns.length - 1; index >= 0; index--) {
|
|
255
|
+
if (turns[index].role === 'user') {
|
|
256
|
+
latestUserIndex = index;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
235
259
|
}
|
|
236
|
-
if (
|
|
260
|
+
if (latestUserIndex < 0) return null;
|
|
237
261
|
|
|
238
262
|
const describe = (start, end) => {
|
|
239
263
|
let representative = null;
|
|
@@ -251,10 +275,20 @@ export function readLatestLogicalTurnCompletions(transcriptPath) {
|
|
|
251
275
|
fragmentTurnNumbers,
|
|
252
276
|
};
|
|
253
277
|
};
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
278
|
+
let previous = null;
|
|
279
|
+
let end = latestUserIndex;
|
|
280
|
+
for (let index = latestUserIndex - 1; index >= 0; index--) {
|
|
281
|
+
if (turns[index].role !== 'user') continue;
|
|
282
|
+
const group = describe(index, end);
|
|
283
|
+
if (group.fragmentTurnNumbers.length > 0) {
|
|
284
|
+
previous = group;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
// assistant断片の無いgroup(続けて届いた入力)。1つ手前を見る。最初のgroupまで断片が無ければ、それを返す。
|
|
288
|
+
previous = group;
|
|
289
|
+
end = index;
|
|
290
|
+
}
|
|
291
|
+
return { latest: describe(latestUserIndex, turns.length), previous };
|
|
258
292
|
}
|
|
259
293
|
|
|
260
294
|
/**
|
package/src/turn-backfill.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
|
+
import { hostAdapterForSessionId } from './hosts/index.mjs';
|
|
23
24
|
import {
|
|
24
25
|
extractDetailBlocks,
|
|
25
26
|
getLogicalTurnGroups,
|
|
@@ -57,6 +58,98 @@ export function deriveTranscriptPath(projectPath, sessionId) {
|
|
|
57
58
|
return join(homedir(), '.claude', 'projects', mungedProjectPath, `${sessionId}.jsonl`);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 書き直される transcript の、世代1つぶんの turn_number の幅(ADR 0056)。
|
|
63
|
+
* 世代 g のターンは `g * TRANSCRIPT_GENERATION_SPAN + transcript 内の位置` で保存する。世代 0 は今までの番号と同じ。
|
|
64
|
+
*/
|
|
65
|
+
export const TRANSCRIPT_GENERATION_SPAN = 1_000_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* host が transcript を書き直す会話(Grok の自動圧縮・巻き戻し)の回収 (ADR 0056)。
|
|
69
|
+
*
|
|
70
|
+
* turn_number は transcript 内の位置なので、書き直された後の位置は、保存済みの別のターンの番号と重なる。
|
|
71
|
+
* 位置だけで「保存済み」を決めると、書き直しをまたいだターンを飛ばす。ここでは本文も比べる。
|
|
72
|
+
*
|
|
73
|
+
* - 保存済みか: どれかの世代で、群の断片と同じ位置に、同じ本文の assistant 行がある。
|
|
74
|
+
* - 書き直されたか: 今の世代で保存した assistant 行のうち、transcript の同じ位置に同じ本文が無い行がある。
|
|
75
|
+
* その時は次の世代へ進み、保存していない群を新しい世代の番号で入れる。
|
|
76
|
+
*
|
|
77
|
+
* 世代は保存済みの turn_number から読める(`floor(turn_number / SPAN)`)ので、別の置き場を持たない。
|
|
78
|
+
*/
|
|
79
|
+
function backfillRewritableTranscript(db, { targetSessionId, originSessionId, groups, now }) {
|
|
80
|
+
const stored = new Map();
|
|
81
|
+
let currentGeneration = 0;
|
|
82
|
+
for (const row of db
|
|
83
|
+
.prepare(`SELECT turn_number, text FROM bodies WHERE origin_session_id = ? AND role = 'assistant'`)
|
|
84
|
+
.all(originSessionId)) {
|
|
85
|
+
stored.set(row.turn_number, row.text);
|
|
86
|
+
currentGeneration = Math.max(currentGeneration, Math.floor(row.turn_number / TRANSCRIPT_GENERATION_SPAN));
|
|
87
|
+
}
|
|
88
|
+
const generations = Array.from({ length: currentGeneration + 1 }, (_, index) => currentGeneration - index);
|
|
89
|
+
|
|
90
|
+
const fragmentAt = new Map();
|
|
91
|
+
for (const g of groups) for (const f of g.fragments) fragmentAt.set(f.index, f.content);
|
|
92
|
+
const currentBase = currentGeneration * TRANSCRIPT_GENERATION_SPAN;
|
|
93
|
+
const rewritten = [...stored].some(([turnNumber, text]) =>
|
|
94
|
+
turnNumber >= currentBase && fragmentAt.get(turnNumber - currentBase) !== text);
|
|
95
|
+
const writeBase = (rewritten ? currentGeneration + 1 : currentGeneration) * TRANSCRIPT_GENERATION_SPAN;
|
|
96
|
+
|
|
97
|
+
/** 保存済みの群なら、その行の turn_number。断片が複数の行に当たる時は、最後の行。 */
|
|
98
|
+
const storedTurnNumber = (g) => {
|
|
99
|
+
for (const generation of generations) {
|
|
100
|
+
const base = generation * TRANSCRIPT_GENERATION_SPAN;
|
|
101
|
+
for (let index = g.fragments.length - 1; index >= 0; index--) {
|
|
102
|
+
const f = g.fragments[index];
|
|
103
|
+
if (stored.get(base + f.index) === f.content) return base + f.index;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const insertBody = db.prepare(
|
|
110
|
+
`INSERT OR IGNORE INTO bodies
|
|
111
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at, turn_start)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
113
|
+
);
|
|
114
|
+
let insertedTurns = 0;
|
|
115
|
+
let skippedExisting = 0;
|
|
116
|
+
const turnNumbers = [];
|
|
117
|
+
|
|
118
|
+
db.exec('BEGIN');
|
|
119
|
+
try {
|
|
120
|
+
for (const g of groups) {
|
|
121
|
+
const existing = storedTurnNumber(g);
|
|
122
|
+
if (existing !== null) {
|
|
123
|
+
skippedExisting++;
|
|
124
|
+
turnNumbers.push(existing);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const turnNumber = writeBase + g.representative.index;
|
|
128
|
+
const assistantAt = g.representative.timestamp ?? now;
|
|
129
|
+
const userAt = g.user.timestamp ?? assistantAt;
|
|
130
|
+
insertBody.run(targetSessionId, originSessionId, turnNumber, 'user', g.user.content,
|
|
131
|
+
Math.round(g.user.content.length / 4), userAt, g.user.start);
|
|
132
|
+
insertBody.run(targetSessionId, originSessionId, turnNumber, 'assistant', g.representative.content,
|
|
133
|
+
Math.round(g.representative.content.length / 4), assistantAt, null);
|
|
134
|
+
insertedTurns++;
|
|
135
|
+
turnNumbers.push(turnNumber);
|
|
136
|
+
}
|
|
137
|
+
db.exec('COMMIT');
|
|
138
|
+
} catch (err) {
|
|
139
|
+
db.exec('ROLLBACK');
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
groups: groups.length,
|
|
145
|
+
insertedTurns,
|
|
146
|
+
skippedExisting,
|
|
147
|
+
lastTurnNumber: turnNumbers.at(-1),
|
|
148
|
+
turnNumbers,
|
|
149
|
+
rewritten,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
60
153
|
/**
|
|
61
154
|
* transcript の未捕捉完了ターンを bodies へ回収する。
|
|
62
155
|
*
|
|
@@ -73,6 +166,9 @@ export function backfillBodies(db, { targetSessionId, originSessionId, transcrip
|
|
|
73
166
|
if (groups.length === 0) {
|
|
74
167
|
return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] };
|
|
75
168
|
}
|
|
169
|
+
if (hostAdapterForSessionId(originSessionId).rewritesTranscript) {
|
|
170
|
+
return backfillRewritableTranscript(db, { targetSessionId, originSessionId, groups, now });
|
|
171
|
+
}
|
|
76
172
|
|
|
77
173
|
const existing = new Set(
|
|
78
174
|
db
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { tmpdir, homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
-
import { backfillBodies, captureInFlightTurn, deriveTranscriptPath } from './turn-backfill.mjs';
|
|
7
|
+
import { backfillBodies, captureInFlightTurn, deriveTranscriptPath, TRANSCRIPT_GENERATION_SPAN } from './turn-backfill.mjs';
|
|
8
8
|
|
|
9
9
|
function makeDb() {
|
|
10
10
|
const db = new DatabaseSync(':memory:');
|
|
@@ -293,6 +293,159 @@ function withDetails(db) {
|
|
|
293
293
|
const toolUse = (id, name, input) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } });
|
|
294
294
|
const toolResult = (id, content) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content }] } });
|
|
295
295
|
|
|
296
|
+
// --- host が書き直す transcript(Grok。ADR 0056) ---
|
|
297
|
+
|
|
298
|
+
const GROK = 'grok:11111111-2222-3333-4444-555555555555';
|
|
299
|
+
const grokUser = (text, extra = {}) => ({ type: 'user', content: [{ type: 'text', text }], ...extra });
|
|
300
|
+
const grokAssistant = (text) => ({ type: 'assistant', content: text, model_id: 'm' });
|
|
301
|
+
const grokSystem = { type: 'system', content: 'system prompt' };
|
|
302
|
+
const storedRows = (db) => db
|
|
303
|
+
.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role DESC')
|
|
304
|
+
.all()
|
|
305
|
+
.map((row) => `${row.turn_number}${row.role[0]}=${row.text}`);
|
|
306
|
+
const grokBackfill = (db, entries) =>
|
|
307
|
+
withData(entries, (path) => backfillBodies(db, { targetSessionId: GROK, originSessionId: GROK, transcriptPath: path, now: 999 }));
|
|
308
|
+
|
|
309
|
+
const GROK_BEFORE_COMPACTION = [
|
|
310
|
+
grokSystem,
|
|
311
|
+
grokUser('preamble'),
|
|
312
|
+
grokUser('reminder', { synthetic_reason: 'system_reminder' }),
|
|
313
|
+
grokUser('request 1', { prompt_index: 0 }),
|
|
314
|
+
grokAssistant('working 1'),
|
|
315
|
+
grokAssistant('answer 1'), // 4
|
|
316
|
+
grokUser('request 2', { prompt_index: 1 }),
|
|
317
|
+
grokAssistant('working 2'),
|
|
318
|
+
grokAssistant('working 2b'),
|
|
319
|
+
grokAssistant('answer 2'), // 8
|
|
320
|
+
];
|
|
321
|
+
// 圧縮の後。位置 4〜9 に、圧縮をまたいだターンの断片が並ぶ。位置 4 と 8 は保存済みの番号と重なる。
|
|
322
|
+
const grokAfterCompaction = (fragments) => [
|
|
323
|
+
grokSystem,
|
|
324
|
+
grokUser('summary', { synthetic_reason: 'compaction_meta' }),
|
|
325
|
+
grokUser('line kept by the compaction'),
|
|
326
|
+
grokUser('continue', { synthetic_reason: 'compaction_meta' }),
|
|
327
|
+
grokUser('reminder', { synthetic_reason: 'system_reminder' }),
|
|
328
|
+
...fragments.map(grokAssistant),
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
test('backfillBodies: Grok が圧縮で書き直した後のターンは、番号が保存済みのターンと重なっても、新しい世代の番号で保存する', () => {
|
|
332
|
+
const db = makeDb();
|
|
333
|
+
const first = grokBackfill(db, GROK_BEFORE_COMPACTION);
|
|
334
|
+
assert.deepEqual(first.turnNumbers, [4, 8]);
|
|
335
|
+
assert.equal(first.rewritten, false);
|
|
336
|
+
|
|
337
|
+
// 代表ではない断片(位置 8)だけが保存済みの番号と重なる形。直す前は、群を飛ばした後に受領で失敗した。
|
|
338
|
+
const after = grokAfterCompaction(['after 1', 'after 2', 'after 3', 'after 4', 'after 5', 'answer 3']);
|
|
339
|
+
const second = grokBackfill(db, after);
|
|
340
|
+
const span = TRANSCRIPT_GENERATION_SPAN;
|
|
341
|
+
assert.deepEqual(
|
|
342
|
+
{ groups: second.groups, insertedTurns: second.insertedTurns, skippedExisting: second.skippedExisting, rewritten: second.rewritten },
|
|
343
|
+
{ groups: 1, insertedTurns: 1, skippedExisting: 0, rewritten: true },
|
|
344
|
+
);
|
|
345
|
+
assert.deepEqual(second.turnNumbers, [span + 9]);
|
|
346
|
+
assert.equal(second.lastTurnNumber, span + 9);
|
|
347
|
+
assert.deepEqual(storedRows(db), [
|
|
348
|
+
'4u=request 1', '4a=answer 1', '8u=request 2', '8a=answer 2',
|
|
349
|
+
`${span + 9}u=line kept by the compaction`, `${span + 9}a=answer 3`,
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
// 同じ transcript をもう一度読んでも増えず、世代も進まない。
|
|
353
|
+
const again = grokBackfill(db, after);
|
|
354
|
+
assert.deepEqual(
|
|
355
|
+
{ insertedTurns: again.insertedTurns, skippedExisting: again.skippedExisting, rewritten: again.rewritten, turnNumbers: again.turnNumbers },
|
|
356
|
+
{ insertedTurns: 0, skippedExisting: 1, rewritten: false, turnNumbers: [span + 9] },
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// 次のターンは同じ世代の続きの番号で入る。
|
|
360
|
+
const next = grokBackfill(db, [...after, grokUser('request 4', { prompt_index: 3 }), grokAssistant('answer 4')]);
|
|
361
|
+
assert.deepEqual(next.turnNumbers, [span + 9, span + 11]);
|
|
362
|
+
assert.equal(next.insertedTurns, 1);
|
|
363
|
+
assert.equal(next.rewritten, false);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('backfillBodies: Grok の書き直し後に、代表の断片の番号が保存済みのターンと重なっても、黙って落とさない', () => {
|
|
367
|
+
const db = makeDb();
|
|
368
|
+
grokBackfill(db, GROK_BEFORE_COMPACTION);
|
|
369
|
+
// 代表(位置 8)が、保存済みの「answer 2」の番号と重なる形。直す前は、失敗も出さずに落としていた。
|
|
370
|
+
const result = grokBackfill(db, grokAfterCompaction(['after 1', 'after 2', 'after 3', 'after 4', 'answer 3']));
|
|
371
|
+
assert.equal(result.insertedTurns, 1);
|
|
372
|
+
assert.deepEqual(result.turnNumbers, [TRANSCRIPT_GENERATION_SPAN + 8]);
|
|
373
|
+
assert.equal(
|
|
374
|
+
db.prepare(`SELECT text FROM bodies WHERE turn_number = ? AND role = 'assistant'`).get(TRANSCRIPT_GENERATION_SPAN + 8).text,
|
|
375
|
+
'answer 3',
|
|
376
|
+
);
|
|
377
|
+
assert.equal(db.prepare(`SELECT text FROM bodies WHERE turn_number = 8 AND role = 'assistant'`).get().text, 'answer 2');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('backfillBodies: Grok の2回目の書き直しは、さらに次の世代の番号になる', () => {
|
|
381
|
+
const db = makeDb();
|
|
382
|
+
const span = TRANSCRIPT_GENERATION_SPAN;
|
|
383
|
+
grokBackfill(db, GROK_BEFORE_COMPACTION);
|
|
384
|
+
grokBackfill(db, grokAfterCompaction(['after 1', 'answer 3'])); // 世代 1 の位置 5
|
|
385
|
+
const third = grokBackfill(db, grokAfterCompaction(['later 1', 'later 2', 'answer 5']));
|
|
386
|
+
assert.equal(third.rewritten, true);
|
|
387
|
+
assert.deepEqual(third.turnNumbers, [2 * span + 6]);
|
|
388
|
+
assert.deepEqual(storedRows(db).filter((row) => row.includes('a=')), [
|
|
389
|
+
'4a=answer 1', '8a=answer 2', `${span + 5}a=answer 3`, `${2 * span + 6}a=answer 5`,
|
|
390
|
+
]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('backfillBodies: Grok の巻き戻しで後ろだけが変わった時、変わっていないターンは重ねて入れない', () => {
|
|
394
|
+
const db = makeDb();
|
|
395
|
+
grokBackfill(db, GROK_BEFORE_COMPACTION);
|
|
396
|
+
// 「request 2」より後ろを巻き戻して、別の依頼をした。位置 6〜7 に別の本文が入る。
|
|
397
|
+
const rewound = [...GROK_BEFORE_COMPACTION.slice(0, 6), grokUser('request 2 again', { prompt_index: 1 }), grokAssistant('working'), grokAssistant('new answer 2')];
|
|
398
|
+
const result = grokBackfill(db, rewound);
|
|
399
|
+
assert.deepEqual(
|
|
400
|
+
{ insertedTurns: result.insertedTurns, skippedExisting: result.skippedExisting, rewritten: result.rewritten },
|
|
401
|
+
{ insertedTurns: 1, skippedExisting: 1, rewritten: true },
|
|
402
|
+
);
|
|
403
|
+
assert.deepEqual(result.turnNumbers, [4, TRANSCRIPT_GENERATION_SPAN + 7]);
|
|
404
|
+
assert.deepEqual(storedRows(db).filter((row) => row.includes('a=')), [
|
|
405
|
+
'4a=answer 1', '8a=answer 2', `${TRANSCRIPT_GENERATION_SPAN + 7}a=new answer 2`,
|
|
406
|
+
]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('backfillBodies: Grok の合成行を user として保存していた頃のターンは、群がまとまっても重ねて入れない', () => {
|
|
410
|
+
const db = makeDb();
|
|
411
|
+
// 直す前は、ターンの途中の system_reminder が群を2つに割っていた(代表は位置 3 と 5)。
|
|
412
|
+
const insert = db.prepare(`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, 1)`);
|
|
413
|
+
for (const [turn, role, text] of [[3, 'user', 'request 1'], [3, 'assistant', 'working 1'], [5, 'user', 'reminder'], [5, 'assistant', 'answer 1']]) {
|
|
414
|
+
insert.run(GROK, GROK, turn, role, text);
|
|
415
|
+
}
|
|
416
|
+
const result = grokBackfill(db, [
|
|
417
|
+
grokSystem,
|
|
418
|
+
grokUser('preamble'),
|
|
419
|
+
grokUser('reminder', { synthetic_reason: 'system_reminder' }),
|
|
420
|
+
grokUser('request 1', { prompt_index: 0 }),
|
|
421
|
+
grokAssistant('working 1'), // 3
|
|
422
|
+
grokUser('reminder', { synthetic_reason: 'system_reminder' }),
|
|
423
|
+
grokAssistant('answer 1'), // 5
|
|
424
|
+
]);
|
|
425
|
+
assert.deepEqual(
|
|
426
|
+
{ groups: result.groups, insertedTurns: result.insertedTurns, skippedExisting: result.skippedExisting, rewritten: result.rewritten },
|
|
427
|
+
{ groups: 1, insertedTurns: 0, skippedExisting: 1, rewritten: false },
|
|
428
|
+
);
|
|
429
|
+
// 受領は、行が実際にある番号(最後に当たった行)で出す。
|
|
430
|
+
assert.deepEqual(result.turnNumbers, [5]);
|
|
431
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM bodies').get().n, 4);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('backfillBodies: 追記だけの host(Claude Code)は、今までどおり位置だけで保存済みを決める', () => {
|
|
435
|
+
const db = makeDb();
|
|
436
|
+
const insert = db.prepare(`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, 1)`);
|
|
437
|
+
insert.run('target', 'origin', 1, 'user', 'stored under another text');
|
|
438
|
+
insert.run('target', 'origin', 1, 'assistant', 'stored under another text');
|
|
439
|
+
withData([entry('user', 'question'), entry('assistant', 'answer')], (path) => {
|
|
440
|
+
const result = backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 });
|
|
441
|
+
assert.deepEqual(
|
|
442
|
+
{ insertedTurns: result.insertedTurns, skippedExisting: result.skippedExisting, turnNumbers: result.turnNumbers },
|
|
443
|
+
{ insertedTurns: 0, skippedExisting: 1, turnNumbers: [1] },
|
|
444
|
+
);
|
|
445
|
+
assert.equal(result.rewritten, undefined);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
296
449
|
test('captureInFlightTurn: 止めたターンの発言を全部つないで本文にし、末尾の道具まで details に入れる。合流時の回収は重ねない', () => {
|
|
297
450
|
withData(
|
|
298
451
|
[
|
|
@@ -152,6 +152,71 @@ test('Claude Stop flush barrierは、前後の空白を除いてmarkerと本文
|
|
|
152
152
|
}
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
test('Claude Stop flush barrierは、Stop直後に待っていた入力が2通以上続けて届いても、未捕捉の完了turnを採用する', async () => {
|
|
156
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-queued-inputs-'));
|
|
157
|
+
const transcriptPath = join(root, 'transcript.jsonl');
|
|
158
|
+
const text = (role, value) => ({ type: role, message: { role, content: [{ type: 'text', text: value }] } });
|
|
159
|
+
let elapsed = 0;
|
|
160
|
+
const clock = { now: () => elapsed, wait: async (milliseconds) => { elapsed += milliseconds; } };
|
|
161
|
+
const write = (entries) => writeFileSync(transcriptPath, entries.map((entry) => JSON.stringify(entry)).join('\n'), 'utf8');
|
|
162
|
+
const completed = [
|
|
163
|
+
text('user', 'first request'),
|
|
164
|
+
text('assistant', 'first answer'),
|
|
165
|
+
text('user', 'second request'),
|
|
166
|
+
text('assistant', 'second answer'),
|
|
167
|
+
];
|
|
168
|
+
try {
|
|
169
|
+
for (const queued of [2, 3]) {
|
|
170
|
+
write([...completed, ...Array.from({ length: queued }, (_, index) => text('user', `queued request ${index}`))]);
|
|
171
|
+
const captured = [];
|
|
172
|
+
elapsed = 0;
|
|
173
|
+
const result = await waitForClaudeStopTranscriptFlush({
|
|
174
|
+
transcriptPath,
|
|
175
|
+
lastAssistantMessage: 'second answer',
|
|
176
|
+
timeoutMs: 100,
|
|
177
|
+
intervalMs: 10,
|
|
178
|
+
isTurnCaptured: (fragmentTurnNumbers) => { captured.push(fragmentTurnNumbers); return false; },
|
|
179
|
+
...clock,
|
|
180
|
+
});
|
|
181
|
+
assert.deepEqual(result, { status: 'ready', userTurnNumber: 2, assistantTurnNumber: 3 }, `queued=${queued}`);
|
|
182
|
+
assert.deepEqual(captured, [[3]]);
|
|
183
|
+
assert.equal(elapsed, 0, 'the completed turn is already flushed; no waiting');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 捕捉済みの同文answerは今回のStopの完了ではない。
|
|
187
|
+
elapsed = 0;
|
|
188
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
189
|
+
transcriptPath, lastAssistantMessage: 'second answer', timeoutMs: 30, intervalMs: 10,
|
|
190
|
+
isTurnCaptured: () => true, ...clock,
|
|
191
|
+
}), /not visible before deadline/);
|
|
192
|
+
|
|
193
|
+
// 本文を持つgroupを飛ばして、その前のturnを採用しない。
|
|
194
|
+
elapsed = 0;
|
|
195
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
196
|
+
transcriptPath, lastAssistantMessage: 'first answer', timeoutMs: 30, intervalMs: 10,
|
|
197
|
+
isTurnCaptured: () => false, ...clock,
|
|
198
|
+
}), /not visible before deadline/);
|
|
199
|
+
|
|
200
|
+
// 届いた入力のturnが本文を書き始めていたら採用しない。
|
|
201
|
+
appendFileSync(transcriptPath, `\n${JSON.stringify(text('assistant', 'working on the queued requests'))}`, 'utf8');
|
|
202
|
+
elapsed = 0;
|
|
203
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
204
|
+
transcriptPath, lastAssistantMessage: 'second answer', timeoutMs: 30, intervalMs: 10,
|
|
205
|
+
isTurnCaptured: () => false, ...clock,
|
|
206
|
+
}), /not visible before deadline/);
|
|
207
|
+
|
|
208
|
+
// 本文を持つgroupが1つも無い時は、採用する物が無い。
|
|
209
|
+
write([text('user', 'only request'), text('user', 'queued request 0'), text('user', 'queued request 1')]);
|
|
210
|
+
elapsed = 0;
|
|
211
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
212
|
+
transcriptPath, lastAssistantMessage: 'second answer', timeoutMs: 30, intervalMs: 10,
|
|
213
|
+
isTurnCaptured: () => false, ...clock,
|
|
214
|
+
}), /not visible before deadline/);
|
|
215
|
+
} finally {
|
|
216
|
+
rmSync(root, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
155
220
|
test('Claude Stop flush barrierは、Stop直後に次のuser行が届いても、未捕捉の完了turnを待たずに採用する', async () => {
|
|
156
221
|
const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-next-user-'));
|
|
157
222
|
const transcriptPath = join(root, 'transcript.jsonl');
|