throughline 0.13.0 → 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 +25 -1
- package/README.ja.md +14 -7
- package/README.md +14 -7
- package/bin/throughline.mjs +10 -4
- package/docs/02_clear_auto_handoff_plan.md +2 -2
- package/docs/05_codex_first_roadmap.md +3 -2
- package/docs/adr/0032-claude-compact-continuation.md +2 -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 +28 -3
- package/rag/INDEX.md +1 -1
- package/src/claude-auto-handoff.mjs +419 -137
- package/src/claude-auto-handoff.test.mjs +489 -193
- package/src/claude-pre-compact.mjs +21 -7
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +24 -18
- package/src/cli/install.mjs +67 -18
- package/src/cli/install.test.mjs +22 -4
- 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 +26 -34
- package/src/resume-context.test.mjs +26 -37
- package/src/runtime-error-store.mjs +6 -1
- package/src/session-start.mjs +8 -28
- package/src/transcript-reader.mjs +4 -4
- package/src/transcript-reader.test.mjs +19 -0
- package/src/turn-backfill.mjs +2 -7
- package/src/turn-backfill.test.mjs +0 -22
- package/src/turn-processor.mjs +4 -0
|
@@ -1,148 +1,468 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import {
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
requestClaudeAutoHandoff,
|
|
11
|
+
stopClaudeTurnForHandoff,
|
|
12
|
+
completeClaudeTurnWithoutHandoff,
|
|
13
|
+
recordClaudeSuccessorTarget,
|
|
14
|
+
acceptClaudeAutoContinuation,
|
|
15
|
+
runClaudeAutoHandoffWorker,
|
|
16
|
+
readClaudeAutoHandoff,
|
|
17
|
+
listClaudeAutoHandoffs,
|
|
18
|
+
publicClaudeAutoHandoff,
|
|
19
|
+
claudeContinuationInput,
|
|
13
20
|
} from './claude-auto-handoff.mjs';
|
|
14
21
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
15
22
|
|
|
16
23
|
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
24
|
const CLI = join(REPO_ROOT, 'bin', 'throughline.mjs');
|
|
18
25
|
const SESSION = '0f3a6c1e-7b2d-4e5f-9a81-3c4d5e6f7a8b';
|
|
26
|
+
const SUCCESSOR = 'a41a97ae-6b2a-421b-b2d5-1a0b07988d06';
|
|
19
27
|
const ENABLED = { enabled: true, projects: [] };
|
|
20
28
|
|
|
21
29
|
function withDir(fn) {
|
|
22
30
|
const dir = mkdtempSync(join(tmpdir(), 'tl-claude-auto-'));
|
|
23
|
-
|
|
24
|
-
|
|
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;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
HOME: home,
|
|
32
|
-
USERPROFILE: home,
|
|
33
|
-
XDG_CONFIG_HOME: join(home, '.config'),
|
|
34
|
-
XDG_STATE_HOME: join(home, '.local', 'state'),
|
|
35
|
-
LOCALAPPDATA: join(home, 'AppData', 'Local'),
|
|
36
|
-
THROUGHLINE_NO_VSCODE: '1',
|
|
37
|
-
CLAUDE_PROJECT_DIR: '',
|
|
38
|
-
};
|
|
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;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const assistant = (text, timestamp) => ({ type: 'assistant', timestamp, message: { role: 'assistant', content: [{ type: 'text', text }] } });
|
|
47
|
-
const toolUse = (id, name) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input: {} }] } });
|
|
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: {} }] } });
|
|
48
51
|
const toolResult = (id, text) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text }] } });
|
|
49
|
-
const compactSummary = () => ({ type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true,
|
|
50
|
-
message: { role: 'user', content: 'This session is being continued from a previous conversation that ran out of context.' } });
|
|
51
52
|
const jsonl = entries => entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
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);
|
|
56
79
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}));
|
|
59
86
|
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
test('PreCompact: 手動・無効・対象外のproject・scriptから起動した会話・subagent では圧縮を止めない', () => withDir(dir => {
|
|
88
|
+
for (const [overrides, reason] of [
|
|
89
|
+
[{ payload: { trigger: 'manual' } }, 'manual_compact'],
|
|
62
90
|
[{ config: { enabled: false, projects: [] } }, 'auto_handoff_disabled'],
|
|
63
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'],
|
|
64
94
|
]) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
assert.equal(
|
|
68
|
-
assert.
|
|
69
|
-
assert.deepEqual(readdirSync(dir), [], reason);
|
|
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);
|
|
70
99
|
}
|
|
71
|
-
|
|
72
|
-
// 有効判定は会話を起動した project で行う(cwd は Bash の cd に追従する)
|
|
73
|
-
const started = recordClaudeCompactRequest({ ...args, payload: { ...payload, cwd: '/work/app/sub' },
|
|
74
|
-
env: { CLAUDE_PROJECT_DIR: '/work/app' }, config: { enabled: true, projects: ['/work/app'] } });
|
|
75
|
-
assert.equal(started.status, 'requested');
|
|
76
|
-
assert.equal(started.projectPath, '/work/app');
|
|
100
|
+
assert.equal(request(dir, { env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).result.status, 'requested');
|
|
77
101
|
}));
|
|
78
102
|
|
|
79
|
-
test('PreCompact: subagent
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
assert.equal(listClaudeCompactRequests({ dir })[0].requested_at, 1_000);
|
|
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);
|
|
87
109
|
}));
|
|
88
110
|
|
|
89
|
-
test('PreCompact: session id が無い・path
|
|
90
|
-
assert.throws(() =>
|
|
91
|
-
|
|
92
|
-
|
|
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/);
|
|
93
115
|
|
|
94
116
|
const now = Date.parse('2026-10-04T12:00:00Z');
|
|
95
|
-
mkdirSync(dir, { recursive: true });
|
|
96
|
-
const stale = join(dir, 'aaaaaaaa-0000-4000-8000-000000000001.json');
|
|
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')];
|
|
97
119
|
const fresh = join(dir, 'aaaaaaaa-0000-4000-8000-000000000002.json');
|
|
98
|
-
for (const path of [stale, fresh]) writeFileSync(path, '{}');
|
|
99
|
-
utimesSync(
|
|
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));
|
|
100
122
|
utimesSync(fresh, new Date(now - 3_600_000), new Date(now - 3_600_000));
|
|
101
|
-
|
|
102
|
-
assert.deepEqual(readdirSync(dir).
|
|
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')), []);
|
|
103
127
|
}));
|
|
104
128
|
|
|
105
|
-
test('
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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/);
|
|
120
266
|
}));
|
|
121
267
|
|
|
122
|
-
test('
|
|
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 を受け取る', () => {
|
|
123
403
|
assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
|
|
124
404
|
assert.deepEqual(
|
|
125
405
|
(({ action, host, project, json }) => ({ action, host, project, json }))(parseAutoHandoffArgs(['enable', '--host', 'claude', '--project', REPO_ROOT, '--json'])),
|
|
126
406
|
{ action: 'enable', host: 'claude', project: REPO_ROOT, json: true },
|
|
127
407
|
);
|
|
128
|
-
|
|
129
|
-
|
|
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]]) {
|
|
130
412
|
assert.throws(() => parseAutoHandoffArgs(args), JSON.stringify(args));
|
|
131
413
|
}
|
|
132
414
|
});
|
|
133
415
|
|
|
134
|
-
|
|
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 => {
|
|
135
454
|
const settingsPath = join(home, '.claude', 'settings.json');
|
|
136
455
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
137
|
-
// 他製品のhookと、
|
|
138
|
-
|
|
139
|
-
{ matcher: '
|
|
140
|
-
|
|
141
|
-
|
|
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 } }));
|
|
142
462
|
|
|
143
463
|
const before = JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout);
|
|
144
464
|
assert.deepEqual(before, { host: 'claude', config: { schema: 'throughline.claude-auto-handoff.v1', enabled: false, projects: [] },
|
|
145
|
-
|
|
465
|
+
hooks: { registered: false }, handoffs: [] });
|
|
146
466
|
assert.equal(existsSync(join(home, '.throughline')), false, 'status は何も作らない');
|
|
147
467
|
|
|
148
468
|
const enabled = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
@@ -150,27 +470,28 @@ test('公開CLI: enable は PreCompact hook と設定を書き、Codex の設定
|
|
|
150
470
|
assert.equal(JSON.parse(enabled.stdout).status, 'enabled');
|
|
151
471
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
152
472
|
assert.equal(settings.model, 'sonnet');
|
|
153
|
-
assert.deepEqual(settings.hooks
|
|
154
|
-
{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] },
|
|
155
|
-
{ hooks: [{ type: 'command', command: '
|
|
156
|
-
|
|
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
|
+
});
|
|
157
477
|
const again = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
158
478
|
assert.equal(again.status, 0, again.stderr);
|
|
159
479
|
assert.equal(readFileSync(settingsPath, 'utf8'), JSON.stringify(settings, null, 2) + '\n', '2回目は書き換えない');
|
|
160
480
|
|
|
161
|
-
|
|
162
|
-
|
|
481
|
+
assert.equal(cli(home, ['auto-handoff', 'status', '--host', 'claude']).stdout,
|
|
482
|
+
'自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse): 登録済み\n');
|
|
163
483
|
assert.equal(existsSync(join(home, '.throughline', 'codex-auto-handoff.json')), false);
|
|
164
484
|
assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--json']).stdout).config.enabled, false, 'Codex は無効のまま');
|
|
165
485
|
|
|
166
486
|
const disabled = JSON.parse(cli(home, ['auto-handoff', 'disable', '--host', 'claude', '--json']).stdout);
|
|
167
487
|
assert.equal(disabled.config.enabled, false);
|
|
488
|
+
assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, others);
|
|
168
489
|
const unsupported = cli(home, ['auto-handoff', 'resume', '--host', 'claude', '--operation', 'x', '--json']);
|
|
169
490
|
assert.equal(unsupported.status, 1);
|
|
170
491
|
assert.deepEqual(JSON.parse(unsupported.stdout), { status: 'failed', code: 'auto_handoff_action_unsupported' });
|
|
171
492
|
}));
|
|
172
493
|
|
|
173
|
-
test('hook:
|
|
494
|
+
test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し、後継の最初の指示へ止めた時点の依頼と記憶を注入する', () => withDir(async home => {
|
|
174
495
|
const project = join(home, 'project');
|
|
175
496
|
mkdirSync(project, { recursive: true });
|
|
176
497
|
const transcriptPath = join(home, 'transcript.jsonl');
|
|
@@ -183,126 +504,101 @@ test('hook: 自動圧縮の直後に、作業途中のターンと直近の会
|
|
|
183
504
|
toolUse('toolu_1', 'Read'),
|
|
184
505
|
toolResult('toolu_1', 'big1 contents'),
|
|
185
506
|
assistant('big1.txt を読み終えました。次に big2.txt を読みます。', at(12)),
|
|
507
|
+
toolUse('toolu_2', 'Read'),
|
|
186
508
|
]));
|
|
187
|
-
const
|
|
188
|
-
const hook = (command, payload
|
|
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;
|
|
189
513
|
|
|
190
514
|
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--project', project, '--json']).status, 0);
|
|
191
515
|
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }).status, 0);
|
|
192
516
|
|
|
193
|
-
//
|
|
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 は止めない
|
|
194
521
|
const manual = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'manual' });
|
|
195
|
-
assert.
|
|
196
|
-
assert.equal(manual.stdout, '', 'PreCompact の stdout は空(JSON の decision を返さない)');
|
|
197
|
-
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' }).stdout, '');
|
|
522
|
+
assert.deepEqual([manual.status, manual.stdout], [0, '']);
|
|
198
523
|
|
|
199
|
-
//
|
|
524
|
+
// 自動圧縮: 止める(exit code 2)
|
|
200
525
|
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
201
|
-
assert.equal(pre.status,
|
|
526
|
+
assert.equal(pre.status, 2, pre.stderr);
|
|
202
527
|
assert.equal(pre.stdout, '');
|
|
203
|
-
assert.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
assert.
|
|
209
|
-
|
|
210
|
-
assert.
|
|
211
|
-
assert.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 の引き継ぎと同じ)
|
|
216
571
|
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
assert.
|
|
220
|
-
[1, 'user', '成果物の1行目には必ず「約束: MANGO-4417」と書くこと'],
|
|
221
|
-
[1, 'assistant', '了解しました。'],
|
|
222
|
-
], '作業途中のターンは保存しない(途中の発言を回答として固定しない)');
|
|
223
|
-
|
|
224
|
-
// 印は消費済み。次の圧縮が手動なら何も出さない
|
|
225
|
-
assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).pending.length, 0);
|
|
226
|
-
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' }).stdout, '');
|
|
227
|
-
|
|
228
|
-
// 圧縮の後、作業が終わる
|
|
229
|
-
appendFileSync(transcriptPath, jsonl([
|
|
230
|
-
toolUse('toolu_2', 'Read'),
|
|
231
|
-
toolResult('toolu_2', 'big2 contents'),
|
|
232
|
-
assistant('result.txt を作りました。', at(40)),
|
|
233
|
-
]));
|
|
234
|
-
const stop = hook('process-turn', { hook_event_name: 'Stop', last_assistant_message: 'result.txt を作りました。' });
|
|
235
|
-
assert.equal(stop.status, 0, stop.stderr);
|
|
236
|
-
assert.deepEqual(bodies().slice(2), [
|
|
237
|
-
[5, 'user', 'big1.txt と big2.txt を読んで result.txt を作って'],
|
|
238
|
-
[5, 'assistant', 'result.txt を作りました。'],
|
|
239
|
-
]);
|
|
240
|
-
const tools = db.prepare("SELECT tool_name FROM details WHERE kind = 'tool_input' AND turn_number = 5 ORDER BY id").all();
|
|
241
|
-
assert.deepEqual(tools.map(row => row.tool_name), ['Read', 'Read'], '圧縮より前のtoolも同じターンのL3に入る');
|
|
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);
|
|
242
575
|
db.close();
|
|
243
576
|
|
|
244
577
|
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
245
578
|
assert.deepEqual(decisions.filter(entry => entry.phase === 'pre-compact').map(entry => [entry.trigger, entry.auto_continuation, entry.skip_reason]),
|
|
246
579
|
[['manual', 'skipped', 'manual_compact'], ['auto', 'requested', null]]);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
assert.
|
|
250
|
-
assert.equal(continuation[0].in_flight_fragments, 1);
|
|
251
|
-
assert.equal(continuation[0].injected_l2_turns, 1);
|
|
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]);
|
|
252
583
|
}));
|
|
253
584
|
|
|
254
|
-
test('hook:
|
|
585
|
+
test('hook: 自動継続が無効な時、自動圧縮を止めず、何も残さない', () => withDir(home => {
|
|
255
586
|
const project = join(home, 'project');
|
|
256
587
|
mkdirSync(project, { recursive: true });
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
assistant('最初の回答', '2026-10-04T03:00:01Z'),
|
|
261
|
-
{ ...user('前の依頼', '2026-10-04T03:00:10Z'), promptId: 'prompt-2' },
|
|
262
|
-
assistant('前の回答', '2026-10-04T03:00:11Z'),
|
|
263
|
-
]));
|
|
264
|
-
const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath, prompt_id: 'prompt-3' };
|
|
265
|
-
const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
|
|
266
|
-
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
|
|
267
|
-
assert.equal(hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' }).status, 0);
|
|
268
|
-
|
|
269
|
-
const compacted = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' });
|
|
270
|
-
assert.equal(compacted.status, 0, compacted.stderr);
|
|
271
|
-
assert.match(compacted.stdout, /\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。/);
|
|
272
|
-
assert.doesNotMatch(compacted.stdout, /前の依頼|前の回答|圧縮直前のあなたの発言/);
|
|
273
|
-
assert.match(compacted.stdout, /\[user\]: 最初の依頼\n/);
|
|
274
|
-
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
275
|
-
assert.deepEqual(db.prepare('SELECT text FROM bodies ORDER BY id').all().map(row => row.text), ['最初の依頼', '最初の回答']);
|
|
276
|
-
db.close();
|
|
277
|
-
|
|
278
|
-
// prompt_id が合う時は、その群が作業途中のターン
|
|
279
|
-
assert.equal(hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto', prompt_id: 'prompt-2' }).status, 0);
|
|
280
|
-
const matched = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact', prompt_id: 'prompt-2' });
|
|
281
|
-
assert.match(matched.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 前の依頼\n\*\*圧縮直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 前の回答\n/);
|
|
282
|
-
}));
|
|
283
|
-
|
|
284
|
-
test('hook: 自動継続が無効な時、自動圧縮の前後で何も残さず何も出さない', () => withDir(home => {
|
|
285
|
-
const project = join(home, 'project');
|
|
286
|
-
mkdirSync(project, { recursive: true });
|
|
287
|
-
const transcriptPath = join(home, 'transcript.jsonl');
|
|
288
|
-
writeFileSync(transcriptPath, jsonl([user('依頼'), assistant('途中')]));
|
|
289
|
-
const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath };
|
|
290
|
-
const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
|
|
291
|
-
|
|
292
|
-
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
293
|
-
assert.equal(pre.status, 0, pre.stderr);
|
|
294
|
-
assert.equal(pre.stdout, '');
|
|
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, '', '']);
|
|
295
591
|
assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff')), false);
|
|
296
|
-
|
|
297
|
-
assert.equal(compacted.status, 0, compacted.stderr);
|
|
298
|
-
assert.equal(compacted.stdout, '');
|
|
592
|
+
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '無効な時は DB も開かない');
|
|
299
593
|
}));
|
|
300
594
|
|
|
301
|
-
test('hook:
|
|
302
|
-
const
|
|
303
|
-
assert.
|
|
304
|
-
|
|
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, '']);
|
|
305
600
|
const log = readFileSync(join(home, '.throughline', 'logs', 'hook-failures.log'), 'utf8');
|
|
306
601
|
assert.match(log, /HOOK_PRE_COMPACT_FAILED/);
|
|
307
602
|
assert.match(log, /Missing session_id in PreCompact payload/);
|
|
603
|
+
assert.match(log, /HOOK_PRE_TOOL_USE_FAILED/);
|
|
308
604
|
}));
|