throughline 0.16.24 → 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 +60 -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/0047-codex-internal-compaction-is-stopped-without-error.md +7 -0
- package/docs/adr/0053-claude-successor-finds-predecessor-by-handoff-id.md +4 -0
- package/docs/adr/0054-codex-compaction-of-thread-without-rollout-is-left-alone.md +62 -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/claude-auto-handoff.mjs +4 -1
- package/src/codex-auto-handoff.mjs +15 -1
- package/src/codex-auto-handoff.test.mjs +31 -3
- 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,63 @@ 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
|
+
|
|
44
|
+
## [0.16.25] — 2026-10-12
|
|
45
|
+
|
|
46
|
+
### 修正
|
|
47
|
+
|
|
48
|
+
- **macOSのCodex Desktopで、引き継ぎのたびに`handoff_hook_identity_invalid`の失敗が記録されていた**([ADR 0054](docs/adr/0054-codex-compaction-of-thread-without-rollout-is-left-alone.md))。引き継ぎそのものは成功していた。0.16.18で失敗の行へ残すようにした合図の形を、2026-10-11の2回(0.16.23)で読めた。
|
|
49
|
+
- 合図は、引き継ぎ元でも引き継ぎ先でもない会話から来ていた。記録の場所(`transcript_path`)がnullで、会話のIDとターンのIDは正しい形。Codex Desktopのログでは、Desktop自身が立てた、画面に出ない会話(MCPが起動し、ターンが始まって終わる。画面の側は「unknown conversation」として受ける)。旧タスクのターンが止まった直後と、後継の画面を開いた直後に1つずつ出来て、最初のターンで自動圧縮を始めていた。
|
|
50
|
+
- Throughlineは、これを失敗として記録し、その会話のターンを止めていた。
|
|
51
|
+
- 直した後は、`PreCompact(auto)`の合図に記録の場所が無い時、引き継ぎの対象にしない。失敗にせず、圧縮を止める指示も返さない。通した事を`~/.throughline/codex-auto-handoff/unrecorded-compactions.jsonl`へ1行残す(時刻・会話のID・ターンのID・モデル名)。
|
|
52
|
+
- 記録の場所が相対pathの時と、記録を持つ会話の扱いは変えていない。
|
|
53
|
+
- 0.16.14([ADR 0047](docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md))が想定した形(ターンのIDが`auto-compact-N`)は、実機ではまだ来ていない。
|
|
54
|
+
|
|
55
|
+
### 対応の範囲
|
|
56
|
+
|
|
57
|
+
| | macOS | Linux | Windows |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| Codex | 直した。起きた端末。単体試験と、実機で本物のhookの命令へ起きた形の合図を渡した(入っていた0.16.24は失敗を記録して`continue: false`。この版は何も出力せず、失敗の記録は増えない)。本物のCodex Desktopの引き継ぎでは、まだ通していない | 直した。同じ確認。実機でこの失敗が起きた事は無い | 直した。同じ確認。実機でこの失敗が起きた事は無い |
|
|
60
|
+
| Claude Code | 変更なし(この失敗はCodexの`PreCompact`だけが出す。Claude Codeの会話は必ず記録を持つ) | 同じ | 同じ |
|
|
61
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
62
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
63
|
+
|
|
64
|
+
### 確かめていない事
|
|
65
|
+
|
|
66
|
+
- 本物のCodex Desktopが、記録なしの会話から合図を出す場面での、この版の動き。狙って起こせない。次のmacOSの引き継ぎで、失敗ログが増えず、`unrecorded-compactions.jsonl`に行が出る事で確かめる。
|
|
67
|
+
- Codex Desktopが、その会話を何に使っているか。Throughlineが止めていた間に、Desktopの側で欠けていた物があったかも分かっていない。
|
|
68
|
+
- 2026-10-10の5回(0.16.12と0.16.16)は、失敗の行に形が残っていない。同じ検査・同じ場面だが、同じ形だったとは確かめられない。
|
|
69
|
+
|
|
13
70
|
## [0.16.24] — 2026-10-11
|
|
14
71
|
|
|
15
72
|
### 修正
|
|
@@ -2435,7 +2492,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2435
2492
|
|
|
2436
2493
|
---
|
|
2437
2494
|
|
|
2438
|
-
[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
|
|
2497
|
+
[0.16.25]: https://github.com/kitepon/Throughline/compare/v0.16.24...v0.16.25
|
|
2439
2498
|
[0.16.24]: https://github.com/kitepon/Throughline/compare/v0.16.23...v0.16.24
|
|
2440
2499
|
[0.16.23]: https://github.com/kitepon/Throughline/compare/v0.16.22...v0.16.23
|
|
2441
2500
|
[0.16.22]: https://github.com/kitepon/Throughline/compare/v0.16.21...v0.16.22
|
|
@@ -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つ以上遡らない事は変えていない。
|
|
@@ -53,3 +53,10 @@ Codex は、利用者のターンとは別に、自分で作る内部の文脈
|
|
|
53
53
|
- 0.16.18 で、この失敗の行へ合図の形(`identity`。ID・種類・絶対 path かどうか)を残す。次に起きた行を読んで、
|
|
54
54
|
扱いを決める。この ADR の Decision 1〜4 は、それまで変えない。
|
|
55
55
|
|
|
56
|
+
## 追記(2026-10-12)
|
|
57
|
+
|
|
58
|
+
0.16.23 の macOS の実機で、形の付いた失敗が2回出た(2026-10-11 16:22:00 と 16:23:01)。不正だったのは `transcript_path`
|
|
59
|
+
(null)で、ターンの ID は UUID だった。合図の会話は、引き継ぎ元でも引き継ぎ先でもない、Codex Desktop が自分で立てた
|
|
60
|
+
記録なしの会話だった。扱いは [ADR 0054](0054-codex-compaction-of-thread-without-rollout-is-left-alone.md) で決めた。
|
|
61
|
+
この ADR の Decision 1〜4(記録を持つ会話の、ターンの ID が `auto-compact-N` の合図)は変えていない。実機でその形が
|
|
62
|
+
来た事は、まだ無い(macOS の実機に `internal-compactions.jsonl` は出来ていない)。
|
|
@@ -47,3 +47,7 @@ Claude Code の自動継続(ADR 0033)は、`PreCompact(auto)` で圧縮を
|
|
|
47
47
|
- 旧い会話は、記録がある間、文を受ければ文で答える。道具は断る(変えていない)。旧い会話あての連絡を今の続きへ渡すのは、
|
|
48
48
|
連絡を運ぶ側の仕事で、5 の口で今の続きを引ける。
|
|
49
49
|
- 記憶が入らなかった後継へ、後から記憶を入れる処理は無い。`throughline recall --session <旧い会話の id>` で引ける。
|
|
50
|
+
- ほかの製品が、次の2つの形に頼っている(通話MCP `grokbot-bridge` 0.3.6 の `UserPromptSubmit` の hook。後継へ、前任が持っていた
|
|
51
|
+
通話の引き取りを知らせる)。変える時は、先にその製品の担当へ知らせる。
|
|
52
|
+
- 後継の最初の指示の頭: `Throughline自動継続 ` に続けて、36字の引き継ぎ ID。
|
|
53
|
+
- `auto-handoff status --host claude --operation <引き継ぎID> --json` の `handoffs[0].source_session_id`。
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ADR 0054: 記録(rollout)を持たない Codex の会話の自動圧縮には、手を出さない
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Codex の自動継続(ADR 0034)は、`PreCompact(auto)` の hook で圧縮を止めて、新しいタスクへ引き継ぐ。hook は、合図の
|
|
9
|
+
会話の ID・記録の場所(`transcript_path`)・ターンの ID を検査し、合わなければ `handoff_hook_identity_invalid` で失敗にして、
|
|
10
|
+
圧縮を止める指示(`continue: false`)を返していた。
|
|
11
|
+
|
|
12
|
+
macOS の実機で、この失敗が7回出た(2026-10-10 に5回、10-11 に2回)。どれも Codex の引き継ぎの直後で、引き継ぎそのものは
|
|
13
|
+
成功している。0.16.14(ADR 0047)は「ターンの ID が `auto-compact-N`」の形を想定して直したが、外れていた。0.16.18 で
|
|
14
|
+
失敗の行へ合図の形を残し、10-11 の2回で形が分かった。
|
|
15
|
+
|
|
16
|
+
| | 1回目 | 2回目 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| 失敗の時刻(JST) | 16:22:00.396 | 16:23:01.422 |
|
|
19
|
+
| 会話の ID | `01a129d6-fe64…`(作られたのは 16:21:58.116) | `01a129d7-e996…`(16:22:58.326) |
|
|
20
|
+
| ターンの ID | UUID(16:21:58.225) | UUID(16:22:58.611) |
|
|
21
|
+
| `transcript_path` | null | null |
|
|
22
|
+
| `cwd`・モデル・`trigger` | 絶対 path・`gpt-6.1-sol`・`auto` | 同じ |
|
|
23
|
+
| agent の ID・種類 | 無し | 無し |
|
|
24
|
+
|
|
25
|
+
引き継ぎは 16:21:57.996 に予約(旧タスク `01a124ce-f984…`)、後継 `01a129d7-de33…` が 16:22:55.411 に出来て、
|
|
26
|
+
16:23:06 に済んだ。失敗した合図の会話は、旧タスクでも後継でもない。
|
|
27
|
+
|
|
28
|
+
Codex Desktop のログ(アプリの行だけを読んだ)では、この2つの会話は Desktop 自身の App Server の中にあり、MCP が
|
|
29
|
+
起動し、`turn/started` と `turn/completed` が出ている。画面の側は「unknown conversation」として受けている。1つ目は
|
|
30
|
+
旧タスクのターンが止まって通知を出した直後、2つ目は後継の画面を開いた直後に出来ている。Throughline の worker は、
|
|
31
|
+
後継のほかに会話を作らない(`thread/start` は1か所)。要約の `codex exec --ephemeral` は別のモデル(既定
|
|
32
|
+
`gpt-5.6-luna`)で動く。
|
|
33
|
+
|
|
34
|
+
つまり、Codex Desktop が自分で立てた、画面に出ない・記録を持たない会話が、最初のターンで自動圧縮を始め、その合図が
|
|
35
|
+
Throughline の hook へ来ていた。Throughline は失敗として記録し、その会話のターンを止めていた(2回とも、hook の失敗の
|
|
36
|
+
0.3〜0.5 秒後に `turn/completed`)。Desktop がその会話を何に使っているかは、分かっていない。
|
|
37
|
+
|
|
38
|
+
Throughline は、会話の記録(rollout)から発言を取り込む。記録を持たない会話は取り込んでいないので、引き継ぐ記憶が無く、
|
|
39
|
+
後継も作れない。
|
|
40
|
+
|
|
41
|
+
## Decision
|
|
42
|
+
|
|
43
|
+
1. `PreCompact(auto)` の合図の `transcript_path` が null か、項目が無い時は、引き継ぎの対象にしない。失敗にせず、
|
|
44
|
+
`continue: false` も返さない(hook は何も出力せず、終了 code 0)。圧縮は Codex がそのまま行う。
|
|
45
|
+
2. 通した事を `~/.throughline/codex-auto-handoff/unrecorded-compactions.jsonl` に1行残す(時刻・会話の ID・ターンの ID・
|
|
46
|
+
モデル名。記号の混じる値は長さだけ)。会話の本文と path は書かない。
|
|
47
|
+
3. `transcript_path` が文字列で、絶対 path でない時は、今までどおり失敗にする(記録を持つはずの会話で、場所だけが不正)。
|
|
48
|
+
4. 記録を持つ会話の扱い(ADR 0034・0047)は変えない。
|
|
49
|
+
|
|
50
|
+
## Consequences
|
|
51
|
+
|
|
52
|
+
- macOS の Codex Desktop の引き継ぎのたびに出ていた失敗の記録が、出なくなる見込み。次の実機の引き継ぎで、失敗ログが
|
|
53
|
+
増えず、`unrecorded-compactions.jsonl` に行が出る事で確かめる。
|
|
54
|
+
- Desktop が立てる画面に出ない会話は、Throughline に止められなくなり、Codex の圧縮が通る。自動継続を入れる前
|
|
55
|
+
(0.11.0 より前)と同じ動きに戻る。
|
|
56
|
+
- 「host の自動圧縮を通さない」という決め(ADR 0034)は、Throughline が記録している会話についての物として読む。
|
|
57
|
+
記録を持たない会話は、その外に置く。
|
|
58
|
+
- 利用者が自分で起こす記録なしの会話(`codex exec --ephemeral` など)も、同じ扱いになる。今までは失敗として止めていた。
|
|
59
|
+
どちらでも引き継ぎは出来ない。
|
|
60
|
+
- Codex が、記録を持つ会話の最初のターンで、記録を作る前に `transcript_path` を null で渡す事があれば、その圧縮も通る。
|
|
61
|
+
実機では見ていない。起きた時は `unrecorded-compactions.jsonl` の会話の ID が、後で記録を持つ会話と一致する事で気づける。
|
|
62
|
+
- 10-10 の5回は、失敗の行に形が残っていない。同じ検査・同じ場面(引き継ぎの直後)だが、同じ形だったとは確かめられない。
|
|
@@ -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
|
@@ -689,7 +689,10 @@ export function completeClaudeTurnWithoutHandoff({ sessionId, dir = claudeAutoHa
|
|
|
689
689
|
return true;
|
|
690
690
|
}
|
|
691
691
|
|
|
692
|
-
/**
|
|
692
|
+
/**
|
|
693
|
+
* 後継へ送る継続の指示。引き継ぎIDを含め、後継の最初の指示がこの配送のものかを照合できるようにする。
|
|
694
|
+
* 文の頭の形(`Throughline自動継続 ` + 36字の引き継ぎID)は、ほかの製品の hook も読んでいる(ADR 0053)。変える時は先に知らせる。
|
|
695
|
+
*/
|
|
693
696
|
export function claudeContinuationInput(record) {
|
|
694
697
|
// この文は後継の最初の指示(利用者の発言)として届く。止めた道具をやり直さずに「済んだ」と書いて
|
|
695
698
|
// 先へ進む後継が出たので(Haiku 4.5、macOS で実測)、実行されていない道具から始めることを明記する。
|
|
@@ -132,6 +132,12 @@ function recordInternalCompactionStop(entry) {
|
|
|
132
132
|
appendFileSync(join(stateRoot(), 'internal-compactions.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** 記録(rollout)を持たない会話の自動圧縮を通した記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
136
|
+
function recordUnrecordedCompactionPass(entry) {
|
|
137
|
+
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
138
|
+
appendFileSync(join(stateRoot(), 'unrecorded-compactions.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
139
|
+
}
|
|
140
|
+
|
|
135
141
|
/** 引き継ぎ済みの旧タスクを止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
136
142
|
function recordAutoHandoffRedirect(entry) {
|
|
137
143
|
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
@@ -186,10 +192,18 @@ function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, re
|
|
|
186
192
|
export async function requestCodexAutoHandoff({ payload, db = null,
|
|
187
193
|
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
|
|
188
194
|
findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
|
|
189
|
-
recordInternalCompaction = recordInternalCompactionStop,
|
|
195
|
+
recordInternalCompaction = recordInternalCompactionStop, recordUnrecordedCompaction = recordUnrecordedCompactionPass,
|
|
190
196
|
processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
|
|
191
197
|
if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
|
|
192
198
|
const threadId = payload.session_id, turnId = payload.turn_id;
|
|
199
|
+
// 記録(rollout)を持たない会話からの合図。Codex Desktop は、ターンの終わりや会話を開いた時に、画面に出ない会話を
|
|
200
|
+
// 自分で立てる(2026-10-11、macOS。`transcript_path` が null、会話の ID は引き継ぎ元でも引き継ぎ先でもない)。
|
|
201
|
+
// Throughline はその会話を記録していないので、引き継ぐ記憶が無く、後継も作れない。失敗にせず、圧縮にも手を出さない (ADR 0054)。
|
|
202
|
+
if (payload.transcript_path === null || payload.transcript_path === undefined) {
|
|
203
|
+
recordUnrecordedCompaction({ at: new Date(now).toISOString(), thread_id: identityText(threadId), turn_id: identityText(turnId),
|
|
204
|
+
model: identityText(payload.model) });
|
|
205
|
+
return { status: 'skipped', unrecordedThread: true };
|
|
206
|
+
}
|
|
193
207
|
const located = CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') &&
|
|
194
208
|
typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) && isAbsolute(payload.cwd ?? '');
|
|
195
209
|
// Codex が自分で作った内部の文脈(ターンの ID が `auto-compact-N`)からの圧縮。利用者のターンではないので、
|
|
@@ -339,6 +339,34 @@ test('タスクの今の続きを引く。先までたどり、消えた後継
|
|
|
339
339
|
void Z;
|
|
340
340
|
}));
|
|
341
341
|
|
|
342
|
+
test('記録(rollout)を持たない会話からの圧縮の合図は、失敗にせず、圧縮にも手を出さない。引き継ぎは作らない', async () => withDb(async (db, home) => {
|
|
343
|
+
// 2026-10-11 16:22 JST、macOS の Codex Desktop。会話の ID は引き継ぎ元でも引き継ぎ先でもなく、`transcript_path` が null だった
|
|
344
|
+
const payload = { trigger: 'auto', cwd: home, session_id: '01a129d6-fe64-7010-9c79-19fe3b82c64d', turn_id: '01a129d6-fed1-7662-8286-280519f8d9bf',
|
|
345
|
+
transcript_path: null, model: 'gpt-6.1-sol' };
|
|
346
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
347
|
+
let launched = 0; const passed = [], stopped = [];
|
|
348
|
+
const args = { db, config, payload, launchWorker: async () => { launched++; }, recordUnrecordedCompaction: entry => passed.push(entry),
|
|
349
|
+
recordInternalCompaction: entry => stopped.push(entry), now: Date.parse('2026-10-11T07:22:00.396Z') };
|
|
350
|
+
const result = await requestCodexAutoHandoff(args);
|
|
351
|
+
assert.deepEqual(result, { status: 'skipped', unrecordedThread: true }, '圧縮を止める指示(continue: false)を返さない');
|
|
352
|
+
assert.equal(launched, 0);
|
|
353
|
+
assert.equal(db.prepare('SELECT COUNT(*) n FROM codex_handoffs').get().n, 0, '引き継ぎの行は作らない');
|
|
354
|
+
assert.deepEqual(passed, [{ at: '2026-10-11T07:22:00.396Z', thread_id: payload.session_id, turn_id: payload.turn_id, model: 'gpt-6.1-sol' }]);
|
|
355
|
+
assert.deepEqual(stopped, []);
|
|
356
|
+
|
|
357
|
+
// 項目そのものが無い時と、ターンの ID が内部の形の時も同じ。記号の混じる値は、記録へ中身を残さない
|
|
358
|
+
await requestCodexAutoHandoff({ ...args, payload: { trigger: 'auto', cwd: home, session_id: payload.session_id, turn_id: 'auto-compact-1', model: 'a model/with spaces' } });
|
|
359
|
+
assert.deepEqual(passed[1], { at: '2026-10-11T07:22:00.396Z', thread_id: payload.session_id, turn_id: 'auto-compact-1', model: 'text(19)' });
|
|
360
|
+
assert.deepEqual(stopped, []);
|
|
361
|
+
|
|
362
|
+
// 記録の場所が相対の時は、今までどおり失敗(記録を持つはずの会話で、場所だけが不正)
|
|
363
|
+
await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, transcript_path: 'relative/rollout.jsonl' } }), /handoff_hook_identity_invalid/);
|
|
364
|
+
// 無効な project と手動の圧縮は、記録も残さない
|
|
365
|
+
assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
|
|
366
|
+
assert.equal((await requestCodexAutoHandoff({ ...args, payload: { ...payload, trigger: 'manual' } })).status, 'skipped');
|
|
367
|
+
assert.equal(passed.length, 2);
|
|
368
|
+
}));
|
|
369
|
+
|
|
342
370
|
test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外にせず止めて記録する。引き継ぎは作らない', async () => withDb(async (db, home) => {
|
|
343
371
|
const thread = '11111111-1111-1111-1111-111111111111';
|
|
344
372
|
const file = join(home, 'rollout.jsonl'); writeFileSync(file, '');
|
|
@@ -360,16 +388,16 @@ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外に
|
|
|
360
388
|
|
|
361
389
|
// 形の決まっていない ID と、場所の分からない呼び出しは、今までどおり失敗として扱う
|
|
362
390
|
for (const broken of [{ turn_id: 'something-else' }, { turn_id: undefined }, { turn_id: 'auto-compact-' }, { session_id: 'not-a-uuid' },
|
|
363
|
-
{ transcript_path:
|
|
391
|
+
{ transcript_path: 'relative/rollout.jsonl' }]) {
|
|
364
392
|
await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
|
|
365
393
|
}
|
|
366
394
|
assert.equal(recorded.length, 1);
|
|
367
395
|
|
|
368
396
|
// 失敗には、どの値が不正だったかの形を付ける。path と記号の混じる値は、中身を残さない
|
|
369
|
-
const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path:
|
|
397
|
+
const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: 'relative/rollout.jsonl',
|
|
370
398
|
agent_type: 'reviewer', model: 'gpt-5.5' } }).catch(error => error);
|
|
371
399
|
assert.equal(shaped.code, 'handoff_hook_identity_invalid');
|
|
372
|
-
assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: '
|
|
400
|
+
assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: 'relative(22)', cwd: 'absolute',
|
|
373
401
|
agent_id: 'undefined', agent_type: 'reviewer', model: 'gpt-5.5', trigger: 'auto' });
|
|
374
402
|
assert.deepEqual(hookIdentityShape({ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative/rollout.jsonl', cwd: 7 }),
|
|
375
403
|
{ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative(22)', cwd: 'number',
|
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');
|