throughline 0.3.24 → 0.4.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.
Files changed (116) hide show
  1. package/.claude/commands/tl.md +6 -21
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +632 -0
  4. package/README.ja.md +71 -46
  5. package/README.md +420 -76
  6. package/bin/throughline.mjs +169 -7
  7. package/codex/skills/throughline/SKILL.md +157 -0
  8. package/codex/skills/throughline/agents/openai.yaml +7 -0
  9. package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
  12. package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
  13. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  14. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  15. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  17. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  18. package/docs/archive/CONCEPT.md +476 -0
  19. package/docs/archive/EXPERIMENT.md +371 -0
  20. package/docs/archive/README.md +22 -0
  21. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  22. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  23. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  24. package/docs/throughline-handoff-context.example.json +57 -0
  25. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  26. package/package.json +6 -2
  27. package/src/baton.mjs +17 -45
  28. package/src/baton.test.mjs +4 -41
  29. package/src/cli/codex-capture.mjs +95 -0
  30. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  31. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  32. package/src/cli/codex-handoff-smoke.mjs +163 -0
  33. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  34. package/src/cli/codex-handoff-start.mjs +291 -0
  35. package/src/cli/codex-handoff-start.test.mjs +194 -0
  36. package/src/cli/codex-hook.mjs +276 -0
  37. package/src/cli/codex-hook.test.mjs +293 -0
  38. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  39. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  40. package/src/cli/codex-restore-smoke.mjs +357 -0
  41. package/src/cli/codex-restore-source-audit.mjs +304 -0
  42. package/src/cli/codex-resume.mjs +138 -0
  43. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  44. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  45. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  46. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  47. package/src/cli/codex-summarize.mjs +224 -0
  48. package/src/cli/codex-threads.mjs +89 -0
  49. package/src/cli/codex-visibility-smoke.mjs +196 -0
  50. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  51. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  52. package/src/cli/doctor.mjs +503 -1
  53. package/src/cli/doctor.test.mjs +542 -3
  54. package/src/cli/handoff-preview.mjs +78 -0
  55. package/src/cli/help.test.mjs +64 -0
  56. package/src/cli/install.mjs +226 -3
  57. package/src/cli/install.test.mjs +205 -4
  58. package/src/cli/trim.mjs +564 -0
  59. package/src/codex-app-server.mjs +1816 -0
  60. package/src/codex-app-server.test.mjs +512 -0
  61. package/src/codex-auto-refresh.mjs +194 -0
  62. package/src/codex-auto-refresh.test.mjs +182 -0
  63. package/src/codex-capture.mjs +235 -0
  64. package/src/codex-capture.test.mjs +393 -0
  65. package/src/codex-handoff-model-smoke.mjs +114 -0
  66. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  67. package/src/codex-handoff-smoke.mjs +124 -0
  68. package/src/codex-handoff-smoke.test.mjs +103 -0
  69. package/src/codex-handoff.mjs +331 -0
  70. package/src/codex-handoff.test.mjs +220 -0
  71. package/src/codex-host-primitive-audit.mjs +374 -0
  72. package/src/codex-host-primitive-audit.test.mjs +208 -0
  73. package/src/codex-restore-smoke.test.mjs +639 -0
  74. package/src/codex-restore-source-audit.mjs +1348 -0
  75. package/src/codex-restore-source-audit.test.mjs +623 -0
  76. package/src/codex-resume.test.mjs +242 -0
  77. package/src/codex-rollout-memory.mjs +711 -0
  78. package/src/codex-rollout-memory.test.mjs +610 -0
  79. package/src/codex-sidecar-cli.test.mjs +75 -0
  80. package/src/codex-sidecar.mjs +246 -0
  81. package/src/codex-sidecar.test.mjs +172 -0
  82. package/src/codex-summarize.test.mjs +143 -0
  83. package/src/codex-thread-identity.mjs +23 -0
  84. package/src/codex-thread-index.mjs +173 -0
  85. package/src/codex-thread-index.test.mjs +164 -0
  86. package/src/codex-usage.mjs +110 -0
  87. package/src/codex-usage.test.mjs +140 -0
  88. package/src/codex-visibility-smoke.test.mjs +222 -0
  89. package/src/codex-vscode-restore-smoke.mjs +206 -0
  90. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  91. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  92. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  93. package/src/db-schema.test.mjs +96 -0
  94. package/src/db.mjs +14 -1
  95. package/src/haiku-summarizer.mjs +267 -26
  96. package/src/haiku-summarizer.test.mjs +282 -0
  97. package/src/handoff-preview.test.mjs +108 -0
  98. package/src/handoff-record.mjs +294 -0
  99. package/src/handoff-record.test.mjs +226 -0
  100. package/src/hook-entrypoints.test.mjs +286 -0
  101. package/src/package-files.test.mjs +19 -0
  102. package/src/prompt-submit.mjs +9 -6
  103. package/src/resume-context.mjs +58 -171
  104. package/src/resume-context.test.mjs +177 -0
  105. package/src/session-start.mjs +85 -26
  106. package/src/state-file.mjs +50 -6
  107. package/src/state-file.test.mjs +50 -0
  108. package/src/token-monitor.mjs +14 -10
  109. package/src/token-monitor.test.mjs +27 -0
  110. package/src/trim-cli.test.mjs +1584 -0
  111. package/src/trim-model.mjs +584 -0
  112. package/src/trim-model.test.mjs +568 -0
  113. package/src/turn-processor.mjs +17 -10
  114. package/src/vscode-task.mjs +33 -10
  115. package/src/vscode-task.test.mjs +19 -9
  116. package/src/cli/save-inflight.mjs +0 -81
@@ -0,0 +1,610 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ buildCodexRolloutTrimSource,
9
+ inspectCodexPlannedRollbackRestoreSafety,
10
+ parseCodexRolloutFile,
11
+ } from './codex-rollout-memory.mjs';
12
+
13
+ test('parseCodexRolloutFile: applies rollback events before rendering active work', () => {
14
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
15
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
16
+ try {
17
+ const rollout = writeRollout(home, {
18
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
19
+ cwd: project,
20
+ events: [
21
+ event('user_message', { message: '# AGENTS.md instructions for /repo\nskip me' }),
22
+ event('user_message', { message: 'start active task' }),
23
+ event('task_started'),
24
+ event('agent_message', { message: 'turn one answer' }),
25
+ event('task_complete'),
26
+ event('user_message', { message: '<hook_prompt hook_run_id="stop">skip me</hook_prompt>' }),
27
+ event('user_message', { message: 'continue active task' }),
28
+ event('task_started'),
29
+ event('agent_message', { message: 'turn two answer' }),
30
+ event('task_complete'),
31
+ event('user_message', { message: 'rolled back request' }),
32
+ event('task_started'),
33
+ event('agent_message', { message: 'rolled back answer' }),
34
+ event('task_complete'),
35
+ event('thread_rolled_back', { num_turns: 1 }),
36
+ responseItem({
37
+ type: 'message',
38
+ role: 'developer',
39
+ content: [
40
+ {
41
+ type: 'input_text',
42
+ text: '## Throughline Trim Memory Preview\n\ninjected trim memory',
43
+ },
44
+ ],
45
+ }),
46
+ event('agent_message', { message: 'post rollback continuation' }),
47
+ responseItem({
48
+ type: 'message',
49
+ role: 'developer',
50
+ content: [
51
+ {
52
+ type: 'input_text',
53
+ text: '## Throughline: Active Work Context\n\ninjected active-work memory',
54
+ },
55
+ ],
56
+ }),
57
+ responseItem({
58
+ type: 'message',
59
+ role: 'developer',
60
+ content: [{ type: 'input_text', text: '[caveat] unrelated developer notice' }],
61
+ }),
62
+ event('user_message', { message: 'new request after rollback' }),
63
+ event('task_started'),
64
+ event('agent_message', { message: 'new answer after rollback' }),
65
+ event('task_complete'),
66
+ ],
67
+ });
68
+
69
+ const parsed = parseCodexRolloutFile(rollout);
70
+
71
+ assert.equal(parsed.activeTurnCount, 4);
72
+ assert.equal(parsed.stats.rolledBackTurns, 1);
73
+ assert.equal(parsed.stats.injectedDeveloperMessages, 1);
74
+ assert.deepEqual(
75
+ parsed.entries.map((entry) => entry.text),
76
+ [
77
+ 'start active task',
78
+ 'turn one answer',
79
+ 'continue active task',
80
+ 'turn two answer',
81
+ 'post rollback continuation',
82
+ 'new request after rollback',
83
+ 'new answer after rollback',
84
+ ],
85
+ );
86
+ } finally {
87
+ rmSync(home, { recursive: true, force: true });
88
+ rmSync(project, { recursive: true, force: true });
89
+ }
90
+ });
91
+
92
+ test('parseCodexRolloutFile: keeps final post-rollback assistant continuation as synthetic turn', () => {
93
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
94
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
95
+ try {
96
+ const rollout = writeRollout(home, {
97
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
98
+ cwd: project,
99
+ events: [
100
+ event('user_message', { message: 'stable request' }),
101
+ event('task_started'),
102
+ event('agent_message', { message: 'stable answer' }),
103
+ event('task_complete'),
104
+ event('user_message', { message: 'rolled back request' }),
105
+ event('task_started'),
106
+ event('agent_message', { message: 'rolled back answer' }),
107
+ event('thread_rolled_back', { num_turns: 1 }),
108
+ responseItem({
109
+ type: 'message',
110
+ role: 'developer',
111
+ content: [
112
+ {
113
+ type: 'input_text',
114
+ text: '## Throughline Trim Memory Preview\n\ninjected trim memory',
115
+ },
116
+ ],
117
+ }),
118
+ event('agent_message', { message: 'continuation after rollback' }),
119
+ event('agent_message', { message: 'still same synthetic turn' }),
120
+ ],
121
+ });
122
+
123
+ const parsed = parseCodexRolloutFile(rollout);
124
+ const trimParsed = parseCodexRolloutFile(rollout, { includeInFlightTurn: false });
125
+
126
+ assert.equal(parsed.activeTurnCount, 2);
127
+ assert.equal(trimParsed.activeTurnCount, 1);
128
+ assert.equal(trimParsed.stats.inFlightTurnsExcluded, 1);
129
+ assert.deepEqual(
130
+ parsed.entries.map((entry) => [entry.turn, entry.role, entry.text]),
131
+ [
132
+ [1, 'user', 'stable request'],
133
+ [1, 'assistant', 'stable answer'],
134
+ ['rollout-11', 'assistant', 'continuation after rollback'],
135
+ ['rollout-11', 'assistant', 'still same synthetic turn'],
136
+ ],
137
+ );
138
+ } finally {
139
+ rmSync(home, { recursive: true, force: true });
140
+ rmSync(project, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test('parseCodexRolloutFile: rollback removes synthetic continuation inside rolled-back tail', () => {
145
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
146
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
147
+ try {
148
+ const rollout = writeRollout(home, {
149
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
150
+ cwd: project,
151
+ events: [
152
+ event('user_message', { message: 'kept request' }),
153
+ event('task_started'),
154
+ event('agent_message', { message: 'kept answer' }),
155
+ event('task_complete'),
156
+ event('user_message', { message: 'removed request one' }),
157
+ event('task_started'),
158
+ event('agent_message', { message: 'removed answer one' }),
159
+ event('task_complete'),
160
+ event('thread_rolled_back', { num_turns: 1 }),
161
+ event('agent_message', { message: 'post rollback continuation' }),
162
+ event('user_message', { message: 'removed request two' }),
163
+ event('task_started'),
164
+ event('agent_message', { message: 'removed answer two' }),
165
+ event('task_complete'),
166
+ event('thread_rolled_back', { num_turns: 2 }),
167
+ ],
168
+ });
169
+
170
+ const parsed = parseCodexRolloutFile(rollout);
171
+
172
+ assert.equal(parsed.activeTurnCount, 1);
173
+ assert.deepEqual(
174
+ parsed.entries.map((entry) => [entry.turn, entry.role, entry.text]),
175
+ [
176
+ [1, 'user', 'kept request'],
177
+ [1, 'assistant', 'kept answer'],
178
+ ],
179
+ );
180
+ } finally {
181
+ rmSync(home, { recursive: true, force: true });
182
+ rmSync(project, { recursive: true, force: true });
183
+ }
184
+ });
185
+
186
+ test('parseCodexRolloutFile: keeps final pending messages as current synthetic turn', () => {
187
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
188
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
189
+ try {
190
+ const rollout = writeRollout(home, {
191
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
192
+ cwd: project,
193
+ events: [
194
+ event('user_message', { message: 'finished request' }),
195
+ event('task_started'),
196
+ event('agent_message', { message: 'finished answer' }),
197
+ event('task_complete'),
198
+ event('agent_message', { message: 'follow-up after task complete' }),
199
+ ],
200
+ });
201
+
202
+ const parsed = parseCodexRolloutFile(rollout);
203
+
204
+ assert.equal(parsed.activeTurnCount, 2);
205
+ assert.deepEqual(
206
+ parsed.entries.map((entry) => [entry.turn, entry.role, entry.text]),
207
+ [
208
+ [1, 'user', 'finished request'],
209
+ [1, 'assistant', 'finished answer'],
210
+ ['rollout-6', 'assistant', 'follow-up after task complete'],
211
+ ],
212
+ );
213
+ } finally {
214
+ rmSync(home, { recursive: true, force: true });
215
+ rmSync(project, { recursive: true, force: true });
216
+ }
217
+ });
218
+
219
+ test('parseCodexRolloutFile: trim source can exclude the current in-flight turn', () => {
220
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
221
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
222
+ try {
223
+ const rollout = writeRollout(home, {
224
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
225
+ cwd: project,
226
+ events: [
227
+ event('user_message', { message: 'finished request' }),
228
+ event('task_started'),
229
+ event('agent_message', { message: 'finished answer' }),
230
+ event('task_complete'),
231
+ event('user_message', { message: 'current request' }),
232
+ event('task_started'),
233
+ event('agent_message', { message: 'current answer still running' }),
234
+ ],
235
+ });
236
+
237
+ const defaultParsed = parseCodexRolloutFile(rollout);
238
+ const trimParsed = parseCodexRolloutFile(rollout, { includeInFlightTurn: false });
239
+
240
+ assert.equal(defaultParsed.activeTurnCount, 2);
241
+ assert.equal(trimParsed.activeTurnCount, 1);
242
+ assert.equal(trimParsed.stats.inFlightTurnsExcluded, 1);
243
+ assert.deepEqual(
244
+ trimParsed.entries.map((entry) => [entry.turn, entry.role, entry.text]),
245
+ [
246
+ [1, 'user', 'finished request'],
247
+ [1, 'assistant', 'finished answer'],
248
+ ],
249
+ );
250
+ } finally {
251
+ rmSync(home, { recursive: true, force: true });
252
+ rmSync(project, { recursive: true, force: true });
253
+ }
254
+ });
255
+
256
+ test('parseCodexRolloutFile: splits post-complete assistant continuation before next task', () => {
257
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
258
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
259
+ try {
260
+ const rollout = writeRollout(home, {
261
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
262
+ cwd: project,
263
+ events: [
264
+ event('user_message', { message: 'first request' }),
265
+ event('task_started'),
266
+ event('agent_message', { message: 'first answer' }),
267
+ event('task_complete'),
268
+ event('agent_message', { message: 'post-complete continuation' }),
269
+ event('user_message', { message: 'next request' }),
270
+ event('task_started'),
271
+ event('agent_message', { message: 'next answer' }),
272
+ event('task_complete'),
273
+ ],
274
+ });
275
+
276
+ const parsed = parseCodexRolloutFile(rollout);
277
+
278
+ assert.equal(parsed.activeTurnCount, 3);
279
+ assert.equal(parsed.stats.syntheticContinuationTurns, 1);
280
+ assert.deepEqual(
281
+ parsed.entries.map((entry) => [entry.turn, entry.role, entry.text]),
282
+ [
283
+ [1, 'user', 'first request'],
284
+ [1, 'assistant', 'first answer'],
285
+ ['rollout-8', 'assistant', 'post-complete continuation'],
286
+ [2, 'user', 'next request'],
287
+ [2, 'assistant', 'next answer'],
288
+ ],
289
+ );
290
+ } finally {
291
+ rmSync(home, { recursive: true, force: true });
292
+ rmSync(project, { recursive: true, force: true });
293
+ }
294
+ });
295
+
296
+ test('parseCodexRolloutFile: splits assistant-only continuation when next user is logged after task start', () => {
297
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
298
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
299
+ try {
300
+ const rollout = writeRollout(home, {
301
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
302
+ cwd: project,
303
+ events: [
304
+ event('user_message', { message: 'first request' }),
305
+ event('task_started'),
306
+ event('agent_message', { message: 'first answer' }),
307
+ event('task_complete'),
308
+ event('agent_message', { message: 'post-complete continuation' }),
309
+ event('task_started'),
310
+ event('user_message', { message: 'next request' }),
311
+ event('agent_message', { message: 'next answer' }),
312
+ event('task_complete'),
313
+ ],
314
+ });
315
+
316
+ const parsed = parseCodexRolloutFile(rollout);
317
+
318
+ assert.equal(parsed.activeTurnCount, 3);
319
+ assert.equal(parsed.stats.syntheticContinuationTurns, 1);
320
+ assert.deepEqual(
321
+ parsed.entries.map((entry) => [entry.turn, entry.role, entry.text]),
322
+ [
323
+ [1, 'user', 'first request'],
324
+ [1, 'assistant', 'first answer'],
325
+ ['rollout-7', 'assistant', 'post-complete continuation'],
326
+ [2, 'user', 'next request'],
327
+ [2, 'assistant', 'next answer'],
328
+ ],
329
+ );
330
+ } finally {
331
+ rmSync(home, { recursive: true, force: true });
332
+ rmSync(project, { recursive: true, force: true });
333
+ }
334
+ });
335
+
336
+ test('parseCodexRolloutFile: maps function calls to active turn details', () => {
337
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
338
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
339
+ try {
340
+ const rollout = writeRollout(home, {
341
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
342
+ cwd: project,
343
+ events: [
344
+ event('user_message', { message: 'inspect files' }),
345
+ event('task_started'),
346
+ responseItem({
347
+ type: 'function_call',
348
+ name: 'exec_command',
349
+ arguments: '{"cmd":"rtk rg TODO"}',
350
+ call_id: 'call_123',
351
+ }),
352
+ responseItem({
353
+ type: 'function_call_output',
354
+ call_id: 'call_123',
355
+ output: 'Chunk ID: abc\nOutput:\nTODO item\n',
356
+ }),
357
+ event('agent_message', { message: 'found TODO item' }),
358
+ event('task_complete'),
359
+ ],
360
+ });
361
+
362
+ const parsed = parseCodexRolloutFile(rollout);
363
+
364
+ assert.equal(parsed.stats.toolInputs, 1);
365
+ assert.equal(parsed.stats.toolOutputs, 1);
366
+ assert.deepEqual(parsed.activeTurns[0].details, [
367
+ {
368
+ time: '2026-05-06T00:40:51.500Z',
369
+ kind: 'tool_input',
370
+ tool_name: 'exec_command',
371
+ source_id: 'call_123',
372
+ input_text: '{"cmd":"rtk rg TODO"}',
373
+ output_text: null,
374
+ },
375
+ {
376
+ time: '2026-05-06T00:40:51.500Z',
377
+ kind: 'tool_output',
378
+ tool_name: 'exec_command',
379
+ source_id: 'call_123:output',
380
+ input_text: null,
381
+ output_text: 'Chunk ID: abc\nOutput:\nTODO item\n',
382
+ },
383
+ ]);
384
+ } finally {
385
+ rmSync(home, { recursive: true, force: true });
386
+ rmSync(project, { recursive: true, force: true });
387
+ }
388
+ });
389
+
390
+ test('parseCodexRolloutFile: flags rollback text retained in compacted replacement history', () => {
391
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
392
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
393
+ try {
394
+ const rollout = writeRollout(home, {
395
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
396
+ cwd: project,
397
+ events: [
398
+ event('user_message', { message: 'stable request' }),
399
+ event('task_started'),
400
+ event('agent_message', { message: 'stable answer' }),
401
+ event('task_complete'),
402
+ event('user_message', { message: 'rolled back compacted request' }),
403
+ event('task_started'),
404
+ event('agent_message', { message: 'rolled back compacted answer' }),
405
+ event('task_complete'),
406
+ compacted([
407
+ userReplacement('stable request'),
408
+ userReplacement('rolled back compacted request'),
409
+ ]),
410
+ event('context_compacted'),
411
+ event('thread_rolled_back', { num_turns: 1 }),
412
+ responseItem({
413
+ type: 'message',
414
+ role: 'user',
415
+ content: [{ type: 'input_text', text: 'rolled back compacted request' }],
416
+ }),
417
+ event('user_message', { message: 'rolled back compacted request' }),
418
+ event('task_started'),
419
+ event('agent_message', { message: 'restart answer' }),
420
+ event('task_complete'),
421
+ ],
422
+ });
423
+
424
+ const parsed = parseCodexRolloutFile(rollout);
425
+
426
+ assert.equal(parsed.stats.compactedRows, 1);
427
+ assert.equal(parsed.stats.compactedReplacementUserMessages, 2);
428
+ assert.equal(parsed.stats.rolledBackUserMessages, 1);
429
+ assert.equal(parsed.stats.userMessagesAfterRollback, 2);
430
+ assert.equal(parsed.stats.latestRollbackAt, '2026-05-06T00:40:51.000Z');
431
+ assert.equal(parsed.stats.rollbackTextRetainedInCompacted, 1);
432
+ assert.equal(parsed.stats.resurrectedUserMessages, 2);
433
+ assert.equal(parsed.restoreSafety.status, 'risk');
434
+ assert.deepEqual(
435
+ parsed.restoreSafety.risks.map((risk) => risk.type),
436
+ [
437
+ 'rollback_text_retained_in_compacted_replacement_history',
438
+ 'rolled_back_user_text_reappeared_after_rollback',
439
+ ],
440
+ );
441
+ assert.deepEqual(parsed.restoreSafety.retainedTexts, [
442
+ {
443
+ textPreview: 'rolled back compacted request',
444
+ rolledBackCount: 1,
445
+ compactedReplacementCount: 1,
446
+ },
447
+ ]);
448
+ assert.deepEqual(parsed.restoreSafety.rolledBackTexts, [
449
+ {
450
+ textPreview: 'rolled back compacted request',
451
+ count: 1,
452
+ },
453
+ ]);
454
+ } finally {
455
+ rmSync(home, { recursive: true, force: true });
456
+ rmSync(project, { recursive: true, force: true });
457
+ }
458
+ });
459
+
460
+ test('inspectCodexPlannedRollbackRestoreSafety flags pre-rollback compacted retention', () => {
461
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
462
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
463
+ try {
464
+ const rollout = writeRollout(home, {
465
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
466
+ cwd: project,
467
+ events: [
468
+ event('user_message', { message: 'stable request' }),
469
+ event('task_started'),
470
+ event('agent_message', { message: 'stable answer' }),
471
+ event('task_complete'),
472
+ event('user_message', { message: 'would be rolled back from compacted history' }),
473
+ event('task_started'),
474
+ event('agent_message', { message: 'candidate answer' }),
475
+ event('task_complete'),
476
+ compacted([
477
+ userReplacement('stable request'),
478
+ userReplacement('would be rolled back from compacted history'),
479
+ ]),
480
+ event('context_compacted'),
481
+ ],
482
+ });
483
+
484
+ const oneTurn = inspectCodexPlannedRollbackRestoreSafety({
485
+ rolloutPath: rollout,
486
+ rollbackTurns: 1,
487
+ });
488
+ const zeroTurns = inspectCodexPlannedRollbackRestoreSafety({
489
+ rolloutPath: rollout,
490
+ rollbackTurns: 0,
491
+ });
492
+
493
+ assert.equal(oneTurn.status, 'risk');
494
+ assert.equal(oneTurn.plannedRollbackUserMessages, 1);
495
+ assert.equal(oneTurn.rollbackTextRetainedInCompacted, 1);
496
+ assert.deepEqual(oneTurn.risks.map((risk) => risk.type), [
497
+ 'planned_rollback_text_retained_in_compacted_replacement_history',
498
+ ]);
499
+ assert.deepEqual(oneTurn.retainedTexts, [
500
+ {
501
+ textPreview: 'would be rolled back from compacted history',
502
+ plannedRollbackCount: 1,
503
+ compactedReplacementCount: 1,
504
+ },
505
+ ]);
506
+ assert.equal(zeroTurns.status, 'ok');
507
+ assert.equal(zeroTurns.rollbackTextRetainedInCompacted, 0);
508
+ } finally {
509
+ rmSync(home, { recursive: true, force: true });
510
+ rmSync(project, { recursive: true, force: true });
511
+ }
512
+ });
513
+
514
+ test('buildCodexRolloutTrimSource: returns trim source for explicit current-project Codex thread', () => {
515
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
516
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
517
+ try {
518
+ writeRollout(home, {
519
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
520
+ cwd: project,
521
+ events: [
522
+ event('user_message', { message: 'implement rollout source' }),
523
+ event('task_started'),
524
+ event('agent_message', { message: 'rollout source implemented' }),
525
+ event('task_complete'),
526
+ ],
527
+ });
528
+
529
+ const source = buildCodexRolloutTrimSource({
530
+ threadId: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
531
+ codexHome: home,
532
+ projectPath: project,
533
+ });
534
+
535
+ assert.equal(source.source, 'codex-rollout');
536
+ assert.equal(source.capturedTurns, 1);
537
+ assert.equal(source.threadId, '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9');
538
+ assert.equal(source.contextEstimate.method, 'chars_div_4');
539
+ assert.equal(source.contextEstimate.activeTurns, 1);
540
+ assert.ok(source.contextEstimate.activeEstimatedTokens > 0);
541
+ assert.equal(source.contextEstimate.turns.length, 1);
542
+ assert.equal(source.restoreSafety.status, 'ok');
543
+ assert.match(source.memoryPreview.text, /Active Work Thread \(Codex Rollout\)/);
544
+ assert.match(source.memoryPreview.text, /implement rollout source/);
545
+ assert.match(source.memoryPreview.text, /rolled-back tail turns are not included/);
546
+ } finally {
547
+ rmSync(home, { recursive: true, force: true });
548
+ rmSync(project, { recursive: true, force: true });
549
+ }
550
+ });
551
+
552
+ function writeRollout(home, { id, cwd, events }) {
553
+ const dir = join(home, 'sessions', '2026', '05', '06');
554
+ mkdirSync(dir, { recursive: true });
555
+ const path = join(dir, `rollout-2026-05-06T09-40-50-${id}.jsonl`);
556
+ const rows = [
557
+ {
558
+ timestamp: '2026-05-06T00:40:50.000Z',
559
+ type: 'session_meta',
560
+ payload: {
561
+ id,
562
+ timestamp: '2026-05-06T00:40:50.000Z',
563
+ cwd,
564
+ source: 'vscode',
565
+ cli_version: '0.128.0-alpha.1',
566
+ },
567
+ },
568
+ ...events,
569
+ ];
570
+ writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
571
+ return path;
572
+ }
573
+
574
+ function event(type, payload = {}) {
575
+ return {
576
+ timestamp: '2026-05-06T00:40:51.000Z',
577
+ type: 'event_msg',
578
+ payload: {
579
+ type,
580
+ ...payload,
581
+ },
582
+ };
583
+ }
584
+
585
+ function responseItem(payload) {
586
+ return {
587
+ timestamp: '2026-05-06T00:40:51.500Z',
588
+ type: 'response_item',
589
+ payload,
590
+ };
591
+ }
592
+
593
+ function compacted(replacementHistory) {
594
+ return {
595
+ timestamp: '2026-05-06T00:40:51.250Z',
596
+ type: 'compacted',
597
+ payload: {
598
+ message: '',
599
+ replacement_history: replacementHistory,
600
+ },
601
+ };
602
+ }
603
+
604
+ function userReplacement(text) {
605
+ return {
606
+ type: 'message',
607
+ role: 'user',
608
+ content: [{ type: 'input_text', text }],
609
+ };
610
+ }
@@ -0,0 +1,75 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
+
11
+ test('codex-sidecar-diagnostics CLI exits 0 only for configured diagnostics', () => {
12
+ const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-cli-'));
13
+ try {
14
+ const bin = join(dir, 'fake-sidecar');
15
+ writeFileSync(bin, '#!/usr/bin/env bash\nprintf "ok\\n"\nexit 0\n');
16
+ chmodSync(bin, 0o755);
17
+
18
+ const result = spawnSync(
19
+ process.execPath,
20
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'codex-sidecar-diagnostics', '--project', '/repo'],
21
+ {
22
+ env: {
23
+ ...process.env,
24
+ THROUGHLINE_CODEX_SIDECAR_BIN: bin,
25
+ },
26
+ encoding: 'utf8',
27
+ },
28
+ );
29
+
30
+ assert.equal(result.status, 0, result.stderr);
31
+ const json = JSON.parse(result.stdout);
32
+ assert.equal(json.status, 'configured');
33
+ } finally {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ test('codex-sidecar-dry-run CLI exits 0 for normalized dry-run request', () => {
39
+ const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-dry-run-cli-'));
40
+ try {
41
+ const bin = join(dir, 'fake-sidecar');
42
+ writeFileSync(
43
+ bin,
44
+ '#!/usr/bin/env bash\nprintf \'{"status":"dry-run","workflow":"risk-check","normalizedRequest":{"dryRun":true}}\\n\'\n',
45
+ );
46
+ chmodSync(bin, 0o755);
47
+
48
+ const result = spawnSync(
49
+ process.execPath,
50
+ [
51
+ join(REPO_ROOT, 'bin/throughline.mjs'),
52
+ 'codex-sidecar-dry-run',
53
+ '--project',
54
+ '/repo',
55
+ '--preset',
56
+ 'risk-check',
57
+ 'check risks',
58
+ ],
59
+ {
60
+ env: {
61
+ ...process.env,
62
+ THROUGHLINE_CODEX_SIDECAR_BIN: bin,
63
+ },
64
+ encoding: 'utf8',
65
+ },
66
+ );
67
+
68
+ assert.equal(result.status, 0, result.stderr);
69
+ const json = JSON.parse(result.stdout);
70
+ assert.equal(json.status, 'dry-run');
71
+ assert.equal(json.workflow, 'risk-check');
72
+ } finally {
73
+ rmSync(dir, { recursive: true, force: true });
74
+ }
75
+ });