throughline 0.12.9 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -1
- package/README.ja.md +27 -0
- package/README.md +27 -0
- package/bin/throughline.mjs +16 -1
- package/docs/02_clear_auto_handoff_plan.md +2 -1
- package/docs/05_codex_first_roadmap.md +3 -0
- package/docs/adr/0032-claude-compact-continuation.md +90 -0
- package/docs/adr/0033-claude-auto-handoff-new-session.md +86 -0
- package/package.json +2 -2
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +81 -0
- package/rag/INDEX.md +4 -0
- package/src/claude-auto-handoff-config.mjs +38 -0
- package/src/claude-auto-handoff.mjs +489 -0
- package/src/claude-auto-handoff.test.mjs +604 -0
- package/src/claude-pre-compact.mjs +69 -0
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +49 -5
- package/src/cli/install.mjs +77 -0
- package/src/cli/install.test.mjs +24 -0
- package/src/handoff-executor.mjs +24 -12
- package/src/hosts/claude.mjs +10 -0
- package/src/prompt-submit.mjs +1 -0
- package/src/resume-context.mjs +93 -5
- package/src/resume-context.test.mjs +61 -0
- package/src/runtime-error-store.mjs +11 -0
- package/src/session-start.mjs +13 -1
- package/src/transcript-reader.mjs +62 -6
- package/src/transcript-reader.test.mjs +109 -0
- package/src/turn-backfill.test.mjs +36 -0
- package/src/turn-processor.mjs +4 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import {
|
|
10
|
+
requestClaudeAutoHandoff,
|
|
11
|
+
stopClaudeTurnForHandoff,
|
|
12
|
+
completeClaudeTurnWithoutHandoff,
|
|
13
|
+
recordClaudeSuccessorTarget,
|
|
14
|
+
acceptClaudeAutoContinuation,
|
|
15
|
+
runClaudeAutoHandoffWorker,
|
|
16
|
+
readClaudeAutoHandoff,
|
|
17
|
+
listClaudeAutoHandoffs,
|
|
18
|
+
publicClaudeAutoHandoff,
|
|
19
|
+
claudeContinuationInput,
|
|
20
|
+
} from './claude-auto-handoff.mjs';
|
|
21
|
+
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
24
|
+
const CLI = join(REPO_ROOT, 'bin', 'throughline.mjs');
|
|
25
|
+
const SESSION = '0f3a6c1e-7b2d-4e5f-9a81-3c4d5e6f7a8b';
|
|
26
|
+
const SUCCESSOR = 'a41a97ae-6b2a-421b-b2d5-1a0b07988d06';
|
|
27
|
+
const ENABLED = { enabled: true, projects: [] };
|
|
28
|
+
|
|
29
|
+
function withDir(fn) {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-claude-auto-'));
|
|
31
|
+
const finish = () => rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
let result;
|
|
33
|
+
try { result = fn(dir); }
|
|
34
|
+
catch (error) { finish(); throw error; }
|
|
35
|
+
if (result && typeof result.then === 'function') return result.finally(finish);
|
|
36
|
+
finish();
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeBatonDb() {
|
|
41
|
+
const db = new DatabaseSync(':memory:');
|
|
42
|
+
db.exec('CREATE TABLE handoff_batons (project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL)');
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const user = (text, timestamp, extra = {}) => ({ type: 'user', timestamp, ...extra, message: { role: 'user', content: text } });
|
|
47
|
+
const assistant = (text, timestamp, model = 'claude-fable-5-1') =>
|
|
48
|
+
({ type: 'assistant', timestamp, message: { role: 'assistant', model, content: [{ type: 'text', text }] } });
|
|
49
|
+
const toolUse = (id, name) =>
|
|
50
|
+
({ type: 'assistant', message: { role: 'assistant', model: 'claude-fable-5-1', content: [{ type: 'tool_use', id, name, input: {} }] } });
|
|
51
|
+
const toolResult = (id, text) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text }] } });
|
|
52
|
+
const jsonl = entries => entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
|
|
53
|
+
|
|
54
|
+
function request(dir, overrides = {}) {
|
|
55
|
+
const db = overrides.db ?? makeBatonDb();
|
|
56
|
+
const result = requestClaudeAutoHandoff({
|
|
57
|
+
payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: overrides.transcriptPath ?? null,
|
|
58
|
+
...overrides.payload },
|
|
59
|
+
env: overrides.env ?? {}, config: overrides.config ?? ENABLED, openDb: () => db, dir, now: overrides.now ?? 1_000,
|
|
60
|
+
});
|
|
61
|
+
return { result, db };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
|
|
65
|
+
const { result, db } = request(dir);
|
|
66
|
+
assert.equal(result.status, 'requested');
|
|
67
|
+
assert.equal(result.block, true);
|
|
68
|
+
assert.equal(result.projectPath, '/work/app');
|
|
69
|
+
assert.deepEqual({ ...db.prepare('SELECT project_path, session_id, created_at FROM handoff_batons').get() },
|
|
70
|
+
{ project_path: '/work/app', session_id: SESSION, created_at: 1_000 }, '/tl と同じ印');
|
|
71
|
+
const record = readClaudeAutoHandoff(SESSION, dir);
|
|
72
|
+
assert.equal(record.state, 'requested');
|
|
73
|
+
assert.equal(record.handoff_id, result.handoffId);
|
|
74
|
+
|
|
75
|
+
// 同じ圧縮で hook が2回届いても、同じ引き継ぎのまま止め続ける
|
|
76
|
+
const again = request(dir, { db, now: 6_000 }).result;
|
|
77
|
+
assert.deepEqual([again.status, again.block, again.handoffId], ['already_requested', true, result.handoffId]);
|
|
78
|
+
assert.equal(db.prepare('SELECT created_at FROM handoff_batons').get().created_at, 1_000);
|
|
79
|
+
|
|
80
|
+
// 有効判定は会話を起動した project で行う(cwd は Bash の cd に追従する)
|
|
81
|
+
rmSync(join(dir, `${SESSION}.json`));
|
|
82
|
+
const started = request(dir, { payload: { cwd: '/work/app/sub' }, env: { CLAUDE_PROJECT_DIR: '/work/app' },
|
|
83
|
+
config: { enabled: true, projects: ['/work/app'] } }).result;
|
|
84
|
+
assert.deepEqual([started.status, started.projectPath], ['requested', '/work/app']);
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
test('PreCompact: 手動・無効・対象外のproject・scriptから起動した会話・subagent では圧縮を止めない', () => withDir(dir => {
|
|
88
|
+
for (const [overrides, reason] of [
|
|
89
|
+
[{ payload: { trigger: 'manual' } }, 'manual_compact'],
|
|
90
|
+
[{ config: { enabled: false, projects: [] } }, 'auto_handoff_disabled'],
|
|
91
|
+
[{ config: { enabled: true, projects: ['/work/other'] } }, 'auto_handoff_disabled'],
|
|
92
|
+
[{ env: { CLAUDE_CODE_ENTRYPOINT: 'sdk-cli' } }, 'handoff_host_unsupported'],
|
|
93
|
+
[{ payload: { agent_id: 'agent-1' } }, 'subagent_compact'],
|
|
94
|
+
]) {
|
|
95
|
+
const { result, db } = request(dir, overrides);
|
|
96
|
+
assert.deepEqual([result.status, result.block, result.reason], ['skipped', false, reason]);
|
|
97
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0, reason);
|
|
98
|
+
assert.deepEqual(listClaudeAutoHandoffs({ dir }), [], reason);
|
|
99
|
+
}
|
|
100
|
+
assert.equal(request(dir, { env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).result.status, 'requested');
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
test('PreCompact: 手動の/compactは止める前の記録を取り下げ、subagentの圧縮は親の記録に触らない', () => withDir(dir => {
|
|
104
|
+
request(dir);
|
|
105
|
+
assert.equal(request(dir, { payload: { trigger: 'auto', agent_id: 'agent-1' } }).result.reason, 'subagent_compact');
|
|
106
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'requested');
|
|
107
|
+
assert.equal(request(dir, { payload: { trigger: 'manual' } }).result.reason, 'manual_compact');
|
|
108
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir), null);
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
test('PreCompact: session id が無い・pathを含む時は失敗し、期限を過ぎた記録は掃除する', () => withDir(dir => {
|
|
112
|
+
assert.throws(() => requestClaudeAutoHandoff({ payload: { trigger: 'auto' }, env: {}, config: ENABLED, openDb: makeBatonDb, dir }),
|
|
113
|
+
/Missing session_id/);
|
|
114
|
+
assert.throws(() => request(dir, { payload: { session_id: '../escape' } }), /auto_handoff_session_id_invalid/);
|
|
115
|
+
|
|
116
|
+
const now = Date.parse('2026-10-04T12:00:00Z');
|
|
117
|
+
mkdirSync(join(dir, 'targets'), { recursive: true });
|
|
118
|
+
const stale = [join(dir, 'aaaaaaaa-0000-4000-8000-000000000001.json'), join(dir, 'targets', 'aaaaaaaa-0000-4000-8000-000000000003.json')];
|
|
119
|
+
const fresh = join(dir, 'aaaaaaaa-0000-4000-8000-000000000002.json');
|
|
120
|
+
for (const path of [...stale, fresh]) writeFileSync(path, '{}');
|
|
121
|
+
for (const path of stale) utimesSync(path, new Date(now - 25 * 3_600_000), new Date(now - 25 * 3_600_000));
|
|
122
|
+
utimesSync(fresh, new Date(now - 3_600_000), new Date(now - 3_600_000));
|
|
123
|
+
request(dir, { now });
|
|
124
|
+
assert.deepEqual(readdirSync(dir).filter(name => name.endsWith('.json')).sort(),
|
|
125
|
+
[`${SESSION}.json`, 'aaaaaaaa-0000-4000-8000-000000000002.json'].sort());
|
|
126
|
+
assert.deepEqual(readdirSync(join(dir, 'targets')), []);
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
test('PreToolUse: 記録のある会話の道具を実行させずに止め、止めた時点の依頼と設定を写して worker を1回だけ起動する', () => withDir(async dir => {
|
|
130
|
+
const transcriptPath = join(dir, 'transcript.jsonl');
|
|
131
|
+
writeFileSync(transcriptPath, jsonl([
|
|
132
|
+
user('前の依頼', '2026-10-04T03:00:00Z'),
|
|
133
|
+
assistant('前の回答', '2026-10-04T03:00:01Z'),
|
|
134
|
+
user('big1 と big2 を読んで result.txt を作って', '2026-10-04T03:00:10Z'),
|
|
135
|
+
toolUse('toolu_1', 'Read'),
|
|
136
|
+
toolResult('toolu_1', 'contents'),
|
|
137
|
+
assistant('big1 を読み終えました。次に big2 を読みます。', '2026-10-04T03:00:12Z'),
|
|
138
|
+
{ type: 'assistant', message: { role: 'assistant', model: '<synthetic>', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Read', input: {} }] } },
|
|
139
|
+
]));
|
|
140
|
+
const launched = [];
|
|
141
|
+
const launchWorker = async sessionId => { launched.push(sessionId); };
|
|
142
|
+
const payload = { session_id: SESSION, transcript_path: transcriptPath, cwd: '/work/app', tool_name: 'Read', tool_use_id: 'toolu_2',
|
|
143
|
+
permission_mode: 'acceptEdits', effort: { level: 'high' } };
|
|
144
|
+
|
|
145
|
+
assert.equal(await stopClaudeTurnForHandoff({ payload, dir, launchWorker }), null, '記録が無い会話には何もしない');
|
|
146
|
+
const { result } = request(dir, { transcriptPath });
|
|
147
|
+
assert.equal(await stopClaudeTurnForHandoff({ payload: { ...payload, agent_id: 'agent-1' }, dir, launchWorker }), null,
|
|
148
|
+
'subagent の中の道具は止めない');
|
|
149
|
+
|
|
150
|
+
// 同じ応答に並んだ2つの道具
|
|
151
|
+
const [first, second] = await Promise.all([
|
|
152
|
+
stopClaudeTurnForHandoff({ payload, dir, launchWorker, now: 2_000 }),
|
|
153
|
+
stopClaudeTurnForHandoff({ payload: { ...payload, tool_name: 'Write' }, dir, launchWorker, now: 2_000 }),
|
|
154
|
+
]);
|
|
155
|
+
for (const output of [first, second]) {
|
|
156
|
+
assert.equal(output.continue, false);
|
|
157
|
+
assert.match(output.stopReason, new RegExp(`自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${result.handoffId})`));
|
|
158
|
+
assert.deepEqual(output.hookSpecificOutput, { hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
159
|
+
permissionDecisionReason: output.stopReason });
|
|
160
|
+
}
|
|
161
|
+
assert.deepEqual(launched, [SESSION]);
|
|
162
|
+
const record = readClaudeAutoHandoff(SESSION, dir);
|
|
163
|
+
assert.equal(record.state, 'stopped');
|
|
164
|
+
assert.deepEqual(record.settings, { model: null, effort: 'high', permission_mode: 'acceptEdits' },
|
|
165
|
+
'権限と推論強度は hook の入力から写す。モデルと依頼は worker が transcript から読む');
|
|
166
|
+
assert.equal(record.stopped_tool_use_id, 'toolu_2');
|
|
167
|
+
assert.equal(record.in_flight, null);
|
|
168
|
+
|
|
169
|
+
// 止めた後に届いた道具も止める。worker はもう起動しない
|
|
170
|
+
assert.equal((await stopClaudeTurnForHandoff({ payload, dir, launchWorker })).continue, false);
|
|
171
|
+
assert.deepEqual(launched, [SESSION]);
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
test('PreToolUse: worker を起動できなければ failed にし、次の道具でもう一度立ち上げる', () => withDir(async dir => {
|
|
175
|
+
request(dir);
|
|
176
|
+
const payload = { session_id: SESSION, cwd: '/work/app', tool_name: 'Read' };
|
|
177
|
+
const failing = async () => { throw new Error('spawn failed'); };
|
|
178
|
+
const output = await stopClaudeTurnForHandoff({ payload, dir, launchWorker: failing });
|
|
179
|
+
assert.equal(output.continue, false, '立ち上げに失敗しても、圧縮されていない会話の道具は止める');
|
|
180
|
+
assert.deepEqual([readClaudeAutoHandoff(SESSION, dir).state, readClaudeAutoHandoff(SESSION, dir).error_code],
|
|
181
|
+
['failed', 'handoff_worker_start_failed']);
|
|
182
|
+
const launched = [];
|
|
183
|
+
await stopClaudeTurnForHandoff({ payload, dir, launchWorker: async sessionId => { launched.push(sessionId); } });
|
|
184
|
+
assert.deepEqual(launched, [SESSION]);
|
|
185
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'stopped');
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
test('Stop: 圧縮を止めた後、道具を呼ばずにターンが終わったら記録を取り下げる(印は残す)', () => withDir(async dir => {
|
|
189
|
+
const { db } = request(dir);
|
|
190
|
+
assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: 'aaaaaaaa-0000-4000-8000-000000000009', dir }), false);
|
|
191
|
+
assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: `grok:${SESSION}`, dir }), false);
|
|
192
|
+
assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: SESSION, dir }), true);
|
|
193
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir), null);
|
|
194
|
+
assert.equal(db.prepare('SELECT session_id FROM handoff_batons').get().session_id, SESSION);
|
|
195
|
+
|
|
196
|
+
// 止めた後(worker が動いている)の記録は取り下げない
|
|
197
|
+
request(dir);
|
|
198
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async () => {} });
|
|
199
|
+
assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: SESSION, dir }), false);
|
|
200
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'stopped');
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
/** 止めた直後の記録を作り、後継の SessionStart と配送を差し替えて worker を走らせる。 */
|
|
204
|
+
async function runWorker(dir, { spawn, send, onLaunched }) {
|
|
205
|
+
request(dir);
|
|
206
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', permission_mode: 'acceptEdits',
|
|
207
|
+
effort: { level: 'high' } }, dir, launchWorker: async () => {} });
|
|
208
|
+
const calls = { spawn: [], send: [] };
|
|
209
|
+
const record = await runClaudeAutoHandoffWorker(SESSION, {
|
|
210
|
+
dir, env: { PATH: '/bin', CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'claude-desktop', CLAUDE_CODE_MESSAGING_SOCKET: '/old.sock',
|
|
211
|
+
CLAUDE_CODE_MESSAGING_TOKEN: 'old-token', CLAUDE_PROJECT_DIR: '/work/app', CLAUDE_CODE_USE_BEDROCK: '1' },
|
|
212
|
+
targetTimeoutMs: 300, sendTimeoutMs: 300, pollMs: 10,
|
|
213
|
+
spawn: (command, args, options) => {
|
|
214
|
+
calls.spawn.push({ command, args, options });
|
|
215
|
+
const result = spawn(command, args, options);
|
|
216
|
+
if (result.status === 0) onLaunched?.();
|
|
217
|
+
return result;
|
|
218
|
+
},
|
|
219
|
+
send: async (target, text, options) => { calls.send.push({ target, text }); return send(target, text, options); },
|
|
220
|
+
});
|
|
221
|
+
return { record, calls };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const launchedOk = () => ({ status: 0, stdout: 'Starting background service…\nbackgrounded · a41a97ae · tl-app-1234 (idle — send a prompt to start)\n', stderr: '' });
|
|
225
|
+
const successorStarts = dir => () => recordClaudeSuccessorTarget({
|
|
226
|
+
payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
|
|
227
|
+
env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/271650.sock', CLAUDE_CODE_MESSAGING_TOKEN: 'secret-token' }, dir });
|
|
228
|
+
|
|
229
|
+
test('worker: 指示を待つ後継を同じprojectに立て、後継の受け口へ継続の指示を1通送る', () => withDir(async dir => {
|
|
230
|
+
const { record, calls } = await runWorker(dir, {
|
|
231
|
+
spawn: launchedOk,
|
|
232
|
+
onLaunched: successorStarts(dir),
|
|
233
|
+
send: async (target, text, options) => {
|
|
234
|
+
// 届いた指示で、後継の最初の UserPromptSubmit が受領を記録する
|
|
235
|
+
const accepted = acceptClaudeAutoContinuation({ predecessorId: SESSION, successorSessionId: SUCCESSOR, prompt: text, dir });
|
|
236
|
+
assert.ok(accepted);
|
|
237
|
+
return await options.confirm_acceptance(new AbortController().signal)
|
|
238
|
+
? { status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }
|
|
239
|
+
: { status: 'unknown', outcome_unknown: true, reason: 'unconfirmed' };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
assert.equal(calls.spawn.length, 1);
|
|
243
|
+
const { command, args, options } = calls.spawn[0];
|
|
244
|
+
assert.equal(command, 'claude');
|
|
245
|
+
assert.deepEqual(args, ['--bg', '--name', `tl-app-${record.handoff_id.slice(0, 8)}`, '--effort', 'high',
|
|
246
|
+
'--permission-mode', 'acceptEdits', '--settings', '{"worktree":{"bgIsolation":"none"}}'],
|
|
247
|
+
'指示は起動時に渡さない。モデルは transcript が無いので付かない');
|
|
248
|
+
assert.equal(options.cwd, '/work/app');
|
|
249
|
+
assert.deepEqual(options.env, { PATH: '/bin', CLAUDE_CODE_USE_BEDROCK: '1' }, '会話ごとの環境変数は後継へ渡さない');
|
|
250
|
+
|
|
251
|
+
assert.equal(calls.send.length, 1);
|
|
252
|
+
assert.deepEqual(calls.send[0].target, { socket_path: '/tmp/cc-socks/271650.sock', token: 'secret-token' });
|
|
253
|
+
assert.equal(calls.send[0].text, claudeContinuationInput(record));
|
|
254
|
+
assert.match(calls.send[0].text, new RegExp(`^Throughline自動継続 ${record.handoff_id}\\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。`));
|
|
255
|
+
|
|
256
|
+
assert.deepEqual([record.state, record.error_code], ['sent', null]);
|
|
257
|
+
assert.deepEqual(record.successor, { short_id: 'a41a97ae', session_id: SUCCESSOR });
|
|
258
|
+
assert.deepEqual(readdirSync(join(dir, 'targets')), [], '受け口の控えは読んだら消す');
|
|
259
|
+
assert.doesNotMatch(JSON.stringify(publicClaudeAutoHandoff(record)), /secret-token|cc-socks/);
|
|
260
|
+
assert.doesNotMatch(readFileSync(join(dir, `${SESSION}.json`), 'utf8'), /secret-token/, 'token は記録に残さない');
|
|
261
|
+
|
|
262
|
+
// 引き継ぎ済みの会話の道具は、後継を案内して止める
|
|
263
|
+
const output = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir,
|
|
264
|
+
launchWorker: async () => { throw new Error('must not launch again'); } });
|
|
265
|
+
assert.match(output.stopReason, /引き継ぎ済みです.*claude attach a41a97ae/);
|
|
266
|
+
}));
|
|
267
|
+
|
|
268
|
+
test('worker: 止めた時点の依頼とモデルを transcript から写し、色付きの出力からも後継のIDを読む。後継が止められた時は元の依頼を運ぶ', () => withDir(async dir => {
|
|
269
|
+
const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
|
|
270
|
+
const colored = () => ({ status: 0, stderr: '',
|
|
271
|
+
stdout: 'backgrounded · \u001b[36ma41a97ae\u001b[39m · tl-app-1234\u001b[2m (idle — send a prompt to start)\u001b[22m\n' });
|
|
272
|
+
const run = async (sessionId, transcriptPath, toolUseId) => {
|
|
273
|
+
const db = makeBatonDb();
|
|
274
|
+
requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
|
|
275
|
+
env: {}, config: ENABLED, openDb: () => db, dir });
|
|
276
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath,
|
|
277
|
+
tool_use_id: toolUseId }, dir, launchWorker: async () => {} });
|
|
278
|
+
const calls = [];
|
|
279
|
+
const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, send: sendOk, pollMs: 10, targetTimeoutMs: 300,
|
|
280
|
+
transcriptTimeoutMs: 300,
|
|
281
|
+
spawn: (command, args) => {
|
|
282
|
+
calls.push(args);
|
|
283
|
+
// 止めた道具の呼び出しは、hook の後で transcript に書かれる
|
|
284
|
+
recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
|
|
285
|
+
env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
|
|
286
|
+
return colored();
|
|
287
|
+
} });
|
|
288
|
+
return { record, calls };
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// 対話の会話 A。止めた道具の行は、worker が待っている間に書かれる
|
|
292
|
+
const transcriptA = join(dir, 'a.jsonl');
|
|
293
|
+
writeFileSync(transcriptA, jsonl([
|
|
294
|
+
user('14個のファイルを順に読んで', '2026-10-04T03:00:10Z'),
|
|
295
|
+
assistant('part04 を読みました。', '2026-10-04T03:00:12Z', 'claude-opus-5'),
|
|
296
|
+
]));
|
|
297
|
+
setTimeout(() => writeFileSync(transcriptA, jsonl([
|
|
298
|
+
user('14個のファイルを順に読んで', '2026-10-04T03:00:10Z'),
|
|
299
|
+
assistant('part04 を読みました。', '2026-10-04T03:00:12Z', 'claude-opus-5'),
|
|
300
|
+
assistant('part05 を読みました。次に part06 を読みます。', '2026-10-04T03:00:14Z', 'claude-opus-5'),
|
|
301
|
+
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_6', name: 'Read', input: {} }] } },
|
|
302
|
+
])), 50);
|
|
303
|
+
const first = await run(SESSION, transcriptA, 'toolu_6');
|
|
304
|
+
assert.equal(first.record.state, 'sent');
|
|
305
|
+
assert.equal(first.record.successor.short_id, 'a41a97ae');
|
|
306
|
+
assert.equal(first.record.settings.model, 'claude-opus-5');
|
|
307
|
+
assert.deepEqual(first.calls[0].slice(3, 5), ['--model', 'claude-opus-5']);
|
|
308
|
+
assert.deepEqual(first.record.in_flight, {
|
|
309
|
+
user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
|
|
310
|
+
last_fragment: { content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
|
|
311
|
+
}, '止めた道具の直前の発言まで出そろってから写す');
|
|
312
|
+
|
|
313
|
+
// 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
|
|
314
|
+
const transcriptB = join(dir, 'b.jsonl');
|
|
315
|
+
writeFileSync(transcriptB, jsonl([
|
|
316
|
+
user(`Another Claude session sent a message:\n${claudeContinuationInput(first.record)}\n\nThis came from another Claude session — not typed by your user.`,
|
|
317
|
+
'2026-10-04T03:01:00Z'),
|
|
318
|
+
assistant('part09 を読みました。次に part10 を読みます。', '2026-10-04T03:01:20Z', 'claude-opus-5'),
|
|
319
|
+
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_10', name: 'Read', input: {} }] } },
|
|
320
|
+
]));
|
|
321
|
+
rmSync(join(dir, 'targets'), { recursive: true, force: true });
|
|
322
|
+
const second = await run(SUCCESSOR, transcriptB, 'toolu_10');
|
|
323
|
+
assert.deepEqual(second.record.in_flight, {
|
|
324
|
+
user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
|
|
325
|
+
last_fragment: { content: 'part09 を読みました。次に part10 を読みます。', timestamp: Date.parse('2026-10-04T03:01:20Z') },
|
|
326
|
+
});
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
test('worker: 立ち上げ・受け口・配送の失敗は固定の理由を残し、結果が不明な配送は再送しない', () => withDir(async dir => {
|
|
330
|
+
const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
|
|
331
|
+
const cases = [
|
|
332
|
+
[{ spawn: () => ({ error: Object.assign(new Error('not found'), { code: 'ENOENT' }) }), send: sendOk }, 'failed', 'handoff_claude_cli_unavailable', 0],
|
|
333
|
+
[{ spawn: () => ({ status: 1, stdout: '', stderr: 'Workspace not trusted' }), send: sendOk }, 'failed', 'handoff_successor_launch_failed', 0],
|
|
334
|
+
[{ spawn: () => ({ status: 0, stdout: 'unexpected output', stderr: '' }), send: sendOk }, 'failed', 'handoff_successor_launch_failed', 0],
|
|
335
|
+
[{ spawn: launchedOk, send: sendOk }, 'failed', 'handoff_successor_target_unavailable', 0],
|
|
336
|
+
[{ spawn: launchedOk, onLaunched: successorStarts(dir),
|
|
337
|
+
send: async () => ({ status: 'not_sent', outcome_unknown: false, reason: 'connect_failed' }) }, 'failed', 'handoff_delivery_connect_failed', 1],
|
|
338
|
+
[{ spawn: launchedOk, onLaunched: successorStarts(dir),
|
|
339
|
+
send: async () => ({ status: 'unknown', outcome_unknown: true, reason: 'unconfirmed' }) }, 'unknown', 'handoff_delivery_unconfirmed', 1],
|
|
340
|
+
];
|
|
341
|
+
for (const [deps, state, code, sends] of cases) {
|
|
342
|
+
rmSync(join(dir, `${SESSION}.json`), { force: true });
|
|
343
|
+
rmSync(join(dir, `${SESSION}.claim`), { force: true });
|
|
344
|
+
const { record, calls } = await runWorker(dir, deps);
|
|
345
|
+
assert.deepEqual([record.state, record.error_code, calls.send.length], [state, code, sends], code);
|
|
346
|
+
}
|
|
347
|
+
// 結果が不明な引き継ぎは、次の道具でも立ち上げ直さない
|
|
348
|
+
const launched = [];
|
|
349
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async id => { launched.push(id); } });
|
|
350
|
+
assert.deepEqual(launched, []);
|
|
351
|
+
assert.equal((await runClaudeAutoHandoffWorker(SESSION, { dir, spawn: () => { throw new Error('must not spawn'); } })).state, 'unknown');
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
test('SessionStart: 後継の立ち上げ中の引き継ぎがある project の、新しく始まった会話だけが受け口を控える', () => withDir(async dir => {
|
|
355
|
+
const env = { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock', CLAUDE_CODE_MESSAGING_TOKEN: 't' };
|
|
356
|
+
const start = (overrides = {}, environment = env) => recordClaudeSuccessorTarget({
|
|
357
|
+
payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app', ...overrides }, env: environment, dir });
|
|
358
|
+
assert.equal(start(), false, '引き継ぎが無い');
|
|
359
|
+
request(dir);
|
|
360
|
+
assert.equal(start(), false, 'まだ旧い会話を止めていない');
|
|
361
|
+
|
|
362
|
+
const write = fields => writeFileSync(join(dir, `${SESSION}.json`),
|
|
363
|
+
JSON.stringify({ ...readClaudeAutoHandoff(SESSION, dir), ...fields }));
|
|
364
|
+
write({ state: 'launching' });
|
|
365
|
+
assert.equal(start({ cwd: '/work/other' }), false, '別の project');
|
|
366
|
+
assert.equal(start({ source: 'resume' }), false);
|
|
367
|
+
assert.equal(start({ source: 'compact' }), false);
|
|
368
|
+
assert.equal(start({ agent_id: 'agent-1' }), false);
|
|
369
|
+
assert.equal(start({}, {}), false, '受け口を持たない会話');
|
|
370
|
+
assert.equal(start({ session_id: `grok:${SUCCESSOR}` }), false);
|
|
371
|
+
assert.equal(start(), true);
|
|
372
|
+
rmSync(join(dir, 'targets'), { recursive: true });
|
|
373
|
+
|
|
374
|
+
write({ state: 'launched', successor: { short_id: 'a41a97ae', session_id: null } });
|
|
375
|
+
assert.equal(start({ session_id: 'bbbbbbbb-0000-4000-8000-000000000001' }), false, '後継のIDと合わない会話');
|
|
376
|
+
assert.equal(start(), true);
|
|
377
|
+
write({ state: 'sent' });
|
|
378
|
+
rmSync(join(dir, 'targets'), { recursive: true });
|
|
379
|
+
assert.equal(start(), false, '送り終えた引き継ぎ');
|
|
380
|
+
}));
|
|
381
|
+
|
|
382
|
+
test('最初の指示: 後継だけが止めた時点の依頼を受け取り、この引き継ぎの配送だけが受領になる', () => withDir(async dir => {
|
|
383
|
+
request(dir);
|
|
384
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async () => {} });
|
|
385
|
+
const accept = (overrides = {}) => acceptClaudeAutoContinuation({ predecessorId: SESSION, successorSessionId: SUCCESSOR, dir, ...overrides });
|
|
386
|
+
assert.equal(accept(), null, '後継がまだ立っていない');
|
|
387
|
+
const record = readClaudeAutoHandoff(SESSION, dir);
|
|
388
|
+
const inFlight = { user: { content: '依頼', timestamp: 1 }, last_fragment: null };
|
|
389
|
+
writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ ...record, state: 'sending',
|
|
390
|
+
successor: { short_id: 'a41a97ae', session_id: SUCCESSOR }, in_flight: inFlight }));
|
|
391
|
+
assert.equal(accept({ successorSessionId: 'bbbbbbbb-0000-4000-8000-000000000001' }), null, '後継ではない会話');
|
|
392
|
+
assert.equal(accept({ predecessorId: 'aaaaaaaa-0000-4000-8000-000000000009' }), null);
|
|
393
|
+
assert.equal(accept({ predecessorId: `codex:${SESSION}` }), null);
|
|
394
|
+
|
|
395
|
+
// 人が先に別の指示を打った時は、依頼は渡すが受領にはしない
|
|
396
|
+
assert.deepEqual(accept({ prompt: '別の指示' }), { handoffId: record.handoff_id, projectPath: '/work/app', inFlight, transcriptPath: null });
|
|
397
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).accepted_at, null);
|
|
398
|
+
accept({ prompt: `Another Claude session sent a message:\n${claudeContinuationInput(record)}`, now: 9_000 });
|
|
399
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).accepted_at, 9_000);
|
|
400
|
+
}));
|
|
401
|
+
|
|
402
|
+
test('公開CLI: --host claude は enable・disable・status・worker を受け取る', () => {
|
|
403
|
+
assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
|
|
404
|
+
assert.deepEqual(
|
|
405
|
+
(({ action, host, project, json }) => ({ action, host, project, json }))(parseAutoHandoffArgs(['enable', '--host', 'claude', '--project', REPO_ROOT, '--json'])),
|
|
406
|
+
{ action: 'enable', host: 'claude', project: REPO_ROOT, json: true },
|
|
407
|
+
);
|
|
408
|
+
assert.equal(parseAutoHandoffArgs(['status', '--host', 'claude', '--operation', 'id']).operation, 'id');
|
|
409
|
+
assert.equal(parseAutoHandoffArgs(['worker', '--host', 'claude', '--operation', SESSION]).action, 'worker');
|
|
410
|
+
for (const args of [['resume', '--host', 'claude', '--operation', 'id'], ['detail', '--host', 'claude', '--operation', 'id', '--origin', 'A', '--turn', '1'],
|
|
411
|
+
['worker', '--host', 'claude'], ['status', '--host', 'grok'], ['status', '--host'], ['disable', '--host', 'claude', '--project', REPO_ROOT]]) {
|
|
412
|
+
assert.throws(() => parseAutoHandoffArgs(args), JSON.stringify(args));
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---- 実際の CLI を temp HOME で動かす ----
|
|
417
|
+
|
|
418
|
+
function childEnv(home, extra = {}) {
|
|
419
|
+
// 設定とstateの置き場も一時HOMEへ向ける(本物のruntime error storeへ書かない)。
|
|
420
|
+
return {
|
|
421
|
+
...process.env,
|
|
422
|
+
HOME: home,
|
|
423
|
+
USERPROFILE: home,
|
|
424
|
+
XDG_CONFIG_HOME: join(home, '.config'),
|
|
425
|
+
XDG_STATE_HOME: join(home, '.local', 'state'),
|
|
426
|
+
LOCALAPPDATA: join(home, 'AppData', 'Local'),
|
|
427
|
+
THROUGHLINE_NO_VSCODE: '1',
|
|
428
|
+
CLAUDE_PROJECT_DIR: '',
|
|
429
|
+
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
|
430
|
+
CLAUDE_CODE_MESSAGING_SOCKET: '',
|
|
431
|
+
CLAUDE_CODE_MESSAGING_TOKEN: '',
|
|
432
|
+
...extra,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function cli(home, args, input = '', extra = {}) {
|
|
437
|
+
return spawnSync(process.execPath, [CLI, ...args], { cwd: REPO_ROOT, env: childEnv(home, extra), input, encoding: 'utf8' });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 本物の claude を起動させない。すぐ失敗する偽物を PATH の先頭に置く。 */
|
|
441
|
+
function fakeClaudePath(home) {
|
|
442
|
+
const bin = join(home, 'fake-bin');
|
|
443
|
+
mkdirSync(bin, { recursive: true });
|
|
444
|
+
if (process.platform === 'win32') writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nexit /b 1\r\n');
|
|
445
|
+
else {
|
|
446
|
+
writeFileSync(join(bin, 'claude'), '#!/bin/sh\nexit 1\n');
|
|
447
|
+
chmodSync(join(bin, 'claude'), 0o755);
|
|
448
|
+
}
|
|
449
|
+
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') ?? 'PATH';
|
|
450
|
+
return { [pathKey]: `${bin}${delimiter}${process.env[pathKey] ?? ''}` };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
test('公開CLI: enable は2つの hook と設定を書き、disable は自分の hook だけを外す。Codex の設定には触らない', () => withDir(home => {
|
|
454
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
455
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
456
|
+
// 他製品の hook と、0.13.0 が置いた PreCompact がある状態から始める
|
|
457
|
+
const others = { PreCompact: [{ hooks: [{ type: 'command', command: 'other-product pre-compact' }] }],
|
|
458
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook claude' }] }] };
|
|
459
|
+
writeFileSync(settingsPath, JSON.stringify({ model: 'sonnet', hooks: {
|
|
460
|
+
PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, ...others.PreCompact],
|
|
461
|
+
PreToolUse: others.PreToolUse } }));
|
|
462
|
+
|
|
463
|
+
const before = JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout);
|
|
464
|
+
assert.deepEqual(before, { host: 'claude', config: { schema: 'throughline.claude-auto-handoff.v1', enabled: false, projects: [] },
|
|
465
|
+
hooks: { registered: false }, handoffs: [] });
|
|
466
|
+
assert.equal(existsSync(join(home, '.throughline')), false, 'status は何も作らない');
|
|
467
|
+
|
|
468
|
+
const enabled = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
469
|
+
assert.equal(enabled.status, 0, enabled.stderr);
|
|
470
|
+
assert.equal(JSON.parse(enabled.stdout).status, 'enabled');
|
|
471
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
472
|
+
assert.equal(settings.model, 'sonnet');
|
|
473
|
+
assert.deepEqual(settings.hooks, {
|
|
474
|
+
PreCompact: [...others.PreCompact, { hooks: [{ type: 'command', command: 'throughline pre-compact' }] }],
|
|
475
|
+
PreToolUse: [...others.PreToolUse, { hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }] }],
|
|
476
|
+
});
|
|
477
|
+
const again = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
478
|
+
assert.equal(again.status, 0, again.stderr);
|
|
479
|
+
assert.equal(readFileSync(settingsPath, 'utf8'), JSON.stringify(settings, null, 2) + '\n', '2回目は書き換えない');
|
|
480
|
+
|
|
481
|
+
assert.equal(cli(home, ['auto-handoff', 'status', '--host', 'claude']).stdout,
|
|
482
|
+
'自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse): 登録済み\n');
|
|
483
|
+
assert.equal(existsSync(join(home, '.throughline', 'codex-auto-handoff.json')), false);
|
|
484
|
+
assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--json']).stdout).config.enabled, false, 'Codex は無効のまま');
|
|
485
|
+
|
|
486
|
+
const disabled = JSON.parse(cli(home, ['auto-handoff', 'disable', '--host', 'claude', '--json']).stdout);
|
|
487
|
+
assert.equal(disabled.config.enabled, false);
|
|
488
|
+
assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, others);
|
|
489
|
+
const unsupported = cli(home, ['auto-handoff', 'resume', '--host', 'claude', '--operation', 'x', '--json']);
|
|
490
|
+
assert.equal(unsupported.status, 1);
|
|
491
|
+
assert.deepEqual(JSON.parse(unsupported.stdout), { status: 'failed', code: 'auto_handoff_action_unsupported' });
|
|
492
|
+
}));
|
|
493
|
+
|
|
494
|
+
test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し、後継の最初の指示へ止めた時点の依頼と記憶を注入する', () => withDir(async home => {
|
|
495
|
+
const project = join(home, 'project');
|
|
496
|
+
mkdirSync(project, { recursive: true });
|
|
497
|
+
const transcriptPath = join(home, 'transcript.jsonl');
|
|
498
|
+
const base = Date.parse('2026-10-04T03:00:00Z');
|
|
499
|
+
const at = seconds => new Date(base + seconds * 1000).toISOString();
|
|
500
|
+
writeFileSync(transcriptPath, jsonl([
|
|
501
|
+
user('成果物の1行目には必ず「約束: MANGO-4417」と書くこと', at(0)),
|
|
502
|
+
assistant('了解しました。', at(1)),
|
|
503
|
+
user('big1.txt と big2.txt を読んで result.txt を作って', at(10)),
|
|
504
|
+
toolUse('toolu_1', 'Read'),
|
|
505
|
+
toolResult('toolu_1', 'big1 contents'),
|
|
506
|
+
assistant('big1.txt を読み終えました。次に big2.txt を読みます。', at(12)),
|
|
507
|
+
toolUse('toolu_2', 'Read'),
|
|
508
|
+
]));
|
|
509
|
+
const extra = fakeClaudePath(home);
|
|
510
|
+
const hook = (command, payload, sessionId = SESSION) =>
|
|
511
|
+
cli(home, [command], JSON.stringify({ session_id: sessionId, cwd: project, transcript_path: transcriptPath, ...payload }), extra);
|
|
512
|
+
const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
|
|
513
|
+
|
|
514
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--project', project, '--json']).status, 0);
|
|
515
|
+
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }).status, 0);
|
|
516
|
+
|
|
517
|
+
// 記録が無い間、道具の hook は何も出さずに終わる
|
|
518
|
+
const idle = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read' });
|
|
519
|
+
assert.deepEqual([idle.status, idle.stdout, idle.stderr], [0, '', '']);
|
|
520
|
+
// 手動の /compact は止めない
|
|
521
|
+
const manual = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'manual' });
|
|
522
|
+
assert.deepEqual([manual.status, manual.stdout], [0, '']);
|
|
523
|
+
|
|
524
|
+
// 自動圧縮: 止める(exit code 2)
|
|
525
|
+
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
526
|
+
assert.equal(pre.status, 2, pre.stderr);
|
|
527
|
+
assert.equal(pre.stdout, '');
|
|
528
|
+
assert.match(pre.stderr, /Throughlineが自動圧縮を止めました。新しい会話へ引き継ぎます/);
|
|
529
|
+
assert.deepEqual(status().map(item => item.state), ['requested']);
|
|
530
|
+
|
|
531
|
+
// 次の道具: 実行させずに止め、worker を起動する
|
|
532
|
+
const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', permission_mode: 'acceptEdits' });
|
|
533
|
+
assert.equal(stopped.status, 0, stopped.stderr);
|
|
534
|
+
const output = JSON.parse(stopped.stdout);
|
|
535
|
+
assert.equal(output.continue, false);
|
|
536
|
+
assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
|
|
537
|
+
// worker は偽の claude(すぐ失敗)で止まる。本物の claude は起動しない
|
|
538
|
+
let handoff;
|
|
539
|
+
for (let i = 0; i < 100 && (handoff = status()[0]).state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
|
|
540
|
+
assert.deepEqual([handoff.state, handoff.error_code], ['failed', 'handoff_successor_launch_failed']);
|
|
541
|
+
|
|
542
|
+
// 後継が立って指示が届いた場面を、記録を進めて再現する
|
|
543
|
+
const handoffDir = join(home, '.throughline', 'claude-auto-handoff');
|
|
544
|
+
const recordFile = join(handoffDir, `${SESSION}.json`);
|
|
545
|
+
const record = JSON.parse(readFileSync(recordFile, 'utf8'));
|
|
546
|
+
assert.equal(record.settings.permission_mode, 'acceptEdits');
|
|
547
|
+
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
|
|
548
|
+
const started = hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }, SUCCESSOR);
|
|
549
|
+
assert.equal(started.status, 0, started.stderr);
|
|
550
|
+
assert.equal(started.stdout, '');
|
|
551
|
+
assert.equal(existsSync(join(handoffDir, 'targets')), false, '受け口を持たない会話は控えない');
|
|
552
|
+
const withSocket = cli(home, ['session-start'], JSON.stringify({ session_id: SUCCESSOR, cwd: project, hook_event_name: 'SessionStart', source: 'startup' }),
|
|
553
|
+
{ ...extra, CLAUDE_CODE_MESSAGING_SOCKET: join(home, 'in.sock'), CLAUDE_CODE_MESSAGING_TOKEN: 'secret-token' });
|
|
554
|
+
assert.equal(withSocket.status, 0, withSocket.stderr);
|
|
555
|
+
assert.deepEqual(readdirSync(join(handoffDir, 'targets')), [`${SUCCESSOR}.json`]);
|
|
556
|
+
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'sending', error_code: null,
|
|
557
|
+
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
558
|
+
|
|
559
|
+
const prompt = `Another Claude session sent a message:\n${claudeContinuationInput(record)}`;
|
|
560
|
+
const first = cli(home, ['prompt-submit'], JSON.stringify({ session_id: SUCCESSOR, cwd: project, hook_event_name: 'UserPromptSubmit', prompt }), extra);
|
|
561
|
+
assert.equal(first.status, 0, first.stderr);
|
|
562
|
+
assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
|
|
563
|
+
assert.match(first.stdout, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: big1\.txt と big2\.txt を読んで result\.txt を作って\n/);
|
|
564
|
+
assert.match(first.stdout, /\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: big1\.txt を読み終えました。次に big2\.txt を読みます。\n/);
|
|
565
|
+
assert.match(first.stdout, /\[user\]: 成果物の1行目には必ず「約束: MANGO-4417」と書くこと\n/);
|
|
566
|
+
assert.doesNotMatch(first.stdout, /宣言|\/clear/);
|
|
567
|
+
assert.ok(first.stdout.length <= 9_501);
|
|
568
|
+
assert.ok(JSON.parse(readFileSync(recordFile, 'utf8')).accepted_at > 0, '届いた指示で受領を記録する');
|
|
569
|
+
|
|
570
|
+
// 記憶は後継へ合流している(今の /tl の引き継ぎと同じ)
|
|
571
|
+
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
572
|
+
assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
|
|
573
|
+
assert.deepEqual(db.prepare('SELECT DISTINCT session_id FROM bodies').all().map(row => row.session_id), [SUCCESSOR]);
|
|
574
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
575
|
+
db.close();
|
|
576
|
+
|
|
577
|
+
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
578
|
+
assert.deepEqual(decisions.filter(entry => entry.phase === 'pre-compact').map(entry => [entry.trigger, entry.auto_continuation, entry.skip_reason]),
|
|
579
|
+
[['manual', 'skipped', 'manual_compact'], ['auto', 'requested', null]]);
|
|
580
|
+
assert.equal(decisions.filter(entry => entry.phase === 'pre-tool-use-stop').length, 1);
|
|
581
|
+
const merged = decisions.find(entry => entry.phase === 'prompt-submit' && entry.merged);
|
|
582
|
+
assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['baton', record.handoff_id]);
|
|
583
|
+
}));
|
|
584
|
+
|
|
585
|
+
test('hook: 自動継続が無効な時、自動圧縮を止めず、何も残さない', () => withDir(home => {
|
|
586
|
+
const project = join(home, 'project');
|
|
587
|
+
mkdirSync(project, { recursive: true });
|
|
588
|
+
const payload = { session_id: SESSION, cwd: project, hook_event_name: 'PreCompact', trigger: 'auto' };
|
|
589
|
+
const pre = cli(home, ['pre-compact'], JSON.stringify(payload));
|
|
590
|
+
assert.deepEqual([pre.status, pre.stdout, pre.stderr], [0, '', '']);
|
|
591
|
+
assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff')), false);
|
|
592
|
+
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '無効な時は DB も開かない');
|
|
593
|
+
}));
|
|
594
|
+
|
|
595
|
+
test('hook: 自分の失敗では圧縮も道具も止めず(exit code 1)、理由を端末に残す', () => withDir(home => {
|
|
596
|
+
const compact = cli(home, ['pre-compact'], '{"trigger":"auto"}');
|
|
597
|
+
assert.deepEqual([compact.status, compact.stdout], [1, '']);
|
|
598
|
+
const tool = cli(home, ['pre-tool-use'], '{not json');
|
|
599
|
+
assert.deepEqual([tool.status, tool.stdout], [1, '']);
|
|
600
|
+
const log = readFileSync(join(home, '.throughline', 'logs', 'hook-failures.log'), 'utf8');
|
|
601
|
+
assert.match(log, /HOOK_PRE_COMPACT_FAILED/);
|
|
602
|
+
assert.match(log, /Missing session_id in PreCompact payload/);
|
|
603
|
+
assert.match(log, /HOOK_PRE_TOOL_USE_FAILED/);
|
|
604
|
+
}));
|