@inceptionstack/roundhouse 0.5.29 → 0.5.30

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `@inceptionstack/roundhouse` are documented here.
4
4
 
5
+ ## [0.5.30] — 2026-05-14
6
+
7
+ ### Fixed
8
+ - **Soft-reset robustness fixes from codex review of v0.5.29:**
9
+ - **P1 — byte-cap could cut mid-turn.** When `findSoftResetCutIndex()` hit the byte budget before reaching `keepRecentUserTurns`, it returned `i + 1` which could land on an assistant reply or toolResult whose user prompt was about to be dropped. The kept tail then started mid-turn and tool-pairing repair didn't fix that (only orphans, not turn boundaries). Fixed: byte-cap path now snaps to the most-recent user-message boundary we've walked through.
10
+ - **P2 — byte cap measured in JS code units, not real bytes.** `JSON.stringify(e).length` counts UTF-16 code units; non-ASCII content (emoji, CJK) overshot the advertised 250k ceiling 2–3x. Now uses `Buffer.byteLength(..., 'utf8')` end-to-end so reported `bytesAfter` and the cap decision both reflect actual file bytes.
11
+ - **P2 — trim + repair was not atomic end-to-end.** Old flow wrote the trimmed file, then called `repairSessionFile()` which re-backed-up the *already-trimmed* file and rewrote it again. A crash between the two writes left a partial state and lost the true original. Refactored: extracted `repairEntriesInMemory()` so trim + tool-pair repair compose in memory and land as a single backup + atomic rename.
12
+ - **P2 — `isContextOverflowError()` only inspected top-level `.message`.** Wrapped provider errors (`err.cause.message`, Bedrock `ValidationException` carrying overflow text in nested SDK fields) fell through to re-arming `pendingCompact` instead of triggering recovery. Now mirrors `isToolPairingError()`'s nested handling: walks the `cause` chain (bounded, cycle-safe) and stringify-searches gated on a 4xx/`ValidationException` shape so we don't false-positive on unrelated 5xx noise.
13
+ - 7 regression tests added (534 total passing): byte-cap user-boundary snap, UTF-8 byte accounting, single-atomic-write backup integrity, wrapped-cause classification, Bedrock validation classification, false-positive gating, circular-cause safety.
14
+
5
15
  ## [0.5.29] — 2026-05-14
6
16
 
7
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.29",
3
+ "version": "0.5.30",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -505,6 +505,104 @@ describe('softResetSessionFile', () => {
505
505
  expect(() => softResetSessionFile('/nonexistent/path.jsonl')).toThrow(/not found/);
506
506
  });
507
507
 
508
+ it('softResetSessionFile_ByteCapHit_SnapsToUserTurnBoundary_NeverStartsMidTurn', () => {
509
+ // Regression test for codex P1: byte-cap path used to return `i + 1`
510
+ // which could land mid-turn (assistant reply or toolResult with no user
511
+ // prompt above it). Fixed to snap to the most-recent user-message index.
512
+ // Arrange: many small turns, byte cap forces an early cut.
513
+ const entries: object[] = [HEADER, MODEL_CHANGE];
514
+ let parent: string | null = 'mc-1';
515
+ for (let i = 1; i <= 30; i++) {
516
+ entries.push(...userTurn(`t${i}`, parent));
517
+ parent = `t${i}a`;
518
+ }
519
+ const path = tmpJsonl(entries);
520
+
521
+ // Act: very tight byte budget so cap fires before keepRecentUserTurns reached.
522
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 100, maxBytes: 600 });
523
+
524
+ // Assert: reset happened AND first kept entry is a user message.
525
+ expect(report.reset).toBe(true);
526
+ const trimmed = parseSessionFile(path);
527
+ expect(trimmed[0].type).toBe('session'); // header preserved
528
+ expect(trimmed[1].message?.role).toBe('user'); // first kept = user turn
529
+ expect(trimmed[1].parentId).toBeNull(); // re-parented
530
+ });
531
+
532
+ it('softResetSessionFile_NonAsciiContent_ReportedBytesMatchActualFileBytes', () => {
533
+ // Regression test for codex P2: trim used JSON.stringify(e).length
534
+ // (UTF-16 code units) but reported bytesAfter from real file bytes.
535
+ // After fix, both use Buffer.byteLength(..., 'utf8').
536
+ // Arrange: turns containing multi-byte UTF-8 (each emoji = 4 bytes,
537
+ // length 2 in code units — 2x discrepancy).
538
+ const entries: object[] = [HEADER, MODEL_CHANGE];
539
+ const emojis = '🚀🔥🎉✨💡'.repeat(20); // ~100 bytes per turn
540
+ let parent: string | null = 'mc-1';
541
+ for (let i = 1; i <= 20; i++) {
542
+ entries.push(
543
+ userMsg(`t${i}u`, parent, `${emojis} text-${i}`),
544
+ {
545
+ type: 'message', id: `t${i}a`, parentId: `t${i}u`,
546
+ timestamp: '2026-05-01T00:00:04Z',
547
+ message: {
548
+ role: 'assistant',
549
+ content: [{ type: 'text', text: `${emojis} reply-${i}` }],
550
+ api: 'bedrock-converse-stream', provider: 'amazon-bedrock', model: 'claude',
551
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
552
+ stopReason: 'endTurn', timestamp: 4,
553
+ },
554
+ },
555
+ );
556
+ parent = `t${i}a`;
557
+ }
558
+ const path = tmpJsonl(entries);
559
+
560
+ // Act
561
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 100, maxBytes: 2000 });
562
+
563
+ // Assert: reported bytesAfter matches actual file bytes (true UTF-8 size).
564
+ expect(report.reset).toBe(true);
565
+ const actualBytes = readFileSync(path).length;
566
+ expect(report.bytesAfter).toBe(actualBytes);
567
+ // And we honored the cap (allow some slack for snap-to-user-boundary).
568
+ expect(report.bytesAfter).toBeLessThan(4000);
569
+ });
570
+
571
+ it('softResetSessionFile_OnSingleAtomicWrite_OriginalBackupIsRecoverable', () => {
572
+ // Regression test for codex P2: previously trim wrote once, then
573
+ // repairSessionFile() wrote again with its OWN backup of the
574
+ // already-trimmed file. After fix, only one backup exists and it's
575
+ // the true original.
576
+ // Arrange: session with orphaned tool pair so post-cut repair fires.
577
+ const oldToolCall = assistantToolCall('a-old', 'mc-1', 'call-X');
578
+ const orphanedResult = {
579
+ type: 'message', id: 'tr-1', parentId: 'a-old',
580
+ timestamp: '2026-05-01T00:00:05Z',
581
+ message: { role: 'toolResult', toolCallId: 'call-X', content: 'ok', timestamp: 5 },
582
+ };
583
+ const entries: object[] = [HEADER, MODEL_CHANGE, userMsg('u-old', 'mc-1', 'old'), oldToolCall];
584
+ let parent: string | null = 'a-old';
585
+ for (let i = 1; i <= 5; i++) {
586
+ entries.push(...userTurn(`f${i}`, parent));
587
+ parent = `f${i}a`;
588
+ }
589
+ entries.splice(6, 0, orphanedResult);
590
+ const path = tmpJsonl(entries);
591
+ const originalBytes = readFileSync(path);
592
+
593
+ // Act
594
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 3 });
595
+
596
+ // Assert: backup contents = TRUE original (pre-trim, pre-repair),
597
+ // not an intermediate trimmed-but-unrepaired state.
598
+ expect(report.reset).toBe(true);
599
+ expect(report.backupPath).toBeDefined();
600
+ const backup = readFileSync(report.backupPath!);
601
+ expect(backup.equals(originalBytes)).toBe(true);
602
+ // Final on-disk file is internally consistent.
603
+ expect(inspectSessionFile(path).hasOrphans).toBe(false);
604
+ });
605
+
508
606
  it('softResetSessionFile_BytesCapHonored_StopsCutAtCap', () => {
509
607
  // Arrange: each turn is small but we set a tiny byte cap so we cut early.
510
608
  const entries: object[] = [HEADER, MODEL_CHANGE];
@@ -549,4 +647,46 @@ describe('isContextOverflowError', () => {
549
647
  expect(isContextOverflowError(undefined)).toBe(false);
550
648
  expect(isContextOverflowError({})).toBe(false);
551
649
  });
650
+
651
+ it('classifies overflow when text lives in err.cause.message (wrapped SDK error)', () => {
652
+ // Regression test for codex P2: wrapped provider errors used to fall
653
+ // through to re-arming pendingCompact. After fix, cause-chain is walked.
654
+ const inner = new Error('prompt is too long: 212776 tokens > 200000 maximum');
655
+ const outer = new Error('Summarization failed');
656
+ (outer as { cause?: unknown }).cause = inner;
657
+ expect(isContextOverflowError(outer)).toBe(true);
658
+ });
659
+
660
+ it('classifies overflow on Bedrock ValidationException with nested overflow text', () => {
661
+ // Regression test: Bedrock SDK can carry the useful text in nested
662
+ // $metadata or stringify-only fields. We only stringify-search when
663
+ // the error LOOKS like a 4xx validation (mirrors isToolPairingError).
664
+ const err = Object.assign(new Error('validation failed'), {
665
+ name: 'ValidationException',
666
+ $metadata: { httpStatusCode: 400 },
667
+ detail: { reason: 'prompt is too long' },
668
+ });
669
+ expect(isContextOverflowError(err)).toBe(true);
670
+ });
671
+
672
+ it('does NOT stringify-search arbitrary errors that contain overflow keywords', () => {
673
+ // Negative case: gating prevents false-positives on unrelated 5xx errors
674
+ // whose payload happens to contain trigger phrases.
675
+ const err = Object.assign(new Error('internal error'), {
676
+ name: 'InternalServerError',
677
+ $metadata: { httpStatusCode: 500 },
678
+ diagnostics: 'log line: prompt is too long check disabled',
679
+ });
680
+ expect(isContextOverflowError(err)).toBe(false);
681
+ });
682
+
683
+ it('does not loop forever on circular cause chains', () => {
684
+ // Safety: cause walk is bounded.
685
+ const a = new Error('outer');
686
+ const b = new Error('inner');
687
+ (a as { cause?: unknown }).cause = b;
688
+ (b as { cause?: unknown }).cause = a; // cycle
689
+ expect(() => isContextOverflowError(a)).not.toThrow();
690
+ expect(isContextOverflowError(a)).toBe(false);
691
+ });
552
692
  });
@@ -246,44 +246,65 @@ export function inspectSessionFile(path: string): {
246
246
  *
247
247
  * @returns report describing what was repaired
248
248
  */
249
- export function repairSessionFile(path: string): SessionRepairReport {
250
- if (!existsSync(path)) {
251
- throw new Error(`Session file not found: ${path}`);
252
- }
253
-
254
- const entries = parseSessionFile(path);
249
+ /**
250
+ * Pure in-memory tool-pairing repair. Takes entries, returns repaired entries
251
+ * + a report. Does not touch the filesystem. Used directly by
252
+ * `softResetSessionFile` so trim + repair land as a single atomic write, and
253
+ * via a thin wrapper by `repairSessionFile` for on-disk repair.
254
+ */
255
+ function repairEntriesInMemory(entries: SessionFileEntry[]): {
256
+ entries: SessionFileEntry[];
257
+ report: SessionRepairReport;
258
+ } {
255
259
  const { messages } = extractMessages(entries);
256
260
  const validation = validateToolPairing(messages);
257
261
 
258
262
  if (validation.isValid) {
259
263
  return {
260
- repaired: false,
261
- droppedEntryIds: [],
262
- droppedToolCallIds: [],
263
- droppedToolResultIds: [],
264
- totalEntries: entries.length,
264
+ entries,
265
+ report: {
266
+ repaired: false,
267
+ droppedEntryIds: [],
268
+ droppedToolCallIds: [],
269
+ droppedToolResultIds: [],
270
+ totalEntries: entries.length,
271
+ },
265
272
  };
266
273
  }
267
274
 
268
275
  const orphanedCalls = new Set(validation.orphanedToolCallIds);
269
276
  const orphanedResults = new Set(validation.orphanedToolResultIds);
270
-
271
277
  const { entriesToDrop, entriesToEdit } = findEntriesToDrop(entries, orphanedCalls, orphanedResults);
272
278
  const edited = applyEntryEdits(entries, entriesToEdit);
273
279
  const kept = reparentDroppedEntries(edited, entriesToDrop);
274
280
 
281
+ return {
282
+ entries: kept,
283
+ report: {
284
+ repaired: true,
285
+ droppedEntryIds: Array.from(entriesToDrop),
286
+ droppedToolCallIds: validation.orphanedToolCallIds,
287
+ droppedToolResultIds: validation.orphanedToolResultIds,
288
+ totalEntries: entries.length,
289
+ },
290
+ };
291
+ }
292
+
293
+ export function repairSessionFile(path: string): SessionRepairReport {
294
+ if (!existsSync(path)) {
295
+ throw new Error(`Session file not found: ${path}`);
296
+ }
297
+
298
+ const entries = parseSessionFile(path);
299
+ const { entries: repaired, report } = repairEntriesInMemory(entries);
300
+
301
+ if (!report.repaired) return report;
302
+
275
303
  const backupPath = backupFile(path);
276
- const newContent = kept.map(e => JSON.stringify(e)).join('\n') + '\n';
304
+ const newContent = repaired.map(e => JSON.stringify(e)).join('\n') + '\n';
277
305
  atomicWrite(path, newContent);
278
306
 
279
- return {
280
- repaired: true,
281
- droppedEntryIds: Array.from(entriesToDrop),
282
- droppedToolCallIds: validation.orphanedToolCallIds,
283
- droppedToolResultIds: validation.orphanedToolResultIds,
284
- backupPath,
285
- totalEntries: entries.length,
286
- };
307
+ return { ...report, backupPath };
287
308
  }
288
309
 
289
310
  // ── Soft reset (recovery from already-overflowed sessions) ──────────────
@@ -323,12 +344,19 @@ export interface SoftResetReport {
323
344
 
324
345
  /**
325
346
  * Find a safe cut index in the entries array. Walk backwards from the end
326
- * looking for user message entries; the cut sits *just before* the Nth
327
- * most-recent user message we encounter. Returns the index of the first
328
- * entry to KEEP (i.e. all entries[0..cutIdx) are dropped).
347
+ * looking for user message entries; the cut sits *at* the Nth most-recent
348
+ * user message we encounter (so the kept tail starts on a user turn).
349
+ * Returns the index of the first entry to KEEP (i.e. all entries[0..cutIdx)
350
+ * are dropped).
329
351
  *
330
- * If we can't find enough user messages, returns 1 to keep everything except
331
- * the session header (which we preserve separately).
352
+ * Byte-cap path: if we exceed the byte budget before reaching N user turns,
353
+ * we still snap the cut to the most-recent user-message boundary we've seen.
354
+ * That guarantees the kept tail always starts with a user message — never an
355
+ * orphaned assistant reply or toolResult whose user prompt was dropped.
356
+ *
357
+ * If we can't find ANY user messages, returns entries.length (drop everything
358
+ * but header) so the caller produces a header-only no-op session rather than
359
+ * a malformed tail.
332
360
  */
333
361
  function findSoftResetCutIndex(
334
362
  entries: SessionFileEntry[],
@@ -337,24 +365,32 @@ function findSoftResetCutIndex(
337
365
  ): { cutIdx: number; reason: string } {
338
366
  let userTurnsSeen = 0;
339
367
  let bytesAccumulated = 0;
368
+ /** Most recent user-message index we've walked through, or -1 if none yet. */
369
+ let lastUserIdx = -1;
340
370
  // Scan tail-to-head, stop when we've collected enough user turns OR exceeded byte budget.
341
371
  for (let i = entries.length - 1; i >= 0; i--) {
342
372
  const e = entries[i];
343
- bytesAccumulated += JSON.stringify(e).length + 1; // +1 for newline
373
+ bytesAccumulated += Buffer.byteLength(JSON.stringify(e), 'utf8') + 1; // +1 for newline
344
374
  if (e.type === 'message' && e.message?.role === 'user') {
345
375
  userTurnsSeen++;
376
+ lastUserIdx = i;
346
377
  if (userTurnsSeen >= keepRecentUserTurns) {
347
378
  return { cutIdx: i, reason: `kept-${userTurnsSeen}-user-turns` };
348
379
  }
349
380
  }
350
381
  // Byte cap is a safety net for sessions where a single turn is enormous
351
- // (e.g. one turn dumped a 200k file). Stop once we'd exceed the cap.
382
+ // (e.g. one turn dumped a 200k file). When we hit it we MUST snap the cut
383
+ // to the most recent user-message boundary — otherwise the kept tail could
384
+ // start mid-turn (assistant/toolResult with no user prompt above it), and
385
+ // tool-pairing repair won't fix that.
352
386
  if (bytesAccumulated > maxBytes && userTurnsSeen > 0) {
353
- return { cutIdx: i + 1, reason: `byte-cap-${bytesAccumulated}b` };
387
+ return { cutIdx: lastUserIdx, reason: `byte-cap-${bytesAccumulated}b` };
354
388
  }
355
389
  }
356
- // Not enough user turns in the file keep everything except header.
357
- // (Header is always at index 0 and is preserved by the writer separately.)
390
+ // Fewer user turns than targettreat as no-op. Soft-reset is recovery
391
+ // from overflow; if the session has fewer turns than our target it isn't
392
+ // overflowed and we shouldn't mutate it. Returning 1 means "keep everything
393
+ // after the header", which the caller's `cutIdx <= 1` gate maps to reset:false.
358
394
  return { cutIdx: 1, reason: 'fewer-turns-than-target' };
359
395
  }
360
396
 
@@ -418,24 +454,27 @@ export function softResetSessionFile(
418
454
  }
419
455
  const trimmed = [header, ...tail];
420
456
 
457
+ // Run tool-pair repair *in memory* on the trimmed entries before writing,
458
+ // so the on-disk update is a single atomic backup + atomic rename. Doing
459
+ // disk-write → repairSessionFile() (another disk-write) would mean a crash
460
+ // between the two leaves a partially-processed file AND a backup of the
461
+ // already-trimmed file rather than the true original.
462
+ const repaired = repairEntriesInMemory(trimmed);
463
+
421
464
  const backupPath = backupFile(path);
422
- const newContent = trimmed.map(e => JSON.stringify(e)).join('\n') + '\n';
465
+ const newContent = repaired.entries.map(e => JSON.stringify(e)).join('\n') + '\n';
423
466
  atomicWrite(path, newContent);
424
467
 
425
- // The cut may have orphaned tool pairs (e.g. toolResult kept but its
426
- // toolCall is now in the dropped section). Run repair to clean those up.
427
- const postRepair = repairSessionFile(path);
428
-
429
- const bytesAfter = readFileSync(path).length;
468
+ const bytesAfter = Buffer.byteLength(newContent, 'utf8');
430
469
  return {
431
470
  reset: true,
432
471
  reason,
433
472
  entriesBefore: entries.length,
434
- entriesAfter: trimmed.length - postRepair.droppedEntryIds.length,
473
+ entriesAfter: repaired.entries.length,
435
474
  bytesBefore,
436
475
  bytesAfter,
437
476
  backupPath,
438
- postRepair,
477
+ postRepair: repaired.report,
439
478
  };
440
479
  }
441
480
 
@@ -447,10 +486,14 @@ export function softResetSessionFile(
447
486
  *
448
487
  * Triggers soft-reset recovery in the memory lifecycle. Intentionally narrow:
449
488
  * only matches the well-known overflow phrasings, not generic 4xx errors.
489
+ *
490
+ * Mirrors `isToolPairingError`'s nested-error handling: provider SDKs commonly
491
+ * wrap the useful text under `cause.message` or in serialized fields on
492
+ * Bedrock ValidationException. Stringify-search is gated on a 4xx / validation
493
+ * shape so we don't false-positive on noisy unrelated errors.
450
494
  */
451
495
  export function isContextOverflowError(err: unknown): boolean {
452
496
  if (!err) return false;
453
- const msg = (err as { message?: string }).message ?? String(err);
454
497
  const patterns = [
455
498
  /prompt is too long/i,
456
499
  /tokens?\s*[>>]\s*\d+\s*maximum/i,
@@ -458,7 +501,35 @@ export function isContextOverflowError(err: unknown): boolean {
458
501
  /context length exceeded/i,
459
502
  /maximum context length/i,
460
503
  ];
461
- return patterns.some(p => p.test(msg));
504
+
505
+ // 1. Top-level message.
506
+ const msg = (err as { message?: string }).message ?? String(err);
507
+ if (patterns.some(p => p.test(msg))) return true;
508
+
509
+ // 2. Walk the cause chain (a few hops — don't loop forever on circular).
510
+ let cur: unknown = (err as { cause?: unknown }).cause;
511
+ for (let hop = 0; hop < 5 && cur; hop++) {
512
+ const causeMsg = (cur as { message?: string }).message ?? String(cur);
513
+ if (patterns.some(p => p.test(causeMsg))) return true;
514
+ cur = (cur as { cause?: unknown }).cause;
515
+ }
516
+
517
+ // 3. Bedrock ValidationException sometimes carries the overflow text in
518
+ // nested SDK fields. Only stringify-search when the error LOOKS like a 4xx
519
+ // validation error — mirrors the gating in isToolPairingError.
520
+ const name = (err as { name?: string }).name ?? '';
521
+ const httpStatus =
522
+ (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
523
+ if (name === 'ValidationException' || httpStatus === 400) {
524
+ try {
525
+ const full = JSON.stringify(err);
526
+ if (patterns.some(p => p.test(full))) return true;
527
+ } catch {
528
+ /* circular structure — give up */
529
+ }
530
+ }
531
+
532
+ return false;
462
533
  }
463
534
 
464
535
  /**