throughline 0.9.1 → 0.10.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.
@@ -1,6 +1,12 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { isBatonCommand, isClearCommand } from './prompt-submit.mjs';
3
+ import {
4
+ commandTextFromPrompt,
5
+ isBatonCommand,
6
+ isClearCommand,
7
+ launchGrokContinueAfterTl,
8
+ shouldLaunchGrokContinue,
9
+ } from './prompt-submit.mjs';
4
10
 
5
11
  test('isBatonCommand: bare /tl', () => {
6
12
  assert.equal(isBatonCommand('/tl'), true);
@@ -64,3 +70,54 @@ test('isClearCommand: rejects empty / non-string', () => {
64
70
  assert.equal(isClearCommand(undefined), false);
65
71
  assert.equal(isClearCommand(42), false);
66
72
  });
73
+
74
+ test('commandTextFromPrompt unwraps Grok user_query and leaves bare Claude text', () => {
75
+ assert.equal(commandTextFromPrompt('/tl'), '/tl');
76
+ assert.equal(
77
+ commandTextFromPrompt('<user_query>\n/tl\n</user_query>\n<skill_information>saved</skill_information>'),
78
+ '/tl',
79
+ );
80
+ });
81
+
82
+ test('shouldLaunchGrokContinue is only Grok /tl', () => {
83
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'tl' }), true);
84
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'clear' }), false);
85
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'old-session', trigger: 'tl' }), false);
86
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'codex:thread', trigger: 'tl' }), false);
87
+ });
88
+
89
+ test('launchGrokContinueAfterTl calls grok-continue with the source session', () => {
90
+ const calls = [];
91
+ const code = launchGrokContinueAfterTl({
92
+ sessionId: 'grok:abc',
93
+ cwd: '/work/Throughline',
94
+ continueRun: (argv, opts) => {
95
+ calls.push({ argv, opts });
96
+ return 0;
97
+ },
98
+ });
99
+ assert.equal(code, 0);
100
+ assert.deepEqual(calls, [{
101
+ argv: ['--session', 'grok:abc'],
102
+ opts: { cwd: '/work/Throughline' },
103
+ }]);
104
+ });
105
+
106
+ test('isBatonCommand: Grok user_query wrap around /tl', () => {
107
+ const wrapped =
108
+ '<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>';
109
+ assert.equal(isBatonCommand(wrapped), true);
110
+ });
111
+
112
+ test('isBatonCommand: Grok wrap does not treat skill body /tl as the command', () => {
113
+ const wrapped =
114
+ '<user_query>\n続けてくれ\n</user_query>\n<skill_information>\nType /tl to hand off.\n</skill_information>';
115
+ assert.equal(isBatonCommand(wrapped), false);
116
+ });
117
+
118
+ test('isClearCommand: Grok user_query wrap around /clear and /new', () => {
119
+ assert.equal(isClearCommand('<user_query>\n/clear\n</user_query>'), true);
120
+ assert.equal(isClearCommand('<user_query>\n/new\n</user_query>'), true);
121
+ assert.equal(isClearCommand('/new'), true);
122
+ assert.equal(isClearCommand('/newest'), false);
123
+ });
@@ -32,7 +32,7 @@ import { logDecision } from './decision-log.mjs';
32
32
  import { existsSync } from 'node:fs';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
- import { isUnsupportedNonClaudeEnvelope } from './hook-envelope.mjs';
35
+ import { normalizeHookPayload } from './hook-envelope.mjs';
36
36
 
37
37
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
38
38
 
@@ -62,6 +62,7 @@ function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
62
62
  AND merged_into IS NULL
63
63
  AND session_id != ?
64
64
  AND session_id NOT LIKE 'codex:%'
65
+ AND session_id NOT LIKE 'grok:%'
65
66
  ORDER BY updated_at DESC
66
67
  LIMIT 5`,
67
68
  )
@@ -91,8 +92,7 @@ export async function run() {
91
92
  process.stdin.on('end', resolve);
92
93
  });
93
94
 
94
- const payload = JSON.parse(raw);
95
- if (isUnsupportedNonClaudeEnvelope(payload)) return;
95
+ const payload = normalizeHookPayload(JSON.parse(raw));
96
96
  const { session_id, cwd, source, transcript_path } = payload;
97
97
 
98
98
  if (!session_id) throw new Error('Missing session_id in SessionStart payload');
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { getLogicalTurnGroups, readTranscript } from './transcript-reader.mjs';
8
+
9
+ test('readTranscript accepts Grok chat_history.jsonl user/assistant rows', () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-transcript-'));
11
+ const path = join(dir, 'chat_history.jsonl');
12
+ writeFileSync(
13
+ path,
14
+ [
15
+ JSON.stringify({ type: 'system', content: 'ignore' }),
16
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
17
+ JSON.stringify({ type: 'assistant', content: 'captured reply' }),
18
+ JSON.stringify({ type: 'tool_result', content: 'not a turn' }),
19
+ ].join('\n'),
20
+ );
21
+ try {
22
+ const turns = readTranscript(path);
23
+ assert.deepEqual(
24
+ turns.map((t) => ({ role: t.role, content: t.content })),
25
+ [
26
+ { role: 'user', content: 'hello grok' },
27
+ { role: 'assistant', content: 'captured reply' },
28
+ ],
29
+ );
30
+ const groups = getLogicalTurnGroups(path);
31
+ assert.equal(groups.length, 1);
32
+ assert.equal(groups[0].user.content, 'hello grok');
33
+ assert.equal(groups[0].representative.content, 'captured reply');
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
@@ -59,14 +59,18 @@ export function readTranscript(transcriptPath) {
59
59
  if (entry.isSidechain === true) continue;
60
60
 
61
61
  const msg = entry.message;
62
- if (!msg || !msg.role || msg.content == null) continue;
62
+ const grokContent = entry.content;
63
+ const role = msg?.role ?? entry.type;
64
+ const rawContent = msg?.content ?? grokContent;
65
+ if (!role || rawContent == null) continue;
63
66
 
64
- const text = extractText(msg.content);
67
+ const text = extractText(rawContent);
65
68
  if (!text) continue;
66
69
 
67
70
  const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
71
+ // grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
68
72
  turns.push({
69
- role: msg.role,
73
+ role,
70
74
  content: text,
71
75
  turn_number: turns.length,
72
76
  timestamp: Number.isNaN(ts) ? null : ts,
@@ -46,7 +46,7 @@ import { readLatestUsage } from './transcript-usage.mjs';
46
46
  import { pathToFileURL } from 'node:url';
47
47
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
48
48
  import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
49
- import { isUnsupportedNonClaudeEnvelope } from './hook-envelope.mjs';
49
+ import { normalizeHookPayload } from './hook-envelope.mjs';
50
50
 
51
51
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
52
52
  export const L2_WINDOW = 20;
@@ -191,8 +191,7 @@ export async function run() {
191
191
  process.stdin.on('end', resolve);
192
192
  });
193
193
 
194
- const payload = JSON.parse(raw || '{}');
195
- if (isUnsupportedNonClaudeEnvelope(payload)) return;
194
+ const payload = normalizeHookPayload(JSON.parse(raw || '{}'));
196
195
  const { session_id, transcript_path, cwd, last_assistant_message } = payload;
197
196
  if (!session_id) throw new Error('Missing session_id in Stop payload');
198
197
 
@@ -206,10 +205,12 @@ export async function run() {
206
205
  process.stderr.write(`[vscode-task] ${msg}\n`);
207
206
  }
208
207
 
209
- await waitForClaudeStopTranscriptFlush({
210
- transcriptPath: transcript_path,
211
- lastAssistantMessage: last_assistant_message,
212
- });
208
+ if (!session_id.startsWith('grok:')) {
209
+ await waitForClaudeStopTranscriptFlush({
210
+ transcriptPath: transcript_path,
211
+ lastAssistantMessage: last_assistant_message,
212
+ });
213
+ }
213
214
 
214
215
  // Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
215
216
  // アシスタント応答終了時刻まで追従する