throughline 0.4.10 → 0.4.12

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.
@@ -101,7 +101,8 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
101
101
  assert.ok(!text.includes('内訳の読み方'), 'glossary block must be gone');
102
102
  assert.ok(!text.includes('現在進行中の作業の active work context'), 'verbose framing must be gone');
103
103
 
104
- // 残るのは 2 行: 自然な続き + Bash 呼び出し方法
104
+ // 残るのは 3 行: 現在地参照案内 + 自然な続き + Bash 呼び出し方法
105
+ assert.match(text, /下の「現在地」が直前のやりとりです/);
105
106
  assert.match(text, /直前の対話の自然な続きとして応答してください/);
106
107
  assert.match(
107
108
  text,
@@ -109,6 +110,132 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
109
110
  );
110
111
  });
111
112
 
113
+ test('buildResumeContext: 現在地 anchor surfaces the latest user/assistant exchange above L1/L2', () => {
114
+ const db = makeDb();
115
+ // 25 turns to exercise an L2 window edge and ensure the anchor picks the newest.
116
+ for (let t = 1; t <= 25; t += 1) {
117
+ insertBody(db, {
118
+ session: 'new',
119
+ origin: 'old',
120
+ turn: t,
121
+ role: 'user',
122
+ text: `user turn ${t}`,
123
+ createdAt: 1000 + t * 10,
124
+ });
125
+ insertBody(db, {
126
+ session: 'new',
127
+ origin: 'old',
128
+ turn: t,
129
+ role: 'assistant',
130
+ text: `assistant turn ${t}`,
131
+ createdAt: 1000 + t * 10 + 1,
132
+ });
133
+ }
134
+
135
+ const text = buildResumeContext(db, {
136
+ sessionId: 'new',
137
+ isInheritance: true,
138
+ });
139
+
140
+ assert.ok(text);
141
+
142
+ const anchorIdx = text.indexOf('### 現在地 (直前のやりとり)');
143
+ const l2Idx = text.indexOf('### 直前の対話 (L2 / active work thread, 古い順)');
144
+ assert.ok(anchorIdx > 0, '現在地 anchor section should be present');
145
+ assert.ok(l2Idx > anchorIdx, '現在地 anchor must appear before the L2 section');
146
+
147
+ // The anchor must point to turn 25 (the latest), not any earlier turn.
148
+ assert.match(text, /\*\*最新ユーザー指示\*\* \[\d\d:\d\d:\d\d\]: user turn 25$/m);
149
+ assert.match(text, /\*\*直前のアシスタント\*\* \[\d\d:\d\d:\d\d\]: assistant turn 25$/m);
150
+ });
151
+
152
+ test('buildResumeContext: 現在地 anchor truncates long bodies but full body still appears in L2', () => {
153
+ const db = makeDb();
154
+ const longText = 'a'.repeat(1200);
155
+ insertBody(db, {
156
+ session: 'new',
157
+ origin: 'old',
158
+ turn: 1,
159
+ role: 'assistant',
160
+ text: longText,
161
+ createdAt: 1000,
162
+ });
163
+
164
+ const text = buildResumeContext(db, {
165
+ sessionId: 'new',
166
+ isInheritance: true,
167
+ });
168
+
169
+ assert.ok(text);
170
+
171
+ const anchorLine = text
172
+ .split('\n')
173
+ .find((l) => l.startsWith('**直前のアシスタント**'));
174
+ assert.ok(anchorLine, '直前のアシスタント anchor line should be present');
175
+ // Anchor must be truncated with ellipsis (originally 1200 chars > 600 cap).
176
+ assert.ok(anchorLine.endsWith(' …'), 'long anchor body must end with the ellipsis marker');
177
+ assert.ok(
178
+ anchorLine.length < longText.length,
179
+ 'anchor line should be shorter than the original body',
180
+ );
181
+
182
+ // Full body must still appear in the L2 section below.
183
+ assert.match(text, new RegExp(`\\[assistant\\]: ${longText}`));
184
+ });
185
+
186
+ test('buildResumeContext: 現在地 anchor is omitted for non-inheritance sessions', () => {
187
+ const db = makeDb();
188
+ insertBody(db, {
189
+ session: 'new',
190
+ origin: 'old',
191
+ turn: 1,
192
+ role: 'assistant',
193
+ text: 'a body',
194
+ createdAt: 1000,
195
+ });
196
+
197
+ const text = buildResumeContext(db, {
198
+ sessionId: 'new',
199
+ isInheritance: false,
200
+ });
201
+
202
+ assert.ok(text);
203
+ assert.ok(
204
+ !text.includes('現在地'),
205
+ 'normal sessions (isInheritance=false) must not include the 現在地 anchor',
206
+ );
207
+ assert.ok(
208
+ !text.includes('最新ユーザー指示'),
209
+ 'normal sessions must not surface a latest-user pointer',
210
+ );
211
+ });
212
+
213
+ test('buildResumeContext: 現在地 anchor handles a single-role recent window', () => {
214
+ const db = makeDb();
215
+ // Only user rows (no assistant) — anchor should still render with just the user line.
216
+ insertBody(db, {
217
+ session: 'new',
218
+ origin: 'old',
219
+ turn: 1,
220
+ role: 'user',
221
+ text: 'lone user message',
222
+ createdAt: 1000,
223
+ });
224
+
225
+ const text = buildResumeContext(db, {
226
+ sessionId: 'new',
227
+ isInheritance: true,
228
+ });
229
+
230
+ assert.ok(text);
231
+ assert.ok(text.includes('### 現在地 (直前のやりとり)'));
232
+ assert.match(text, /\*\*最新ユーザー指示\*\* \[\d\d:\d\d:\d\d\]: lone user message/);
233
+ assert.ok(
234
+ !text.includes('**直前のアシスタント**'),
235
+ 'no assistant body present → no 直前のアシスタント line',
236
+ );
237
+ });
238
+
112
239
  test('buildResumeContext: L2 is the very last section (anchored at bottom for attention)', () => {
113
240
  const db = makeDb();
114
241
  insertSkeleton(db, {
@@ -239,9 +366,11 @@ test('buildResumeContext: L2 entries get inline (詳細:…) suffixes with too
239
366
 
240
367
  assert.ok(text);
241
368
 
369
+ // Look for the L2 body line specifically (not the 現在地 anchor line which also
370
+ // contains the latest assistant body).
242
371
  const turnWithToolsLine = text
243
372
  .split('\n')
244
- .find((l) => l.includes('turn with tools'));
373
+ .find((l) => /^\[\d\d:\d\d:\d\d\] \[assistant\]: turn with tools/.test(l));
245
374
  assert.ok(turnWithToolsLine, 'L2 line for turn 5 should exist');
246
375
  // - tool_input + tool_output は tool 名で集約 (Bash ×2)
247
376
  // - hook 出力 (system) は suffix から除外
@@ -265,7 +394,9 @@ test('buildResumeContext: L2 entries get inline (詳細:…) suffixes with too
265
394
  'per-line should not repeat the throughline detail command (the header announces it)',
266
395
  );
267
396
 
268
- const plainLine = text.split('\n').find((l) => l.includes('plain user message'));
397
+ const plainLine = text
398
+ .split('\n')
399
+ .find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: plain user message/.test(l));
269
400
  assert.ok(plainLine, 'L2 line for turn 6 should exist');
270
401
  assert.ok(
271
402
  !plainLine.includes('詳細:'),
@@ -341,9 +472,14 @@ test('buildResumeContext: (詳細:…) suffix appears only on the last role ro
341
472
 
342
473
  assert.ok(text);
343
474
 
344
- const userTurn5 = text.split('\n').find((l) => l.includes('user side of turn 5'));
345
- const assistantTurn5 = text.split('\n').find((l) => l.includes('assistant side of turn 5'));
346
- const userTurn6 = text.split('\n').find((l) => l.includes('lone user turn'));
475
+ // Match L2 body lines specifically (`[HH:MM:SS] [role]: ...`) to avoid colliding
476
+ // with the 現在地 anchor lines (`**最新ユーザー指示** [HH:MM:SS]: ...`).
477
+ const lines = text.split('\n');
478
+ const userTurn5 = lines.find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: user side of turn 5/.test(l));
479
+ const assistantTurn5 = lines.find(
480
+ (l) => /^\[\d\d:\d\d:\d\d\] \[assistant\]: assistant side of turn 5/.test(l),
481
+ );
482
+ const userTurn6 = lines.find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: lone user turn/.test(l));
347
483
 
348
484
  assert.ok(userTurn5 && assistantTurn5 && userTurn6);
349
485
  // Turn 5: only assistant (last role of the turn) gets the suffix
@@ -0,0 +1,4 @@
1
+ // Keep tests hermetic when they are run from inside a live Codex session.
2
+ // Individual tests that need these values set them explicitly in child env.
3
+ delete process.env.THROUGHLINE_CODEX_THREAD_ID;
4
+ delete process.env.CODEX_THREAD_ID;
@@ -1,5 +1,6 @@
1
1
  import { inspectCodexPlannedRollbackRestoreSafety } from './codex-rollout-memory.mjs';
2
2
  import { buildHandoffRecord, N_RECENT_L2 } from './handoff-record.mjs';
3
+ import { sameProjectPath } from './project-path.mjs';
3
4
  import { estimateTokens } from './token-estimator.mjs';
4
5
 
5
6
  export const DEFAULT_TRIM_KEEP_RECENT = N_RECENT_L2;
@@ -31,15 +32,14 @@ function loadSession(db, sessionId) {
31
32
  export function findLatestSessionIdForProject(db, projectPath) {
32
33
  if (!projectPath) return null;
33
34
  try {
34
- const row = db
35
+ const rows = db
35
36
  .prepare(
36
- `SELECT session_id
37
+ `SELECT session_id, project_path
37
38
  FROM sessions
38
- WHERE lower(project_path) = lower(?)
39
- ORDER BY updated_at DESC
40
- LIMIT 1`,
39
+ ORDER BY updated_at DESC`,
41
40
  )
42
- .get(projectPath);
41
+ .all();
42
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
43
43
  return row?.session_id ?? null;
44
44
  } catch {
45
45
  return null;