throughline 0.3.24 → 0.3.25

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 (111) hide show
  1. package/.claude/commands/tl-trim.md +42 -0
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +583 -0
  4. package/README.ja.md +42 -5
  5. package/README.md +383 -23
  6. package/bin/throughline.mjs +168 -4
  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 +146 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +184 -0
  12. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  13. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  14. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  15. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  17. package/docs/archive/CONCEPT.md +476 -0
  18. package/docs/archive/EXPERIMENT.md +371 -0
  19. package/docs/archive/README.md +22 -0
  20. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  21. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  22. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  23. package/docs/throughline-handoff-context.example.json +57 -0
  24. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  25. package/package.json +6 -2
  26. package/src/cli/codex-capture.mjs +95 -0
  27. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  28. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  29. package/src/cli/codex-handoff-smoke.mjs +163 -0
  30. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  31. package/src/cli/codex-handoff-start.mjs +291 -0
  32. package/src/cli/codex-handoff-start.test.mjs +194 -0
  33. package/src/cli/codex-hook.mjs +276 -0
  34. package/src/cli/codex-hook.test.mjs +293 -0
  35. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  36. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  37. package/src/cli/codex-restore-smoke.mjs +357 -0
  38. package/src/cli/codex-restore-source-audit.mjs +304 -0
  39. package/src/cli/codex-resume.mjs +138 -0
  40. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  41. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  42. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  43. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  44. package/src/cli/codex-summarize.mjs +224 -0
  45. package/src/cli/codex-threads.mjs +89 -0
  46. package/src/cli/codex-visibility-smoke.mjs +196 -0
  47. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  48. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  49. package/src/cli/doctor.mjs +503 -1
  50. package/src/cli/doctor.test.mjs +542 -3
  51. package/src/cli/handoff-preview.mjs +78 -0
  52. package/src/cli/help.test.mjs +64 -0
  53. package/src/cli/install.mjs +227 -4
  54. package/src/cli/install.test.mjs +207 -4
  55. package/src/cli/trim.mjs +564 -0
  56. package/src/codex-app-server.mjs +1816 -0
  57. package/src/codex-app-server.test.mjs +512 -0
  58. package/src/codex-auto-refresh.mjs +194 -0
  59. package/src/codex-auto-refresh.test.mjs +182 -0
  60. package/src/codex-capture.mjs +235 -0
  61. package/src/codex-capture.test.mjs +393 -0
  62. package/src/codex-handoff-model-smoke.mjs +114 -0
  63. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  64. package/src/codex-handoff-smoke.mjs +124 -0
  65. package/src/codex-handoff-smoke.test.mjs +103 -0
  66. package/src/codex-handoff.mjs +331 -0
  67. package/src/codex-handoff.test.mjs +220 -0
  68. package/src/codex-host-primitive-audit.mjs +374 -0
  69. package/src/codex-host-primitive-audit.test.mjs +208 -0
  70. package/src/codex-restore-smoke.test.mjs +639 -0
  71. package/src/codex-restore-source-audit.mjs +1348 -0
  72. package/src/codex-restore-source-audit.test.mjs +623 -0
  73. package/src/codex-resume.test.mjs +242 -0
  74. package/src/codex-rollout-memory.mjs +711 -0
  75. package/src/codex-rollout-memory.test.mjs +610 -0
  76. package/src/codex-sidecar-cli.test.mjs +75 -0
  77. package/src/codex-sidecar.mjs +246 -0
  78. package/src/codex-sidecar.test.mjs +172 -0
  79. package/src/codex-summarize.test.mjs +143 -0
  80. package/src/codex-thread-identity.mjs +23 -0
  81. package/src/codex-thread-index.mjs +173 -0
  82. package/src/codex-thread-index.test.mjs +164 -0
  83. package/src/codex-usage.mjs +110 -0
  84. package/src/codex-usage.test.mjs +140 -0
  85. package/src/codex-visibility-smoke.test.mjs +222 -0
  86. package/src/codex-vscode-restore-smoke.mjs +206 -0
  87. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  88. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  89. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  90. package/src/db-schema.test.mjs +97 -0
  91. package/src/haiku-summarizer.mjs +267 -26
  92. package/src/haiku-summarizer.test.mjs +282 -0
  93. package/src/handoff-preview.test.mjs +108 -0
  94. package/src/handoff-record.mjs +294 -0
  95. package/src/handoff-record.test.mjs +226 -0
  96. package/src/hook-entrypoints.test.mjs +326 -0
  97. package/src/package-files.test.mjs +19 -0
  98. package/src/prompt-submit.mjs +9 -6
  99. package/src/resume-context.mjs +44 -140
  100. package/src/resume-context.test.mjs +172 -0
  101. package/src/session-start.mjs +8 -5
  102. package/src/state-file.mjs +50 -6
  103. package/src/state-file.test.mjs +50 -0
  104. package/src/token-monitor.mjs +14 -10
  105. package/src/token-monitor.test.mjs +27 -0
  106. package/src/trim-cli.test.mjs +1584 -0
  107. package/src/trim-model.mjs +584 -0
  108. package/src/trim-model.test.mjs +568 -0
  109. package/src/turn-processor.mjs +17 -10
  110. package/src/vscode-task.mjs +33 -10
  111. package/src/vscode-task.test.mjs +19 -9
@@ -0,0 +1,325 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import {
10
+ buildCodexVsCodeRestoreSmokeMemory,
11
+ buildCodexVsCodeRestoreSmokePrompt,
12
+ inspectCodexVsCodeRestoreSmokeRollout,
13
+ } from './codex-vscode-restore-smoke.mjs';
14
+
15
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
+
17
+ function makeTempHome() {
18
+ return mkdtempSync(join(tmpdir(), 'tl-codex-vscode-restore-home-'));
19
+ }
20
+
21
+ function makeTempProject() {
22
+ return mkdtempSync(join(tmpdir(), 'tl-codex-vscode-restore-project-'));
23
+ }
24
+
25
+ function makeFakeCodexAppServer(dir) {
26
+ const script = join(dir, 'fake-codex-vscode-restore-app-server.mjs');
27
+ writeFileSync(
28
+ script,
29
+ `#!/usr/bin/env node
30
+ import { createInterface } from 'node:readline';
31
+ const rl = createInterface({ input: process.stdin });
32
+ let injected = false;
33
+ function send(message) { process.stdout.write(JSON.stringify(message) + '\\n'); }
34
+ rl.on('line', (line) => {
35
+ const msg = JSON.parse(line);
36
+ if (msg.method === 'initialized') return;
37
+ if (msg.method === 'initialize') {
38
+ send({ id: msg.id, result: { userAgent: 'fake-codex' } });
39
+ } else if (msg.method === 'thread/read' || msg.method === 'thread/resume') {
40
+ send({ id: msg.id, result: { thread: { id: msg.params.threadId, turns: [{ id: 'turn-1' }] } } });
41
+ } else if (msg.method === 'thread/inject_items') {
42
+ injected = true;
43
+ send({ id: msg.id, result: { thread: { id: msg.params.threadId, turns: [{ id: 'turn-1' }, { id: 'memory' }] } } });
44
+ } else if (msg.method === 'turn/start') {
45
+ send({ id: msg.id, error: { code: -32601, message: injected ? 'unexpected turn/start' : 'not injected' } });
46
+ } else {
47
+ send({ id: msg.id, error: { code: -32601, message: 'unknown method' } });
48
+ }
49
+ });
50
+ `,
51
+ );
52
+ chmodSync(script, 0o755);
53
+ return script;
54
+ }
55
+
56
+ function runCli(home, codexHome, project, args = [], extraEnv = {}) {
57
+ return spawnSync(
58
+ process.execPath,
59
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'codex-vscode-restore-smoke', ...args],
60
+ {
61
+ cwd: project,
62
+ env: {
63
+ ...process.env,
64
+ HOME: home,
65
+ USERPROFILE: home,
66
+ CODEX_HOME: codexHome,
67
+ ...extraEnv,
68
+ },
69
+ encoding: 'utf8',
70
+ },
71
+ );
72
+ }
73
+
74
+ function writeRollout(codexHome, { project, threadId, rows }) {
75
+ const dir = join(codexHome, 'sessions', '2026', '05', '07');
76
+ mkdirSync(dir, { recursive: true });
77
+ const path = join(dir, `rollout-2026-05-07T00-00-00-${threadId}.jsonl`);
78
+ const allRows = [
79
+ {
80
+ timestamp: '2026-05-07T00:00:00.000Z',
81
+ type: 'session_meta',
82
+ payload: {
83
+ id: threadId,
84
+ cwd: project,
85
+ source: 'vscode',
86
+ },
87
+ },
88
+ ...rows,
89
+ ];
90
+ writeFileSync(path, allRows.map((row) => JSON.stringify(row)).join('\n') + '\n');
91
+ return path;
92
+ }
93
+
94
+ function event(timestamp, type, payload) {
95
+ return {
96
+ timestamp,
97
+ type: 'event_msg',
98
+ payload: {
99
+ type,
100
+ ...payload,
101
+ },
102
+ };
103
+ }
104
+
105
+ test('VS Code restore smoke memory uses active-work header and prompt does not leak marker', () => {
106
+ const marker = 'TL_CODEX_VSCODE_RESTORE_TEST';
107
+ const memory = buildCodexVsCodeRestoreSmokeMemory({ marker });
108
+ const prompt = buildCodexVsCodeRestoreSmokePrompt();
109
+
110
+ assert.match(memory, /^## Throughline: Active Work Context/);
111
+ assert.match(memory, new RegExp(marker));
112
+ assert.doesNotMatch(prompt, new RegExp(marker));
113
+ });
114
+
115
+ test('codex-vscode-restore-smoke prepare injects hidden marker memory behind explicit env', () => {
116
+ const home = makeTempHome();
117
+ const codexHome = makeTempHome();
118
+ const project = makeTempProject();
119
+ const fake = makeFakeCodexAppServer(project);
120
+ const threadId = '019dfdef-1000-7000-8000-000000000001';
121
+ try {
122
+ const refused = runCli(home, codexHome, project, [
123
+ '--prepare',
124
+ '--codex-thread-id',
125
+ threadId,
126
+ '--marker',
127
+ 'TL_CODEX_VSCODE_RESTORE_PREP',
128
+ '--codex-app-server-bin',
129
+ fake,
130
+ '--json',
131
+ ]);
132
+ assert.equal(refused.status, 1);
133
+ assert.equal(JSON.parse(refused.stdout).reason, 'experimental_env_required');
134
+
135
+ const result = runCli(
136
+ home,
137
+ codexHome,
138
+ project,
139
+ [
140
+ '--prepare',
141
+ '--codex-thread-id',
142
+ threadId,
143
+ '--marker',
144
+ 'TL_CODEX_VSCODE_RESTORE_PREP',
145
+ '--codex-app-server-bin',
146
+ fake,
147
+ '--json',
148
+ ],
149
+ { THROUGHLINE_EXPERIMENTAL_CODEX_VSCODE_RESTORE_SMOKE: '1' },
150
+ );
151
+ assert.equal(result.status, 0, result.stderr);
152
+ const payload = JSON.parse(result.stdout);
153
+ assert.equal(payload.status, 'prepared');
154
+ assert.equal(payload.restartSafe, false);
155
+ assert.equal(payload.inject.status, 'injected');
156
+ assert.equal(payload.prompt.includes(payload.marker), false);
157
+ assert.deepEqual(payload.verifyArgs.slice(0, 3), [
158
+ 'throughline',
159
+ 'codex-vscode-restore-smoke',
160
+ '--verify',
161
+ ]);
162
+ } finally {
163
+ rmSync(project, { recursive: true, force: true });
164
+ rmSync(home, { recursive: true, force: true });
165
+ rmSync(codexHome, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test('inspectCodexVsCodeRestoreSmokeRollout proves marker only with restart acknowledgement', () => {
170
+ const codexHome = makeTempHome();
171
+ const project = makeTempProject();
172
+ const threadId = '019dfdef-1000-7000-8000-000000000002';
173
+ const marker = 'TL_CODEX_VSCODE_RESTORE_VERIFY';
174
+ const preparedAt = '2026-05-07T00:00:10.000Z';
175
+ try {
176
+ writeRollout(codexHome, {
177
+ project,
178
+ threadId,
179
+ rows: [
180
+ event('2026-05-07T00:00:11.000Z', 'user_message', {
181
+ message:
182
+ 'Throughline VS Code restore smoke: Read the marker from your developer memory and reply with exactly that marker and nothing else.',
183
+ }),
184
+ event('2026-05-07T00:00:12.000Z', 'agent_message', { message: marker }),
185
+ ],
186
+ });
187
+
188
+ const withoutAck = inspectCodexVsCodeRestoreSmokeRollout({
189
+ threadId,
190
+ codexHome,
191
+ projectPath: project,
192
+ marker,
193
+ preparedAt,
194
+ });
195
+ assert.equal(withoutAck.status, 'marker-visible-restart-unacknowledged');
196
+ assert.equal(withoutAck.restartSafe, false);
197
+
198
+ const withAck = inspectCodexVsCodeRestoreSmokeRollout({
199
+ threadId,
200
+ codexHome,
201
+ projectPath: project,
202
+ marker,
203
+ preparedAt,
204
+ afterVsCodeRestart: true,
205
+ });
206
+ assert.equal(withAck.status, 'vscode-restart-visible');
207
+ assert.equal(withAck.restartSafe, true);
208
+ assert.equal(withAck.promptMatches.length, 1);
209
+ assert.equal(withAck.assistantMarkerMatches.length, 1);
210
+ assert.equal(withAck.userMarkerMatches.length, 0);
211
+ } finally {
212
+ rmSync(project, { recursive: true, force: true });
213
+ rmSync(codexHome, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test('codex-vscode-restore-smoke verify rejects marker leaked in user prompt', () => {
218
+ const home = makeTempHome();
219
+ const codexHome = makeTempHome();
220
+ const project = makeTempProject();
221
+ const threadId = '019dfdef-1000-7000-8000-000000000003';
222
+ const marker = 'TL_CODEX_VSCODE_RESTORE_LEAK';
223
+ try {
224
+ writeRollout(codexHome, {
225
+ project,
226
+ threadId,
227
+ rows: [
228
+ event('2026-05-07T00:00:11.000Z', 'user_message', {
229
+ message: `Throughline VS Code restore smoke: reply with ${marker}`,
230
+ }),
231
+ event('2026-05-07T00:00:12.000Z', 'agent_message', { message: marker }),
232
+ ],
233
+ });
234
+
235
+ const result = runCli(home, codexHome, project, [
236
+ '--verify',
237
+ '--codex-thread-id',
238
+ threadId,
239
+ '--marker',
240
+ marker,
241
+ '--prepared-at',
242
+ '2026-05-07T00:00:10.000Z',
243
+ '--after-vscode-restart',
244
+ '--json',
245
+ ]);
246
+ assert.equal(result.status, 1);
247
+ const payload = JSON.parse(result.stdout);
248
+ assert.equal(payload.status, 'invalid');
249
+ assert.equal(payload.reason, 'marker_leaked_in_user_prompt');
250
+ assert.equal(payload.restartSafe, false);
251
+ } finally {
252
+ rmSync(project, { recursive: true, force: true });
253
+ rmSync(home, { recursive: true, force: true });
254
+ rmSync(codexHome, { recursive: true, force: true });
255
+ }
256
+ });
257
+
258
+ test('inspectCodexVsCodeRestoreSmokeRollout rejects non-exact assistant marker mentions', () => {
259
+ const codexHome = makeTempHome();
260
+ const project = makeTempProject();
261
+ const threadId = '019dfdef-1000-7000-8000-000000000004';
262
+ const marker = 'TL_CODEX_VSCODE_RESTORE_MENTION';
263
+ try {
264
+ writeRollout(codexHome, {
265
+ project,
266
+ threadId,
267
+ rows: [
268
+ event('2026-05-07T00:00:11.000Z', 'user_message', {
269
+ message:
270
+ 'Throughline VS Code restore smoke: Read the marker from your developer memory and reply with exactly that marker and nothing else.',
271
+ }),
272
+ event('2026-05-07T00:00:12.000Z', 'agent_message', {
273
+ message: `I can see ${marker}, but this is not an exact marker-only answer.`,
274
+ }),
275
+ ],
276
+ });
277
+
278
+ const result = inspectCodexVsCodeRestoreSmokeRollout({
279
+ threadId,
280
+ codexHome,
281
+ projectPath: project,
282
+ marker,
283
+ preparedAt: '2026-05-07T00:00:10.000Z',
284
+ afterVsCodeRestart: true,
285
+ });
286
+
287
+ assert.equal(result.status, 'pending');
288
+ assert.equal(result.restartSafe, false);
289
+ assert.equal(result.assistantMarkerMatches.length, 0);
290
+ assert.equal(result.assistantMarkerMentions.length, 1);
291
+ } finally {
292
+ rmSync(project, { recursive: true, force: true });
293
+ rmSync(codexHome, { recursive: true, force: true });
294
+ }
295
+ });
296
+
297
+ test('inspectCodexVsCodeRestoreSmokeRollout rejects exact marker answer without smoke prompt', () => {
298
+ const codexHome = makeTempHome();
299
+ const project = makeTempProject();
300
+ const threadId = '019dfdef-1000-7000-8000-000000000005';
301
+ const marker = 'TL_CODEX_VSCODE_RESTORE_NO_PROMPT';
302
+ try {
303
+ writeRollout(codexHome, {
304
+ project,
305
+ threadId,
306
+ rows: [event('2026-05-07T00:00:12.000Z', 'agent_message', { message: marker })],
307
+ });
308
+
309
+ const result = inspectCodexVsCodeRestoreSmokeRollout({
310
+ threadId,
311
+ codexHome,
312
+ projectPath: project,
313
+ marker,
314
+ preparedAt: '2026-05-07T00:00:10.000Z',
315
+ afterVsCodeRestart: true,
316
+ });
317
+
318
+ assert.equal(result.status, 'invalid');
319
+ assert.equal(result.reason, 'marker_answer_without_smoke_prompt');
320
+ assert.equal(result.restartSafe, false);
321
+ } finally {
322
+ rmSync(project, { recursive: true, force: true });
323
+ rmSync(codexHome, { recursive: true, force: true });
324
+ }
325
+ });
@@ -0,0 +1,90 @@
1
+ import { buildCodexRolloutTrimSource } from './codex-rollout-memory.mjs';
2
+ import { defaultCodexHome } from './codex-thread-index.mjs';
3
+
4
+ export function inspectCodexVsCodeRollbackSmoke({
5
+ threadId,
6
+ codexHome = defaultCodexHome(),
7
+ projectPath = process.cwd(),
8
+ afterVsCodeRestart = false,
9
+ } = {}) {
10
+ assertNonEmptyString(threadId, 'threadId');
11
+
12
+ const trimSource = buildCodexRolloutTrimSource({
13
+ threadId,
14
+ codexHome,
15
+ projectPath,
16
+ sourceReason: 'vscode_rollback_nonresurrection_smoke',
17
+ });
18
+
19
+ if (!trimSource) {
20
+ return {
21
+ status: 'refused',
22
+ reason: 'codex_rollout_source_required',
23
+ proofScope: 'none',
24
+ restartSafe: false,
25
+ threadId,
26
+ };
27
+ }
28
+
29
+ const stats = trimSource.stats ?? {};
30
+ const restoreSafety = trimSource.restoreSafety ?? null;
31
+ const base = {
32
+ threadId,
33
+ rolloutPath: trimSource.rolloutPath,
34
+ afterVsCodeRestart: Boolean(afterVsCodeRestart),
35
+ stats,
36
+ restoreSafety,
37
+ };
38
+
39
+ if ((stats.rollbackEvents ?? 0) < 1) {
40
+ return pending(base, 'no_rollback_event_observed');
41
+ }
42
+ if ((stats.rolledBackUserMessages ?? 0) < 1) {
43
+ return pending(base, 'no_rolled_back_user_message_observed');
44
+ }
45
+ if ((stats.userMessagesAfterRollback ?? 0) < 1) {
46
+ return pending(base, 'no_user_message_after_rollback_observed');
47
+ }
48
+ if (restoreSafety?.status !== 'ok') {
49
+ return {
50
+ ...base,
51
+ status: 'risk',
52
+ reason: 'restore_safety_risk',
53
+ proofScope: 'codex_rollout_restore_safety_only',
54
+ restartSafe: false,
55
+ };
56
+ }
57
+ if (!afterVsCodeRestart) {
58
+ return {
59
+ ...base,
60
+ status: 'rollback-nonresurrection-visible-restart-unacknowledged',
61
+ reason: 'rollback_user_text_absent_after_rollback_without_restart_ack',
62
+ proofScope: 'codex_rollout_restore_safety_only',
63
+ restartSafe: false,
64
+ };
65
+ }
66
+
67
+ return {
68
+ ...base,
69
+ status: 'vscode-restart-rollback-nonresurrection-visible',
70
+ reason: 'rollback_user_text_absent_after_restart_ack',
71
+ proofScope: 'manual_vscode_reload_plus_rollout_restore_safety',
72
+ restartSafe: true,
73
+ };
74
+ }
75
+
76
+ function pending(base, reason) {
77
+ return {
78
+ ...base,
79
+ status: 'pending',
80
+ reason,
81
+ proofScope: 'codex_rollout_restore_safety_only',
82
+ restartSafe: false,
83
+ };
84
+ }
85
+
86
+ function assertNonEmptyString(value, name) {
87
+ if (typeof value !== 'string' || value.length === 0) {
88
+ throw new Error(`${name} must be a non-empty string`);
89
+ }
90
+ }
@@ -0,0 +1,290 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { inspectCodexVsCodeRollbackSmoke } from './codex-vscode-rollback-smoke.mjs';
10
+
11
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
12
+
13
+ test('inspectCodexVsCodeRollbackSmoke: passes only with rollback, later user turn, restore safety ok, and restart ack', () => {
14
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-home-'));
15
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-project-'));
16
+ const threadId = '019dfe10-0000-7000-8000-000000000001';
17
+ try {
18
+ writeRollout(codexHome, {
19
+ project,
20
+ threadId,
21
+ events: [
22
+ event('user_message', { message: 'stable request' }),
23
+ event('task_started'),
24
+ event('agent_message', { message: 'stable answer' }),
25
+ event('task_complete'),
26
+ event('user_message', { message: 'rolled back request' }),
27
+ event('task_started'),
28
+ event('agent_message', { message: 'rolled back answer' }),
29
+ event('task_complete'),
30
+ event('thread_rolled_back', { num_turns: 1 }),
31
+ event('user_message', { message: 'post restart verifier prompt' }),
32
+ event('task_started'),
33
+ event('agent_message', { message: 'post restart answer' }),
34
+ event('task_complete'),
35
+ ],
36
+ });
37
+
38
+ const result = inspectCodexVsCodeRollbackSmoke({
39
+ threadId,
40
+ codexHome,
41
+ projectPath: project,
42
+ afterVsCodeRestart: true,
43
+ });
44
+
45
+ assert.equal(result.status, 'vscode-restart-rollback-nonresurrection-visible');
46
+ assert.equal(result.restartSafe, true);
47
+ assert.equal(result.proofScope, 'manual_vscode_reload_plus_rollout_restore_safety');
48
+ assert.equal(result.stats.rollbackEvents, 1);
49
+ assert.equal(result.stats.rolledBackUserMessages, 1);
50
+ assert.equal(result.stats.userMessagesAfterRollback, 1);
51
+ assert.equal(result.restoreSafety.status, 'ok');
52
+ } finally {
53
+ rmSync(codexHome, { recursive: true, force: true });
54
+ rmSync(project, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ test('inspectCodexVsCodeRollbackSmoke: does not claim restart safety without restart ack', () => {
59
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-home-'));
60
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-project-'));
61
+ const threadId = '019dfe10-0000-7000-8000-000000000002';
62
+ try {
63
+ writeRollout(codexHome, {
64
+ project,
65
+ threadId,
66
+ events: [
67
+ event('user_message', { message: 'rolled back request' }),
68
+ event('task_started'),
69
+ event('agent_message', { message: 'rolled back answer' }),
70
+ event('task_complete'),
71
+ event('thread_rolled_back', { num_turns: 1 }),
72
+ event('user_message', { message: 'post rollback prompt' }),
73
+ event('task_started'),
74
+ ],
75
+ });
76
+
77
+ const result = inspectCodexVsCodeRollbackSmoke({
78
+ threadId,
79
+ codexHome,
80
+ projectPath: project,
81
+ });
82
+
83
+ assert.equal(result.status, 'rollback-nonresurrection-visible-restart-unacknowledged');
84
+ assert.equal(result.restartSafe, false);
85
+ } finally {
86
+ rmSync(codexHome, { recursive: true, force: true });
87
+ rmSync(project, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ test('inspectCodexVsCodeRollbackSmoke: reports restore safety risk instead of passing', () => {
92
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-home-'));
93
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-project-'));
94
+ const threadId = '019dfe10-0000-7000-8000-000000000003';
95
+ try {
96
+ writeRollout(codexHome, {
97
+ project,
98
+ threadId,
99
+ events: [
100
+ event('user_message', { message: 'rolled back compacted request' }),
101
+ event('task_started'),
102
+ event('agent_message', { message: 'rolled back compacted answer' }),
103
+ event('task_complete'),
104
+ compacted([userReplacement('rolled back compacted request')]),
105
+ event('thread_rolled_back', { num_turns: 1 }),
106
+ event('user_message', { message: 'post restart verifier prompt' }),
107
+ event('task_started'),
108
+ ],
109
+ });
110
+
111
+ const result = inspectCodexVsCodeRollbackSmoke({
112
+ threadId,
113
+ codexHome,
114
+ projectPath: project,
115
+ afterVsCodeRestart: true,
116
+ });
117
+
118
+ assert.equal(result.status, 'risk');
119
+ assert.equal(result.reason, 'restore_safety_risk');
120
+ assert.equal(result.restartSafe, false);
121
+ assert.equal(result.restoreSafety.rollbackTextRetainedInCompacted, 1);
122
+ } finally {
123
+ rmSync(codexHome, { recursive: true, force: true });
124
+ rmSync(project, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test('codex-vscode-rollback-smoke CLI accepts env thread id and prints JSON', () => {
129
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-user-home-'));
130
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-home-'));
131
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-project-'));
132
+ const threadId = '019dfe10-0000-7000-8000-000000000004';
133
+ try {
134
+ writeRollout(codexHome, {
135
+ project,
136
+ threadId,
137
+ events: [
138
+ event('user_message', { message: 'rolled back request' }),
139
+ event('task_started'),
140
+ event('agent_message', { message: 'rolled back answer' }),
141
+ event('task_complete'),
142
+ event('thread_rolled_back', { num_turns: 1 }),
143
+ event('user_message', { message: 'post restart verifier prompt' }),
144
+ event('task_started'),
145
+ ],
146
+ });
147
+
148
+ const result = spawnSync(
149
+ process.execPath,
150
+ [
151
+ join(REPO_ROOT, 'bin/throughline.mjs'),
152
+ 'codex-vscode-rollback-smoke',
153
+ '--verify',
154
+ '--codex-home',
155
+ codexHome,
156
+ '--after-vscode-restart',
157
+ '--json',
158
+ ],
159
+ {
160
+ cwd: project,
161
+ env: {
162
+ ...process.env,
163
+ HOME: home,
164
+ USERPROFILE: home,
165
+ CODEX_THREAD_ID: threadId,
166
+ },
167
+ encoding: 'utf8',
168
+ },
169
+ );
170
+
171
+ assert.equal(result.status, 0, result.stderr);
172
+ const payload = JSON.parse(result.stdout);
173
+ assert.equal(payload.status, 'vscode-restart-rollback-nonresurrection-visible');
174
+ assert.equal(payload.threadId, threadId);
175
+ } finally {
176
+ rmSync(home, { recursive: true, force: true });
177
+ rmSync(codexHome, { recursive: true, force: true });
178
+ rmSync(project, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test('codex-vscode-rollback-smoke text output summarizes restore-safety risks', () => {
183
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-user-home-'));
184
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-home-'));
185
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-rollback-project-'));
186
+ const threadId = '019dfe10-0000-7000-8000-000000000005';
187
+ try {
188
+ writeRollout(codexHome, {
189
+ project,
190
+ threadId,
191
+ events: [
192
+ event('user_message', { message: 'rolled back compacted request' }),
193
+ event('task_started'),
194
+ event('agent_message', { message: 'rolled back compacted answer' }),
195
+ event('task_complete'),
196
+ compacted([userReplacement('rolled back compacted request')]),
197
+ event('thread_rolled_back', { num_turns: 1 }),
198
+ event('user_message', { message: 'post restart verifier prompt' }),
199
+ event('task_started'),
200
+ ],
201
+ });
202
+
203
+ const result = spawnSync(
204
+ process.execPath,
205
+ [
206
+ join(REPO_ROOT, 'bin/throughline.mjs'),
207
+ 'codex-vscode-rollback-smoke',
208
+ '--verify',
209
+ '--codex-thread-id',
210
+ threadId,
211
+ '--codex-home',
212
+ codexHome,
213
+ '--after-vscode-restart',
214
+ ],
215
+ {
216
+ cwd: project,
217
+ env: {
218
+ ...process.env,
219
+ HOME: home,
220
+ USERPROFILE: home,
221
+ },
222
+ encoding: 'utf8',
223
+ },
224
+ );
225
+
226
+ assert.equal(result.status, 1);
227
+ assert.match(result.stdout, /restore safety:\s+risk/);
228
+ assert.match(result.stdout, /rollback text retained in compacted:\s+1/);
229
+ assert.match(
230
+ result.stdout,
231
+ /risks: rollback_text_retained_in_compacted_replacement_history:1/,
232
+ );
233
+ } finally {
234
+ rmSync(home, { recursive: true, force: true });
235
+ rmSync(codexHome, { recursive: true, force: true });
236
+ rmSync(project, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ function writeRollout(codexHome, { project, threadId, events }) {
241
+ const dir = join(codexHome, 'sessions', '2026', '05', '07');
242
+ mkdirSync(dir, { recursive: true });
243
+ const path = join(dir, `rollout-2026-05-07T00-00-00-${threadId}.jsonl`);
244
+ const rows = [
245
+ {
246
+ timestamp: '2026-05-07T00:00:00.000Z',
247
+ type: 'session_meta',
248
+ payload: {
249
+ id: threadId,
250
+ timestamp: '2026-05-07T00:00:00.000Z',
251
+ cwd: project,
252
+ source: 'vscode',
253
+ cli_version: '0.128.0-alpha.1',
254
+ },
255
+ },
256
+ ...events,
257
+ ];
258
+ writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
259
+ return path;
260
+ }
261
+
262
+ function event(type, payload = {}) {
263
+ return {
264
+ timestamp: '2026-05-07T00:00:01.000Z',
265
+ type: 'event_msg',
266
+ payload: {
267
+ type,
268
+ ...payload,
269
+ },
270
+ };
271
+ }
272
+
273
+ function compacted(replacementHistory) {
274
+ return {
275
+ timestamp: '2026-05-07T00:00:01.000Z',
276
+ type: 'compacted',
277
+ payload: {
278
+ message: '',
279
+ replacement_history: replacementHistory,
280
+ },
281
+ };
282
+ }
283
+
284
+ function userReplacement(text) {
285
+ return {
286
+ type: 'message',
287
+ role: 'user',
288
+ content: [{ type: 'input_text', text }],
289
+ };
290
+ }