throughline 0.13.0 → 0.14.1
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 +46 -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 +104 -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 +540 -135
- package/src/claude-auto-handoff.test.mjs +623 -193
- package/src/claude-pre-compact.mjs +21 -7
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +25 -19
- 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 +108 -40
- package/src/resume-context.test.mjs +88 -31
- package/src/runtime-error-store.mjs +6 -1
- package/src/session-start.mjs +8 -28
- package/src/transcript-reader.mjs +26 -4
- package/src/transcript-reader.test.mjs +43 -0
- package/src/turn-backfill.mjs +82 -8
- package/src/turn-backfill.test.mjs +75 -23
- package/src/turn-processor.mjs +4 -0
|
@@ -1,148 +1,587 @@
|
|
|
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, input = {}) =>
|
|
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')), []);
|
|
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');
|
|
103
186
|
}));
|
|
104
187
|
|
|
105
|
-
test('
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
assert.equal(
|
|
109
|
-
assert.equal(
|
|
110
|
-
|
|
111
|
-
assert.equal(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
assert.throws(() => consumeClaudeCompactRequest({ sessionId: SESSION, dir }), /auto_handoff_request_invalid/);
|
|
119
|
-
assert.deepEqual(readdirSync(dir), [], '読めない印は残さない');
|
|
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');
|
|
120
201
|
}));
|
|
121
202
|
|
|
122
|
-
|
|
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
|
+
steps: [
|
|
312
|
+
{ kind: 'text', content: 'part04 を読みました。', timestamp: Date.parse('2026-10-04T03:00:12Z') },
|
|
313
|
+
{ kind: 'text', content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
|
|
314
|
+
],
|
|
315
|
+
earlier_steps: null,
|
|
316
|
+
stopped_tools: [{ name: 'Read', target: '' }],
|
|
317
|
+
stopped_tools_total: 1,
|
|
318
|
+
turn: null,
|
|
319
|
+
}, '止めた道具の直前の発言まで出そろってから写す');
|
|
320
|
+
|
|
321
|
+
// 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
|
|
322
|
+
const transcriptB = join(dir, 'b.jsonl');
|
|
323
|
+
writeFileSync(transcriptB, jsonl([
|
|
324
|
+
user(`Another Claude session sent a message:\n${claudeContinuationInput(first.record)}\n\nThis came from another Claude session — not typed by your user.`,
|
|
325
|
+
'2026-10-04T03:01:00Z'),
|
|
326
|
+
assistant('part09 を読みました。次に part10 を読みます。', '2026-10-04T03:01:20Z', 'claude-opus-5'),
|
|
327
|
+
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_10', name: 'Read', input: {} }] } },
|
|
328
|
+
]));
|
|
329
|
+
rmSync(join(dir, 'targets'), { recursive: true, force: true });
|
|
330
|
+
const second = await run(SUCCESSOR, transcriptB, 'toolu_10');
|
|
331
|
+
assert.deepEqual(second.record.in_flight, {
|
|
332
|
+
user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
|
|
333
|
+
last_fragment: { content: 'part09 を読みました。次に part10 を読みます。', timestamp: Date.parse('2026-10-04T03:01:20Z') },
|
|
334
|
+
steps: [{ kind: 'text', content: 'part09 を読みました。次に part10 を読みます。', timestamp: Date.parse('2026-10-04T03:01:20Z') }],
|
|
335
|
+
earlier_steps: null,
|
|
336
|
+
stopped_tools: [{ name: 'Read', target: '' }],
|
|
337
|
+
stopped_tools_total: 1,
|
|
338
|
+
turn: null,
|
|
339
|
+
});
|
|
340
|
+
}));
|
|
341
|
+
|
|
342
|
+
function makeMemoryDb() {
|
|
343
|
+
const db = makeBatonDb();
|
|
344
|
+
db.exec(`
|
|
345
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, merged_into TEXT);
|
|
346
|
+
CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL,
|
|
347
|
+
turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, token_count INTEGER, created_at INTEGER NOT NULL,
|
|
348
|
+
turn_start TEXT, UNIQUE(session_id, origin_session_id, turn_number, role));
|
|
349
|
+
CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT,
|
|
350
|
+
turn_number INTEGER, tool_name TEXT NOT NULL, input_text TEXT, output_text TEXT, token_count INTEGER NOT NULL DEFAULT 0,
|
|
351
|
+
created_at INTEGER NOT NULL, kind TEXT, source_id TEXT);
|
|
352
|
+
CREATE UNIQUE INDEX uq_details_source ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL;
|
|
353
|
+
`);
|
|
354
|
+
return db;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
test('worker: 止めたターンを DB へ取り込み、ここまでにしたことと、実行されなかった道具を記録へ写す', () => withDir(async dir => {
|
|
358
|
+
const block = (id, content, timestamp) =>
|
|
359
|
+
({ type: 'assistant', timestamp, message: { id, role: 'assistant', model: 'claude-opus-5', content: [content] } });
|
|
360
|
+
const result = (id, text, isError = false) =>
|
|
361
|
+
({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text, ...(isError ? { is_error: true } : {}) }] } });
|
|
362
|
+
const transcript = join(dir, 'work.jsonl');
|
|
363
|
+
writeFileSync(transcript, jsonl([
|
|
364
|
+
user('前の依頼', '2026-10-04T02:00:00Z'),
|
|
365
|
+
assistant('前の回答', '2026-10-04T02:00:05Z', 'claude-opus-5'),
|
|
366
|
+
user('a.mjs を直して', '2026-10-04T03:00:00Z'),
|
|
367
|
+
block('m1', { type: 'text', text: '読みます。' }, '2026-10-04T03:00:01Z'),
|
|
368
|
+
block('m1', { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/work/a.mjs' } }, '2026-10-04T03:00:01Z'),
|
|
369
|
+
result('t1', 'export const a = 1;'),
|
|
370
|
+
block('m2', { type: 'text', text: '直します。' }, '2026-10-04T03:00:03Z'),
|
|
371
|
+
block('m2', { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/work/app/src/a.mjs', old_string: '1', new_string: '2' } }, '2026-10-04T03:00:03Z'),
|
|
372
|
+
result('t2', 'ok'),
|
|
373
|
+
block('m3', { type: 'tool_use', id: 't3', name: 'Bash', input: { command: 'npm test\n --silent' } }, '2026-10-04T03:00:05Z'),
|
|
374
|
+
result('t3', 'Exit code 1', true),
|
|
375
|
+
// ここで自動圧縮を止めた。次の応答が呼んだ道具は、2つとも実行されていない
|
|
376
|
+
block('m4', { type: 'text', text: '試験が落ちました。\n原因を見ます。' }, '2026-10-04T03:00:08Z'),
|
|
377
|
+
block('m4', { type: 'tool_use', id: 't4', name: 'Read', input: { file_path: `/work/app/test/${'d'.repeat(200)}/a.test.mjs` } }, '2026-10-04T03:00:08Z'),
|
|
378
|
+
block('m4', { type: 'tool_use', id: 't5', name: 'Grep', input: { pattern: 'expect\\(' } }, '2026-10-04T03:00:08Z'),
|
|
379
|
+
result('t5', 'PreToolUse:Grep hook error', true),
|
|
380
|
+
result('t4', 'PreToolUse:Read hook error', true),
|
|
381
|
+
]));
|
|
382
|
+
const db = makeMemoryDb();
|
|
383
|
+
requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
|
|
384
|
+
env: {}, config: ENABLED, openDb: () => db, dir });
|
|
385
|
+
// hook は並んで走る。記録に残る止めた道具は、応答の中の2つ目のこともある
|
|
386
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 't5' },
|
|
387
|
+
dir, launchWorker: async () => {} });
|
|
388
|
+
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, targetTimeoutMs: 300, transcriptTimeoutMs: 300,
|
|
389
|
+
openDb: () => db,
|
|
390
|
+
send: async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }),
|
|
391
|
+
spawn: () => {
|
|
392
|
+
recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
|
|
393
|
+
env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
|
|
394
|
+
return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
|
|
395
|
+
} });
|
|
396
|
+
assert.equal(record.state, 'sent');
|
|
397
|
+
assert.deepEqual(record.in_flight.steps, [
|
|
398
|
+
{ kind: 'text', content: '読みます。', timestamp: Date.parse('2026-10-04T03:00:01Z') },
|
|
399
|
+
{ kind: 'tool', name: 'Read', target: '/work/a.mjs', failed: false },
|
|
400
|
+
{ kind: 'text', content: '直します。', timestamp: Date.parse('2026-10-04T03:00:03Z') },
|
|
401
|
+
{ kind: 'tool', name: 'Edit', target: 'src/a.mjs', failed: false },
|
|
402
|
+
{ kind: 'tool', name: 'Bash', target: 'npm test --silent', failed: true },
|
|
403
|
+
{ kind: 'text', content: '試験が落ちました。 原因を見ます。', timestamp: Date.parse('2026-10-04T03:00:08Z') },
|
|
404
|
+
], '前のターンは入れない。止めた道具は、ここまでにしたことに入れない。作業ディレクトリの中は相対で書く');
|
|
405
|
+
assert.deepEqual(record.in_flight.stopped_tools, [
|
|
406
|
+
{ name: 'Read', target: `…${'d'.repeat(159 - '/a.test.mjs'.length)}/a.test.mjs` }, { name: 'Grep', target: 'expect\\(' }],
|
|
407
|
+
'長い場所は末尾を残す');
|
|
408
|
+
assert.equal(record.in_flight.stopped_tools_total, 2);
|
|
409
|
+
assert.deepEqual(record.in_flight.turn, { origin_session_id: SESSION, turn_number: 5,
|
|
410
|
+
user_at: Date.parse('2026-10-04T03:00:00Z'), assistant_at: Date.parse('2026-10-04T03:00:08Z'), details: 10 });
|
|
411
|
+
assert.equal(publicClaudeAutoHandoff(record).in_flight_captured, true);
|
|
412
|
+
assert.equal(JSON.stringify(publicClaudeAutoHandoff(record)).includes('a.mjs'), false, '道具の対象は外へ見せない');
|
|
413
|
+
|
|
414
|
+
// DB: 止めたターンは、発言を全部つないだ本文と、道具の入出力(止めた道具を含む)で入る
|
|
415
|
+
assert.deepEqual(db.prepare('SELECT session_id, turn_number, role, text, created_at FROM bodies ORDER BY id').all().map(row => ({ ...row })), [
|
|
416
|
+
{ session_id: SESSION, turn_number: 5, role: 'user', text: 'a.mjs を直して', created_at: Date.parse('2026-10-04T03:00:00Z') },
|
|
417
|
+
{ session_id: SESSION, turn_number: 5, role: 'assistant', text: '読みます。\n\n直します。\n\n試験が落ちました。\n原因を見ます。',
|
|
418
|
+
created_at: Date.parse('2026-10-04T03:00:08Z') },
|
|
419
|
+
]);
|
|
420
|
+
assert.deepEqual(db.prepare(`SELECT kind, tool_name, turn_number FROM details WHERE origin_session_id = ? ORDER BY id`).all(SESSION)
|
|
421
|
+
.map(row => `${row.kind}:${row.tool_name}:${row.turn_number}`), [
|
|
422
|
+
'tool_input:Read:5', 'tool_output:Read:5', 'tool_input:Edit:5', 'tool_output:Edit:5', 'tool_input:Bash:5', 'tool_output:Bash:5',
|
|
423
|
+
'tool_input:Read:5', 'tool_input:Grep:5', 'tool_output:Grep:5', 'tool_output:Read:5']);
|
|
424
|
+
}));
|
|
425
|
+
|
|
426
|
+
test('worker: 止めたターンを取り込めなくても後継を立て、取り込めなかったことを記録に残す', () => withDir(async dir => {
|
|
427
|
+
const transcript = join(dir, 'work.jsonl');
|
|
428
|
+
writeFileSync(transcript, jsonl([user('依頼', '2026-10-04T03:00:00Z'), assistant('途中です。', '2026-10-04T03:00:02Z'), toolUse('toolu_1', 'Read')]));
|
|
429
|
+
const db = makeBatonDb(); // bodies も sessions も無い
|
|
430
|
+
requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
|
|
431
|
+
env: {}, config: ENABLED, openDb: () => db, dir });
|
|
432
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 'toolu_1' },
|
|
433
|
+
dir, launchWorker: async () => {} });
|
|
434
|
+
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, targetTimeoutMs: 300, transcriptTimeoutMs: 300,
|
|
435
|
+
openDb: () => db,
|
|
436
|
+
send: async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }),
|
|
437
|
+
spawn: () => {
|
|
438
|
+
recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
|
|
439
|
+
env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
|
|
440
|
+
return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
|
|
441
|
+
} });
|
|
442
|
+
assert.equal(record.state, 'sent');
|
|
443
|
+
assert.equal(record.in_flight.turn, null);
|
|
444
|
+
assert.equal(record.in_flight.steps.length, 1);
|
|
445
|
+
assert.equal(publicClaudeAutoHandoff(record).in_flight_captured, false);
|
|
446
|
+
}));
|
|
447
|
+
|
|
448
|
+
test('worker: 立ち上げ・受け口・配送の失敗は固定の理由を残し、結果が不明な配送は再送しない', () => withDir(async dir => {
|
|
449
|
+
const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
|
|
450
|
+
const cases = [
|
|
451
|
+
[{ spawn: () => ({ error: Object.assign(new Error('not found'), { code: 'ENOENT' }) }), send: sendOk }, 'failed', 'handoff_claude_cli_unavailable', 0],
|
|
452
|
+
[{ spawn: () => ({ status: 1, stdout: '', stderr: 'Workspace not trusted' }), send: sendOk }, 'failed', 'handoff_successor_launch_failed', 0],
|
|
453
|
+
[{ spawn: () => ({ status: 0, stdout: 'unexpected output', stderr: '' }), send: sendOk }, 'failed', 'handoff_successor_launch_failed', 0],
|
|
454
|
+
[{ spawn: launchedOk, send: sendOk }, 'failed', 'handoff_successor_target_unavailable', 0],
|
|
455
|
+
[{ spawn: launchedOk, onLaunched: successorStarts(dir),
|
|
456
|
+
send: async () => ({ status: 'not_sent', outcome_unknown: false, reason: 'connect_failed' }) }, 'failed', 'handoff_delivery_connect_failed', 1],
|
|
457
|
+
[{ spawn: launchedOk, onLaunched: successorStarts(dir),
|
|
458
|
+
send: async () => ({ status: 'unknown', outcome_unknown: true, reason: 'unconfirmed' }) }, 'unknown', 'handoff_delivery_unconfirmed', 1],
|
|
459
|
+
];
|
|
460
|
+
for (const [deps, state, code, sends] of cases) {
|
|
461
|
+
rmSync(join(dir, `${SESSION}.json`), { force: true });
|
|
462
|
+
rmSync(join(dir, `${SESSION}.claim`), { force: true });
|
|
463
|
+
const { record, calls } = await runWorker(dir, deps);
|
|
464
|
+
assert.deepEqual([record.state, record.error_code, calls.send.length], [state, code, sends], code);
|
|
465
|
+
}
|
|
466
|
+
// 結果が不明な引き継ぎは、次の道具でも立ち上げ直さない
|
|
467
|
+
const launched = [];
|
|
468
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async id => { launched.push(id); } });
|
|
469
|
+
assert.deepEqual(launched, []);
|
|
470
|
+
assert.equal((await runClaudeAutoHandoffWorker(SESSION, { dir, spawn: () => { throw new Error('must not spawn'); } })).state, 'unknown');
|
|
471
|
+
}));
|
|
472
|
+
|
|
473
|
+
test('SessionStart: 後継の立ち上げ中の引き継ぎがある project の、新しく始まった会話だけが受け口を控える', () => withDir(async dir => {
|
|
474
|
+
const env = { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock', CLAUDE_CODE_MESSAGING_TOKEN: 't' };
|
|
475
|
+
const start = (overrides = {}, environment = env) => recordClaudeSuccessorTarget({
|
|
476
|
+
payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app', ...overrides }, env: environment, dir });
|
|
477
|
+
assert.equal(start(), false, '引き継ぎが無い');
|
|
478
|
+
request(dir);
|
|
479
|
+
assert.equal(start(), false, 'まだ旧い会話を止めていない');
|
|
480
|
+
|
|
481
|
+
const write = fields => writeFileSync(join(dir, `${SESSION}.json`),
|
|
482
|
+
JSON.stringify({ ...readClaudeAutoHandoff(SESSION, dir), ...fields }));
|
|
483
|
+
write({ state: 'launching' });
|
|
484
|
+
assert.equal(start({ cwd: '/work/other' }), false, '別の project');
|
|
485
|
+
assert.equal(start({ source: 'resume' }), false);
|
|
486
|
+
assert.equal(start({ source: 'compact' }), false);
|
|
487
|
+
assert.equal(start({ agent_id: 'agent-1' }), false);
|
|
488
|
+
assert.equal(start({}, {}), false, '受け口を持たない会話');
|
|
489
|
+
assert.equal(start({ session_id: `grok:${SUCCESSOR}` }), false);
|
|
490
|
+
assert.equal(start(), true);
|
|
491
|
+
rmSync(join(dir, 'targets'), { recursive: true });
|
|
492
|
+
|
|
493
|
+
write({ state: 'launched', successor: { short_id: 'a41a97ae', session_id: null } });
|
|
494
|
+
assert.equal(start({ session_id: 'bbbbbbbb-0000-4000-8000-000000000001' }), false, '後継のIDと合わない会話');
|
|
495
|
+
assert.equal(start(), true);
|
|
496
|
+
write({ state: 'sent' });
|
|
497
|
+
rmSync(join(dir, 'targets'), { recursive: true });
|
|
498
|
+
assert.equal(start(), false, '送り終えた引き継ぎ');
|
|
499
|
+
}));
|
|
500
|
+
|
|
501
|
+
test('最初の指示: 後継だけが止めた時点の依頼を受け取り、この引き継ぎの配送だけが受領になる', () => withDir(async dir => {
|
|
502
|
+
request(dir);
|
|
503
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async () => {} });
|
|
504
|
+
const accept = (overrides = {}) => acceptClaudeAutoContinuation({ predecessorId: SESSION, successorSessionId: SUCCESSOR, dir, ...overrides });
|
|
505
|
+
assert.equal(accept(), null, '後継がまだ立っていない');
|
|
506
|
+
const record = readClaudeAutoHandoff(SESSION, dir);
|
|
507
|
+
const inFlight = { user: { content: '依頼', timestamp: 1 }, last_fragment: null };
|
|
508
|
+
writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ ...record, state: 'sending',
|
|
509
|
+
successor: { short_id: 'a41a97ae', session_id: SUCCESSOR }, in_flight: inFlight }));
|
|
510
|
+
assert.equal(accept({ successorSessionId: 'bbbbbbbb-0000-4000-8000-000000000001' }), null, '後継ではない会話');
|
|
511
|
+
assert.equal(accept({ predecessorId: 'aaaaaaaa-0000-4000-8000-000000000009' }), null);
|
|
512
|
+
assert.equal(accept({ predecessorId: `codex:${SESSION}` }), null);
|
|
513
|
+
|
|
514
|
+
// 人が先に別の指示を打った時は、依頼は渡すが受領にはしない
|
|
515
|
+
assert.deepEqual(accept({ prompt: '別の指示' }), { handoffId: record.handoff_id, projectPath: '/work/app', inFlight, transcriptPath: null });
|
|
516
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).accepted_at, null);
|
|
517
|
+
accept({ prompt: `Another Claude session sent a message:\n${claudeContinuationInput(record)}`, now: 9_000 });
|
|
518
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).accepted_at, 9_000);
|
|
519
|
+
}));
|
|
520
|
+
|
|
521
|
+
test('公開CLI: --host claude は enable・disable・status・worker を受け取る', () => {
|
|
123
522
|
assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
|
|
124
523
|
assert.deepEqual(
|
|
125
524
|
(({ action, host, project, json }) => ({ action, host, project, json }))(parseAutoHandoffArgs(['enable', '--host', 'claude', '--project', REPO_ROOT, '--json'])),
|
|
126
525
|
{ action: 'enable', host: 'claude', project: REPO_ROOT, json: true },
|
|
127
526
|
);
|
|
128
|
-
|
|
129
|
-
|
|
527
|
+
assert.equal(parseAutoHandoffArgs(['status', '--host', 'claude', '--operation', 'id']).operation, 'id');
|
|
528
|
+
assert.equal(parseAutoHandoffArgs(['worker', '--host', 'claude', '--operation', SESSION]).action, 'worker');
|
|
529
|
+
for (const args of [['resume', '--host', 'claude', '--operation', 'id'], ['detail', '--host', 'claude', '--operation', 'id', '--origin', 'A', '--turn', '1'],
|
|
530
|
+
['worker', '--host', 'claude'], ['status', '--host', 'grok'], ['status', '--host'], ['disable', '--host', 'claude', '--project', REPO_ROOT]]) {
|
|
130
531
|
assert.throws(() => parseAutoHandoffArgs(args), JSON.stringify(args));
|
|
131
532
|
}
|
|
132
533
|
});
|
|
133
534
|
|
|
134
|
-
|
|
535
|
+
// ---- 実際の CLI を temp HOME で動かす ----
|
|
536
|
+
|
|
537
|
+
function childEnv(home, extra = {}) {
|
|
538
|
+
// 設定とstateの置き場も一時HOMEへ向ける(本物のruntime error storeへ書かない)。
|
|
539
|
+
return {
|
|
540
|
+
...process.env,
|
|
541
|
+
HOME: home,
|
|
542
|
+
USERPROFILE: home,
|
|
543
|
+
XDG_CONFIG_HOME: join(home, '.config'),
|
|
544
|
+
XDG_STATE_HOME: join(home, '.local', 'state'),
|
|
545
|
+
LOCALAPPDATA: join(home, 'AppData', 'Local'),
|
|
546
|
+
THROUGHLINE_NO_VSCODE: '1',
|
|
547
|
+
CLAUDE_PROJECT_DIR: '',
|
|
548
|
+
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
|
549
|
+
CLAUDE_CODE_MESSAGING_SOCKET: '',
|
|
550
|
+
CLAUDE_CODE_MESSAGING_TOKEN: '',
|
|
551
|
+
...extra,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function cli(home, args, input = '', extra = {}) {
|
|
556
|
+
return spawnSync(process.execPath, [CLI, ...args], { cwd: REPO_ROOT, env: childEnv(home, extra), input, encoding: 'utf8' });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** 本物の claude を起動させない。すぐ失敗する偽物を PATH の先頭に置く。 */
|
|
560
|
+
function fakeClaudePath(home) {
|
|
561
|
+
const bin = join(home, 'fake-bin');
|
|
562
|
+
mkdirSync(bin, { recursive: true });
|
|
563
|
+
if (process.platform === 'win32') writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nexit /b 1\r\n');
|
|
564
|
+
else {
|
|
565
|
+
writeFileSync(join(bin, 'claude'), '#!/bin/sh\nexit 1\n');
|
|
566
|
+
chmodSync(join(bin, 'claude'), 0o755);
|
|
567
|
+
}
|
|
568
|
+
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') ?? 'PATH';
|
|
569
|
+
return { [pathKey]: `${bin}${delimiter}${process.env[pathKey] ?? ''}` };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
test('公開CLI: enable は2つの hook と設定を書き、disable は自分の hook だけを外す。Codex の設定には触らない', () => withDir(home => {
|
|
135
573
|
const settingsPath = join(home, '.claude', 'settings.json');
|
|
136
574
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
137
|
-
// 他製品のhookと、
|
|
138
|
-
|
|
139
|
-
{ matcher: '
|
|
140
|
-
|
|
141
|
-
|
|
575
|
+
// 他製品の hook と、0.13.0 が置いた PreCompact がある状態から始める
|
|
576
|
+
const others = { PreCompact: [{ hooks: [{ type: 'command', command: 'other-product pre-compact' }] }],
|
|
577
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook claude' }] }] };
|
|
578
|
+
writeFileSync(settingsPath, JSON.stringify({ model: 'sonnet', hooks: {
|
|
579
|
+
PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, ...others.PreCompact],
|
|
580
|
+
PreToolUse: others.PreToolUse } }));
|
|
142
581
|
|
|
143
582
|
const before = JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout);
|
|
144
583
|
assert.deepEqual(before, { host: 'claude', config: { schema: 'throughline.claude-auto-handoff.v1', enabled: false, projects: [] },
|
|
145
|
-
|
|
584
|
+
hooks: { registered: false }, handoffs: [] });
|
|
146
585
|
assert.equal(existsSync(join(home, '.throughline')), false, 'status は何も作らない');
|
|
147
586
|
|
|
148
587
|
const enabled = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
@@ -150,27 +589,28 @@ test('公開CLI: enable は PreCompact hook と設定を書き、Codex の設定
|
|
|
150
589
|
assert.equal(JSON.parse(enabled.stdout).status, 'enabled');
|
|
151
590
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
152
591
|
assert.equal(settings.model, 'sonnet');
|
|
153
|
-
assert.deepEqual(settings.hooks
|
|
154
|
-
{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] },
|
|
155
|
-
{ hooks: [{ type: 'command', command: '
|
|
156
|
-
|
|
592
|
+
assert.deepEqual(settings.hooks, {
|
|
593
|
+
PreCompact: [...others.PreCompact, { hooks: [{ type: 'command', command: 'throughline pre-compact' }] }],
|
|
594
|
+
PreToolUse: [...others.PreToolUse, { hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }] }],
|
|
595
|
+
});
|
|
157
596
|
const again = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
158
597
|
assert.equal(again.status, 0, again.stderr);
|
|
159
598
|
assert.equal(readFileSync(settingsPath, 'utf8'), JSON.stringify(settings, null, 2) + '\n', '2回目は書き換えない');
|
|
160
599
|
|
|
161
|
-
|
|
162
|
-
|
|
600
|
+
assert.equal(cli(home, ['auto-handoff', 'status', '--host', 'claude']).stdout,
|
|
601
|
+
'自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse): 登録済み\n');
|
|
163
602
|
assert.equal(existsSync(join(home, '.throughline', 'codex-auto-handoff.json')), false);
|
|
164
603
|
assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--json']).stdout).config.enabled, false, 'Codex は無効のまま');
|
|
165
604
|
|
|
166
605
|
const disabled = JSON.parse(cli(home, ['auto-handoff', 'disable', '--host', 'claude', '--json']).stdout);
|
|
167
606
|
assert.equal(disabled.config.enabled, false);
|
|
607
|
+
assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, others);
|
|
168
608
|
const unsupported = cli(home, ['auto-handoff', 'resume', '--host', 'claude', '--operation', 'x', '--json']);
|
|
169
609
|
assert.equal(unsupported.status, 1);
|
|
170
610
|
assert.deepEqual(JSON.parse(unsupported.stdout), { status: 'failed', code: 'auto_handoff_action_unsupported' });
|
|
171
611
|
}));
|
|
172
612
|
|
|
173
|
-
test('hook:
|
|
613
|
+
test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し、後継の最初の指示へ止めた時点の依頼と記憶を注入する', () => withDir(async home => {
|
|
174
614
|
const project = join(home, 'project');
|
|
175
615
|
mkdirSync(project, { recursive: true });
|
|
176
616
|
const transcriptPath = join(home, 'transcript.jsonl');
|
|
@@ -180,129 +620,119 @@ test('hook: 自動圧縮の直後に、作業途中のターンと直近の会
|
|
|
180
620
|
user('成果物の1行目には必ず「約束: MANGO-4417」と書くこと', at(0)),
|
|
181
621
|
assistant('了解しました。', at(1)),
|
|
182
622
|
user('big1.txt と big2.txt を読んで result.txt を作って', at(10)),
|
|
183
|
-
toolUse('toolu_1', 'Read'),
|
|
623
|
+
toolUse('toolu_1', 'Read', { file_path: '/p/big1.txt' }),
|
|
184
624
|
toolResult('toolu_1', 'big1 contents'),
|
|
185
625
|
assistant('big1.txt を読み終えました。次に big2.txt を読みます。', at(12)),
|
|
626
|
+
toolUse('toolu_2', 'Read', { file_path: '/p/big2.txt' }),
|
|
186
627
|
]));
|
|
187
|
-
const
|
|
188
|
-
const hook = (command, payload
|
|
628
|
+
const extra = fakeClaudePath(home);
|
|
629
|
+
const hook = (command, payload, sessionId = SESSION) =>
|
|
630
|
+
cli(home, [command], JSON.stringify({ session_id: sessionId, cwd: project, transcript_path: transcriptPath, ...payload }), extra);
|
|
631
|
+
const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
|
|
189
632
|
|
|
190
633
|
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--project', project, '--json']).status, 0);
|
|
191
634
|
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }).status, 0);
|
|
192
635
|
|
|
193
|
-
//
|
|
636
|
+
// 記録が無い間、道具の hook は何も出さずに終わる
|
|
637
|
+
const idle = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read' });
|
|
638
|
+
assert.deepEqual([idle.status, idle.stdout, idle.stderr], [0, '', '']);
|
|
639
|
+
// 手動の /compact は止めない
|
|
194
640
|
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, '');
|
|
641
|
+
assert.deepEqual([manual.status, manual.stdout], [0, '']);
|
|
198
642
|
|
|
199
|
-
//
|
|
643
|
+
// 自動圧縮: 止める(exit code 2)
|
|
200
644
|
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
201
|
-
assert.equal(pre.status,
|
|
645
|
+
assert.equal(pre.status, 2, pre.stderr);
|
|
202
646
|
assert.equal(pre.stdout, '');
|
|
203
|
-
assert.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
assert.
|
|
209
|
-
|
|
210
|
-
assert.
|
|
211
|
-
assert.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
647
|
+
assert.match(pre.stderr, /Throughlineが自動圧縮を止めました。新しい会話へ引き継ぎます/);
|
|
648
|
+
assert.deepEqual(status().map(item => item.state), ['requested']);
|
|
649
|
+
|
|
650
|
+
// 次の道具: 実行させずに止め、worker を起動する
|
|
651
|
+
const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_2', permission_mode: 'acceptEdits' });
|
|
652
|
+
assert.equal(stopped.status, 0, stopped.stderr);
|
|
653
|
+
const output = JSON.parse(stopped.stdout);
|
|
654
|
+
assert.equal(output.continue, false);
|
|
655
|
+
assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
|
|
656
|
+
// worker は偽の claude(すぐ失敗)で止まる。本物の claude は起動しない
|
|
657
|
+
let handoff;
|
|
658
|
+
for (let i = 0; i < 100 && (handoff = status()[0]).state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
|
|
659
|
+
assert.deepEqual([handoff.state, handoff.error_code], ['failed', 'handoff_successor_launch_failed']);
|
|
660
|
+
|
|
661
|
+
// 後継が立って指示が届いた場面を、記録を進めて再現する
|
|
662
|
+
const handoffDir = join(home, '.throughline', 'claude-auto-handoff');
|
|
663
|
+
const recordFile = join(handoffDir, `${SESSION}.json`);
|
|
664
|
+
const record = JSON.parse(readFileSync(recordFile, 'utf8'));
|
|
665
|
+
assert.equal(record.settings.permission_mode, 'acceptEdits');
|
|
666
|
+
// worker は後継を立てる前に、止めたターンを DB へ取り込んでいる
|
|
667
|
+
assert.equal(handoff.in_flight_captured, true);
|
|
668
|
+
assert.equal(record.in_flight.turn.origin_session_id, SESSION);
|
|
669
|
+
assert.equal(record.in_flight.turn.details, 3);
|
|
670
|
+
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
|
|
671
|
+
const started = hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }, SUCCESSOR);
|
|
672
|
+
assert.equal(started.status, 0, started.stderr);
|
|
673
|
+
assert.equal(started.stdout, '');
|
|
674
|
+
assert.equal(existsSync(join(handoffDir, 'targets')), false, '受け口を持たない会話は控えない');
|
|
675
|
+
const withSocket = cli(home, ['session-start'], JSON.stringify({ session_id: SUCCESSOR, cwd: project, hook_event_name: 'SessionStart', source: 'startup' }),
|
|
676
|
+
{ ...extra, CLAUDE_CODE_MESSAGING_SOCKET: join(home, 'in.sock'), CLAUDE_CODE_MESSAGING_TOKEN: 'secret-token' });
|
|
677
|
+
assert.equal(withSocket.status, 0, withSocket.stderr);
|
|
678
|
+
assert.deepEqual(readdirSync(join(handoffDir, 'targets')), [`${SUCCESSOR}.json`]);
|
|
679
|
+
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'sending', error_code: null,
|
|
680
|
+
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
681
|
+
|
|
682
|
+
const prompt = `Another Claude session sent a message:\n${claudeContinuationInput(record)}`;
|
|
683
|
+
const first = cli(home, ['prompt-submit'], JSON.stringify({ session_id: SUCCESSOR, cwd: project, hook_event_name: 'UserPromptSubmit', prompt }), extra);
|
|
684
|
+
assert.equal(first.status, 0, first.stderr);
|
|
685
|
+
assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
|
|
686
|
+
assert.match(first.stdout, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: big1\.txt と big2\.txt を読んで result\.txt を作って\n/);
|
|
687
|
+
assert.match(first.stdout, new RegExp('\\*\\*このターンでここまでにしたこと\\*\\*(古い順。どれも完了済み。道具の入出力の全文: `throughline detail \\d\\d:\\d\\d:\\d\\d`):\\n' +
|
|
688
|
+
'- 道具: Read /p/big1\\.txt\\n- 発言: big1\\.txt を読み終えました。次に big2\\.txt を読みます。\\n' +
|
|
689
|
+
'\\*\\*止める直前に呼ぼうとして、実行されなかった道具\\*\\*: Read /p/big2\\.txt\\n'));
|
|
690
|
+
assert.match(first.stdout, /\[user\]: 成果物の1行目には必ず「約束: MANGO-4417」と書くこと\n/);
|
|
691
|
+
assert.equal(first.stdout.split('big1.txt と big2.txt を読んで').length, 2, '止めたターンの依頼は現在地に1回だけ');
|
|
692
|
+
assert.doesNotMatch(first.stdout, /宣言|\/clear/);
|
|
693
|
+
assert.ok(first.stdout.length <= 9_501);
|
|
694
|
+
assert.ok(JSON.parse(readFileSync(recordFile, 'utf8')).accepted_at > 0, '届いた指示で受領を記録する');
|
|
695
|
+
|
|
696
|
+
// 記憶は後継へ合流している(今の /tl の引き継ぎと同じ)
|
|
216
697
|
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 を作りました。'],
|
|
698
|
+
assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
|
|
699
|
+
assert.deepEqual(db.prepare('SELECT DISTINCT session_id FROM bodies').all().map(row => row.session_id), [SUCCESSOR]);
|
|
700
|
+
// 止めたターンは、道具の入出力ごと後継の記憶に入っている(`throughline detail` で取り出せる)
|
|
701
|
+
assert.deepEqual(db.prepare(`SELECT b.role, b.text, (SELECT COUNT(*) FROM details d WHERE d.session_id = b.session_id
|
|
702
|
+
AND d.origin_session_id = b.origin_session_id AND d.turn_number = b.turn_number) AS details
|
|
703
|
+
FROM bodies b WHERE b.origin_session_id = ? AND b.turn_number = ? ORDER BY b.id`).all(SESSION, record.in_flight.turn.turn_number)
|
|
704
|
+
.map(row => ({ ...row })), [
|
|
705
|
+
{ role: 'user', text: 'big1.txt と big2.txt を読んで result.txt を作って', details: 3 },
|
|
706
|
+
{ role: 'assistant', text: 'big1.txt を読み終えました。次に big2.txt を読みます。', details: 3 },
|
|
239
707
|
]);
|
|
240
|
-
|
|
241
|
-
assert.deepEqual(tools.map(row => row.tool_name), ['Read', 'Read'], '圧縮より前のtoolも同じターンのL3に入る');
|
|
708
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
242
709
|
db.close();
|
|
243
710
|
|
|
244
711
|
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
245
712
|
assert.deepEqual(decisions.filter(entry => entry.phase === 'pre-compact').map(entry => [entry.trigger, entry.auto_continuation, entry.skip_reason]),
|
|
246
713
|
[['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);
|
|
252
|
-
}));
|
|
253
|
-
|
|
254
|
-
test('hook: transcript に今の依頼がまだ無い時(prompt_id が合わない)、前のターンを作業中として見せず、保存もしない', () => withDir(home => {
|
|
255
|
-
const project = join(home, 'project');
|
|
256
|
-
mkdirSync(project, { recursive: true });
|
|
257
|
-
const transcriptPath = join(home, 'transcript.jsonl');
|
|
258
|
-
writeFileSync(transcriptPath, jsonl([
|
|
259
|
-
{ ...user('最初の依頼', '2026-10-04T03:00:00Z'), promptId: 'prompt-1' },
|
|
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/);
|
|
714
|
+
assert.equal(decisions.filter(entry => entry.phase === 'pre-tool-use-stop').length, 1);
|
|
715
|
+
const merged = decisions.find(entry => entry.phase === 'prompt-submit' && entry.merged);
|
|
716
|
+
assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['baton', record.handoff_id]);
|
|
282
717
|
}));
|
|
283
718
|
|
|
284
|
-
test('hook:
|
|
719
|
+
test('hook: 自動継続が無効な時、自動圧縮を止めず、何も残さない', () => withDir(home => {
|
|
285
720
|
const project = join(home, 'project');
|
|
286
721
|
mkdirSync(project, { recursive: true });
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
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, '');
|
|
722
|
+
const payload = { session_id: SESSION, cwd: project, hook_event_name: 'PreCompact', trigger: 'auto' };
|
|
723
|
+
const pre = cli(home, ['pre-compact'], JSON.stringify(payload));
|
|
724
|
+
assert.deepEqual([pre.status, pre.stdout, pre.stderr], [0, '', '']);
|
|
295
725
|
assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff')), false);
|
|
296
|
-
|
|
297
|
-
assert.equal(compacted.status, 0, compacted.stderr);
|
|
298
|
-
assert.equal(compacted.stdout, '');
|
|
726
|
+
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '無効な時は DB も開かない');
|
|
299
727
|
}));
|
|
300
728
|
|
|
301
|
-
test('hook:
|
|
302
|
-
const
|
|
303
|
-
assert.
|
|
304
|
-
|
|
729
|
+
test('hook: 自分の失敗では圧縮も道具も止めず(exit code 1)、理由を端末に残す', () => withDir(home => {
|
|
730
|
+
const compact = cli(home, ['pre-compact'], '{"trigger":"auto"}');
|
|
731
|
+
assert.deepEqual([compact.status, compact.stdout], [1, '']);
|
|
732
|
+
const tool = cli(home, ['pre-tool-use'], '{not json');
|
|
733
|
+
assert.deepEqual([tool.status, tool.stdout], [1, '']);
|
|
305
734
|
const log = readFileSync(join(home, '.throughline', 'logs', 'hook-failures.log'), 'utf8');
|
|
306
735
|
assert.match(log, /HOOK_PRE_COMPACT_FAILED/);
|
|
307
736
|
assert.match(log, /Missing session_id in PreCompact payload/);
|
|
737
|
+
assert.match(log, /HOOK_PRE_TOOL_USE_FAILED/);
|
|
308
738
|
}));
|