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,639 @@
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
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
+
11
+ function makeTempHome() {
12
+ return mkdtempSync(join(tmpdir(), 'tl-codex-restore-home-'));
13
+ }
14
+
15
+ function makeTempProject() {
16
+ return mkdtempSync(join(tmpdir(), 'tl-codex-restore-project-'));
17
+ }
18
+
19
+ function makeFakeCodexAppServer(
20
+ dir,
21
+ {
22
+ turnCounts = [22, 22],
23
+ turnsListCounts = null,
24
+ supportsTurnsList = true,
25
+ retainedText = null,
26
+ retainedTextLocation = 'itemText',
27
+ } = {},
28
+ ) {
29
+ const script = join(dir, 'fake-codex-restore-app-server.mjs');
30
+ const state = join(dir, 'fake-codex-restore-state.json');
31
+ writeFileSync(
32
+ script,
33
+ `#!/usr/bin/env node
34
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
35
+ import { createInterface } from 'node:readline';
36
+
37
+ const statePath = ${JSON.stringify(state)};
38
+ const turnCounts = ${JSON.stringify(turnCounts)};
39
+ const turnsListCounts = ${JSON.stringify(turnsListCounts)};
40
+ const supportsTurnsList = ${JSON.stringify(supportsTurnsList)};
41
+ const retainedText = ${JSON.stringify(retainedText)};
42
+ const retainedTextLocation = ${JSON.stringify(retainedTextLocation)};
43
+ let launches = 0;
44
+ if (existsSync(statePath)) launches = JSON.parse(readFileSync(statePath, 'utf8')).launches;
45
+ const turnCount = turnCounts[Math.min(launches, turnCounts.length - 1)];
46
+ const turnsListCountSource = Array.isArray(turnsListCounts) ? turnsListCounts : turnCounts;
47
+ const turnsListCount = turnsListCountSource[Math.min(launches, turnsListCountSource.length - 1)];
48
+ writeFileSync(statePath, JSON.stringify({ launches: launches + 1 }));
49
+ function buildTurn(index) {
50
+ const turn = { id: 'turn-' + (index + 1) };
51
+ if (retainedText && index === 0) {
52
+ if (retainedTextLocation === 'aggregatedOutput') {
53
+ turn.items = [{ type: 'commandExecution', aggregatedOutput: retainedText }];
54
+ } else if (retainedTextLocation === 'replacementHistory') {
55
+ turn.replacement_history = [
56
+ {
57
+ type: 'message',
58
+ role: 'user',
59
+ content: [{ type: 'input_text', text: retainedText }],
60
+ },
61
+ ];
62
+ } else {
63
+ turn.items = [{ type: 'userMessage', text: retainedText }];
64
+ }
65
+ }
66
+ return turn;
67
+ }
68
+ const turns = Array.from({ length: turnCount }, (_, index) => buildTurn(index));
69
+ const listedTurns = Array.from({ length: turnsListCount }, (_, index) => buildTurn(index));
70
+ const rl = createInterface({ input: process.stdin });
71
+ function send(message) { process.stdout.write(JSON.stringify(message) + '\\n'); }
72
+ rl.on('line', (line) => {
73
+ const msg = JSON.parse(line);
74
+ if (msg.method === 'initialized') return;
75
+ if (msg.method === 'initialize') {
76
+ send({ id: msg.id, result: { userAgent: 'fake-codex' } });
77
+ } else if (msg.method === 'thread/read' || msg.method === 'thread/resume') {
78
+ send({ id: msg.id, result: { thread: { id: msg.params.threadId, turns } } });
79
+ } else if (supportsTurnsList && msg.method === 'thread/turns/list') {
80
+ send({ id: msg.id, result: { data: listedTurns, nextCursor: null, backwardsCursor: null } });
81
+ } else {
82
+ send({ id: msg.id, error: { code: -32601, message: 'unknown method' } });
83
+ }
84
+ });
85
+ `,
86
+ );
87
+ chmodSync(script, 0o755);
88
+ return script;
89
+ }
90
+
91
+ function runRestoreSmoke(home, codexHome, project, args = [], extraEnv = {}) {
92
+ return spawnSync(
93
+ process.execPath,
94
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'codex-restore-smoke', ...args],
95
+ {
96
+ cwd: project,
97
+ env: {
98
+ ...process.env,
99
+ HOME: home,
100
+ USERPROFILE: home,
101
+ CODEX_HOME: codexHome,
102
+ ...extraEnv,
103
+ },
104
+ encoding: 'utf8',
105
+ },
106
+ );
107
+ }
108
+
109
+ function writeCodexRollout(codexHome, { project, threadId, turnCount, restoreRisk = false }) {
110
+ const dir = join(codexHome, 'sessions', '2026', '05', '07');
111
+ mkdirSync(dir, { recursive: true });
112
+ const path = join(dir, `rollout-2026-05-07T00-00-00-${threadId}.jsonl`);
113
+ const rows = [
114
+ {
115
+ timestamp: '2026-05-07T00:00:00.000Z',
116
+ type: 'session_meta',
117
+ payload: {
118
+ id: threadId,
119
+ timestamp: '2026-05-07T00:00:00.000Z',
120
+ cwd: project,
121
+ source: 'vscode',
122
+ cli_version: '0.128.0-alpha.1',
123
+ },
124
+ },
125
+ ];
126
+
127
+ for (let turn = 1; turn <= turnCount; turn++) {
128
+ rows.push({
129
+ timestamp: `2026-05-07T00:00:${String(turn).padStart(2, '0')}.000Z`,
130
+ type: 'event_msg',
131
+ payload: {
132
+ type: 'user_message',
133
+ message: `restore smoke user turn ${turn}`,
134
+ },
135
+ });
136
+ rows.push({
137
+ timestamp: `2026-05-07T00:00:${String(turn).padStart(2, '0')}.100Z`,
138
+ type: 'event_msg',
139
+ payload: { type: 'task_started' },
140
+ });
141
+ rows.push({
142
+ timestamp: `2026-05-07T00:00:${String(turn).padStart(2, '0')}.200Z`,
143
+ type: 'event_msg',
144
+ payload: {
145
+ type: 'agent_message',
146
+ message: `restore smoke assistant turn ${turn}`,
147
+ },
148
+ });
149
+ rows.push({
150
+ timestamp: `2026-05-07T00:00:${String(turn).padStart(2, '0')}.300Z`,
151
+ type: 'event_msg',
152
+ payload: { type: 'task_complete' },
153
+ });
154
+ }
155
+
156
+ if (restoreRisk) {
157
+ const riskyText = `restore smoke user turn ${turnCount}`;
158
+ rows.push({
159
+ timestamp: '2026-05-07T00:01:00.000Z',
160
+ type: 'compacted',
161
+ payload: {
162
+ message: '',
163
+ replacement_history: [
164
+ {
165
+ type: 'message',
166
+ role: 'user',
167
+ content: [{ type: 'input_text', text: riskyText }],
168
+ },
169
+ ],
170
+ },
171
+ });
172
+ rows.push({
173
+ timestamp: '2026-05-07T00:01:00.100Z',
174
+ type: 'event_msg',
175
+ payload: { type: 'thread_rolled_back', num_turns: 1 },
176
+ });
177
+ rows.push({
178
+ timestamp: '2026-05-07T00:01:00.200Z',
179
+ type: 'event_msg',
180
+ payload: { type: 'user_message', message: riskyText },
181
+ });
182
+ }
183
+
184
+ writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
185
+ return path;
186
+ }
187
+
188
+ test('codex-restore-smoke refuses without explicit experimental env', () => {
189
+ const home = makeTempHome();
190
+ const codexHome = makeTempHome();
191
+ const project = makeTempProject();
192
+ try {
193
+ const result = runRestoreSmoke(home, codexHome, project, ['--codex-thread-id', 'thread-restore', '--json']);
194
+
195
+ assert.equal(result.status, 1);
196
+ const payload = JSON.parse(result.stdout);
197
+ assert.equal(payload.status, 'refused');
198
+ assert.equal(payload.reason, 'experimental_env_required');
199
+ assert.equal(payload.restartSafe, false);
200
+ } finally {
201
+ rmSync(project, { recursive: true, force: true });
202
+ rmSync(home, { recursive: true, force: true });
203
+ rmSync(codexHome, { recursive: true, force: true });
204
+ }
205
+ });
206
+
207
+ test('codex-restore-smoke reports stable fresh app-server restore counts', () => {
208
+ const home = makeTempHome();
209
+ const codexHome = makeTempHome();
210
+ const project = makeTempProject();
211
+ const threadId = '019dfdef-0000-7000-8000-000000000001';
212
+ try {
213
+ writeCodexRollout(codexHome, { project, threadId, turnCount: 22 });
214
+ const fake = makeFakeCodexAppServer(project, { turnCounts: [22, 22] });
215
+ const result = runRestoreSmoke(
216
+ home,
217
+ codexHome,
218
+ project,
219
+ ['--codex-thread-id', threadId, '--codex-app-server-bin', fake, '--json'],
220
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
221
+ );
222
+
223
+ assert.equal(result.status, 0, result.stderr);
224
+ const payload = JSON.parse(result.stdout);
225
+ assert.equal(payload.status, 'app-server-restart-stable');
226
+ assert.equal(payload.reason, 'fresh_app_server_restore_counts_stable');
227
+ assert.equal(payload.proofScope, 'app_server_process_restart_only');
228
+ assert.equal(payload.restartSafe, false);
229
+ assert.equal(payload.expectedTurns, 22);
230
+ assert.equal(payload.observations.length, 2);
231
+ assert.equal(payload.observations[0].turnCountCheck.status, 'match');
232
+ assert.equal(payload.observations[1].turnCountCheck.status, 'match');
233
+ assert.equal(payload.observations[0].turnsListTurns, 22);
234
+ assert.equal(payload.observations[0].turnsListComplete, true);
235
+ } finally {
236
+ rmSync(project, { recursive: true, force: true });
237
+ rmSync(home, { recursive: true, force: true });
238
+ rmSync(codexHome, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ test('codex-restore-smoke reports mismatch when fresh app-server restore counts drift', () => {
243
+ const home = makeTempHome();
244
+ const codexHome = makeTempHome();
245
+ const project = makeTempProject();
246
+ const threadId = '019dfdef-0000-7000-8000-000000000002';
247
+ try {
248
+ writeCodexRollout(codexHome, { project, threadId, turnCount: 22 });
249
+ const fake = makeFakeCodexAppServer(project, { turnCounts: [22, 21] });
250
+ const result = runRestoreSmoke(
251
+ home,
252
+ codexHome,
253
+ project,
254
+ ['--codex-thread-id', threadId, '--codex-app-server-bin', fake, '--json'],
255
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
256
+ );
257
+
258
+ assert.equal(result.status, 1);
259
+ const payload = JSON.parse(result.stdout);
260
+ assert.equal(payload.status, 'app-server-restart-mismatch');
261
+ assert.equal(payload.reason, 'fresh_app_server_restore_counts_mismatch');
262
+ assert.equal(payload.restartSafe, false);
263
+ assert.equal(payload.observations[1].turnCountCheck.status, 'mismatch');
264
+ } finally {
265
+ rmSync(project, { recursive: true, force: true });
266
+ rmSync(home, { recursive: true, force: true });
267
+ rmSync(codexHome, { recursive: true, force: true });
268
+ }
269
+ });
270
+
271
+ test('codex-restore-smoke reports mismatch when thread turns/list differs from read and resume', () => {
272
+ const home = makeTempHome();
273
+ const codexHome = makeTempHome();
274
+ const project = makeTempProject();
275
+ const threadId = '019dfdef-0000-7000-8000-000000000004';
276
+ try {
277
+ writeCodexRollout(codexHome, { project, threadId, turnCount: 22 });
278
+ const fake = makeFakeCodexAppServer(project, { turnCounts: [22, 22], turnsListCounts: [21, 21] });
279
+ const result = runRestoreSmoke(
280
+ home,
281
+ codexHome,
282
+ project,
283
+ ['--codex-thread-id', threadId, '--codex-app-server-bin', fake, '--json'],
284
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
285
+ );
286
+
287
+ assert.equal(result.status, 1);
288
+ const payload = JSON.parse(result.stdout);
289
+ assert.equal(payload.status, 'app-server-restart-mismatch');
290
+ assert.equal(payload.observations[0].readTurns, 22);
291
+ assert.equal(payload.observations[0].resumedTurns, 22);
292
+ assert.equal(payload.observations[0].turnsListTurns, 21);
293
+ assert.equal(payload.observations[0].turnCountCheck.status, 'mismatch');
294
+ } finally {
295
+ rmSync(project, { recursive: true, force: true });
296
+ rmSync(home, { recursive: true, force: true });
297
+ rmSync(codexHome, { recursive: true, force: true });
298
+ }
299
+ });
300
+
301
+ test('codex-restore-smoke reports structured error when thread turns/list is unavailable', () => {
302
+ const home = makeTempHome();
303
+ const codexHome = makeTempHome();
304
+ const project = makeTempProject();
305
+ const threadId = '019dfdef-0000-7000-8000-000000000005';
306
+ try {
307
+ writeCodexRollout(codexHome, { project, threadId, turnCount: 22 });
308
+ const fake = makeFakeCodexAppServer(project, {
309
+ turnCounts: [22, 22],
310
+ supportsTurnsList: false,
311
+ });
312
+ const result = runRestoreSmoke(
313
+ home,
314
+ codexHome,
315
+ project,
316
+ ['--codex-thread-id', threadId, '--codex-app-server-bin', fake, '--json'],
317
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
318
+ );
319
+
320
+ assert.equal(result.status, 1);
321
+ const payload = JSON.parse(result.stdout);
322
+ assert.equal(payload.status, 'app-server-restore-smoke-error');
323
+ assert.equal(payload.reason, 'app_server_restore_request_failed');
324
+ assert.match(payload.error, /thread\/turns\/list/);
325
+ assert.equal(payload.restartSafe, false);
326
+ } finally {
327
+ rmSync(project, { recursive: true, force: true });
328
+ rmSync(home, { recursive: true, force: true });
329
+ rmSync(codexHome, { recursive: true, force: true });
330
+ }
331
+ });
332
+
333
+ test('codex-restore-smoke refuses before app-server when rollout restore safety is risky', () => {
334
+ const home = makeTempHome();
335
+ const codexHome = makeTempHome();
336
+ const project = makeTempProject();
337
+ const threadId = '019dfdef-0000-7000-8000-000000000003';
338
+ try {
339
+ writeCodexRollout(codexHome, {
340
+ project,
341
+ threadId,
342
+ turnCount: 22,
343
+ restoreRisk: true,
344
+ });
345
+ const fake = makeFakeCodexAppServer(project, { turnCounts: [22, 22] });
346
+ const result = runRestoreSmoke(
347
+ home,
348
+ codexHome,
349
+ project,
350
+ ['--codex-thread-id', threadId, '--codex-app-server-bin', fake, '--json'],
351
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
352
+ );
353
+
354
+ assert.equal(result.status, 1);
355
+ const payload = JSON.parse(result.stdout);
356
+ assert.equal(payload.status, 'refused');
357
+ assert.equal(payload.reason, 'restore_safety_risk');
358
+ assert.equal(payload.restoreSafety.status, 'risk');
359
+ assert.equal(payload.restartSafe, false);
360
+ } finally {
361
+ rmSync(project, { recursive: true, force: true });
362
+ rmSync(home, { recursive: true, force: true });
363
+ rmSync(codexHome, { recursive: true, force: true });
364
+ }
365
+ });
366
+
367
+ test('codex-restore-smoke can inspect risky rollout read-only and report app-server response text matches', () => {
368
+ const home = makeTempHome();
369
+ const codexHome = makeTempHome();
370
+ const project = makeTempProject();
371
+ const threadId = '019dfdef-0000-7000-8000-000000000006';
372
+ const retainedText = 'restore smoke user turn 22';
373
+ try {
374
+ writeCodexRollout(codexHome, {
375
+ project,
376
+ threadId,
377
+ turnCount: 22,
378
+ restoreRisk: true,
379
+ });
380
+ const fake = makeFakeCodexAppServer(project, {
381
+ turnCounts: [22, 22],
382
+ turnsListCounts: [22, 22],
383
+ retainedText,
384
+ });
385
+ const result = runRestoreSmoke(
386
+ home,
387
+ codexHome,
388
+ project,
389
+ [
390
+ '--codex-thread-id',
391
+ threadId,
392
+ '--codex-app-server-bin',
393
+ fake,
394
+ '--inspect-risky-rollout',
395
+ '--json',
396
+ ],
397
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
398
+ );
399
+
400
+ assert.equal(result.status, 1);
401
+ const payload = JSON.parse(result.stdout);
402
+ assert.equal(payload.status, 'app-server-restore-text-retained');
403
+ assert.equal(payload.reason, 'restore_text_seen_in_app_server_response');
404
+ assert.equal(payload.restoreSafety.status, 'risk');
405
+ assert.equal(payload.restoreSafetyRiskInspected, true);
406
+ assert.equal(payload.restartSafe, false);
407
+ assert.equal(payload.restoreTextNeedles.length, 1);
408
+ assert.equal(payload.restoreTextMatchCheck.status, 'matches-found');
409
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[0], {
410
+ source: 'thread_read',
411
+ cycles: [1, 2],
412
+ matchedNeedleIds: ['retained_rollback_text_1'],
413
+ samplePaths: ['$.thread.turns[0].items[0].text'],
414
+ locationKinds: ['item_text_field'],
415
+ locationRisks: ['direct_turn_text_candidate'],
416
+ blockingKinds: ['item_text_field'],
417
+ nonBlockingKinds: [],
418
+ hasBlockingCandidates: true,
419
+ });
420
+ assert.equal(payload.restoreTextMatchCheck.hasBlockingCandidates, true);
421
+ assert.deepEqual(payload.restoreTextMatchCheck.blockingKinds, ['item_text_field']);
422
+ assert.deepEqual(payload.restoreTextMatchCheck.locationRisks, [
423
+ 'direct_turn_text_candidate',
424
+ ]);
425
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[1].samplePaths, [
426
+ '$.thread.turns[0].items[0].text',
427
+ ]);
428
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[1].locationKinds, [
429
+ 'item_text_field',
430
+ ]);
431
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[2].samplePaths, [
432
+ '$[0].items[0].text',
433
+ ]);
434
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[2].locationKinds, [
435
+ 'item_text_field',
436
+ ]);
437
+ assert.equal(payload.observations[0].responseTextMatches.status, 'matches-found');
438
+ assert.deepEqual(
439
+ payload.observations[0].responseTextMatches.sources.map((source) => source.source),
440
+ ['thread_read', 'thread_resume', 'thread_turns_list'],
441
+ );
442
+ assert.equal(
443
+ payload.observations[0].responseTextMatches.matchedNeedles[0].id,
444
+ 'retained_rollback_text_1',
445
+ );
446
+ assert.deepEqual(
447
+ payload.observations[0].responseTextMatches.sources[0].matches[0].locations.map(
448
+ (location) => location.path,
449
+ ),
450
+ ['$.thread.turns[0].items[0].text'],
451
+ );
452
+ assert.deepEqual(
453
+ payload.observations[0].responseTextMatches.sources[0].matches[0].locations.map(
454
+ (location) => location.kind,
455
+ ),
456
+ ['item_text_field'],
457
+ );
458
+ assert.deepEqual(
459
+ payload.observations[0].responseTextMatches.sources[0].matches[0].locations.map(
460
+ (location) => location.risk,
461
+ ),
462
+ ['direct_turn_text_candidate'],
463
+ );
464
+ assert.deepEqual(
465
+ payload.observations[0].responseTextMatches.sources[0].matches[0].locations.map(
466
+ (location) => location.blockingCandidate,
467
+ ),
468
+ [true],
469
+ );
470
+ } finally {
471
+ rmSync(project, { recursive: true, force: true });
472
+ rmSync(home, { recursive: true, force: true });
473
+ rmSync(codexHome, { recursive: true, force: true });
474
+ }
475
+ });
476
+
477
+ test('codex-restore-smoke text output summarizes risky response text matches', () => {
478
+ const home = makeTempHome();
479
+ const codexHome = makeTempHome();
480
+ const project = makeTempProject();
481
+ const threadId = '019dfdef-0000-7000-8000-000000000007';
482
+ const retainedText = 'restore smoke user turn 22';
483
+ try {
484
+ writeCodexRollout(codexHome, {
485
+ project,
486
+ threadId,
487
+ turnCount: 22,
488
+ restoreRisk: true,
489
+ });
490
+ const fake = makeFakeCodexAppServer(project, {
491
+ turnCounts: [22, 22],
492
+ turnsListCounts: [22, 22],
493
+ retainedText,
494
+ });
495
+ const result = runRestoreSmoke(
496
+ home,
497
+ codexHome,
498
+ project,
499
+ ['--codex-thread-id', threadId, '--codex-app-server-bin', fake, '--inspect-risky-rollout'],
500
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
501
+ );
502
+
503
+ assert.equal(result.status, 1);
504
+ assert.match(result.stdout, /status:\s+app-server-restore-text-retained/);
505
+ assert.match(result.stdout, /restore safety risk inspected read-only: yes/);
506
+ assert.match(
507
+ result.stdout,
508
+ /restore text match check: matches-found 1 needle sources=thread_read,thread_resume,thread_turns_list/,
509
+ );
510
+ assert.match(result.stdout, /paths=thread_read:\$\.thread\.turns\[0\]\.items\[0\]\.text/);
511
+ assert.match(result.stdout, /kinds=item_text_field/);
512
+ assert.match(result.stdout, /risks=direct_turn_text_candidate/);
513
+ assert.match(result.stdout, /blocking-candidates=item_text_field/);
514
+ assert.match(
515
+ result.stdout,
516
+ /response text matches: matches-found 1 needle sources=thread_read,thread_resume,thread_turns_list/,
517
+ );
518
+ } finally {
519
+ rmSync(project, { recursive: true, force: true });
520
+ rmSync(home, { recursive: true, force: true });
521
+ rmSync(codexHome, { recursive: true, force: true });
522
+ }
523
+ });
524
+
525
+ test('codex-restore-smoke separates quoted output matches from blocking restore matches', () => {
526
+ const home = makeTempHome();
527
+ const codexHome = makeTempHome();
528
+ const project = makeTempProject();
529
+ const threadId = '019dfdef-0000-7000-8000-000000000008';
530
+ const retainedText = 'restore smoke user turn 22';
531
+ try {
532
+ writeCodexRollout(codexHome, {
533
+ project,
534
+ threadId,
535
+ turnCount: 22,
536
+ restoreRisk: true,
537
+ });
538
+ const fake = makeFakeCodexAppServer(project, {
539
+ turnCounts: [22, 22],
540
+ turnsListCounts: [22, 22],
541
+ retainedText,
542
+ retainedTextLocation: 'aggregatedOutput',
543
+ });
544
+ const result = runRestoreSmoke(
545
+ home,
546
+ codexHome,
547
+ project,
548
+ [
549
+ '--codex-thread-id',
550
+ threadId,
551
+ '--codex-app-server-bin',
552
+ fake,
553
+ '--inspect-risky-rollout',
554
+ '--json',
555
+ ],
556
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
557
+ );
558
+
559
+ assert.equal(result.status, 1);
560
+ const payload = JSON.parse(result.stdout);
561
+ assert.equal(payload.status, 'app-server-restore-text-quoted');
562
+ assert.equal(
563
+ payload.reason,
564
+ 'restore_text_seen_only_in_quoted_or_output_response_fields',
565
+ );
566
+ assert.equal(payload.restoreTextMatchCheck.status, 'matches-found');
567
+ assert.equal(payload.restoreTextMatchCheck.hasBlockingCandidates, false);
568
+ assert.deepEqual(payload.restoreTextMatchCheck.blockingKinds, []);
569
+ assert.deepEqual(payload.restoreTextMatchCheck.nonBlockingKinds, ['aggregated_output']);
570
+ assert.deepEqual(payload.restoreTextMatchCheck.locationRisks, [
571
+ 'quoted_or_tool_output_context',
572
+ ]);
573
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[0].samplePaths, [
574
+ '$.thread.turns[0].items[0].aggregatedOutput',
575
+ ]);
576
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[0].locationKinds, [
577
+ 'aggregated_output',
578
+ ]);
579
+ } finally {
580
+ rmSync(project, { recursive: true, force: true });
581
+ rmSync(home, { recursive: true, force: true });
582
+ rmSync(codexHome, { recursive: true, force: true });
583
+ }
584
+ });
585
+
586
+ test('codex-restore-smoke classifies replacement_history matches as durable restore sources', () => {
587
+ const home = makeTempHome();
588
+ const codexHome = makeTempHome();
589
+ const project = makeTempProject();
590
+ const threadId = '019dfdef-0000-7000-8000-000000000009';
591
+ const retainedText = 'restore smoke user turn 22';
592
+ try {
593
+ writeCodexRollout(codexHome, {
594
+ project,
595
+ threadId,
596
+ turnCount: 22,
597
+ restoreRisk: true,
598
+ });
599
+ const fake = makeFakeCodexAppServer(project, {
600
+ turnCounts: [22, 22],
601
+ turnsListCounts: [22, 22],
602
+ retainedText,
603
+ retainedTextLocation: 'replacementHistory',
604
+ });
605
+ const result = runRestoreSmoke(
606
+ home,
607
+ codexHome,
608
+ project,
609
+ [
610
+ '--codex-thread-id',
611
+ threadId,
612
+ '--codex-app-server-bin',
613
+ fake,
614
+ '--inspect-risky-rollout',
615
+ '--json',
616
+ ],
617
+ { THROUGHLINE_EXPERIMENTAL_CODEX_RESTORE_SMOKE: '1' },
618
+ );
619
+
620
+ assert.equal(result.status, 1);
621
+ const payload = JSON.parse(result.stdout);
622
+ assert.equal(payload.status, 'app-server-restore-text-retained');
623
+ assert.equal(payload.restoreTextMatchCheck.hasBlockingCandidates, true);
624
+ assert.deepEqual(payload.restoreTextMatchCheck.blockingKinds, ['replacement_history']);
625
+ assert.deepEqual(payload.restoreTextMatchCheck.locationRisks, [
626
+ 'durable_restore_source',
627
+ ]);
628
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[0].samplePaths, [
629
+ '$.thread.turns[0].replacement_history[0].content[0].text',
630
+ ]);
631
+ assert.deepEqual(payload.restoreTextMatchCheck.sources[0].locationKinds, [
632
+ 'replacement_history',
633
+ ]);
634
+ } finally {
635
+ rmSync(project, { recursive: true, force: true });
636
+ rmSync(home, { recursive: true, force: true });
637
+ rmSync(codexHome, { recursive: true, force: true });
638
+ }
639
+ });