throughline 0.4.9 → 0.4.11

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.
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  export const CODEX_APP_SERVER_METHODS = Object.freeze({
5
5
  initialize: 'initialize',
6
6
  initialized: 'initialized',
7
+ threadStart: 'thread/start',
7
8
  threadRead: 'thread/read',
8
9
  threadResume: 'thread/resume',
9
10
  threadTurnsList: 'thread/turns/list',
@@ -125,6 +126,32 @@ export function buildInitializedNotification() {
125
126
  };
126
127
  }
127
128
 
129
+ export function buildThreadStartRequest({
130
+ id,
131
+ cwd,
132
+ approvalPolicy = null,
133
+ sandbox = null,
134
+ model = null,
135
+ sessionStartSource = 'clear',
136
+ }) {
137
+ assertRequestId(id, 'buildThreadStartRequest');
138
+ assertNonEmptyString(cwd, 'buildThreadStartRequest: cwd');
139
+ if (sessionStartSource !== 'startup' && sessionStartSource !== 'clear') {
140
+ throw new Error('buildThreadStartRequest: sessionStartSource must be startup or clear');
141
+ }
142
+ return {
143
+ id,
144
+ method: CODEX_APP_SERVER_METHODS.threadStart,
145
+ params: compactNullish({
146
+ cwd,
147
+ approvalPolicy,
148
+ sandbox,
149
+ model,
150
+ sessionStartSource,
151
+ }),
152
+ };
153
+ }
154
+
128
155
  export function buildThreadReadRequest({ id, threadId, includeTurns = true }) {
129
156
  assertRequestId(id, 'buildThreadReadRequest');
130
157
  assertNonEmptyString(threadId, 'buildThreadReadRequest: threadId');
@@ -209,6 +236,48 @@ export function buildThreadRollbackRequest({ id, threadId, numTurns }) {
209
236
  };
210
237
  }
211
238
 
239
+ export function resolveRollbackTurnsForAppServer({
240
+ plannedRollbackTurns,
241
+ expectedTurns = null,
242
+ readTurns = null,
243
+ resumedTurns = null,
244
+ } = {}) {
245
+ if (!Number.isInteger(plannedRollbackTurns) || plannedRollbackTurns < 1) {
246
+ throw new Error('resolveRollbackTurnsForAppServer: plannedRollbackTurns must be an integer >= 1');
247
+ }
248
+ assertOptionalTurnCount(expectedTurns, 'resolveRollbackTurnsForAppServer: expectedTurns');
249
+
250
+ const result = {
251
+ plannedRollbackTurns,
252
+ requestedRollbackTurns: plannedRollbackTurns,
253
+ adjustment: 0,
254
+ basis: 'planned',
255
+ reason: 'using_planned_rollback_turns',
256
+ };
257
+
258
+ if (
259
+ Number.isInteger(expectedTurns) &&
260
+ Number.isInteger(readTurns) &&
261
+ Number.isInteger(resumedTurns) &&
262
+ readTurns === resumedTurns &&
263
+ readTurns !== expectedTurns
264
+ ) {
265
+ const adjustment = readTurns - expectedTurns;
266
+ const adjustedTurns = plannedRollbackTurns + adjustment;
267
+ if (adjustedTurns >= 1) {
268
+ return {
269
+ plannedRollbackTurns,
270
+ requestedRollbackTurns: Math.min(adjustedTurns, readTurns),
271
+ adjustment,
272
+ basis: 'app_server_turn_count',
273
+ reason: 'adjusted_by_app_server_turn_delta',
274
+ };
275
+ }
276
+ }
277
+
278
+ return result;
279
+ }
280
+
212
281
  export function buildThreadInjectItemsRequest({ id, threadId, items }) {
213
282
  assertRequestId(id, 'buildThreadInjectItemsRequest');
214
283
  assertNonEmptyString(threadId, 'buildThreadInjectItemsRequest: threadId');
@@ -313,6 +382,12 @@ export async function runCodexTrimPreflight({
313
382
  );
314
383
  const readTurns = countTurns(beforeRead);
315
384
  const resumedTurns = countTurns(resumed);
385
+ const rollbackResolution = resolveRollbackTurnsForAppServer({
386
+ plannedRollbackTurns: rollbackTurns,
387
+ expectedTurns,
388
+ readTurns,
389
+ resumedTurns,
390
+ });
316
391
 
317
392
  return {
318
393
  status: 'preflight-ready',
@@ -321,6 +396,8 @@ export async function runCodexTrimPreflight({
321
396
  injectSent: false,
322
397
  readTurns,
323
398
  resumedTurns,
399
+ rollbackRequestedTurns: rollbackResolution.requestedRollbackTurns,
400
+ rollbackResolution,
324
401
  turnCountCheck: compareTurnCounts({
325
402
  expectedTurns,
326
403
  readTurns,
@@ -329,7 +406,7 @@ export async function runCodexTrimPreflight({
329
406
  rollbackRequestPreview: buildThreadRollbackRequest({
330
407
  id: 'rollback-preview',
331
408
  threadId,
332
- numTurns: rollbackTurns,
409
+ numTurns: rollbackResolution.requestedRollbackTurns,
333
410
  }),
334
411
  notifications: [...new Set(client.notifications)],
335
412
  stderr: client.stderr,
@@ -964,27 +1041,17 @@ export async function runCodexTrimExecution({
964
1041
  readTurns,
965
1042
  resumedTurns,
966
1043
  });
967
- if (turnCountCheck.status === 'mismatch' || turnCountCheck.status === 'unknown') {
968
- return {
969
- status: 'refused',
970
- reason: 'codex_rollout_app_server_turn_mismatch',
971
- threadId,
972
- rollbackSent: false,
973
- injectSent: false,
974
- injectedItems: 0,
975
- readTurns,
976
- resumedTurns,
977
- rollbackRequestedTurns: rollbackTurns,
978
- turnCountCheck,
979
- notifications: [...new Set(client.notifications)],
980
- stderr: client.stderr,
981
- };
982
- }
1044
+ const rollbackResolution = resolveRollbackTurnsForAppServer({
1045
+ plannedRollbackTurns: rollbackTurns,
1046
+ expectedTurns,
1047
+ readTurns,
1048
+ resumedTurns,
1049
+ });
983
1050
  const rollback = await client.request(
984
1051
  buildThreadRollbackRequest({
985
1052
  id: randomUUID(),
986
1053
  threadId,
987
- numTurns: rollbackTurns,
1054
+ numTurns: rollbackResolution.requestedRollbackTurns,
988
1055
  }),
989
1056
  );
990
1057
  const inject = await client.request(
@@ -1016,7 +1083,8 @@ export async function runCodexTrimExecution({
1016
1083
  injectedItems: 1,
1017
1084
  readTurns,
1018
1085
  resumedTurns,
1019
- rollbackRequestedTurns: rollbackTurns,
1086
+ rollbackRequestedTurns: rollbackResolution.requestedRollbackTurns,
1087
+ rollbackResolution,
1020
1088
  rollbackResultTurns,
1021
1089
  injectResultTurns,
1022
1090
  afterTurns: postInjectRead.turns,
@@ -1365,6 +1433,116 @@ export async function runCodexRollbackModelVisibleVerify({
1365
1433
  }
1366
1434
  }
1367
1435
 
1436
+ export async function runCodexNewThreadHandoff({
1437
+ cwd,
1438
+ prompt,
1439
+ command = 'codex',
1440
+ commandArgs = ['app-server', '--listen', 'stdio://'],
1441
+ timeoutMs = 120_000,
1442
+ requestTimeoutMs = 60_000,
1443
+ waitForTurn = true,
1444
+ sessionStartSource = 'clear',
1445
+ delivery = 'user-turn',
1446
+ } = {}) {
1447
+ assertNonEmptyString(cwd, 'runCodexNewThreadHandoff: cwd');
1448
+ assertNonEmptyString(prompt, 'runCodexNewThreadHandoff: prompt');
1449
+ assertNonEmptyString(command, 'runCodexNewThreadHandoff: command');
1450
+ if (!Array.isArray(commandArgs)) {
1451
+ throw new Error('runCodexNewThreadHandoff: commandArgs must be an array');
1452
+ }
1453
+ if (sessionStartSource !== 'startup' && sessionStartSource !== 'clear') {
1454
+ throw new Error('runCodexNewThreadHandoff: sessionStartSource must be startup or clear');
1455
+ }
1456
+ if (delivery !== 'user-turn' && delivery !== 'developer-item') {
1457
+ throw new Error('runCodexNewThreadHandoff: delivery must be user-turn or developer-item');
1458
+ }
1459
+
1460
+ const client = startAppServerClient({
1461
+ command,
1462
+ args: commandArgs,
1463
+ cwd,
1464
+ timeoutMs,
1465
+ requestTimeoutMs,
1466
+ });
1467
+
1468
+ try {
1469
+ await initializeThroughlineAppServerClient(client, {
1470
+ clientName: 'throughline-codex-new-thread-handoff',
1471
+ clientTitle: 'Throughline Codex New Thread Handoff',
1472
+ });
1473
+
1474
+ const threadStart = await client.request(
1475
+ buildThreadStartRequest({
1476
+ id: randomUUID(),
1477
+ cwd,
1478
+ sessionStartSource,
1479
+ }),
1480
+ );
1481
+ const threadId = findThreadIdInAppServerPayload(threadStart);
1482
+ if (!isCodexThreadId(threadId)) {
1483
+ throw new Error(`thread/start did not return a Codex thread id: ${JSON.stringify(threadStart).slice(0, 500)}`);
1484
+ }
1485
+
1486
+ if (delivery === 'developer-item') {
1487
+ await client.request(
1488
+ buildThreadInjectItemsRequest({
1489
+ id: randomUUID(),
1490
+ threadId,
1491
+ items: [buildDeveloperMessageItem(prompt)],
1492
+ }),
1493
+ );
1494
+ return {
1495
+ status: 'started',
1496
+ reason: 'new_thread_started_and_developer_memory_injected',
1497
+ threadId,
1498
+ delivery,
1499
+ injectSent: true,
1500
+ turnStatus: 'not-started',
1501
+ notifications: [...new Set(client.notifications)],
1502
+ agentText: collectAgentText(client.notificationEvents),
1503
+ stderr: client.stderr,
1504
+ };
1505
+ }
1506
+
1507
+ await client.request(buildTurnStartRequest({ id: randomUUID(), threadId, text: prompt }));
1508
+ const observedTurnEvent = waitForTurn
1509
+ ? await client.waitForNotification({
1510
+ predicate: (event) => event.method === 'turn/completed' || event.method === 'turn/failed',
1511
+ timeoutMs: requestTimeoutMs,
1512
+ })
1513
+ : null;
1514
+ const turnStatus =
1515
+ observedTurnEvent?.method === 'turn/completed'
1516
+ ? 'completed'
1517
+ : observedTurnEvent?.method === 'turn/failed'
1518
+ ? 'failed'
1519
+ : waitForTurn
1520
+ ? 'timeout'
1521
+ : 'unchecked';
1522
+
1523
+ return {
1524
+ status: turnStatus === 'failed' || turnStatus === 'timeout' ? 'started-unverified' : 'started',
1525
+ reason:
1526
+ turnStatus === 'completed'
1527
+ ? 'new_thread_started_and_turn_completed'
1528
+ : turnStatus === 'failed'
1529
+ ? 'new_thread_started_but_turn_failed'
1530
+ : turnStatus === 'timeout'
1531
+ ? 'new_thread_started_but_turn_completion_not_observed'
1532
+ : 'new_thread_started_without_waiting_for_turn',
1533
+ threadId,
1534
+ delivery,
1535
+ injectSent: false,
1536
+ turnStatus,
1537
+ notifications: [...new Set(client.notifications)],
1538
+ agentText: collectAgentText(client.notificationEvents),
1539
+ stderr: client.stderr,
1540
+ };
1541
+ } finally {
1542
+ await client.close();
1543
+ }
1544
+ }
1545
+
1368
1546
  function initializeThroughlineAppServerClient(client, { clientName, clientTitle }) {
1369
1547
  return client
1370
1548
  .request(
@@ -1386,6 +1564,25 @@ function extractRollbackModelVisibleMarkers(text, markerPrefix) {
1386
1564
  return [...new Set(text.match(pattern) ?? [])];
1387
1565
  }
1388
1566
 
1567
+ function findThreadIdInAppServerPayload(value) {
1568
+ if (!isRecord(value)) return null;
1569
+ if (isCodexThreadId(value.id)) return value.id;
1570
+ if (isCodexThreadId(value.threadId)) return value.threadId;
1571
+ if (isRecord(value.thread)) {
1572
+ const nested = findThreadIdInAppServerPayload(value.thread);
1573
+ if (nested) return nested;
1574
+ }
1575
+ if (isRecord(value.params)) {
1576
+ const nested = findThreadIdInAppServerPayload(value.params);
1577
+ if (nested) return nested;
1578
+ }
1579
+ return null;
1580
+ }
1581
+
1582
+ function isCodexThreadId(value) {
1583
+ return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1584
+ }
1585
+
1389
1586
  async function waitForThreadTurnCount({ client, threadId, expectedTurns, attempts, delayMs }) {
1390
1587
  let lastTurns = null;
1391
1588
  for (let attempt = 1; attempt <= attempts; attempt++) {
@@ -14,12 +14,15 @@ import {
14
14
  buildThreadReadRequest,
15
15
  buildThreadResumeRequest,
16
16
  buildThreadRollbackRequest,
17
+ buildThreadStartRequest,
17
18
  buildThreadTurnsListRequest,
18
19
  buildTurnStartRequest,
19
20
  compareTurnCounts,
20
21
  encodeAppServerMessage,
21
22
  parseAppServerLine,
23
+ resolveRollbackTurnsForAppServer,
22
24
  runCodexModelVisibilitySmoke,
25
+ runCodexNewThreadHandoff,
23
26
  runCodexRollbackModelVisiblePrepare,
24
27
  runCodexRollbackModelVisibleVerify,
25
28
  runCodexTrimPreflight,
@@ -84,6 +87,12 @@ test('buildInitializeRequest opts into the experimental app-server API', () => {
84
87
 
85
88
  test('thread request builders encode the verified rollback/inject flow', () => {
86
89
  const threadId = 'thread-1';
90
+ assert.deepEqual(buildThreadStartRequest({ id: 'start-1', cwd: '/repo' }), {
91
+ id: 'start-1',
92
+ method: CODEX_APP_SERVER_METHODS.threadStart,
93
+ params: { cwd: '/repo', sessionStartSource: 'clear' },
94
+ });
95
+
87
96
  assert.deepEqual(buildThreadReadRequest({ id: 1, threadId }), {
88
97
  id: 1,
89
98
  method: CODEX_APP_SERVER_METHODS.threadRead,
@@ -203,6 +212,50 @@ test('compareTurnCounts: rejects invalid expected turn count', () => {
203
212
  );
204
213
  });
205
214
 
215
+ test('resolveRollbackTurnsForAppServer adjusts planned rollback by app-server turn delta', () => {
216
+ assert.deepEqual(
217
+ resolveRollbackTurnsForAppServer({
218
+ plannedRollbackTurns: 6,
219
+ expectedTurns: 6,
220
+ readTurns: 7,
221
+ resumedTurns: 7,
222
+ }),
223
+ {
224
+ plannedRollbackTurns: 6,
225
+ requestedRollbackTurns: 7,
226
+ adjustment: 1,
227
+ basis: 'app_server_turn_count',
228
+ reason: 'adjusted_by_app_server_turn_delta',
229
+ },
230
+ );
231
+
232
+ assert.deepEqual(
233
+ resolveRollbackTurnsForAppServer({
234
+ plannedRollbackTurns: 2,
235
+ expectedTurns: 22,
236
+ readTurns: 21,
237
+ resumedTurns: 21,
238
+ }),
239
+ {
240
+ plannedRollbackTurns: 2,
241
+ requestedRollbackTurns: 1,
242
+ adjustment: -1,
243
+ basis: 'app_server_turn_count',
244
+ reason: 'adjusted_by_app_server_turn_delta',
245
+ },
246
+ );
247
+
248
+ assert.equal(
249
+ resolveRollbackTurnsForAppServer({
250
+ plannedRollbackTurns: 6,
251
+ expectedTurns: 6,
252
+ readTurns: 7,
253
+ resumedTurns: 8,
254
+ }).requestedRollbackTurns,
255
+ 6,
256
+ );
257
+ });
258
+
206
259
  test('summarizeAppServerStderr: compacts repeated unknown-turn item warnings', () => {
207
260
  const stderr = [
208
261
  '2026-05-06T00:00:00Z WARN codex_app_server_protocol::protocol::thread_history: dropping turn-scoped item for unknown turn id `turn-a` item_id="call_1"',