throughline 0.16.25 → 0.16.27
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 +64 -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/0033-claude-auto-handoff-new-session.md +8 -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/docs/adr/0057-claude-aiterm-seat-conversation-is-not-handed-off.md +88 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +40 -2
- package/src/claude-auto-handoff.test.mjs +118 -0
- package/src/claude-pre-compact.mjs +2 -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,67 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.27] — 2026-10-13
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **Aitermが起こしたClaude Codeの席の会話が、自動継続で裏の後継へ引き継がれ、呼び手の文が後継へ届かなくなっていた**([ADR 0057](docs/adr/0057-claude-aiterm-seat-conversation-is-not-handed-off.md))。Windowsの実機で2026-10-13に起きた(10-11にも1回)。
|
|
18
|
+
- Aitermの席は、起こした時の会話に結び付いている。後継は別の会話なので、呼び手が席へ送った文は旧い会話へ入り、後継の答えも呼び手へ戻らなかった。
|
|
19
|
+
- 直した後は、席の会話では自動圧縮を止めず、後継も立てない。席は同じ画面のまま続く。理由は判定のログに`aiterm_seat`で残る。
|
|
20
|
+
- 席と数えるのは、hookの環境に`AITERM_AGENT_LAUNCH_ID`があり、`AITERM_AGENT_VENDOR_SESSION_ID`がその会話のIDと同じ時だけ。起動の印だけでは外さない。裏の会話(`claude --bg`)はClaude Codeの常駐のprocessの環境を継ぐので、席の中から常駐が起きると、関係の無い裏の会話も席の印を持つ(Linux、Claude Code 2.1.296で実測)。
|
|
21
|
+
- **効くのは、`AITERM_AGENT_VENDOR_SESSION_ID`を出す版のAiterm(0.59.0の次の版)が起こした席から。** それまでの席は、今までどおり裏の後継へ引き継がれる。
|
|
22
|
+
- 席の会話に、後継を立てる前の引き継ぎの記録が残っている時は、記録を取り下げて圧縮と道具を通す。もう後継へ引き継いだ席の会話は、今までどおり後継を案内する。
|
|
23
|
+
|
|
24
|
+
### 変わる動き
|
|
25
|
+
|
|
26
|
+
- 効いた後の席では、文脈が一杯になるとClaude Codeの自動圧縮が走る。席の同じ画面で新しい会話へ替える形(圧縮なし)は、Aitermと合わせて次に作る。
|
|
27
|
+
- Aitermの席でない会話(端末・Claude Desktopで直接使う会話、その後継)の引き継ぎは変わらない。
|
|
28
|
+
|
|
29
|
+
### 対応の範囲
|
|
30
|
+
|
|
31
|
+
| | macOS | Linux | Windows |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| Claude Code(Aitermの席) | 直した。単体試験と、実機で候補の版の本物のhookの入口(`pre-compact`・`pre-tool-use`)へ、席・席の印を継いだだけの会話・印の無い会話の環境を通した。本物の席での通しは、会話IDを出す版のAitermが出てから | 直した。macOSと同じ確認(2台)。裏の会話が常駐の環境を継ぐ事は、ここで見た | 直した。起きた端末。macOSと同じ確認。本物の席での通しは、会話IDを出す版のAitermが出てから |
|
|
34
|
+
| Codex | 変更なし(自動継続はCodex Desktopだけ。Aitermの席のCLIは対象外) | 同じ | 同じ |
|
|
35
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
36
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
37
|
+
|
|
38
|
+
### 確かめていない事
|
|
39
|
+
|
|
40
|
+
- 会話IDを出す版のAitermが起こした本物の席で、本物の自動圧縮が起きた時の動き。その版はまだ出ていない。
|
|
41
|
+
- macOSとWindowsで、裏の会話が常駐の環境を継ぐか。
|
|
42
|
+
|
|
43
|
+
## [0.16.26] — 2026-10-12
|
|
44
|
+
|
|
45
|
+
### 修正
|
|
46
|
+
|
|
47
|
+
- **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`。
|
|
48
|
+
- Grokは自動圧縮で`chat_history.jsonl`を書き直す。Throughlineはターンの番号を記録の中の位置で振っているので、書き直した後の位置が、保存済みの別のターンの番号と重なった。重なった群を「保存済み」として飛ばし、完了受領を書く所で失敗していた。
|
|
49
|
+
- 代表の断片の位置が重なる形では、失敗も出ずに、圧縮をまたいだターンが落ちていた(2026-09-30の別の会話)。
|
|
50
|
+
- 直した後は、Grokの記録では、同じ位置に同じ本文のassistant行がある時だけ保存済みとする。書き直しの後のターンは、世代を分けた番号(世代×1,000,000+位置)で保存する。すでに番号が重なっている会話は、この版の最初のStopで、落ちていたターンが入る。
|
|
51
|
+
- **Grokが自分で書いたuser行(`system_reminder`・`compaction_meta`)を、人の発言として保存していた**(同じADR)。依頼の直後に注意書きが来ると、そのターンのuserの本文が注意書きになっていた。ターンの途中に来ると、1つのターンが2つに割れていた。直した後は、ターンの始まりにも本文にもしない。Throughlineが引き継ぎで注入する行も、この印で書いている。
|
|
52
|
+
- **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に起きた。
|
|
53
|
+
- 完了したターンを探す処理が、直後の入力を1通までしか遡らなかった(ADR 0026)。2通が続けて書かれると、完了したターンは2つ前の群になる。
|
|
54
|
+
- 保存の欠けは無かった。次のStopが、そのターンをまとめて取り込む。受領がそのぶん遅れ、失敗が1回数えられていた。
|
|
55
|
+
- 直した後は、assistantの本文が無い群(続けて届いた入力)を飛ばして、完了したターンを探す。採用の条件(未捕捉である事、次のターンが本文を書き始めていない事)は変えていない。
|
|
56
|
+
|
|
57
|
+
### 対応の範囲
|
|
58
|
+
|
|
59
|
+
| | macOS | Linux | Windows |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| Grok(記録の書き直し・合成行) | 直した。単体試験と、実機で本物のhookの命令(`process-turn`)へ合成した記録を通した。本物のGrokの自動圧縮をまたぐ場面は見ていない | 直した。起きた端末(コンテナ)。同じ確認と、コンテナの本物のDBのGrokの会話110件をメモリへ写した空実行(書き直しと判定したのは圧縮の跡がある2件だけ)。本物の自動圧縮をまたぐ場面は、直した版では見ていない | 直した。macOSと同じ確認。本物の自動圧縮をまたぐ場面は見ていない |
|
|
62
|
+
| Claude Code(待っていた入力が2通以上) | 直した。単体試験と、実機で本物のhookの命令へ合成した記録を通した。本物のClaude Codeでこの形が起きた事は無い | 直した。起きた端末(コンテナ)。同じ確認。直した版で本物の形が起きた事は、まだ無い | 直した。macOSと同じ確認 |
|
|
63
|
+
| Codex | 変更なし(Stopは別の入口で、rolloutから取り込む。rolloutは追記だけ) | 同じ | 同じ |
|
|
64
|
+
| Cursor | 変更なし(Stopの待ちを使わない。記録を書き直すかは確かめていない。コンテナの取り込みの記録169会話に、群が減った跡は無い) | 同じ | 同じ |
|
|
65
|
+
|
|
66
|
+
### 確かめていない事
|
|
67
|
+
|
|
68
|
+
- 本物のGrokが自動圧縮をまたぐ場面での、この版の動き。狙って起こせるかを確かめていない。コンテナの、番号が重なっている会話の次のStopで分かる。
|
|
69
|
+
- Grokの巻き戻しが`chat_history.jsonl`をどう書き直すか。実物を見ていない。
|
|
70
|
+
- 圧縮をまたいだターンの、元の依頼の文が`chat_history.jsonl`に残っているか。保存されるuserの本文は、圧縮の後に残った印の無いuser行。
|
|
71
|
+
- 本物のClaude Codeが、待っていた入力を2通続けて書く場面での、この版の動き。
|
|
72
|
+
- Cursorが記録を書き直すか。
|
|
73
|
+
|
|
13
74
|
## [0.16.25] — 2026-10-12
|
|
14
75
|
|
|
15
76
|
### 修正
|
|
@@ -2461,7 +2522,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2461
2522
|
|
|
2462
2523
|
---
|
|
2463
2524
|
|
|
2464
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2525
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.27...HEAD
|
|
2526
|
+
[0.16.27]: https://github.com/kitepon/Throughline/compare/v0.16.26...v0.16.27
|
|
2527
|
+
[0.16.26]: https://github.com/kitepon/Throughline/compare/v0.16.25...v0.16.26
|
|
2465
2528
|
[0.16.25]: https://github.com/kitepon/Throughline/compare/v0.16.24...v0.16.25
|
|
2466
2529
|
[0.16.24]: https://github.com/kitepon/Throughline/compare/v0.16.23...v0.16.24
|
|
2467
2530
|
[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つ以上遡らない事は変えていない。
|
|
@@ -106,6 +106,14 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
106
106
|
落として保存する。
|
|
107
107
|
- 依存の `aiterm-steer-delivery` を 0.1.9 から 0.1.13 に上げる。Codex の配送の入口は変わらない。
|
|
108
108
|
|
|
109
|
+
## 追記(2026-10-13、ADR 0057)
|
|
110
|
+
|
|
111
|
+
Decision 1 の「止めない会話」に、Aiterm が起こした席の会話を足した。席は起こした時の会話に結び付いていて、裏の後継へ
|
|
112
|
+
引き継ぐと、呼び手の文が後継へ届かない。席と数える条件と、常駐の process が環境を継がせる事は ADR 0057 に書いた。
|
|
113
|
+
|
|
114
|
+
hook で止めたターンで Stop hook が走らない事は、Claude Code 2.1.296(Windows)の実機の記録でも同じだった
|
|
115
|
+
(10:48:08 に止めた会話の次の Stop は、後から文が入った 11:02:52)。
|
|
116
|
+
|
|
109
117
|
## 確認した範囲
|
|
110
118
|
|
|
111
119
|
- Linux の Claude Code 2.1.289。対話画面の会話から始めて、Haiku 4.5 で3回、Opus 5.5 で6回、連続で引き継ぎ、
|
|
@@ -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 会話に、群が減った跡は無い)。
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# ADR 0057: Aiterm が起こした席の Claude の会話は、裏の後継へ引き継がない
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-13
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Claude Code の自動継続(ADR 0033)は、自動圧縮を止め、次の道具でターンを止めて、`claude --bg` の後継へ引き継ぐ。
|
|
9
|
+
引き継ぎを外すのは、手動の `/compact`、無効な project、subagent の中の圧縮、script から起動した会話
|
|
10
|
+
(`CLAUDE_CODE_ENTRYPOINT` が `sdk-` で始まる)だけだった。
|
|
11
|
+
|
|
12
|
+
Aiterm が起こした Claude の席(Bot がほかの端末に起こす子、通話の MCP が立てる席)は、入口が `cli` の対話の会話なので、
|
|
13
|
+
人の端末の会話と同じに引き継がれていた。席での振る舞いは、決めていなかった。
|
|
14
|
+
|
|
15
|
+
2026-10-13 10:48(JST)、Windows の実機で、Aiterm の remote で起こした席の会話が後継へ引き継がれた。Throughline の記録では
|
|
16
|
+
引き継ぎは成立している(`sent`)。席の側では次の事が起きた(Aiterm の担当の調べ)。
|
|
17
|
+
|
|
18
|
+
- Aiterm の席は、起こした時の会話 ID に結び付いている。別の会話の番の終わりは、席の物と数えない。
|
|
19
|
+
- 後継は Claude Code の常駐の process の下で動き、席の端末とは別の木にある。
|
|
20
|
+
- 呼び手が席へ送った文は旧い会話へ入り、後継へ届かない。後継の答えも呼び手へ戻らない。2通が落ち、呼び手が
|
|
21
|
+
`claude attach` で手でつないだ。
|
|
22
|
+
|
|
23
|
+
同じ形は 2026-10-11 にも1回起きている(通話の MCP が立てた席)。BellTeam のコンテナは、席の配送が終わらなくなる事が
|
|
24
|
+
分かっていたので、自動継続を無効にしてある。Windows・macOS・Linux の3台は全 project で有効にしていて、そこへ Aiterm が
|
|
25
|
+
起こした席が入っていた。
|
|
26
|
+
|
|
27
|
+
オーナーの決め(2026-10-13): 席では引き継がない形を先に入れ、続けて「席の同じ画面で新しい会話へ替える」形を Aiterm と
|
|
28
|
+
作る。後の形が出来れば、席でも圧縮は走らない。この ADR は先に入れる分を決める。
|
|
29
|
+
|
|
30
|
+
### 席を何で見分けるか
|
|
31
|
+
|
|
32
|
+
Aiterm は、席の process の環境へ起動の印(`AITERM_AGENT_LAUNCH_ID`、`AITERM_AGENT_KIND` など)を入れる。人が Aiterm の
|
|
33
|
+
端末の中で手で起こした claude には付かない(その端末にあるのは `AITERM_SESSION_ID` と `AITERM_STATE_BASE` だけ)。
|
|
34
|
+
|
|
35
|
+
印だけで席と決める形は、採れなかった。Linux の実機(Claude Code 2.1.296)で、裏の会話を2本、違う環境で続けて立てた。
|
|
36
|
+
1本目は `AITERM_AGENT_LAUNCH_ID` を付け、2本目は `AITERM_AGENT_*` を全部外した環境から立てた。2本目の会話の hook
|
|
37
|
+
(`SessionStart`・`UserPromptSubmit`・`Stop`)が受けた環境は、1本目と同じ印を持っていた。裏の会話は、起こした側ではなく、
|
|
38
|
+
Claude Code の常駐の process の環境を継ぐ。常駐は、最初に起こした process の環境を持ち続ける。
|
|
39
|
+
|
|
40
|
+
つまり、席の中(席の Bash、席の hook が起こす Throughline の worker)から常駐が起きると、その後に立つ裏の会話は、席と
|
|
41
|
+
関係が無くても席の印を持つ。印だけで外すと、利用者の会話の後継(裏の会話)まで引き継ぎから外れ、自動圧縮が走る。
|
|
42
|
+
Throughline は自動圧縮を置き換える物で、利用者の会話でそれが起きるのは、席の文が落ちる事より重い。
|
|
43
|
+
|
|
44
|
+
Aiterm は、席の Claude を `--session-id <ID>` で起こし、その ID だけを席の物と数える。Aiterm の担当と合わせて、この ID を
|
|
45
|
+
席の環境へ `AITERM_AGENT_VENDOR_SESSION_ID` として出してもらう(Aiterm 0.59.0 の次の版から。席の途中で変わらない)。
|
|
46
|
+
|
|
47
|
+
## Decision
|
|
48
|
+
|
|
49
|
+
1. **席の会話**は、hook の環境に `AITERM_AGENT_LAUNCH_ID` があり、かつ `AITERM_AGENT_VENDOR_SESSION_ID` が hook の
|
|
50
|
+
`session_id` と同じ会話とする(`isAitermSeatConversation`)。起動の印だけでは席と数えない。
|
|
51
|
+
2. `PreCompact(auto)` は、席の会話では圧縮を止めず、引き継ぎの記録も残さない。理由は `aiterm_seat`。判定の順は、
|
|
52
|
+
手動の `/compact` → 進んでいる引き継ぎ → 無効な project → script から起動した会話 → 席。自動継続が無効な端末の
|
|
53
|
+
席は、今までどおり `auto_handoff_disabled` で終わる。
|
|
54
|
+
3. 席の会話に、後継を立てる前の記録(`requested`・`failed`)が残っている時は、`PreCompact` と `PreToolUse` が記録と
|
|
55
|
+
立ち上げの札を取り下げ、圧縮と道具を通す。席を外す前の版が予約した会話と、会話 ID を出さない版の Aiterm の時に
|
|
56
|
+
予約した会話が、これに当たる。
|
|
57
|
+
4. もう後継へ引き継いだ席の会話(`stopped` から先)は、今までどおり圧縮と道具を止め、後継を案内する。続きは後継にある。
|
|
58
|
+
5. 席の印を継いだだけの会話(席が起こした子、席の中から起きた常駐が立てた裏の会話)は、会話 ID が違うので、
|
|
59
|
+
今までどおり引き継ぐ。
|
|
60
|
+
6. 会話 ID を出さない版の Aiterm が起こした席は、見分けられないので、今までどおり引き継ぐ。
|
|
61
|
+
7. `AITERM_AGENT_LAUNCH_ID` と `AITERM_AGENT_VENDOR_SESSION_ID` の名前と意味は、Aiterm の担当と合わせた物とする。
|
|
62
|
+
変える時は、先に知らせ合う。
|
|
63
|
+
|
|
64
|
+
## Consequences
|
|
65
|
+
|
|
66
|
+
- この版が効くのは、`AITERM_AGENT_VENDOR_SESSION_ID` を出す版の Aiterm が起こした席から。それまでの席は、今までどおり
|
|
67
|
+
裏の後継へ引き継がれ、呼び手の文は後継へ届かない。
|
|
68
|
+
- 効いた後の席は、同じ画面のまま続き、呼び手との行き来は切れない。文脈が一杯になると、Claude Code の自動圧縮が走る
|
|
69
|
+
(BellTeam のコンテナの席と同じ)。圧縮より前の細かい文脈は、Claude の要約に置き換わる。会話の記録は、今までどおり
|
|
70
|
+
Stop で Throughline に入る。
|
|
71
|
+
- 「host の自動圧縮を通さない」(ADR 0033)は、席の会話については、同じ画面で新しい会話へ替える形が出来るまで外す。
|
|
72
|
+
- 席の会話を起こし直す・`/clear` するなどで会話 ID が変わった時は、環境の ID と合わなくなり、今までどおり引き継ぐ。
|
|
73
|
+
- 席の環境へ足す印は、同じ道でほかの裏の会話へ継がれる。この先、席だけの動きを足す時も、会話 ID の一致で席と決めてから
|
|
74
|
+
印を読む。
|
|
75
|
+
- 後継を立てる worker は、席の `AITERM_AGENT_*` を外さずに `claude --bg` を呼ぶ(外すのは会話ごとの `CLAUDE_CODE_*` など)。
|
|
76
|
+
常駐が動いている時は、後継の環境は常駐の物になるので、外しても変わらない。ここは変えていない。
|
|
77
|
+
|
|
78
|
+
## 確認した範囲
|
|
79
|
+
|
|
80
|
+
- 単体試験と、本物の CLI の入口(`pre-compact`・`pre-tool-use`)を仮の HOME で動かす試験。
|
|
81
|
+
- 公開前に、候補の tarball を macOS(Node 26.11.1)・Linux(Node 24.20.0 と 26.11.1)・Windows 11(Node 24.20.0)へ置き、
|
|
82
|
+
仮の HOME で本物の hook の入口へ5つの場面を通した。席の環境は圧縮も道具も止めず記録を残さない。席の印を継いだだけの
|
|
83
|
+
環境・起動の印だけの環境・印の無い環境は、今までどおり圧縮を止めて引き継ぎを予約する。予約が残った席の会話は、次の
|
|
84
|
+
道具で記録を取り下げる。4台とも同じ結果だった。
|
|
85
|
+
- 裏の会話が常駐の環境を継ぐ事は、Linux(Claude Code 2.1.296)だけで見た。macOS と Windows では見ていない
|
|
86
|
+
(動いている常駐の下に試験の会話を立てる事になるので、流していない)。
|
|
87
|
+
- `AITERM_AGENT_VENDOR_SESSION_ID` を出す版の Aiterm は、まだ出ていない。本物の席での通し(本物の自動圧縮が起き、
|
|
88
|
+
席が同じ画面のまま答えを返す所)は、その版が入った後に確かめる。
|
package/package.json
CHANGED
|
@@ -260,6 +260,24 @@ function unsupportedEntrypoint(env) {
|
|
|
260
260
|
return typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' && env.CLAUDE_CODE_ENTRYPOINT.startsWith('sdk-');
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
// Aiterm が席の Claude に付ける印(ADR 0057)。名前と意味は Aiterm の担当と合わせてある。変える時は先に知らせ合う。
|
|
264
|
+
// 起動の印と、席の Claude を起こした時の会話 ID(`--session-id` の値。席の途中で変わらない)。
|
|
265
|
+
const AITERM_LAUNCH_ENV = 'AITERM_AGENT_LAUNCH_ID';
|
|
266
|
+
const AITERM_SEAT_SESSION_ENV = 'AITERM_AGENT_VENDOR_SESSION_ID';
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Aiterm が起こした席の会話か(ADR 0057)。席は起こした時の会話に結び付いていて、呼び手はその会話の番の終わりを待っている。
|
|
270
|
+
* 裏の後継へ引き継ぐと、呼び手の文は後継へ届かず、後継の答えも呼び手へ戻らない。席の会話は引き継がない。
|
|
271
|
+
*
|
|
272
|
+
* 席と数えるのは、環境の会話 ID がこの会話の ID と同じ時だけ。起動の印だけでは決めない。印は席の Claude が起こした子へ
|
|
273
|
+
* 継がれ、裏の会話(`claude --bg`)は常駐の process の環境を継ぐ。席の中から常駐が起きると、その後に立つ裏の会話は、
|
|
274
|
+
* 席と関係が無くても席の印を持つ(Linux、Claude Code 2.1.296 で実測)。印だけで外すと、利用者の会話の後継まで外れる。
|
|
275
|
+
* 会話 ID を出さない版の Aiterm が起こした席は、見分けられないので、今までどおり引き継ぐ。
|
|
276
|
+
*/
|
|
277
|
+
export function isAitermSeatConversation(env, sessionId) {
|
|
278
|
+
return Boolean(env?.[AITERM_LAUNCH_ENV]) && typeof sessionId === 'string' && env[AITERM_SEAT_SESSION_ENV] === sessionId;
|
|
279
|
+
}
|
|
280
|
+
|
|
263
281
|
/** transcript の最後の行が持つ出どころ(`cli`、`claude-desktop` など)。読めなければ null。 */
|
|
264
282
|
function readLatestEntrypoint(transcriptPath) {
|
|
265
283
|
if (typeof transcriptPath !== 'string') return null;
|
|
@@ -377,8 +395,12 @@ export function requestClaudeAutoHandoff({
|
|
|
377
395
|
if (existing?.state === 'requested') rmSync(path, { force: true });
|
|
378
396
|
return { status: 'skipped', block: false, reason: 'manual_compact', sessionId, projectPath };
|
|
379
397
|
}
|
|
398
|
+
const seat = isAitermSeatConversation(env, sessionId);
|
|
399
|
+
// 席の会話に、後継を立てる前の記録が残っている(席を外す前の版が予約した)。取り下げて、圧縮を通す。
|
|
400
|
+
const withdrawn = seat && existing && RETRYABLE_STATES.has(existing.state);
|
|
401
|
+
if (withdrawn) withdrawRecord(sessionId, dir);
|
|
380
402
|
// 引き継ぎが進んでいる会話は、設定が後から変わっても圧縮させない。
|
|
381
|
-
if (existing) {
|
|
403
|
+
if (existing && !withdrawn) {
|
|
382
404
|
return { status: 'already_requested', block: true, sessionId, projectPath, handoffId: existing.handoff_id };
|
|
383
405
|
}
|
|
384
406
|
if (!claudeAutoHandoffEnabledFor(config, projectPath)) {
|
|
@@ -387,6 +409,9 @@ export function requestClaudeAutoHandoff({
|
|
|
387
409
|
if (unsupportedEntrypoint(env)) {
|
|
388
410
|
return { status: 'skipped', block: false, reason: 'handoff_host_unsupported', sessionId, projectPath };
|
|
389
411
|
}
|
|
412
|
+
if (seat) {
|
|
413
|
+
return { status: 'skipped', block: false, reason: 'aiterm_seat', sessionId, projectPath };
|
|
414
|
+
}
|
|
390
415
|
|
|
391
416
|
// 後継は、最初の指示が運ぶ引き継ぎ ID からこの記録を引いて、この会話を前任として合流させる(ADR 0053)。
|
|
392
417
|
// project ごとに1つしか無い `/tl` の印は書かない。印を書いてから後継が立つまでの間に、同じ project の別の会話が
|
|
@@ -613,6 +638,12 @@ export async function launchClaudeAutoHandoffWorker(sessionId, { dir = claudeAut
|
|
|
613
638
|
} finally { closeSync(log); }
|
|
614
639
|
}
|
|
615
640
|
|
|
641
|
+
/** 後継を立てる前の記録と、立ち上げの札を消す。その会話は、記録が無い会話と同じ扱いに戻る。 */
|
|
642
|
+
function withdrawRecord(sessionId, dir) {
|
|
643
|
+
rmSync(recordPath(sessionId, dir), { force: true });
|
|
644
|
+
rmSync(join(dir, `${sessionId}.claim`), { force: true });
|
|
645
|
+
}
|
|
646
|
+
|
|
616
647
|
function stopOutput(record) {
|
|
617
648
|
const reason = record.successor?.short_id
|
|
618
649
|
? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
|
|
@@ -631,7 +662,8 @@ function stopOutput(record) {
|
|
|
631
662
|
/**
|
|
632
663
|
* PreToolUse hook の本体。引き継ぎの記録がある会話の道具を、実行させずに止める。
|
|
633
664
|
* 最初に止めた hook だけが、止めた時点の依頼と設定を記録へ写し、後継を立てる worker を起動する。
|
|
634
|
-
* 記録が無い会話と、subagent の中の道具には何もしない(null)。
|
|
665
|
+
* 記録が無い会話と、subagent の中の道具には何もしない(null)。Aiterm が起こした席の会話に、後継を立てる前の記録が
|
|
666
|
+
* 残っている時は、記録を取り下げて何もしない(ADR 0057)。
|
|
635
667
|
*
|
|
636
668
|
* @returns {Promise<object|null>} Claude Code へ返す hook 出力
|
|
637
669
|
*/
|
|
@@ -640,12 +672,18 @@ export async function stopClaudeTurnForHandoff({
|
|
|
640
672
|
now = Date.now(),
|
|
641
673
|
dir = claudeAutoHandoffDir(),
|
|
642
674
|
launchWorker = launchClaudeAutoHandoffWorker,
|
|
675
|
+
env = process.env,
|
|
643
676
|
}) {
|
|
644
677
|
const sessionId = payload?.session_id;
|
|
645
678
|
if (!validSessionId(sessionId) || payload.agent_id) return null;
|
|
646
679
|
let record = readClaudeAutoHandoff(sessionId, dir);
|
|
647
680
|
if (!record) return null;
|
|
648
681
|
if (RETRYABLE_STATES.has(record.state)) {
|
|
682
|
+
// 席の会話は引き継がない(ADR 0057)。席を外す前の版が残した、後継を立てる前の記録は取り下げ、道具を通す。
|
|
683
|
+
if (isAitermSeatConversation(env, sessionId)) {
|
|
684
|
+
withdrawRecord(sessionId, dir);
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
649
687
|
// 同じ応答に並んだ道具の hook は同時に走る。立ち上げは1回だけ。
|
|
650
688
|
const claim = join(dir, `${sessionId}.claim`);
|
|
651
689
|
if (record.state === 'failed') rmSync(claim, { force: true });
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
runClaudeDesktopOpen,
|
|
22
22
|
findClaudeAutoContinuationSource,
|
|
23
23
|
resolveClaudeAutoHandoffSuccessor,
|
|
24
|
+
isAitermSeatConversation,
|
|
24
25
|
} from './claude-auto-handoff.mjs';
|
|
25
26
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
26
27
|
|
|
@@ -182,6 +183,93 @@ test('PreCompact: 手動・無効・対象外のproject・scriptから起動し
|
|
|
182
183
|
assert.equal(request(dir, { env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).result.status, 'requested');
|
|
183
184
|
}));
|
|
184
185
|
|
|
186
|
+
test('席の判定: 起動の印があり、環境の会話 ID がこの会話と同じ時だけ席と数える', () => {
|
|
187
|
+
const OTHER = 'cccccccc-1111-4222-8333-444444444444';
|
|
188
|
+
for (const [env, expected, label] of [
|
|
189
|
+
[{}, false, '印なし(人の端末、Desktop)'],
|
|
190
|
+
[{ AITERM_SESSION_ID: 't1', AITERM_STATE_BASE: '/tmp' }, false, 'Aiterm の素の端末で人が起こした claude'],
|
|
191
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude' }, false, '会話 ID を出さない版の Aiterm の席。印だけでは決めない'],
|
|
192
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: '' }, false, '空の会話 ID'],
|
|
193
|
+
[{ AITERM_AGENT_VENDOR_SESSION_ID: SESSION }, false, '起動の印が無い'],
|
|
194
|
+
[{ AITERM_AGENT_LAUNCH_ID: '', AITERM_AGENT_VENDOR_SESSION_ID: SESSION }, false, '空の印'],
|
|
195
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude', AITERM_AGENT_VENDOR_SESSION_ID: SESSION }, true, '席の会話'],
|
|
196
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude', AITERM_AGENT_VENDOR_SESSION_ID: OTHER }, false,
|
|
197
|
+
'席の印を継いだ別の会話(席が起こした子、席の中から起きた常駐が立てた裏の会話)'],
|
|
198
|
+
]) assert.equal(isAitermSeatConversation(env, SESSION), expected, label);
|
|
199
|
+
assert.equal(isAitermSeatConversation(undefined, SESSION), false);
|
|
200
|
+
assert.equal(isAitermSeatConversation({ AITERM_AGENT_LAUNCH_ID: 'abc' }, undefined), false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const SEAT = { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude', AITERM_AGENT_VENDOR_SESSION_ID: SESSION };
|
|
204
|
+
// 席の印を継いだだけの、別の会話の環境
|
|
205
|
+
const SEAT_DESCENDANT = { ...SEAT, AITERM_AGENT_VENDOR_SESSION_ID: SUCCESSOR };
|
|
206
|
+
|
|
207
|
+
test('PreCompact: Aiterm の席の会話は引き継がない。圧縮を止めず、記録も残さない', () => withDir(dir => {
|
|
208
|
+
for (const env of [SEAT, { ...SEAT, CLAUDE_CODE_ENTRYPOINT: 'cli' }]) {
|
|
209
|
+
const { result, db } = request(dir, { env });
|
|
210
|
+
assert.deepEqual([result.status, result.block, result.reason], ['skipped', false, 'aiterm_seat']);
|
|
211
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
212
|
+
assert.deepEqual(listClaudeAutoHandoffs({ dir }), []);
|
|
213
|
+
}
|
|
214
|
+
// 自動継続が無効な端末の席は、今までどおり「無効」で終わる
|
|
215
|
+
assert.equal(request(dir, { env: SEAT, config: { enabled: false, projects: [] } }).result.reason, 'auto_handoff_disabled');
|
|
216
|
+
// 席の印を継いだ別の会話と、会話 ID を出さない版の Aiterm の席は、今までどおり引き継ぐ
|
|
217
|
+
for (const env of [SEAT_DESCENDANT, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude' }]) {
|
|
218
|
+
assert.equal(request(dir, { env }).result.status, 'requested');
|
|
219
|
+
rmSync(join(dir, `${SESSION}.json`));
|
|
220
|
+
}
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
test('PreCompact: 席の会話に残っていた、後継を立てる前の記録は取り下げる。後継を立てた後の記録は保つ', () => withDir(dir => {
|
|
224
|
+
// 席を外す前の版(か、会話 ID を出さない版の Aiterm の時)に予約した記録
|
|
225
|
+
assert.equal(request(dir).result.status, 'requested');
|
|
226
|
+
writeFileSync(join(dir, `${SESSION}.claim`), '');
|
|
227
|
+
const withdrawn = request(dir, { env: SEAT }).result;
|
|
228
|
+
assert.deepEqual([withdrawn.status, withdrawn.block, withdrawn.reason], ['skipped', false, 'aiterm_seat']);
|
|
229
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir), null);
|
|
230
|
+
assert.equal(existsSync(join(dir, `${SESSION}.claim`)), false);
|
|
231
|
+
|
|
232
|
+
// もう後継へ引き継いだ席の会話は、今までどおり圧縮させない(続きは後継にある)
|
|
233
|
+
const { result } = request(dir);
|
|
234
|
+
const path = join(dir, `${SESSION}.json`);
|
|
235
|
+
writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), state: 'sent',
|
|
236
|
+
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
237
|
+
const kept = request(dir, { env: SEAT }).result;
|
|
238
|
+
assert.deepEqual([kept.status, kept.block, kept.handoffId], ['already_requested', true, result.handoffId]);
|
|
239
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'sent');
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
test('PreToolUse: 席の会話は止めない。後継を立てる前の記録は取り下げ、道具を通す', () => withDir(async dir => {
|
|
243
|
+
const payload = { session_id: SESSION, cwd: '/work/app', tool_name: 'Read', tool_use_id: 'toolu_2' };
|
|
244
|
+
const launched = [];
|
|
245
|
+
const launchWorker = async sessionId => { launched.push(sessionId); };
|
|
246
|
+
|
|
247
|
+
for (const state of ['requested', 'failed']) {
|
|
248
|
+
request(dir);
|
|
249
|
+
const path = join(dir, `${SESSION}.json`);
|
|
250
|
+
writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), state }));
|
|
251
|
+
writeFileSync(join(dir, `${SESSION}.claim`), '');
|
|
252
|
+
assert.equal(await stopClaudeTurnForHandoff({ payload, dir, launchWorker, env: SEAT }), null, state);
|
|
253
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir), null, state);
|
|
254
|
+
assert.equal(existsSync(join(dir, `${SESSION}.claim`)), false, state);
|
|
255
|
+
}
|
|
256
|
+
assert.deepEqual(launched, [], '後継は立てない');
|
|
257
|
+
|
|
258
|
+
// 席の印を継いだ別の会話は、今までどおり止めて後継を立てる
|
|
259
|
+
request(dir);
|
|
260
|
+
const child = await stopClaudeTurnForHandoff({ payload, dir, launchWorker, env: SEAT_DESCENDANT });
|
|
261
|
+
assert.equal(child.continue, false);
|
|
262
|
+
assert.deepEqual(launched, [SESSION]);
|
|
263
|
+
|
|
264
|
+
// もう後継へ引き継いだ席の会話の道具は、今までどおり止めて後継を案内する
|
|
265
|
+
const path = join(dir, `${SESSION}.json`);
|
|
266
|
+
writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), state: 'sent',
|
|
267
|
+
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
268
|
+
const stopped = await stopClaudeTurnForHandoff({ payload, dir, launchWorker, env: SEAT });
|
|
269
|
+
assert.equal(stopped.continue, false);
|
|
270
|
+
assert.match(stopped.stopReason, new RegExp(`claude attach ${SUCCESSOR.slice(0, 8)}`));
|
|
271
|
+
}));
|
|
272
|
+
|
|
185
273
|
test('PreCompact: 手動の/compactは止める前の記録を取り下げ、subagentの圧縮は親の記録に触らない', () => withDir(dir => {
|
|
186
274
|
request(dir);
|
|
187
275
|
assert.equal(request(dir, { payload: { trigger: 'auto', agent_id: 'agent-1' } }).result.reason, 'subagent_compact');
|
|
@@ -1455,6 +1543,36 @@ test('hook: 自動継続が無効な時、自動圧縮を止めず、何も残
|
|
|
1455
1543
|
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '無効な時は DB も開かない');
|
|
1456
1544
|
}));
|
|
1457
1545
|
|
|
1546
|
+
test('hook: Aiterm の席の会話は、自動継続が有効でも自動圧縮を止めず、道具も止めない', () => withDir(home => {
|
|
1547
|
+
const project = join(home, 'project');
|
|
1548
|
+
mkdirSync(project, { recursive: true });
|
|
1549
|
+
const seat = { AITERM_AGENT_LAUNCH_ID: '4c83fc1dd27459a9', AITERM_AGENT_KIND: 'claude', AITERM_SESSION_ID: 'seat-1',
|
|
1550
|
+
AITERM_AGENT_VENDOR_SESSION_ID: SESSION };
|
|
1551
|
+
const payload = { session_id: SESSION, cwd: project, transcript_path: join(home, 'transcript.jsonl') };
|
|
1552
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
|
|
1553
|
+
|
|
1554
|
+
const pre = cli(home, ['pre-compact'], JSON.stringify({ ...payload, hook_event_name: 'PreCompact', trigger: 'auto' }), seat);
|
|
1555
|
+
assert.deepEqual([pre.status, pre.stdout, pre.stderr], [0, '', '']);
|
|
1556
|
+
const tool = cli(home, ['pre-tool-use'], JSON.stringify({ ...payload, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_1' }), seat);
|
|
1557
|
+
assert.deepEqual([tool.status, tool.stdout, tool.stderr], [0, '', '']);
|
|
1558
|
+
assert.deepEqual(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs, []);
|
|
1559
|
+
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
1560
|
+
assert.deepEqual(decisions.filter(entry => entry.phase === 'pre-compact').map(entry => [entry.auto_continuation, entry.skip_reason]),
|
|
1561
|
+
[['skipped', 'aiterm_seat']]);
|
|
1562
|
+
|
|
1563
|
+
// 席の印を継いだだけの別の会話(会話 ID が違う)と、印の無い会話は、今までどおり止める
|
|
1564
|
+
const OTHER = 'cccccccc-1111-4222-8333-444444444444';
|
|
1565
|
+
const descendant = cli(home, ['pre-compact'], JSON.stringify({ ...payload, session_id: OTHER, hook_event_name: 'PreCompact', trigger: 'auto' }), seat);
|
|
1566
|
+
assert.equal(descendant.status, 2);
|
|
1567
|
+
assert.equal(cli(home, ['pre-compact'], JSON.stringify({ ...payload, hook_event_name: 'PreCompact', trigger: 'manual', session_id: OTHER }), seat).status, 0);
|
|
1568
|
+
const plain = cli(home, ['pre-compact'], JSON.stringify({ ...payload, hook_event_name: 'PreCompact', trigger: 'auto' }));
|
|
1569
|
+
assert.equal(plain.status, 2);
|
|
1570
|
+
// その記録が残った席の会話(席を外す前の版が予約した形)は、次の道具で取り下げる
|
|
1571
|
+
const next = cli(home, ['pre-tool-use'], JSON.stringify({ ...payload, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_2' }), seat);
|
|
1572
|
+
assert.deepEqual([next.status, next.stdout], [0, '']);
|
|
1573
|
+
assert.deepEqual(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs, []);
|
|
1574
|
+
}));
|
|
1575
|
+
|
|
1458
1576
|
test('hook: 自分の失敗では圧縮も道具も止めず(exit code 1)、理由を端末に残す', () => withDir(home => {
|
|
1459
1577
|
const compact = cli(home, ['pre-compact'], '{"trigger":"auto"}');
|
|
1460
1578
|
assert.deepEqual([compact.status, compact.stdout], [1, '']);
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 圧縮を止める。Claude Code は PreCompact の exit code 2 を「圧縮を止める」と解釈する。
|
|
9
9
|
* 止めた後の会話は圧縮されないまま続き、次の道具の hook(PreToolUse)が作業を止める。
|
|
10
10
|
*
|
|
11
|
-
* 手動の /compact、無効な project、subagent
|
|
11
|
+
* 手動の /compact、無効な project、subagent の中の圧縮、Aiterm が起こした席の会話(ADR 0057)では、何も止めずに
|
|
12
|
+
* exit code 0 で終わる。
|
|
12
13
|
* この hook 自身が失敗した時は exit code 1 で終わり、圧縮は止めない。
|
|
13
14
|
*/
|
|
14
15
|
|
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');
|