throughline 0.12.3 → 0.12.5
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 +21 -1
- package/bin/throughline.mjs +2 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +1 -0
- package/docs/adr/0022-cursor-host-capture.md +2 -0
- package/docs/adr/0027-stop-flush-barrier-trimmed-marker.md +62 -0
- package/package.json +1 -1
- package/src/cli/codex-hook.mjs +4 -0
- package/src/hook-entrypoints.test.mjs +132 -0
- package/src/hook-failure-log.mjs +47 -0
- package/src/hook-failure-log.test.mjs +49 -0
- package/src/hosts/cursor.mjs +2 -2
- package/src/hosts/cursor.test.mjs +22 -1
- package/src/hosts/index.mjs +6 -1
- package/src/prompt-submit.mjs +4 -2
- package/src/session-start.mjs +4 -2
- package/src/turn-processor.mjs +13 -5
- package/src/turn-processor.test.mjs +47 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.12.5] — 2026-10-04
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Claudeの応答が空白や改行で始まる(終わる)turnで、Stop hookが2秒待って`HOOK_PROCESS_TURN_FAILED`で失敗していた不具合を直す。Claude CodeはStop payloadの`last_assistant_message`を`.trim()`して渡すが、transcriptにはtrimする前の本文が残る。flush barrierは完全一致で比べていたので、transcriptが出そろっていても一致しなかった。前後の空白を除いて比べる([ADR 0027](docs/adr/0027-stop-flush-barrier-trimmed-marker.md))。保存する本文はtranscriptのままで変えない。
|
|
18
|
+
- Macで記録された最後の`HOOK_PROCESS_TURN_FAILED`(2026-08-31)がこの形だった。その時のtranscriptでStopを再生すると、0.10.3・0.12.1・0.12.4のどれも失敗し、この版では成功する。Macに残る8月のtranscriptでは、空白で始まる応答のStopが96回あり、96回とも保存されていなかった。
|
|
19
|
+
|
|
20
|
+
### 追加
|
|
21
|
+
|
|
22
|
+
- hookが失敗した時の理由を、端末内の`~/.throughline/logs/hook-failures.log`へ残す。1行のJSONで、項目は`ts`・`code`・`version`・`name`・`message`(1000字まで)。SessionStart・UserPromptSubmit・Stop・Codex hookが対象。runtime errorの収集・送信の設定とは独立に書き、外へは送らない。runtime error storeは定型codeと回数だけを持つので、これまでは失敗の理由が残らず、後から原因を追えなかった。
|
|
23
|
+
|
|
24
|
+
## [0.12.4] — 2026-10-04
|
|
25
|
+
|
|
26
|
+
### 修正
|
|
27
|
+
|
|
28
|
+
- WindowsのCursorがhookの標準入力へ付けるUTF-8 BOMをJSONとして読もうとして、SessionStart・UserPromptSubmit・Stopが失敗する不具合を直す。共有の読取処理で先頭のBOMを1つ取り除いてから解析する。JSONの文字列中のBOMは保ち、不正なJSONは引き続き失敗として扱う。Windowsの実hook入力でSessionStartの例外を再現し、3種類のhookで会話の保存と不正JSONの拒否を試験した。
|
|
29
|
+
- Cursorの`transcript_path`が未指定の場合、projectのフォルダ名をCursor自身と同じ規則で作る。以前はアンダースコアやドットを残していたため、foxの`C:\Users\kite_\.cache\…`などで実際の記録フォルダを見つけられなかった。英数字以外をハイフンに置き換え、連続するハイフンと両端を整える。指定された`transcript_path`はそのまま優先する。
|
|
30
|
+
|
|
13
31
|
## [0.12.3] — 2026-10-03
|
|
14
32
|
|
|
15
33
|
### 修正
|
|
@@ -1570,7 +1588,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1570
1588
|
|
|
1571
1589
|
---
|
|
1572
1590
|
|
|
1573
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.
|
|
1591
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.5...HEAD
|
|
1592
|
+
[0.12.5]: https://github.com/kitepon/Throughline/compare/v0.12.4...v0.12.5
|
|
1593
|
+
[0.12.4]: https://github.com/kitepon/Throughline/compare/v0.12.3...v0.12.4
|
|
1574
1594
|
[0.12.3]: https://github.com/kitepon/Throughline/compare/v0.12.2...v0.12.3
|
|
1575
1595
|
[0.12.2]: https://github.com/kitepon/Throughline/compare/v0.12.1...v0.12.2
|
|
1576
1596
|
[0.12.1]: https://github.com/kitepon/Throughline/compare/v0.12.0...v0.12.1
|
package/bin/throughline.mjs
CHANGED
|
@@ -237,6 +237,8 @@ switch (cmd) {
|
|
|
237
237
|
if (code) {
|
|
238
238
|
const { recordRuntimeErrorBestEffort } = await import('../src/runtime-error-store.mjs');
|
|
239
239
|
recordRuntimeErrorBestEffort(code);
|
|
240
|
+
const { logHookFailure } = await import('../src/hook-failure-log.mjs');
|
|
241
|
+
logHookFailure(code, error);
|
|
240
242
|
}
|
|
241
243
|
throw error;
|
|
242
244
|
}
|
|
@@ -17,6 +17,7 @@ turn後に現行parserで読むとlatest logical groupは1件だった。Claude
|
|
|
17
17
|
|
|
18
18
|
1. Stop payloadに非空`last_assistant_message`がある場合、latest user groupの非junk assistant本文が
|
|
19
19
|
その値と一致するまで短いbounded intervalでtranscriptを再読する。
|
|
20
|
+
(2026-10-04 [ADR 0027](0027-stop-flush-barrier-trimmed-marker.md)で、前後の空白を除いて比べることにした。Claude Codeはmarkerを`.trim()`して渡す。)
|
|
20
21
|
2. `last_assistant_message`はcompletion identity/flush barrierにだけ使い、L2本文やreceipt digestの
|
|
21
22
|
ソースにはしない。本文は従来どおりtranscriptからDBへcommitしたpairだけを使う。
|
|
22
23
|
3. latest user groupを必須にし、過去の同文assistantや前turnを一致として採用しない。
|
|
@@ -19,6 +19,8 @@ L2 lives in `~/.cursor/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl` wi
|
|
|
19
19
|
## Decision
|
|
20
20
|
|
|
21
21
|
- Detect Cursor envelope (`cursor_version` or `hook_event_name` in the Cursor event set) as host=cursor.
|
|
22
|
+
- Shared hook stdin parsing accepts one leading UTF-8 BOM, as emitted by Windows Cursor. It preserves BOM characters inside JSON strings and still rejects malformed JSON.
|
|
23
|
+
- When `transcript_path` is absent, derive the project folder with Cursor's `utils/workspace-paths` rule: replace non-ASCII-alphanumeric characters with `-`, collapse consecutive hyphens, and remove leading/trailing hyphens. A supplied `transcript_path` remains authoritative.
|
|
22
24
|
- Normalize to the existing snake_case hook contract and prefix ids with `cursor:`. Strip optional `bc-` from cloud conversation ids when looking up transcripts.
|
|
23
25
|
- Prefer payload `transcript_path`. If absent, derive the agent-transcripts path. Do not invent a Claude-shaped transcript.
|
|
24
26
|
- `throughline install` upserts product hooks into `~/.cursor/hooks.json`. Keep factory / personal commands. Command is absolute `node` + `bin/throughline.mjs`. Do not write factory.json. Do not write bare `throughline`.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ADR 0027: flush barrier は marker と本文を前後の空白を除いて比べ、hook 失敗の理由を端末内に残す
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-04
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
ADR 0012 の flush barrier は、Stop payload の `last_assistant_message` と transcript の本文が
|
|
8
|
+
完全に一致するまで最大2秒待ち、一致しなければ `HOOK_PROCESS_TURN_FAILED` で明示失敗する。
|
|
9
|
+
|
|
10
|
+
Claude Code は `last_assistant_message` を、最後の assistant message の text block を改行でつないで
|
|
11
|
+
`.trim()` した値として渡す(Claude Code の実装で確認)。transcript には trim する前の本文が残る。
|
|
12
|
+
本文が空白や改行で始まる(終わる)turn は、transcript が出そろっていても一致しない。
|
|
13
|
+
|
|
14
|
+
Mac の runtime error store には `HOOK_PROCESS_TURN_FAILED` が 679 回あり、最後は
|
|
15
|
+
2026-08-31T13:16:06.049Z だった。その turn の transcript は次の並びだった。
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
13:16:02.150 assistant の最終行(end_turn)。本文は半角空白で始まる
|
|
19
|
+
13:16:04.195 stop_hook_summary(同期 hook の終わり。Stop hook の開始は 13:16:03.9 ごろ)
|
|
20
|
+
13:16:06.049 HOOK_PROCESS_TURN_FAILED(開始から 2.1 秒後。backfill.log に行は無い)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
この transcript をその時点で切り、trim した marker で Stop を再生すると、0.10.3・0.12.1・0.12.4 の
|
|
24
|
+
どれも 2.1 秒後に `Claude Stop transcript completion was not visible before deadline` で失敗した。
|
|
25
|
+
Mac に残る 2026-08-02 以降の transcript では、本文が空白で始まる Stop が8月に 96 回あり、
|
|
26
|
+
96 回とも backfill.log に対応する行が無かった。9月以降は、空白で始まる本文が1回も無い。
|
|
27
|
+
|
|
28
|
+
原因を調べる時、失敗の理由はどこにも残っていなかった。runtime error store は外へ送れる形
|
|
29
|
+
(定型 code と回数)だけを持つ(ADR 0025)。Claude の Stop hook は `async: true` なので、
|
|
30
|
+
stderr は transcript の `stop_hook_summary` にも残らない。Mac の Codex hook 1151 回と
|
|
31
|
+
SessionStart 38 回、main-server の 2026-07-20 の Stop 4 回は、発生時刻と回数しか分からなかった。
|
|
32
|
+
|
|
33
|
+
## Decision
|
|
34
|
+
|
|
35
|
+
1. flush barrier は、marker と transcript の本文を、どちらも前後の空白を除いてから比べる。
|
|
36
|
+
latest group と、ADR 0026 の1つ前の group の両方に同じ比べ方を使う。
|
|
37
|
+
2. 途中の空白は比べる対象に残す。前後の空白以外が違う本文は一致させない。
|
|
38
|
+
3. 空白だけの marker は、marker が無い時(ADR 0012 Decision 5)と同じに扱う。
|
|
39
|
+
4. 本文の正本は transcript のまま。DB と完了受領には trim しない本文を書く(ADR 0012 Decision 2)。
|
|
40
|
+
5. hook が失敗した時、理由を `~/.throughline/logs/hook-failures.log` へ1行の JSON で追記する。
|
|
41
|
+
項目は `ts`・`code`・`version`・`name`・`message`(1000 字まで)。
|
|
42
|
+
6. このログは端末内にだけ置く。runtime error の収集・送信の設定とは独立に書き、外へは送らない。
|
|
43
|
+
runtime error store と report の形(ADR 0025)は変えない。
|
|
44
|
+
7. ログの書き込みに失敗した時は stderr へ理由を出す。hook の終了コードは元の失敗のまま変えない。
|
|
45
|
+
|
|
46
|
+
## Consequences
|
|
47
|
+
|
|
48
|
+
- 本文が空白や改行で始まる(終わる)turn の L2・完了受領・L3 が、同じ Stop で書かれる。
|
|
49
|
+
- 「過去の同文 answer を今回の完了と取り違えない」条件(ADR 0012 Decision 3、ADR 0026)は変わらない。
|
|
50
|
+
前後の空白だけが違う2つの本文は同文として扱うが、どちらの条件も group の位置と捕捉済みかで決まる。
|
|
51
|
+
- 次に hook が失敗した時、時刻・code・版・理由が端末に残る。`runtime-errors snapshot` の回数と
|
|
52
|
+
突き合わせて原因を追える。
|
|
53
|
+
- `message` には例外の文面が入る。path を含むことがあるので、ログを端末の外へ出す時は読んで確かめる。
|
|
54
|
+
- ログの大きさに上限は設けていない。失敗した時だけ1行増える。
|
|
55
|
+
|
|
56
|
+
## 確認できていないこと
|
|
57
|
+
|
|
58
|
+
- Mac の 679 回のうち、空白が原因と確かめたのは transcript が残る 96 回と最後の1回。Stop 直後に次の入力が
|
|
59
|
+
届いた回(ADR 0026、0.12.2 で修理)と、それ以外の回の内訳は、当時の理由が残っていないので分からない。
|
|
60
|
+
- Claude Code が1つの assistant message に text block を複数持たせた時、marker は block を改行でつなぎ、
|
|
61
|
+
Throughline は block をそのままつなぐ。transcript では1行に1 block で書かれていて、複数 block の行は
|
|
62
|
+
確認していない。
|
package/package.json
CHANGED
package/src/cli/codex-hook.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { recordRuntimeErrorBestEffort } from '../runtime-error-store.mjs';
|
|
2
|
+
import { logHookFailure } from '../hook-failure-log.mjs';
|
|
2
3
|
|
|
3
4
|
function parseArgs(argv) {
|
|
4
5
|
const out = {
|
|
@@ -368,6 +369,7 @@ export async function run(argv = []) {
|
|
|
368
369
|
payload = parsePayload(await readStdin());
|
|
369
370
|
} catch (err) {
|
|
370
371
|
recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
|
|
372
|
+
logHookFailure('HOOK_CODEX_FAILED', err);
|
|
371
373
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
372
374
|
process.stderr.write(`[codex-hook] ${msg}\n`);
|
|
373
375
|
process.exit(1);
|
|
@@ -381,6 +383,7 @@ export async function run(argv = []) {
|
|
|
381
383
|
result = await requestCodexAutoHandoff({ payload });
|
|
382
384
|
} catch (error) {
|
|
383
385
|
recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
|
|
386
|
+
logHookFailure('HOOK_CODEX_FAILED', error);
|
|
384
387
|
const code = typeof error.code === 'string' ? error.code : 'handoff_request_failed';
|
|
385
388
|
result = { status: 'failed', continue: false,
|
|
386
389
|
stopReason: `Throughline自動継続が失敗しました: ${code}。記憶の圧縮を停止します。` };
|
|
@@ -415,6 +418,7 @@ export async function run(argv = []) {
|
|
|
415
418
|
process.exit(result.continue === false || result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
|
|
416
419
|
} catch (err) {
|
|
417
420
|
recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
|
|
421
|
+
logHookFailure('HOOK_CODEX_FAILED', err);
|
|
418
422
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
419
423
|
if (parsed.json) {
|
|
420
424
|
process.stdout.write(
|
|
@@ -918,6 +918,74 @@ test('process-turn subprocess captures the completed turn when the next user ent
|
|
|
918
918
|
}
|
|
919
919
|
});
|
|
920
920
|
|
|
921
|
+
test('process-turn subprocess captures a turn whose transcript text starts with whitespace', () => {
|
|
922
|
+
const home = makeTempHome();
|
|
923
|
+
const project = makeTempProject();
|
|
924
|
+
const transcriptPath = join(project, 'transcript.jsonl');
|
|
925
|
+
const text = (role, value) => ({ type: role, message: { role, content: [{ type: 'text', text: value }] } });
|
|
926
|
+
try {
|
|
927
|
+
// Claude Codeは `last_assistant_message` を `.trim()` して渡す。transcriptの本文はそのまま保存する。
|
|
928
|
+
writeFileSync(transcriptPath, [
|
|
929
|
+
text('user', 'request'),
|
|
930
|
+
text('assistant', ' answer that starts with a space'),
|
|
931
|
+
].map((entry) => JSON.stringify(entry)).join('\n'), 'utf8');
|
|
932
|
+
const result = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), 'process-turn'], {
|
|
933
|
+
home,
|
|
934
|
+
cwd: project,
|
|
935
|
+
input: JSON.stringify({
|
|
936
|
+
session_id: 'leading-space-session',
|
|
937
|
+
cwd: project,
|
|
938
|
+
transcript_path: transcriptPath,
|
|
939
|
+
last_assistant_message: 'answer that starts with a space',
|
|
940
|
+
}),
|
|
941
|
+
});
|
|
942
|
+
assert.equal(result.status, 0, result.stderr);
|
|
943
|
+
const db = openDb(home);
|
|
944
|
+
assert.deepEqual(
|
|
945
|
+
db.prepare('SELECT role, text FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
|
|
946
|
+
[{ role: 'assistant', text: ' answer that starts with a space' }, { role: 'user', text: 'request' }],
|
|
947
|
+
);
|
|
948
|
+
db.close();
|
|
949
|
+
assert.equal(readCompletedTurnReceiptSnapshot({ projectPath: project, env: childEnv(home) }).receipts.length, 1);
|
|
950
|
+
assert.equal(existsSync(join(home, '.throughline', 'logs', 'hook-failures.log')), false);
|
|
951
|
+
} finally {
|
|
952
|
+
rmSync(project, { recursive: true, force: true });
|
|
953
|
+
rmSync(home, { recursive: true, force: true });
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test('a failed hook leaves its reason in the local hook failure log', () => {
|
|
958
|
+
const home = makeTempHome();
|
|
959
|
+
const project = makeTempProject();
|
|
960
|
+
const failures = () => readFileSync(join(home, '.throughline', 'logs', 'hook-failures.log'), 'utf8')
|
|
961
|
+
.trim().split('\n').map((line) => JSON.parse(line));
|
|
962
|
+
try {
|
|
963
|
+
// 公開CLI経由(hookが実際に呼ぶ入口)と、moduleの直接起動の両方で残す。
|
|
964
|
+
const viaCli = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), 'process-turn'], {
|
|
965
|
+
home, cwd: project, input: JSON.stringify({ cwd: project }),
|
|
966
|
+
});
|
|
967
|
+
assert.notEqual(viaCli.status, 0);
|
|
968
|
+
const direct = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
969
|
+
home, cwd: project, input: JSON.stringify({ cwd: project }),
|
|
970
|
+
});
|
|
971
|
+
assert.notEqual(direct.status, 0);
|
|
972
|
+
|
|
973
|
+
const entries = failures();
|
|
974
|
+
assert.deepEqual(entries.map((entry) => [entry.code, entry.name, entry.message]), [
|
|
975
|
+
['HOOK_PROCESS_TURN_FAILED', 'Error', 'Missing session_id in Stop payload'],
|
|
976
|
+
['HOOK_SESSION_START_FAILED', 'Error', 'Missing session_id in SessionStart payload'],
|
|
977
|
+
]);
|
|
978
|
+
for (const entry of entries) {
|
|
979
|
+
assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message']);
|
|
980
|
+
assert.match(entry.ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
981
|
+
assert.match(entry.version, /^\d+\.\d+\.\d+$/);
|
|
982
|
+
}
|
|
983
|
+
} finally {
|
|
984
|
+
rmSync(project, { recursive: true, force: true });
|
|
985
|
+
rmSync(home, { recursive: true, force: true });
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
921
989
|
test('first prompt backfills a derived predecessor transcript without a state file', {
|
|
922
990
|
skip: process.platform === 'win32' ? 'Windowsはstate file transcriptPath fallback契約' : undefined,
|
|
923
991
|
}, () => {
|
|
@@ -1278,3 +1346,67 @@ test('Phase 0-5: /tl and /clear prompts do NOT trigger spike', () => {
|
|
|
1278
1346
|
rmSync(home, { recursive: true, force: true });
|
|
1279
1347
|
}
|
|
1280
1348
|
});
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
test('Cursor hooks accept the UTF-8 BOM emitted by Windows and capture the turn', () => {
|
|
1352
|
+
const home = makeTempHome();
|
|
1353
|
+
const project = makeTempProject();
|
|
1354
|
+
const id = '23c59dba-f4b5-4ce7-a2de-27a2fbbb4193';
|
|
1355
|
+
const transcript = join(project, 'cursor.jsonl');
|
|
1356
|
+
writeFileSync(transcript, [
|
|
1357
|
+
{ role: 'user', message: { content: [{ type: 'text', text: 'BOM hook capture' }] } },
|
|
1358
|
+
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Captured' }] } },
|
|
1359
|
+
].map((entry) => JSON.stringify(entry)).join('\n') + '\n');
|
|
1360
|
+
try {
|
|
1361
|
+
for (const [event, command] of [
|
|
1362
|
+
['sessionStart', 'session-start'],
|
|
1363
|
+
['beforeSubmitPrompt', 'prompt-submit'],
|
|
1364
|
+
['stop', 'process-turn'],
|
|
1365
|
+
]) {
|
|
1366
|
+
const result = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), command], {
|
|
1367
|
+
home,
|
|
1368
|
+
cwd: project,
|
|
1369
|
+
input: '\uFEFF' + JSON.stringify({
|
|
1370
|
+
conversation_id: id,
|
|
1371
|
+
session_id: id,
|
|
1372
|
+
hook_event_name: event,
|
|
1373
|
+
cursor_version: '2026.10.01-e373342',
|
|
1374
|
+
workspace_roots: [project],
|
|
1375
|
+
transcript_path: transcript,
|
|
1376
|
+
prompt: '/tl',
|
|
1377
|
+
}),
|
|
1378
|
+
});
|
|
1379
|
+
assert.equal(result.status, 0, `${event}: ${result.stderr}`);
|
|
1380
|
+
}
|
|
1381
|
+
const db = openDb(home);
|
|
1382
|
+
try {
|
|
1383
|
+
assert.equal(db.prepare('SELECT session_id FROM sessions').get().session_id, `cursor:${id}`);
|
|
1384
|
+
assert.equal(db.prepare('SELECT session_id FROM handoff_batons').get().session_id, `cursor:${id}`);
|
|
1385
|
+
const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
|
|
1386
|
+
assert.deepEqual(bodies.map((row) => [row.role, row.text]), [
|
|
1387
|
+
['assistant', 'Captured'], ['user', 'BOM hook capture'],
|
|
1388
|
+
]);
|
|
1389
|
+
} finally {
|
|
1390
|
+
db.close();
|
|
1391
|
+
}
|
|
1392
|
+
} finally {
|
|
1393
|
+
rmSync(project, { recursive: true, force: true });
|
|
1394
|
+
rmSync(home, { recursive: true, force: true });
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
test('a leading BOM does not hide malformed hook JSON', () => {
|
|
1399
|
+
const home = makeTempHome();
|
|
1400
|
+
try {
|
|
1401
|
+
for (const command of ['session-start', 'prompt-submit', 'process-turn']) {
|
|
1402
|
+
const result = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), command], {
|
|
1403
|
+
home, input: '\uFEFF{"conversation_id":',
|
|
1404
|
+
});
|
|
1405
|
+
assert.notEqual(result.status, 0, command);
|
|
1406
|
+
assert.match(result.stderr, /SyntaxError/, command);
|
|
1407
|
+
}
|
|
1408
|
+
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false);
|
|
1409
|
+
} finally {
|
|
1410
|
+
rmSync(home, { recursive: true, force: true });
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-failure-log.mjs — hook 失敗の理由ログ (~/.throughline/logs/hook-failures.log)
|
|
3
|
+
*
|
|
4
|
+
* runtime error store は外へ送れる形(定型 code と回数)だけを持つ (ADR 0025)。失敗した時の
|
|
5
|
+
* 例外の文面はどこにも残らず、後から原因を追えなかった(2026-10-04、Mac と main-server の
|
|
6
|
+
* 7〜8月の失敗)。このログは理由を端末内にだけ残す。runtime error の収集・送信の設定とは
|
|
7
|
+
* 独立で、外へは送らない。
|
|
8
|
+
* 書き込み失敗は stderr に出す(黙って握りつぶさない)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const PACKAGE_VERSION = require('../package.json').version;
|
|
18
|
+
|
|
19
|
+
export const HOOK_FAILURE_MESSAGE_LIMIT = 1000;
|
|
20
|
+
|
|
21
|
+
export function hookFailureLogPath({ home = homedir() } = {}) {
|
|
22
|
+
return join(home, '.throughline', 'logs', 'hook-failures.log');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} code runtime error の定型 code(例: HOOK_PROCESS_TURN_FAILED)
|
|
27
|
+
* @param {unknown} error 失敗の原因
|
|
28
|
+
* @param {{ home?: string, now?: number, stderr?: { write: (s: string) => unknown } }} [options]
|
|
29
|
+
*/
|
|
30
|
+
export function logHookFailure(code, error, { home, now = Date.now(), stderr = process.stderr } = {}) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
const entry = {
|
|
33
|
+
ts: new Date(now).toISOString(),
|
|
34
|
+
code,
|
|
35
|
+
version: PACKAGE_VERSION,
|
|
36
|
+
name: error instanceof Error ? error.name : typeof error,
|
|
37
|
+
message: message.slice(0, HOOK_FAILURE_MESSAGE_LIMIT),
|
|
38
|
+
};
|
|
39
|
+
const path = hookFailureLogPath(home === undefined ? {} : { home });
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
45
|
+
stderr.write(`[hook-failure-log] ${msg}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { HOOK_FAILURE_MESSAGE_LIMIT, hookFailureLogPath, logHookFailure } from './hook-failure-log.mjs';
|
|
7
|
+
|
|
8
|
+
test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
9
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
10
|
+
try {
|
|
11
|
+
logHookFailure('HOOK_PROCESS_TURN_FAILED', new TypeError('first reason'), { home, now: Date.UTC(2026, 9, 4, 0, 0, 0, 5) });
|
|
12
|
+
logHookFailure('HOOK_CODEX_FAILED', 'thrown string', { home, now: Date.UTC(2026, 9, 4, 0, 0, 1) });
|
|
13
|
+
const lines = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
14
|
+
assert.equal(lines.length, 2);
|
|
15
|
+
assert.deepEqual({ ...lines[0], version: 'x' }, {
|
|
16
|
+
ts: '2026-10-04T00:00:00.005Z', code: 'HOOK_PROCESS_TURN_FAILED', version: 'x', name: 'TypeError', message: 'first reason',
|
|
17
|
+
});
|
|
18
|
+
assert.deepEqual({ ...lines[1], version: 'x' }, {
|
|
19
|
+
ts: '2026-10-04T00:00:01.000Z', code: 'HOOK_CODEX_FAILED', version: 'x', name: 'string', message: 'thrown string',
|
|
20
|
+
});
|
|
21
|
+
} finally {
|
|
22
|
+
rmSync(home, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('logHookFailureは、長い文面を上限で切る', () => {
|
|
27
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
28
|
+
try {
|
|
29
|
+
logHookFailure('HOOK_PROMPT_SUBMIT_FAILED', new Error('x'.repeat(HOOK_FAILURE_MESSAGE_LIMIT + 50)), { home });
|
|
30
|
+
const [entry] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
31
|
+
assert.equal(entry.message.length, HOOK_FAILURE_MESSAGE_LIMIT);
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(home, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('logHookFailureは、書けない時にstderrへ理由を出し、例外を投げない', () => {
|
|
38
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
39
|
+
try {
|
|
40
|
+
// `.throughline` をfileにして、logs directoryを作れなくする。
|
|
41
|
+
writeFileSync(join(home, '.throughline'), 'not a directory', 'utf8');
|
|
42
|
+
const written = [];
|
|
43
|
+
logHookFailure('HOOK_SESSION_START_FAILED', new Error('reason'), { home, stderr: { write: (s) => written.push(s) } });
|
|
44
|
+
assert.equal(written.length, 1);
|
|
45
|
+
assert.match(written[0], /^\[hook-failure-log\] /);
|
|
46
|
+
} finally {
|
|
47
|
+
rmSync(home, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
package/src/hosts/cursor.mjs
CHANGED
|
@@ -55,8 +55,8 @@ export function isCursorEnvelope(payload) {
|
|
|
55
55
|
|
|
56
56
|
export function encodeCursorProjectDir(projectPath) {
|
|
57
57
|
if (typeof projectPath !== 'string' || projectPath.length === 0) return null;
|
|
58
|
-
|
|
59
|
-
const encoded =
|
|
58
|
+
// Cursor's utils/workspace-paths normalizes every non-ASCII-alphanumeric run.
|
|
59
|
+
const encoded = projectPath.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
60
60
|
return encoded.length > 0 ? encoded : null;
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -7,8 +7,24 @@ import {
|
|
|
7
7
|
encodeCursorProjectDir,
|
|
8
8
|
isCursorEnvelope,
|
|
9
9
|
normalizeHookPayload,
|
|
10
|
+
parseHookPayload,
|
|
10
11
|
} from './index.mjs';
|
|
11
12
|
|
|
13
|
+
test('parseHookPayload accepts one leading BOM and preserves BOM inside prompt text', () => {
|
|
14
|
+
const raw = JSON.stringify({
|
|
15
|
+
conversation_id: 'cursor-bom',
|
|
16
|
+
hook_event_name: 'beforeSubmitPrompt',
|
|
17
|
+
workspace_roots: ['/project'],
|
|
18
|
+
prompt: 'keep \uFEFF in text',
|
|
19
|
+
});
|
|
20
|
+
const expected = parseHookPayload(raw);
|
|
21
|
+
assert.deepEqual(parseHookPayload('\uFEFF' + raw), expected);
|
|
22
|
+
assert.equal(expected.session_id, 'cursor:cursor-bom');
|
|
23
|
+
assert.equal(expected.prompt, 'keep \uFEFF in text');
|
|
24
|
+
assert.throws(() => parseHookPayload('\uFEFF\uFEFF' + raw), SyntaxError);
|
|
25
|
+
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), SyntaxError);
|
|
26
|
+
});
|
|
27
|
+
|
|
12
28
|
test('isCursorEnvelope detects Cursor events and cursor_version', () => {
|
|
13
29
|
assert.equal(
|
|
14
30
|
isCursorEnvelope({
|
|
@@ -99,6 +115,11 @@ test('encodeCursorProjectDir matches ~/.cursor/projects slug', () => {
|
|
|
99
115
|
);
|
|
100
116
|
assert.equal(
|
|
101
117
|
encodeCursorProjectDir(String.raw`C:\Users\kite_\Developer\dotagents`),
|
|
102
|
-
'C-Users-
|
|
118
|
+
'C-Users-kite-Developer-dotagents',
|
|
119
|
+
);
|
|
120
|
+
assert.equal(
|
|
121
|
+
encodeCursorProjectDir(String.raw`C:\Users\kite_\.cache\aiterm-branch-test\steer-test`),
|
|
122
|
+
'C-Users-kite-cache-aiterm-branch-test-steer-test',
|
|
103
123
|
);
|
|
124
|
+
assert.equal(encodeCursorProjectDir('/work/part_with.dot space/'), 'work-part-with-dot-space');
|
|
104
125
|
});
|
package/src/hosts/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* hosts/index.mjs — host 境界の入口
|
|
3
3
|
*
|
|
4
4
|
* 共有 hook 入口 (session-start / prompt-submit / turn-processor) はここから
|
|
5
|
-
* `
|
|
5
|
+
* `parseHookPayload` と `hostAdapterForSessionId` だけを使い、
|
|
6
6
|
* ベンダー分岐を直接書かない。
|
|
7
7
|
*/
|
|
8
8
|
import { hostOfSessionId, CLAUDE_HOST, CODEX_HOST, GROK_HOST, CURSOR_HOST } from './identity.mjs';
|
|
@@ -27,6 +27,11 @@ const ADAPTERS = Object.freeze({
|
|
|
27
27
|
[CURSOR_HOST]: cursorHostAdapter,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
/** Parse hook stdin, including the leading UTF-8 BOM emitted by Windows Cursor. */
|
|
31
|
+
export function parseHookPayload(raw, options = {}) {
|
|
32
|
+
return normalizeHookPayload(JSON.parse(raw.replace(/^\uFEFF/, '')), options);
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
36
|
* hook stdin payload を Claude snake_case 契約へ正規化する。
|
|
32
37
|
* Grok は camelCase wire、Cursor は hook_event_name が sessionStart 等。
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -40,7 +40,8 @@ import { join, dirname } from 'node:path';
|
|
|
40
40
|
import { homedir } from 'node:os';
|
|
41
41
|
import { pathToFileURL } from 'node:url';
|
|
42
42
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
43
|
-
import {
|
|
43
|
+
import { logHookFailure } from './hook-failure-log.mjs';
|
|
44
|
+
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
44
45
|
|
|
45
46
|
// Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
|
|
46
47
|
const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
|
|
@@ -152,7 +153,7 @@ export async function run() {
|
|
|
152
153
|
process.stdin.on('end', resolve);
|
|
153
154
|
});
|
|
154
155
|
|
|
155
|
-
const payload =
|
|
156
|
+
const payload = parseHookPayload(raw, { env: process.env });
|
|
156
157
|
const { session_id, cwd, prompt } = payload;
|
|
157
158
|
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
158
159
|
|
|
@@ -325,6 +326,7 @@ async function maybeRunPromptSpike({ payload, sessionId, projectPath }) {
|
|
|
325
326
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
326
327
|
run().catch((err) => {
|
|
327
328
|
recordRuntimeErrorBestEffort('HOOK_PROMPT_SUBMIT_FAILED');
|
|
329
|
+
logHookFailure('HOOK_PROMPT_SUBMIT_FAILED', err);
|
|
328
330
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
329
331
|
process.stderr.write(`[prompt-submit] error: ${msg}\n`);
|
|
330
332
|
process.exit(1);
|
package/src/session-start.mjs
CHANGED
|
@@ -32,7 +32,8 @@ import { logDecision } from './decision-log.mjs';
|
|
|
32
32
|
import { existsSync } from 'node:fs';
|
|
33
33
|
import { pathToFileURL } from 'node:url';
|
|
34
34
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
35
|
-
import {
|
|
35
|
+
import { logHookFailure } from './hook-failure-log.mjs';
|
|
36
|
+
import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
36
37
|
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
37
38
|
|
|
38
39
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
@@ -101,7 +102,7 @@ export async function run() {
|
|
|
101
102
|
process.stdin.on('end', resolve);
|
|
102
103
|
});
|
|
103
104
|
|
|
104
|
-
const payload =
|
|
105
|
+
const payload = parseHookPayload(raw, { env: process.env });
|
|
105
106
|
const { session_id, cwd, source, transcript_path } = payload;
|
|
106
107
|
|
|
107
108
|
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
|
@@ -190,6 +191,7 @@ export async function run() {
|
|
|
190
191
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
191
192
|
run().catch((err) => {
|
|
192
193
|
recordRuntimeErrorBestEffort('HOOK_SESSION_START_FAILED');
|
|
194
|
+
logHookFailure('HOOK_SESSION_START_FAILED', err);
|
|
193
195
|
process.stderr.write(`[session-start] error: ${err.message}\n`);
|
|
194
196
|
process.exit(1);
|
|
195
197
|
});
|
package/src/turn-processor.mjs
CHANGED
|
@@ -45,8 +45,9 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
|
45
45
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
46
46
|
import { pathToFileURL } from 'node:url';
|
|
47
47
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
48
|
+
import { logHookFailure } from './hook-failure-log.mjs';
|
|
48
49
|
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
49
|
-
import { hostAdapterForSessionId,
|
|
50
|
+
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
50
51
|
|
|
51
52
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
52
53
|
export const L2_WINDOW = 20;
|
|
@@ -65,6 +66,9 @@ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
65
66
|
* - latest groupにassistant断片がまだ無い(次のturnは本文を書いていない)
|
|
66
67
|
* - 1つ前のgroupの本文がmarkerと一致する
|
|
67
68
|
* - そのturnがまだDBに捕捉されていない(捕捉済みの同文answerは、今回のStopの完了ではない)
|
|
69
|
+
*
|
|
70
|
+
* markerとtranscriptの本文は、前後の空白を除いて比べる(ADR 0027)。Claude Codeはmarkerを
|
|
71
|
+
* `.trim()` して渡すので、本文が空白や改行で始まる(終わる)turnは、そのままでは一致しない。
|
|
68
72
|
*/
|
|
69
73
|
export async function waitForClaudeStopTranscriptFlush({
|
|
70
74
|
transcriptPath,
|
|
@@ -77,7 +81,7 @@ export async function waitForClaudeStopTranscriptFlush({
|
|
|
77
81
|
now = Date.now,
|
|
78
82
|
wait = delay,
|
|
79
83
|
}) {
|
|
80
|
-
if (typeof lastAssistantMessage !== 'string' || lastAssistantMessage.length === 0) {
|
|
84
|
+
if (typeof lastAssistantMessage !== 'string' || lastAssistantMessage.trim().length === 0) {
|
|
81
85
|
return { status: 'marker_unavailable' };
|
|
82
86
|
}
|
|
83
87
|
const read = readCompletion
|
|
@@ -88,11 +92,14 @@ export async function waitForClaudeStopTranscriptFlush({
|
|
|
88
92
|
userTurnNumber: completion.userTurnNumber,
|
|
89
93
|
assistantTurnNumber: completion.assistantTurnNumber,
|
|
90
94
|
});
|
|
95
|
+
const marker = lastAssistantMessage.trim();
|
|
96
|
+
const matchesMarker = (completion) =>
|
|
97
|
+
typeof completion?.assistantContent === 'string' && completion.assistantContent.trim() === marker;
|
|
91
98
|
const deadline = now() + timeoutMs;
|
|
92
99
|
for (;;) {
|
|
93
100
|
const { latest, previous } = read() ?? { latest: null, previous: null };
|
|
94
|
-
if (latest
|
|
95
|
-
if (previous
|
|
101
|
+
if (matchesMarker(latest)) return ready(latest);
|
|
102
|
+
if (matchesMarker(previous) &&
|
|
96
103
|
latest?.fragmentTurnNumbers?.length === 0 &&
|
|
97
104
|
!isTurnCaptured(previous.fragmentTurnNumbers)) {
|
|
98
105
|
return ready(previous);
|
|
@@ -219,7 +226,7 @@ export async function run() {
|
|
|
219
226
|
process.stdin.on('end', resolve);
|
|
220
227
|
});
|
|
221
228
|
|
|
222
|
-
const payload =
|
|
229
|
+
const payload = parseHookPayload(raw || '{}', { env: process.env });
|
|
223
230
|
const { session_id, transcript_path, cwd, last_assistant_message } = payload;
|
|
224
231
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
225
232
|
|
|
@@ -418,6 +425,7 @@ export async function run() {
|
|
|
418
425
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
419
426
|
run().catch((err) => {
|
|
420
427
|
recordRuntimeErrorBestEffort('HOOK_PROCESS_TURN_FAILED');
|
|
428
|
+
logHookFailure('HOOK_PROCESS_TURN_FAILED', err);
|
|
421
429
|
const msg = err instanceof Error ? err.message : String(err);
|
|
422
430
|
process.stderr.write(`[turn-processor] error: ${msg}\n`);
|
|
423
431
|
process.exit(1);
|
|
@@ -105,6 +105,53 @@ test('Claude Stop flush barrierはlatest userの遅延assistantを待ち、過
|
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
test('Claude Stop flush barrierは、前後の空白を除いてmarkerと本文を比べる', async () => {
|
|
109
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-trim-'));
|
|
110
|
+
const transcriptPath = join(root, 'transcript.jsonl');
|
|
111
|
+
const text = (role, value) => ({ type: role, message: { role, content: [{ type: 'text', text: value }] } });
|
|
112
|
+
let elapsed = 0;
|
|
113
|
+
const clock = { now: () => elapsed, wait: async (milliseconds) => { elapsed += milliseconds; } };
|
|
114
|
+
const write = (entries) => writeFileSync(
|
|
115
|
+
transcriptPath, entries.map((entry) => JSON.stringify(entry)).join('\n'), 'utf8');
|
|
116
|
+
try {
|
|
117
|
+
// 実測(2026-08-31、Mac): 本文が空白で始まるturn。Claude Codeはmarkerを `.trim()` して渡す。
|
|
118
|
+
write([text('user', 'request'), text('assistant', ' answer with a leading space')]);
|
|
119
|
+
assert.deepEqual(await waitForClaudeStopTranscriptFlush({
|
|
120
|
+
transcriptPath, lastAssistantMessage: 'answer with a leading space', timeoutMs: 100, intervalMs: 10, ...clock,
|
|
121
|
+
}), { status: 'ready', userTurnNumber: 0, assistantTurnNumber: 1 });
|
|
122
|
+
assert.equal(elapsed, 0, 'a whitespace-only difference must not wait for the deadline');
|
|
123
|
+
|
|
124
|
+
write([text('user', 'request'), text('assistant', '\n\nanswer with newlines\n')]);
|
|
125
|
+
assert.deepEqual(await waitForClaudeStopTranscriptFlush({
|
|
126
|
+
transcriptPath, lastAssistantMessage: 'answer with newlines', timeoutMs: 100, intervalMs: 10, ...clock,
|
|
127
|
+
}), { status: 'ready', userTurnNumber: 0, assistantTurnNumber: 1 });
|
|
128
|
+
|
|
129
|
+
// 1つ前のgroupを採用する経路(ADR 0026)も同じ比べ方をする。
|
|
130
|
+
write([text('user', 'request'), text('assistant', ' answer'), text('user', 'queued request')]);
|
|
131
|
+
assert.deepEqual(await waitForClaudeStopTranscriptFlush({
|
|
132
|
+
transcriptPath, lastAssistantMessage: 'answer', timeoutMs: 100, intervalMs: 10,
|
|
133
|
+
isTurnCaptured: () => false, ...clock,
|
|
134
|
+
}), { status: 'ready', userTurnNumber: 0, assistantTurnNumber: 1 });
|
|
135
|
+
assert.equal(elapsed, 0);
|
|
136
|
+
|
|
137
|
+
// 空白以外が違う本文は一致させない。
|
|
138
|
+
write([text('user', 'request'), text('assistant', ' answer')]);
|
|
139
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
140
|
+
transcriptPath, lastAssistantMessage: 'another answer', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
141
|
+
}), /not visible before deadline/);
|
|
142
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
143
|
+
transcriptPath, lastAssistantMessage: 'ans wer', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
144
|
+
}), /not visible before deadline/);
|
|
145
|
+
|
|
146
|
+
// 空白だけのmarkerは、無いものとして扱う。
|
|
147
|
+
assert.deepEqual(await waitForClaudeStopTranscriptFlush({
|
|
148
|
+
transcriptPath, lastAssistantMessage: ' \n', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
149
|
+
}), { status: 'marker_unavailable' });
|
|
150
|
+
} finally {
|
|
151
|
+
rmSync(root, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
108
155
|
test('Claude Stop flush barrierは、Stop直後に次のuser行が届いても、未捕捉の完了turnを待たずに採用する', async () => {
|
|
109
156
|
const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-next-user-'));
|
|
110
157
|
const transcriptPath = join(root, 'transcript.jsonl');
|